From 452ba3033b49247842573560626fec052494b557 Mon Sep 17 00:00:00 2001 From: RobbStarkAustria <7694336+RobbStarkAustria@users.noreply.github.com> Date: Wed, 5 Nov 2025 19:30:10 +0000 Subject: [PATCH] feat(video, settings, docs): add muted playback, nested Settings tabs, merge holidays tab; bump 2025.1.0-alpha.11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API/DB: add Event.muted with full CRUD wiring (Alembic migration), persist/return with autoplay/loop/volume Dashboard: per‑event video options (autoplay/loop/volume/muted) with system defaults; Settings → Events → Videos defaults Settings UX: nested tabs with controlled selection; Academic Calendar: merge “Schulferien Import”+“Liste” into “📥 Import & Liste” Docs: update README and copilot-instructions (video payload, streaming 206, defaults keys); update program-info.json changelog; bump version to 2025.1.0‑alpha.11 --- .github/copilot-instructions.md | 23 +- .gitignore | 1 + README.md | 34 +- dashboard/public/program-info.json | 15 +- dashboard/src/appointments.tsx | 31 +- dashboard/src/components/CustomEventModal.tsx | 82 +- dashboard/src/settings.tsx | 939 +++++++++++------- dashboard/vite.config.ts | 3 + models/models.py | 1 + .../21226a449037_add_muted_to_events.py | 30 + server/init_defaults.py | 3 + server/routes/events.py | 12 + 12 files changed, 793 insertions(+), 381 deletions(-) create mode 100644 server/alembic/versions/21226a449037_add_muted_to_events.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3b900e7..2f92fdb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -28,6 +28,7 @@ Small multi-service digital signage app (Flask API, React dashboard, MQTT schedu - `server/routes/events.py` — event CRUD and recurrence handling - `dashboard/src/components/CustomEventModal.tsx` — event creation UI - `dashboard/src/media.tsx` — FileManager / upload settings + - `dashboard/src/settings.tsx` — settings UI (nested tabs; system defaults for presentations and videos) ## Big picture @@ -47,6 +48,11 @@ Small multi-service digital signage app (Flask API, React dashboard, MQTT schedu - Dashboard: UI updated to allow selecting uploaded videos for events and to specify autoplay/loop/volume. File upload settings were increased (maxFileSize raised) and the client now validates video duration (max 10 minutes) before upload. - FileManager: uploads compute basic metadata and enqueue conversions for office formats as before; video uploads now surface size and are streamable via the new endpoint. + - Event model & API (new): Added `muted` (Boolean) for video events; create/update and GET endpoints accept, persist, and return `muted` alongside `autoplay`, `loop`, and `volume`. + - Dashboard — Settings: Settings page refactored to nested tabs; added Events → Videos defaults (autoplay, loop, volume, mute) backed by system settings keys (`video_autoplay`, `video_loop`, `video_volume`, `video_muted`). + - Dashboard — Events UI: CustomEventModal now exposes per-event video `muted` and initializes all video fields from system defaults when creating a new event. + - Dashboard — Academic Calendar: Merged “School Holidays Import” and “List” into a single “📥 Import & Liste” tab; nested tab selection is persisted with controlled `selectedItem` state to avoid jumps. + Note: these edits are intentionally backwards-compatible — if the probe fails, the scheduler still emits the stream URL and the client should fallback to a direct play attempt or request richer metadata when available. ## Service boundaries & data flow @@ -76,7 +82,14 @@ Small multi-service digital signage app (Flask API, React dashboard, MQTT schedu - `presentation_page_progress` ("true"/"false", default "true") - `presentation_auto_progress` ("true"/"false", default "true") Seeded in `server/init_defaults.py` if missing. + - Video defaults (system-wide): + - `video_autoplay` ("true"/"false", default "true") + - `video_loop` ("true"/"false", default "true") + - `video_volume` (0.0–1.0, default "0.8") + - `video_muted` ("true"/"false", default "false") + Used as initial values when creating new video events; editable per event. - Events: Added `page_progress` (Boolean) and `auto_progress` (Boolean) for presentation behavior per event. + - Event (video fields): `event_media_id`, `autoplay`, `loop`, `volume`, `muted`. - WebUntis URL: WebUntis uses the existing Vertretungsplan/Supplement-Table URL (`supplement_table_url`). There is no separate `webuntis_url` setting; use `GET/POST /api/system-settings/supplement-table`. - Conversions: @@ -137,8 +150,8 @@ Small multi-service digital signage app (Flask API, React dashboard, MQTT schedu - Settings page (`dashboard/src/settings.tsx`): - Structure: Syncfusion TabComponent with role-gated tabs - 📅 Academic Calendar (all users) - - School Holidays: CSV/TXT import and list - - Academic Periods: select and set active period (uses `/api/academic_periods` routes) + - 📥 Import & Liste: CSV/TXT import and list combined + - 🗂️ Perioden: select and set active period (uses `/api/academic_periods` routes) - 🖥️ Display & Clients (admin+) - Default Settings: placeholders for heartbeat, screenshots, defaults - Client Configuration: quick links to Clients and Groups pages @@ -148,11 +161,13 @@ Small multi-service digital signage app (Flask API, React dashboard, MQTT schedu - 🗓️ Events (admin+) - WebUntis / Vertretungsplan: system-wide supplement table URL with enable/disable, save, and preview; persists via `/api/system-settings/supplement-table` - Presentations: general defaults for slideshow interval, page-progress, and auto-progress; persisted via `/api/system-settings` keys (`presentation_interval`, `presentation_page_progress`, `presentation_auto_progress`). These defaults are applied when creating new presentation events (the custom event modal reads them and falls back to per-event values when editing). - - Other event types (website, video, message, other): placeholders for defaults + - Videos: system-wide defaults for `autoplay`, `loop`, `volume`, and `muted`; persisted via `/api/system-settings` keys (`video_autoplay`, `video_loop`, `video_volume`, `video_muted`). These defaults are applied when creating new video events (the custom event modal reads them and falls back to per-event values when editing). + - Other event types (website, message, other): placeholders for defaults - ⚙️ System (superadmin) - Organization Info and Advanced Configuration placeholders - Role gating: Admin/Superadmin tabs are hidden if the user lacks permission; System is superadmin-only - API clients use relative `/api/...` URLs so Vite dev proxy handles requests without CORS issues. The settings UI calls are centralized in `dashboard/src/apiSystemSettings.ts`. + - Nested tabs: implemented as controlled components using `selectedItem` with stateful handlers to prevent sub-tab resets during updates. - User dropdown technical notes: - Dependencies: `@syncfusion/ej2-react-splitbuttons` and `@syncfusion/ej2-splitbuttons` must be installed. @@ -189,7 +204,7 @@ Note: Syncfusion usage in the dashboard is already documented above; if a UI for - Always compare datetimes in UTC; some DB values may be naive—normalize before comparing (see `routes/events.py`). - Scheduler enforces UTC comparisons and normalizes naive timestamps. It publishes only currently active events and clears retained topics for groups with no active events. It also queries a future window (default: 7 days) and expands recurring events using RFC 5545 rules. Event exceptions are respected. Logging is concise and conversion lookups are cached. - Use retained MQTT messages for state that clients must recover after reconnect (events per group, client group_id). - - Clients should parse `event_type` and then read the corresponding nested payload (`presentation`, `website`, etc.). `website` and `webuntis` use the same nested `website` payload with `type: browser` and a `url`. + - Clients should parse `event_type` and then read the corresponding nested payload (`presentation`, `website`, `video`, etc.). `website` and `webuntis` use the same nested `website` payload with `type: browser` and a `url`. Video events include `autoplay`, `loop`, `volume`, and `muted`. - In-container DB host is `db`; do not use `localhost` inside services. - No separate dev vs prod secret conventions: use the same env var keys across environments (e.g., `DB_CONN`, `MQTT_USER`, `MQTT_PASSWORD`). - When adding a new route: diff --git a/.gitignore b/.gitignore index e132744..d25f60a 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,4 @@ dashboard/assets/responsive-sidebar.css certs/ sync.ffs_db .pnpm-store/ +dashboard/src/nested_tabs.js diff --git a/README.md b/README.md index 483ee47..b3430c7 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Data flow summary: ### 🎯 **Event System** - **Presentations**: PowerPoint/LibreOffice → PDF conversion via Gotenberg - **Websites**: URL-based content display -- **Videos**: Media file streaming + - **Videos**: Media file streaming with per-event playback settings (`autoplay`, `loop`, `volume`, `muted`); system-wide defaults configurable under Settings → Events → Videos - **Messages**: Text announcements - **WebUntis**: Educational schedule integration - Uses the system-wide Vertretungsplan/Supplement-Table URL (`supplement_table_url`) configured under Settings → Events. No separate per-event URL is required; WebUntis events display the same as Website events. @@ -115,6 +115,17 @@ Data flow summary: # or: docker compose up -d --build ``` +Before running the dashboard dev server you may need to install Syncfusion packages used by the UI. Example (install only the packages you use): + +```bash +# from the repository root +cd dashboard +npm install --save @syncfusion/ej2-react-splitbuttons @syncfusion/ej2-splitbuttons \ + @syncfusion/ej2-react-grids @syncfusion/ej2-react-schedule @syncfusion/ej2-react-filemanager +``` + +License note: Syncfusion distributes components under a commercial license with a free community license for qualifying users. Verify licensing for your organization before using Syncfusion in production and document any license keys or compliance steps in this repository. + 4. **Initialize the database (first run only)** ```bash # One-shot: runs all Alembic migrations, creates default admin/group, and seeds academic periods @@ -203,13 +214,18 @@ For detailed deployment instructions, see: - Website and WebUntis events share a unified payload: - `website`: `{ "type": "browser", "url": "https://..." }` - The `event_type` field remains specific (e.g., `presentation`, `website`, `webuntis`) so clients can dispatch appropriately; however, `website` and `webuntis` should be handled identically in clients. + - Videos include a `video` payload with a stream URL and playback flags: + - `video`: includes `url` (streaming endpoint) and `autoplay`, `loop`, `volume`, `muted` + - Streaming endpoint supports byte-range requests (206) to enable seeking: `/api/eventmedia/stream//` ## Recent changes since last commit - Video / Streaming support: Added end-to-end support for video events. The API and dashboard now allow creating `video` events referencing uploaded media. The server exposes a range-capable streaming endpoint at `/api/eventmedia/stream//` so clients can seek during playback. - Scheduler metadata: Scheduler now performs a best-effort HEAD probe for video stream URLs and includes basic metadata in the retained MQTT payload: `mime_type`, `size` (bytes) and `accept_ranges` (bool). Placeholders for richer metadata (`duration`, `resolution`, `bitrate`, `qualities`, `thumbnails`, `checksum`) are emitted as null/empty until a background worker fills them. -- Dashboard & uploads: The dashboard's FileManager upload limits were increased (to support Full-HD uploads) and client-side validation enforces a maximum video length (10 minutes). The event modal exposes playback flags (`autoplay`, `loop`, `volume`). -- DB model: `Event` includes new columns to store playback preferences (`autoplay`, `loop`, `volume`) and reference uploaded media via `event_media_id`. +- Dashboard & uploads: The dashboard's FileManager upload limits were increased (to support Full-HD uploads) and client-side validation enforces a maximum video length (10 minutes). The event modal exposes playback flags (`autoplay`, `loop`, `volume`, `muted`) and initializes them from system defaults for new events. +- DB model & API: `Event` includes `muted` in addition to `autoplay`, `loop`, and `volume`; endpoints accept, persist, and return these fields for video events. Events reference uploaded media via `event_media_id`. +- Settings UI: Settings page refactored to nested tabs; added Events → Videos defaults (autoplay, loop, volume, mute) backed by system settings keys (`video_autoplay`, `video_loop`, `video_volume`, `video_muted`). +- Academic Calendar UI: Merged “School Holidays Import” and “List” into a single “📥 Import & Liste” tab; nested tab selection is persisted with controlled `selectedItem` state to avoid jumps. These changes are designed to be safe if metadata extraction or probes fail — clients should still attempt playback using the provided `url` and fall back to requesting/resolving richer metadata when available. @@ -309,6 +325,7 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v - `GET /api/files/converted/{path}` - Download converted PDFs - `POST /api/conversions/{media_id}/pdf` - Request conversion - `GET /api/conversions/{media_id}/status` - Check conversion status + - `GET /api/eventmedia/stream//` - Stream media with byte-range support (206) for seeking ### System Settings - `GET /api/system-settings` - List all system settings (admin+) @@ -321,6 +338,11 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v - `presentation_interval` (seconds, default "10") - `presentation_page_progress` ("true"/"false", default "true") - `presentation_auto_progress` ("true"/"false", default "true") + - Video defaults stored as keys: + - `video_autoplay` ("true"/"false", default "true") + - `video_loop` ("true"/"false", default "true") + - `video_volume` (0.0–1.0, default "0.8") + - `video_muted` ("true"/"false", default "false") ### Health & Monitoring - `GET /health` - Service health check @@ -353,10 +375,12 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v - **Events**: Schedule management - **Media**: File upload and conversion - **Settings**: Central configuration (tabbed) - - 📅 Academic Calendar (all users): School Holidays import/list and Academic Periods (set active period) + - 📅 Academic Calendar (all users): + - 📥 Import & Liste: CSV/TXT import combined with holidays list + - 🗂️ Perioden: Academic Periods (set active period) - 🖥️ Display & Clients (admin+): Defaults placeholders and quick links to Clients/Groups - 🎬 Media & Files (admin+): Upload settings placeholders and Conversion status overview - - 🗓️ Events (admin+): WebUntis/Vertretungsplan URL enable/disable, save, preview. Presentations: general defaults for slideshow interval, page-progress, and auto-progress; persisted via `/api/system-settings` keys and applied on create in the event modal. + - 🗓️ Events (admin+): WebUntis/Vertretungsplan URL enable/disable, save, preview. Presentations: general defaults for slideshow interval, page-progress, and auto-progress; persisted via `/api/system-settings` keys and applied on create in the event modal. Videos: system-wide defaults for `autoplay`, `loop`, `volume`, and `muted`; persisted via `/api/system-settings` keys and applied on create in the event modal. - ⚙️ System (superadmin): Organization info and Advanced configuration placeholders - **Holidays**: Academic calendar management - **Program info**: Version, build info, tech stack and paginated changelog (reads `dashboard/public/program-info.json`) diff --git a/dashboard/public/program-info.json b/dashboard/public/program-info.json index 0342459..64ad736 100644 --- a/dashboard/public/program-info.json +++ b/dashboard/public/program-info.json @@ -1,11 +1,11 @@ { "appName": "Infoscreen-Management", - "version": "2025.1.0-alpha.10", + "version": "2025.1.0-alpha.11", "copyright": "© 2025 Third-Age-Applications", "supportContact": "support@third-age-applications.com", "description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.", "techStack": { - "Frontend": "React, Vite, TypeScript", + "Frontend": "React, Vite, TypeScript, Syncfusion UI Components (Material 3)", "Backend": "Python (Flask), SQLAlchemy", "Database": "MariaDB", "Realtime": "Mosquitto (MQTT)", @@ -30,6 +30,17 @@ "commitId": "9f2ae8b44c3a" }, "changelog": [ + { + "version": "2025.1.0-alpha.11", + "date": "2025-11-05", + "changes": [ + "🎬 Client: Clients können jetzt Video-Events aus dem Terminplaner abspielen (Streaming mit Seek via Byte-Range).", + "🧭 Einstellungen: Neues verschachteltes Tab-Layout mit kontrollierter Tab-Auswahl (keine Sprünge in Unter-Tabs).", + "📅 Einstellungen › Akademischer Kalender: ‘Schulferien Import’ und ‘Liste’ zusammengeführt in ‘📥 Import & Liste’.", + "🗓️ Events-Modal: Video-Optionen erweitert (Autoplay, Loop, Lautstärke, Ton aus). Werte werden bei neuen Terminen aus System-Defaults initialisiert.", + "⚙️ Einstellungen › Events › Videos: Globale Defaults für Autoplay, Loop, Lautstärke und Mute (Keys: video_autoplay, video_loop, video_volume, video_muted)." + ] + }, { "version": "2025.1.0-alpha.10", "date": "2025-10-25", diff --git a/dashboard/src/appointments.tsx b/dashboard/src/appointments.tsx index 7e43152..1075666 100644 --- a/dashboard/src/appointments.tsx +++ b/dashboard/src/appointments.tsx @@ -63,7 +63,14 @@ type Event = { isHoliday?: boolean; // marker for styling/logic MediaId?: string | number; SlideshowInterval?: number; + PageProgress?: boolean; + AutoProgress?: boolean; WebsiteUrl?: string; + // Video-specific fields + Autoplay?: boolean; + Loop?: boolean; + Volume?: number; + Muted?: boolean; Icon?: string; // <--- Icon ergänzen! Type?: string; // <--- Typ ergänzen, falls benötigt OccurrenceOfId?: string; // Serieninstanz @@ -380,6 +387,14 @@ const Appointments: React.FC = () => { EndTime: parseEventDate(e.EndTime), IsAllDay: e.IsAllDay, MediaId: e.MediaId, + SlideshowInterval: e.SlideshowInterval, + PageProgress: e.PageProgress, + AutoProgress: e.AutoProgress, + WebsiteUrl: e.WebsiteUrl, + Autoplay: e.Autoplay, + Loop: e.Loop, + Volume: e.Volume, + Muted: e.Muted, Icon: e.Icon, Type: e.Type, OccurrenceOfId: e.OccurrenceOfId, @@ -398,6 +413,14 @@ const Appointments: React.FC = () => { EndTime: parseEventDate(e.EndTime), IsAllDay: e.IsAllDay, MediaId: e.MediaId, + SlideshowInterval: e.SlideshowInterval, + PageProgress: e.PageProgress, + AutoProgress: e.AutoProgress, + WebsiteUrl: e.WebsiteUrl, + Autoplay: e.Autoplay, + Loop: e.Loop, + Volume: e.Volume, + Muted: e.Muted, Icon: e.Icon, Type: e.Type, OccurrenceOfId: e.OccurrenceOfId, @@ -810,8 +833,6 @@ const Appointments: React.FC = () => { groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }} groupColor={selectedGroupId ? getGroupColor(selectedGroupId, groups) : undefined} editMode={editMode} // NEU: Prop für Editiermodus - blockHolidays={!allowScheduleOnHolidays} - isHolidayRange={(s, e) => isWithinHolidayRange(s, e)} /> { skipHolidays: isSingleOccurrence ? false : (eventDataToUse.SkipHolidays ?? false), media, slideshowInterval: eventDataToUse.SlideshowInterval ?? 10, + pageProgress: eventDataToUse.PageProgress ?? true, + autoProgress: eventDataToUse.AutoProgress ?? true, websiteUrl: eventDataToUse.WebsiteUrl ?? '', + autoplay: eventDataToUse.Autoplay ?? true, + loop: eventDataToUse.Loop ?? true, + volume: eventDataToUse.Volume ?? 0.8, + muted: eventDataToUse.Muted ?? false, }; setModalInitialData(modalData); diff --git a/dashboard/src/components/CustomEventModal.tsx b/dashboard/src/components/CustomEventModal.tsx index 0219cc1..037cba3 100644 --- a/dashboard/src/components/CustomEventModal.tsx +++ b/dashboard/src/components/CustomEventModal.tsx @@ -28,6 +28,7 @@ type CustomEventData = { autoplay?: boolean; loop?: boolean; volume?: number; + muted?: boolean; }; // Typ für initialData erweitern, damit Id unterstützt wird @@ -117,13 +118,50 @@ const CustomEventModal: React.FC = ({ ); const [websiteUrl, setWebsiteUrl] = React.useState(initialData.websiteUrl ?? ''); - // Video-specific state + // Video-specific state with system defaults loading const [autoplay, setAutoplay] = React.useState(initialData.autoplay ?? true); - const [loop, setLoop] = React.useState(initialData.loop ?? false); + const [loop, setLoop] = React.useState(initialData.loop ?? true); const [volume, setVolume] = React.useState(initialData.volume ?? 0.8); + const [muted, setMuted] = React.useState(initialData.muted ?? false); + const [videoDefaultsLoaded, setVideoDefaultsLoaded] = React.useState(false); const [mediaModalOpen, setMediaModalOpen] = React.useState(false); + // Load system video defaults once when opening for a new video event + React.useEffect(() => { + if (open && !editMode && !videoDefaultsLoaded) { + (async () => { + try { + const api = await import('../apiSystemSettings'); + const keys = ['video_autoplay', 'video_loop', 'video_volume', 'video_muted'] as const; + const [autoplayRes, loopRes, volumeRes, mutedRes] = await Promise.all( + keys.map(k => api.getSetting(k).catch(() => ({ value: null } as { value: string | null }))) + ); + + // Only apply defaults if not already set from initialData + if (initialData.autoplay === undefined) { + setAutoplay(autoplayRes.value == null ? true : autoplayRes.value === 'true'); + } + if (initialData.loop === undefined) { + setLoop(loopRes.value == null ? true : loopRes.value === 'true'); + } + if (initialData.volume === undefined) { + const volParsed = volumeRes.value == null ? 0.8 : parseFloat(String(volumeRes.value)); + setVolume(Number.isFinite(volParsed) ? volParsed : 0.8); + } + if (initialData.muted === undefined) { + setMuted(mutedRes.value == null ? false : mutedRes.value === 'true'); + } + + setVideoDefaultsLoaded(true); + } catch { + // Silently fall back to hard-coded defaults + setVideoDefaultsLoaded(true); + } + })(); + } + }, [open, editMode, videoDefaultsLoaded, initialData]); + React.useEffect(() => { if (open) { const isSingleOccurrence = initialData.isSingleOccurrence || false; @@ -154,12 +192,16 @@ const CustomEventModal: React.FC = ({ setPageProgress(initialData.pageProgress ?? true); setAutoProgress(initialData.autoProgress ?? true); setWebsiteUrl(initialData.websiteUrl ?? ''); - // Video fields - setAutoplay(initialData.autoplay ?? true); - setLoop(initialData.loop ?? false); - setVolume(initialData.volume ?? 0.8); + + // Video fields - use initialData values when editing + if (editMode) { + setAutoplay(initialData.autoplay ?? true); + setLoop(initialData.loop ?? true); + setVolume(initialData.volume ?? 0.8); + setMuted(initialData.muted ?? false); + } } - }, [open, initialData]); + }, [open, initialData, editMode]); React.useEffect(() => { if (!mediaModalOpen && pendingMedia) { @@ -296,6 +338,7 @@ const CustomEventModal: React.FC = ({ payload.autoplay = autoplay; payload.loop = loop; payload.volume = volume; + payload.muted = muted; } try { @@ -664,13 +707,24 @@ const CustomEventModal: React.FC = ({ />
- setVolume(Math.max(0, Math.min(1, Number(e.value))))} - /> + +
+ setVolume(Math.max(0, Math.min(1, Number(e.value))))} + style={{ flex: 1 }} + /> + setMuted(e.checked || false)} + /> +
)} diff --git a/dashboard/src/settings.tsx b/dashboard/src/settings.tsx index 189f318..a84f4dd 100644 --- a/dashboard/src/settings.tsx +++ b/dashboard/src/settings.tsx @@ -1,9 +1,7 @@ import React from 'react'; import { TabComponent, TabItemDirective, TabItemsDirective } from '@syncfusion/ej2-react-navigations'; -import { NumericTextBoxComponent } from '@syncfusion/ej2-react-inputs'; -import { TextBoxComponent } from '@syncfusion/ej2-react-inputs'; -import { ButtonComponent } from '@syncfusion/ej2-react-buttons'; -import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons'; +import { NumericTextBoxComponent, TextBoxComponent } from '@syncfusion/ej2-react-inputs'; +import { ButtonComponent, CheckBoxComponent } from '@syncfusion/ej2-react-buttons'; import { ToastComponent } from '@syncfusion/ej2-react-notifications'; import { listHolidays, uploadHolidaysCsv, type Holiday } from './apiHolidays'; import { getSupplementTableSettings, updateSupplementTableSettings } from './apiSystemSettings'; @@ -12,6 +10,9 @@ import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns'; import { listAcademicPeriods, getActiveAcademicPeriod, setActiveAcademicPeriod, type AcademicPeriod } from './apiAcademicPeriods'; import { Link } from 'react-router-dom'; +// Minimal event type for Syncfusion Tab 'selected' callback +type TabSelectedEvent = { selectedIndex?: number }; + const Einstellungen: React.FC = () => { // Presentation settings state const [presentationInterval, setPresentationInterval] = React.useState(10); @@ -76,6 +77,13 @@ const Einstellungen: React.FC = () => { const [supplementEnabled, setSupplementEnabled] = React.useState(false); const [supplementBusy, setSupplementBusy] = React.useState(false); + // Video defaults state (Admin+) + const [videoAutoplay, setVideoAutoplay] = React.useState(true); + const [videoLoop, setVideoLoop] = React.useState(true); + const [videoVolume, setVideoVolume] = React.useState(0.8); + const [videoMuted, setVideoMuted] = React.useState(false); + const [videoBusy, setVideoBusy] = React.useState(false); + const showToast = (content: string, cssClass: string = 'e-toast-success') => { if (toastRef.current) { toastRef.current.show({ @@ -108,6 +116,30 @@ const Einstellungen: React.FC = () => { } }, []); + // Load video default settings (with fallbacks) + const loadVideoSettings = React.useCallback(async () => { + try { + const api = await import('./apiSystemSettings'); + const keys = ['video_autoplay', 'video_loop', 'video_volume', 'video_muted'] as const; + const [autoplay, loop, volume, muted] = await Promise.all( + keys.map(k => api.getSetting(k).catch(() => ({ value: null } as { value: string | null }))) + ); + setVideoAutoplay(autoplay.value == null ? true : autoplay.value === 'true'); + setVideoLoop(loop.value == null ? true : loop.value === 'true'); + const volParsed = volume.value == null ? 0.8 : parseFloat(String(volume.value)); + setVideoVolume(Number.isFinite(volParsed) ? volParsed : 0.8); + setVideoMuted(muted.value == null ? false : muted.value === 'true'); + } catch (e) { + // Fallback to defaults on any error + setVideoAutoplay(true); + setVideoLoop(true); + setVideoVolume(0.8); + setVideoMuted(false); + const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Video-Standards'; + showToast(msg, 'e-toast-warning'); + } + }, []); + const loadAcademicPeriods = React.useCallback(async () => { try { const [list, active] = await Promise.all([ @@ -130,9 +162,11 @@ const Einstellungen: React.FC = () => { // System settings only for admin/superadmin (will render only if allowed) if (['admin', 'superadmin'].includes(user.role)) { loadSupplementSettings(); + loadPresentationSettings(); + loadVideoSettings(); } } - }, [refresh, loadSupplementSettings, loadAcademicPeriods, user]); + }, [refresh, loadSupplementSettings, loadAcademicPeriods, loadPresentationSettings, loadVideoSettings, user]); const onUpload = async () => { if (!file) return; @@ -174,374 +208,571 @@ const Einstellungen: React.FC = () => { } }; + const onSaveVideoSettings = async () => { + setVideoBusy(true); + try { + const api = await import('./apiSystemSettings'); + await Promise.all([ + api.updateSetting('video_autoplay', videoAutoplay ? 'true' : 'false'), + api.updateSetting('video_loop', videoLoop ? 'true' : 'false'), + api.updateSetting('video_volume', String(videoVolume)), + api.updateSetting('video_muted', videoMuted ? 'true' : 'false'), + ]); + showToast('Video-Standardeinstellungen gespeichert', 'e-toast-success'); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Fehler beim Speichern der Video-Standards'; + showToast(msg, 'e-toast-danger'); + } finally { + setVideoBusy(false); + } + }; + // Determine which tabs to show based on role const isAdmin = !!(user && ['admin', 'superadmin'].includes(user.role)); const isSuperadmin = !!(user && user.role === 'superadmin'); + // Preserve selected nested-tab indices to avoid resets on parent re-render + const [academicTabIndex, setAcademicTabIndex] = React.useState(0); + const [displayTabIndex, setDisplayTabIndex] = React.useState(0); + const [mediaTabIndex, setMediaTabIndex] = React.useState(0); + const [eventsTabIndex, setEventsTabIndex] = React.useState(0); + const [usersTabIndex, setUsersTabIndex] = React.useState(0); + const [systemTabIndex, setSystemTabIndex] = React.useState(0); + + // ---------- Leaf content functions (second-level tabs) ---------- + // Academic Calendar + // (Old separate Import/List tab contents removed in favor of combined tab) + + // Combined Import + List tab content + const HolidaysImportAndListContent = () => ( +
+ {/* Import Card */} +
+
+
+
Schulferien importieren
+
+
+
+

+ Unterstützte Formate: +
• CSV mit Kopfzeile: name, start_date, end_date, optional region +
• TXT/CSV ohne Kopfzeile mit Spalten: interner Name, Name, Start (YYYYMMDD), Ende (YYYYMMDD), optional interne Info (ignoriert) +

+
+ setFile(e.target.files?.[0] ?? null)} /> + + {busy ? 'Importiere…' : 'CSV/TXT importieren'} + +
+ {message &&
{message}
} +
+
+ + {/* List Card */} +
+
+
+
Importierte Ferien
+
+
+
+ {holidays.length === 0 ? ( +
Keine Einträge vorhanden.
+ ) : ( +
    + {holidays.slice(0, 20).map(h => ( +
  • + {h.name}: {h.start_date} – {h.end_date} + {h.region ? ` (${h.region})` : ''} +
  • + ))} +
+ )} +
+
+
+ ); + + const AcademicPeriodsContent = () => ( +
+
+
+
+
Akademische Perioden
+
+
+
+ {periods.length === 0 ? ( +
Keine Perioden gefunden.
+ ) : ( +
+
+ setActivePeriodId(Number(e.value))} + placeholder="Aktive Periode wählen" + popupHeight="250px" + /> +
+ { + if (activePeriodId == null) return; + try { + const p = await setActiveAcademicPeriod(activePeriodId); + showToast(`Aktive Periode gesetzt: ${p.display_name || p.name}`, 'e-toast-success'); + await loadAcademicPeriods(); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Fehler beim Setzen der aktiven Periode'; + showToast(msg, 'e-toast-danger'); + } + }} + > + Als aktiv setzen + +
+ )} +
+
+
+ ); + + // Display & Clients + const DisplayDefaultsContent = () => ( +
+
+
+
+
Standard-Einstellungen
+
+
+
+ Platzhalter für Anzeige-Defaults (z. B. Heartbeat-Timeout, Screenshot-Aufbewahrung, Standard-Gruppe). +
+
+
+ ); + + const ClientsConfigContent = () => ( +
+
+
+
+
Client-Konfiguration
+
+
+
+
+ Infoscreen-Clients öffnen + Raumgruppen öffnen +
+
+
+
+ ); + + const UploadSettingsContent = () => ( +
+
+
+
+
Upload-Einstellungen
+
+
+
+ Platzhalter für Upload-Limits, erlaubte Dateitypen und Standardspeicherorte. +
+
+
+ ); + + const ConversionStatusContent = () => ( +
+
+
+
+
Konvertierungsstatus
+
+
+
+ Platzhalter – Konversionsstatus-Übersicht kann hier integriert werden (siehe API /api/conversions/...). +
+
+
+ ); + + // Events + const WebUntisSettingsContent = () => ( +
+
+
+
+
WebUntis / Vertretungsplan
+
+
+
+
+ + setSupplementUrl(e.value || '')} + cssClass="e-outline" + width="100%" + /> +
+ Diese URL wird verwendet, um die Stundenplan-Änderungstabelle (Vertretungsplan) anzuzeigen. +
+
+ +
+ setSupplementEnabled(e.checked || false)} + /> +
+ +
+ + {supplementBusy ? 'Speichere…' : 'Einstellungen speichern'} + + + Vorschau öffnen + +
+
+
+
+ ); + + const PresentationsDefaultsContent = () => ( +
+
+
+
+
Präsentationen
+
+
+
+
+ + setPresentationInterval(Number(e.value) || 10)} + placeholder="Intervall in Sekunden" + width="120px" + /> +
+
+ setPresentationPageProgress(e.checked || false)} + /> +
+
+ setPresentationAutoProgress(e.checked || false)} + /> +
+ + {presentationBusy ? 'Speichere…' : 'Einstellungen speichern'} + +
+
+
+ ); + + const WebsitesDefaultsContent = () => ( +
+
+
+
+
Webseiten
+
+
+
+ Platzhalter für Standardwerte (Lade-Timeout, Intervall, Sandbox/Embedding) für Webseiten. +
+
+
+ ); + + const VideosDefaultsContent = () => ( +
+
+
+
+
Videos
+
+
+
+
+ setVideoAutoplay(!!e.checked)} + /> +
+ +
+ setVideoLoop(!!e.checked)} + /> +
+ +
+ +
+ { + const v = typeof e.value === 'number' ? e.value : Number(e.value); + if (Number.isFinite(v)) setVideoVolume(Math.max(0, Math.min(1, v))); + }} + placeholder="0.0–1.0" + width="140px" + /> + setVideoMuted(!!e.checked)} + /> +
+
+ + + {videoBusy ? 'Speichere…' : 'Einstellungen speichern'} + +
+
+
+ ); + + const MessagesDefaultsContent = () => ( +
+
+
+
+
Mitteilungen
+
+
+
+ Platzhalter für Standardwerte (Dauer, Layout/Styling) für Mitteilungen. +
+
+
+ ); + + const OtherDefaultsContent = () => ( +
+
+
+
+
Sonstige
+
+
+
+ Platzhalter für sonstige Eventtypen. +
+
+
+ ); + + // Users + const UsersQuickActionsContent = () => ( +
+
+
+
+
Schnellaktionen
+
+
+
+
+ Benutzerverwaltung öffnen + Benutzer einladen +
+
+
+
+ ); + + // System + const OrganizationInfoContent = () => ( +
+
+
+
+
Organisationsinformationen
+
+
+
+ Platzhalter für Organisationsname, Branding, Standard-Lokalisierung. +
+
+
+ ); + + const AdvancedConfigContent = () => ( +
+
+
+
+
Erweiterte Konfiguration
+
+
+
+ Platzhalter für System-weit fortgeschrittene Optionen. +
+
+
+ ); + + // ---------- Nested Tab wrappers (first-level tabs -> second-level content) ---------- + const AcademicCalendarTabs = () => ( + setAcademicTabIndex(e.selectedIndex ?? 0)} + > + + + + + + ); + + const DisplayClientsTabs = () => ( + setDisplayTabIndex(e.selectedIndex ?? 0)} + > + + + + + + ); + + const MediaFilesTabs = () => ( + setMediaTabIndex(e.selectedIndex ?? 0)} + > + + + + + + ); + + const EventsTabs = () => ( + setEventsTabIndex(e.selectedIndex ?? 0)} + > + + + + + + + + + + ); + + const UsersTabs = () => ( + setUsersTabIndex(e.selectedIndex ?? 0)} + > + + + + + ); + + const SystemTabs = () => ( + setSystemTabIndex(e.selectedIndex ?? 0)} + > + + + + + + ); + + // ---------- Top-level (root) tabs ---------- return (
- +

Einstellungen

- + - {/* 📅 Academic Calendar */} - ( -
- {/* Holidays Import */} -
-
-
-
Schulferien importieren
-
-
-
-

- Unterstützte Formate: -
• CSV mit Kopfzeile: name, start_date, end_date, optional region -
• TXT/CSV ohne Kopfzeile mit Spalten: interner Name, Name, Start (YYYYMMDD), Ende (YYYYMMDD), optional interne Info (ignoriert) -

-
- setFile(e.target.files?.[0] ?? null)} /> - - {busy ? 'Importiere…' : 'CSV/TXT importieren'} - -
- {message &&
{message}
} -
-
- - {/* Imported Holidays List */} -
-
-
-
Importierte Ferien
-
-
-
- {holidays.length === 0 ? ( -
Keine Einträge vorhanden.
- ) : ( -
    - {holidays.slice(0, 20).map(h => ( -
  • - {h.name}: {h.start_date} – {h.end_date} - {h.region ? ` (${h.region})` : ''} -
  • - ))} -
- )} -
-
- - {/* Academic Periods */} -
-
-
-
Akademische Perioden
-
-
-
- {periods.length === 0 ? ( -
Keine Perioden gefunden.
- ) : ( -
-
- setActivePeriodId(Number(e.value))} - placeholder="Aktive Periode wählen" - popupHeight="250px" - /> -
- { - if (activePeriodId == null) return; - try { - const p = await setActiveAcademicPeriod(activePeriodId); - showToast(`Aktive Periode gesetzt: ${p.display_name || p.name}`, 'e-toast-success'); - await loadAcademicPeriods(); - } catch (e) { - const msg = e instanceof Error ? e.message : 'Fehler beim Setzen der aktiven Periode'; - showToast(msg, 'e-toast-danger'); - } - }} - > - Als aktiv setzen - -
- )} -
-
-
- )} /> - - {/* 🖥️ Display & Clients (Admin+) */} + {isAdmin && ( - ( -
-
-
-
-
Standard-Einstellungen
-
-
-
- Platzhalter für Anzeige-Defaults (z. B. Heartbeat-Timeout, Screenshot-Aufbewahrung, Standard-Gruppe). -
-
-
-
-
-
Client-Konfiguration
-
-
-
-
- Infoscreen-Clients öffnen - Raumgruppen öffnen -
-
-
-
- )} /> + )} - - {/* 🎬 Media & Files (Admin+) */} {isAdmin && ( - ( -
-
-
-
-
Upload-Einstellungen
-
-
-
- Platzhalter für Upload-Limits, erlaubte Dateitypen und Standardspeicherorte. -
-
-
-
-
-
Konvertierungsstatus
-
-
-
- Platzhalter – Konversionsstatus-Übersicht kann hier integriert werden (siehe API /api/conversions/...). -
-
-
- )} /> + )} - - {/* �️ Events (Admin+): per-event-type defaults and WebUntis link settings */} {isAdmin && ( - ( -
- {/* WebUntis / Supplement table URL */} -
-
-
-
WebUntis / Vertretungsplan
-
-
-
-
- - setSupplementUrl(e.value || '')} - cssClass="e-outline" - width="100%" - /> -
- Diese URL wird verwendet, um die Stundenplan-Änderungstabelle (Vertretungsplan) anzuzeigen. -
-
- -
- setSupplementEnabled(e.checked || false)} - /> -
- -
- - {supplementBusy ? 'Speichere…' : 'Einstellungen speichern'} - - - Vorschau öffnen - -
-
-
- - {/* Presentation defaults */} -
-
-
-
Präsentationen
-
-
-
-
- - setPresentationInterval(Number(e.value) || 10)} - placeholder="Intervall in Sekunden" - width="120px" - /> -
-
- setPresentationPageProgress(e.checked || false)} - /> -
-
- setPresentationAutoProgress(e.checked || false)} - /> -
- - {presentationBusy ? 'Speichere…' : 'Einstellungen speichern'} - -
-
- - {/* Website defaults */} -
-
-
-
Webseiten
-
-
-
- Platzhalter für Standardwerte (Lade-Timeout, Intervall, Sandbox/Embedding) für Webseiten. -
-
- - {/* Video defaults */} -
-
-
-
Videos
-
-
-
- Platzhalter für Standardwerte (Autoplay, Loop, Lautstärke) für Videos. -
-
- - {/* Message defaults */} -
-
-
-
Mitteilungen
-
-
-
- Platzhalter für Standardwerte (Dauer, Layout/Styling) für Mitteilungen. -
-
- - {/* Other defaults */} -
-
-
-
Sonstige
-
-
-
- Platzhalter für sonstige Eventtypen. -
-
-
- )} /> + )} - - {/* 👥 Users (Admin+) */} {isAdmin && ( - ( -
-
-
-
-
Schnellaktionen
-
-
-
-
- Benutzerverwaltung öffnen - Benutzer einladen -
-
-
-
- )} /> + )} - - {/* ⚙️ System (Superadmin) */} {isSuperadmin && ( - ( -
-
-
-
-
Organisationsinformationen
-
-
-
- Platzhalter für Organisationsname, Branding, Standard-Lokalisierung. -
-
- -
-
-
-
Erweiterte Konfiguration
-
-
-
- Platzhalter für System-weit fortgeschrittene Optionen. -
-
-
- )} /> + )}
diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index 34d4a56..2cc4125 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -20,6 +20,9 @@ export default defineConfig({ '@syncfusion/ej2-react-navigations', '@syncfusion/ej2-react-buttons', '@syncfusion/ej2-react-splitbuttons', + '@syncfusion/ej2-react-grids', + '@syncfusion/ej2-react-schedule', + '@syncfusion/ej2-react-filemanager', '@syncfusion/ej2-base', '@syncfusion/ej2-navigations', '@syncfusion/ej2-buttons', diff --git a/models/models.py b/models/models.py index 70d158d..3676c88 100644 --- a/models/models.py +++ b/models/models.py @@ -155,6 +155,7 @@ class Event(Base): autoplay = Column(Boolean, nullable=True) # NEU loop = Column(Boolean, nullable=True) # NEU volume = Column(Float, nullable=True) # NEU + muted = Column(Boolean, nullable=True) # NEU: Video mute slideshow_interval = Column(Integer, nullable=True) # NEU page_progress = Column(Boolean, nullable=True) # NEU: Seitenfortschritt (Page-Progress) auto_progress = Column(Boolean, nullable=True) # NEU: Präsentationsfortschritt (Auto-Progress) diff --git a/server/alembic/versions/21226a449037_add_muted_to_events.py b/server/alembic/versions/21226a449037_add_muted_to_events.py new file mode 100644 index 0000000..45151df --- /dev/null +++ b/server/alembic/versions/21226a449037_add_muted_to_events.py @@ -0,0 +1,30 @@ +"""add_muted_to_events + +Revision ID: 21226a449037 +Revises: 910951fd300a +Create Date: 2025-11-05 17:24:29.168692 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '21226a449037' +down_revision: Union[str, None] = '910951fd300a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Add muted column to events table for video mute control + op.add_column('events', sa.Column('muted', sa.Boolean(), nullable=True)) + + +def downgrade() -> None: + """Downgrade schema.""" + # Remove muted column + op.drop_column('events', 'muted') diff --git a/server/init_defaults.py b/server/init_defaults.py index db792a2..b783135 100644 --- a/server/init_defaults.py +++ b/server/init_defaults.py @@ -50,6 +50,9 @@ with engine.connect() as conn: ('presentation_interval', '10', 'Standard Intervall für Präsentationen (Sekunden)'), ('presentation_page_progress', 'true', 'Seitenfortschrift anzeigen (Page-Progress) für Präsentationen'), ('presentation_auto_progress', 'true', 'Automatischer Fortschritt (Auto-Progress) für Präsentationen'), + ('video_autoplay', 'true', 'Autoplay (automatisches Abspielen) für Videos'), + ('video_loop', 'true', 'Loop (Wiederholung) für Videos'), + ('video_volume', '0.8', 'Standard Lautstärke für Videos (0.0 - 1.0)'), ] for key, value, description in default_settings: diff --git a/server/routes/events.py b/server/routes/events.py index 4331449..396c13b 100644 --- a/server/routes/events.py +++ b/server/routes/events.py @@ -138,7 +138,14 @@ def get_event(event_id): "IsAllDay": False, # Assuming events are not all-day by default "MediaId": str(event.event_media_id) if event.event_media_id else None, "SlideshowInterval": event.slideshow_interval, + "PageProgress": event.page_progress, + "AutoProgress": event.auto_progress, "WebsiteUrl": event.event_media.url if event.event_media and hasattr(event.event_media, 'url') else None, + # Video-specific fields + "Autoplay": event.autoplay, + "Loop": event.loop, + "Volume": event.volume, + "Muted": event.muted, "RecurrenceRule": event.recurrence_rule, "RecurrenceEnd": event.recurrence_end.isoformat() if event.recurrence_end else None, "SkipHolidays": event.skip_holidays, @@ -398,6 +405,7 @@ def create_event(): autoplay = None loop = None volume = None + muted = None if event_type == "video": event_media_id = data.get("event_media_id") if not event_media_id: @@ -406,6 +414,7 @@ def create_event(): autoplay = data.get("autoplay", True) loop = data.get("loop", False) volume = data.get("volume", 0.8) + muted = data.get("muted", False) # created_by aus den Daten holen, Default: None created_by = data.get("created_by") @@ -435,6 +444,7 @@ def create_event(): autoplay=autoplay, loop=loop, volume=volume, + muted=muted, created_by=created_by, # Recurrence recurrence_rule=data.get("recurrence_rule"), @@ -514,6 +524,8 @@ def update_event(event_id): event.loop = data.get("loop") if "volume" in data: event.volume = data.get("volume") + if "muted" in data: + event.muted = data.get("muted") event.created_by = data.get("created_by", event.created_by) # Track previous values to decide on exception regeneration prev_rule = event.recurrence_rule