feat(video, settings, docs): add muted playback, nested Settings tabs, merge holidays tab; bump 2025.1.0-alpha.11
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
This commit is contained in:
23
.github/copilot-instructions.md
vendored
23
.github/copilot-instructions.md
vendored
@@ -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
|
- `server/routes/events.py` — event CRUD and recurrence handling
|
||||||
- `dashboard/src/components/CustomEventModal.tsx` — event creation UI
|
- `dashboard/src/components/CustomEventModal.tsx` — event creation UI
|
||||||
- `dashboard/src/media.tsx` — FileManager / upload settings
|
- `dashboard/src/media.tsx` — FileManager / upload settings
|
||||||
|
- `dashboard/src/settings.tsx` — settings UI (nested tabs; system defaults for presentations and videos)
|
||||||
|
|
||||||
|
|
||||||
## Big picture
|
## 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.
|
- 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.
|
- 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.
|
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
|
## 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_page_progress` ("true"/"false", default "true")
|
||||||
- `presentation_auto_progress` ("true"/"false", default "true")
|
- `presentation_auto_progress` ("true"/"false", default "true")
|
||||||
Seeded in `server/init_defaults.py` if missing.
|
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.
|
- 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`.
|
- 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:
|
- Conversions:
|
||||||
@@ -137,8 +150,8 @@ Small multi-service digital signage app (Flask API, React dashboard, MQTT schedu
|
|||||||
- Settings page (`dashboard/src/settings.tsx`):
|
- Settings page (`dashboard/src/settings.tsx`):
|
||||||
- Structure: Syncfusion TabComponent with role-gated tabs
|
- Structure: Syncfusion TabComponent with role-gated tabs
|
||||||
- 📅 Academic Calendar (all users)
|
- 📅 Academic Calendar (all users)
|
||||||
- School Holidays: CSV/TXT import and list
|
- 📥 Import & Liste: CSV/TXT import and list combined
|
||||||
- Academic Periods: select and set active period (uses `/api/academic_periods` routes)
|
- 🗂️ Perioden: select and set active period (uses `/api/academic_periods` routes)
|
||||||
- 🖥️ Display & Clients (admin+)
|
- 🖥️ Display & Clients (admin+)
|
||||||
- Default Settings: placeholders for heartbeat, screenshots, defaults
|
- Default Settings: placeholders for heartbeat, screenshots, defaults
|
||||||
- Client Configuration: quick links to Clients and Groups pages
|
- 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+)
|
- 🗓️ Events (admin+)
|
||||||
- WebUntis / Vertretungsplan: system-wide supplement table URL with enable/disable, save, and preview; persists via `/api/system-settings/supplement-table`
|
- 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).
|
- 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)
|
- ⚙️ System (superadmin)
|
||||||
- Organization Info and Advanced Configuration placeholders
|
- Organization Info and Advanced Configuration placeholders
|
||||||
- Role gating: Admin/Superadmin tabs are hidden if the user lacks permission; System is superadmin-only
|
- 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`.
|
- 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:
|
- User dropdown technical notes:
|
||||||
- Dependencies: `@syncfusion/ej2-react-splitbuttons` and `@syncfusion/ej2-splitbuttons` must be installed.
|
- 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`).
|
- 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.
|
- 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).
|
- 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.
|
- 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`).
|
- 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:
|
- When adding a new route:
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -98,3 +98,4 @@ dashboard/assets/responsive-sidebar.css
|
|||||||
certs/
|
certs/
|
||||||
sync.ffs_db
|
sync.ffs_db
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
|
dashboard/src/nested_tabs.js
|
||||||
|
|||||||
34
README.md
34
README.md
@@ -63,7 +63,7 @@ Data flow summary:
|
|||||||
### 🎯 **Event System**
|
### 🎯 **Event System**
|
||||||
- **Presentations**: PowerPoint/LibreOffice → PDF conversion via Gotenberg
|
- **Presentations**: PowerPoint/LibreOffice → PDF conversion via Gotenberg
|
||||||
- **Websites**: URL-based content display
|
- **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
|
- **Messages**: Text announcements
|
||||||
- **WebUntis**: Educational schedule integration
|
- **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.
|
- 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
|
# 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)**
|
4. **Initialize the database (first run only)**
|
||||||
```bash
|
```bash
|
||||||
# One-shot: runs all Alembic migrations, creates default admin/group, and seeds academic periods
|
# 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 and WebUntis events share a unified payload:
|
||||||
- `website`: `{ "type": "browser", "url": "https://..." }`
|
- `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.
|
- 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/<media_id>/<filename>`
|
||||||
|
|
||||||
## Recent changes since last commit
|
## 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/<media_id>/<filename>` so clients can seek during playback.
|
- 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/<media_id>/<filename>` 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.
|
- 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`).
|
- 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: `Event` includes new columns to store playback preferences (`autoplay`, `loop`, `volume`) and reference uploaded media via `event_media_id`.
|
- 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.
|
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
|
- `GET /api/files/converted/{path}` - Download converted PDFs
|
||||||
- `POST /api/conversions/{media_id}/pdf` - Request conversion
|
- `POST /api/conversions/{media_id}/pdf` - Request conversion
|
||||||
- `GET /api/conversions/{media_id}/status` - Check conversion status
|
- `GET /api/conversions/{media_id}/status` - Check conversion status
|
||||||
|
- `GET /api/eventmedia/stream/<media_id>/<filename>` - Stream media with byte-range support (206) for seeking
|
||||||
|
|
||||||
### System Settings
|
### System Settings
|
||||||
- `GET /api/system-settings` - List all system settings (admin+)
|
- `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_interval` (seconds, default "10")
|
||||||
- `presentation_page_progress` ("true"/"false", default "true")
|
- `presentation_page_progress` ("true"/"false", default "true")
|
||||||
- `presentation_auto_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
|
### Health & Monitoring
|
||||||
- `GET /health` - Service health check
|
- `GET /health` - Service health check
|
||||||
@@ -353,10 +375,12 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
|
|||||||
- **Events**: Schedule management
|
- **Events**: Schedule management
|
||||||
- **Media**: File upload and conversion
|
- **Media**: File upload and conversion
|
||||||
- **Settings**: Central configuration (tabbed)
|
- **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
|
- 🖥️ Display & Clients (admin+): Defaults placeholders and quick links to Clients/Groups
|
||||||
- 🎬 Media & Files (admin+): Upload settings placeholders and Conversion status overview
|
- 🎬 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
|
- ⚙️ System (superadmin): Organization info and Advanced configuration placeholders
|
||||||
- **Holidays**: Academic calendar management
|
- **Holidays**: Academic calendar management
|
||||||
- **Program info**: Version, build info, tech stack and paginated changelog (reads `dashboard/public/program-info.json`)
|
- **Program info**: Version, build info, tech stack and paginated changelog (reads `dashboard/public/program-info.json`)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"appName": "Infoscreen-Management",
|
"appName": "Infoscreen-Management",
|
||||||
"version": "2025.1.0-alpha.10",
|
"version": "2025.1.0-alpha.11",
|
||||||
"copyright": "© 2025 Third-Age-Applications",
|
"copyright": "© 2025 Third-Age-Applications",
|
||||||
"supportContact": "support@third-age-applications.com",
|
"supportContact": "support@third-age-applications.com",
|
||||||
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
|
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
|
||||||
"techStack": {
|
"techStack": {
|
||||||
"Frontend": "React, Vite, TypeScript",
|
"Frontend": "React, Vite, TypeScript, Syncfusion UI Components (Material 3)",
|
||||||
"Backend": "Python (Flask), SQLAlchemy",
|
"Backend": "Python (Flask), SQLAlchemy",
|
||||||
"Database": "MariaDB",
|
"Database": "MariaDB",
|
||||||
"Realtime": "Mosquitto (MQTT)",
|
"Realtime": "Mosquitto (MQTT)",
|
||||||
@@ -30,6 +30,17 @@
|
|||||||
"commitId": "9f2ae8b44c3a"
|
"commitId": "9f2ae8b44c3a"
|
||||||
},
|
},
|
||||||
"changelog": [
|
"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",
|
"version": "2025.1.0-alpha.10",
|
||||||
"date": "2025-10-25",
|
"date": "2025-10-25",
|
||||||
|
|||||||
@@ -63,7 +63,14 @@ type Event = {
|
|||||||
isHoliday?: boolean; // marker for styling/logic
|
isHoliday?: boolean; // marker for styling/logic
|
||||||
MediaId?: string | number;
|
MediaId?: string | number;
|
||||||
SlideshowInterval?: number;
|
SlideshowInterval?: number;
|
||||||
|
PageProgress?: boolean;
|
||||||
|
AutoProgress?: boolean;
|
||||||
WebsiteUrl?: string;
|
WebsiteUrl?: string;
|
||||||
|
// Video-specific fields
|
||||||
|
Autoplay?: boolean;
|
||||||
|
Loop?: boolean;
|
||||||
|
Volume?: number;
|
||||||
|
Muted?: boolean;
|
||||||
Icon?: string; // <--- Icon ergänzen!
|
Icon?: string; // <--- Icon ergänzen!
|
||||||
Type?: string; // <--- Typ ergänzen, falls benötigt
|
Type?: string; // <--- Typ ergänzen, falls benötigt
|
||||||
OccurrenceOfId?: string; // Serieninstanz
|
OccurrenceOfId?: string; // Serieninstanz
|
||||||
@@ -380,6 +387,14 @@ const Appointments: React.FC = () => {
|
|||||||
EndTime: parseEventDate(e.EndTime),
|
EndTime: parseEventDate(e.EndTime),
|
||||||
IsAllDay: e.IsAllDay,
|
IsAllDay: e.IsAllDay,
|
||||||
MediaId: e.MediaId,
|
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,
|
Icon: e.Icon,
|
||||||
Type: e.Type,
|
Type: e.Type,
|
||||||
OccurrenceOfId: e.OccurrenceOfId,
|
OccurrenceOfId: e.OccurrenceOfId,
|
||||||
@@ -398,6 +413,14 @@ const Appointments: React.FC = () => {
|
|||||||
EndTime: parseEventDate(e.EndTime),
|
EndTime: parseEventDate(e.EndTime),
|
||||||
IsAllDay: e.IsAllDay,
|
IsAllDay: e.IsAllDay,
|
||||||
MediaId: e.MediaId,
|
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,
|
Icon: e.Icon,
|
||||||
Type: e.Type,
|
Type: e.Type,
|
||||||
OccurrenceOfId: e.OccurrenceOfId,
|
OccurrenceOfId: e.OccurrenceOfId,
|
||||||
@@ -810,8 +833,6 @@ const Appointments: React.FC = () => {
|
|||||||
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
|
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
|
||||||
groupColor={selectedGroupId ? getGroupColor(selectedGroupId, groups) : undefined}
|
groupColor={selectedGroupId ? getGroupColor(selectedGroupId, groups) : undefined}
|
||||||
editMode={editMode} // NEU: Prop für Editiermodus
|
editMode={editMode} // NEU: Prop für Editiermodus
|
||||||
blockHolidays={!allowScheduleOnHolidays}
|
|
||||||
isHolidayRange={(s, e) => isWithinHolidayRange(s, e)}
|
|
||||||
/>
|
/>
|
||||||
<ScheduleComponent
|
<ScheduleComponent
|
||||||
ref={scheduleRef}
|
ref={scheduleRef}
|
||||||
@@ -1160,7 +1181,13 @@ const Appointments: React.FC = () => {
|
|||||||
skipHolidays: isSingleOccurrence ? false : (eventDataToUse.SkipHolidays ?? false),
|
skipHolidays: isSingleOccurrence ? false : (eventDataToUse.SkipHolidays ?? false),
|
||||||
media,
|
media,
|
||||||
slideshowInterval: eventDataToUse.SlideshowInterval ?? 10,
|
slideshowInterval: eventDataToUse.SlideshowInterval ?? 10,
|
||||||
|
pageProgress: eventDataToUse.PageProgress ?? true,
|
||||||
|
autoProgress: eventDataToUse.AutoProgress ?? true,
|
||||||
websiteUrl: eventDataToUse.WebsiteUrl ?? '',
|
websiteUrl: eventDataToUse.WebsiteUrl ?? '',
|
||||||
|
autoplay: eventDataToUse.Autoplay ?? true,
|
||||||
|
loop: eventDataToUse.Loop ?? true,
|
||||||
|
volume: eventDataToUse.Volume ?? 0.8,
|
||||||
|
muted: eventDataToUse.Muted ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
setModalInitialData(modalData);
|
setModalInitialData(modalData);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type CustomEventData = {
|
|||||||
autoplay?: boolean;
|
autoplay?: boolean;
|
||||||
loop?: boolean;
|
loop?: boolean;
|
||||||
volume?: number;
|
volume?: number;
|
||||||
|
muted?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Typ für initialData erweitern, damit Id unterstützt wird
|
// Typ für initialData erweitern, damit Id unterstützt wird
|
||||||
@@ -117,13 +118,50 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
);
|
);
|
||||||
const [websiteUrl, setWebsiteUrl] = React.useState<string>(initialData.websiteUrl ?? '');
|
const [websiteUrl, setWebsiteUrl] = React.useState<string>(initialData.websiteUrl ?? '');
|
||||||
|
|
||||||
// Video-specific state
|
// Video-specific state with system defaults loading
|
||||||
const [autoplay, setAutoplay] = React.useState<boolean>(initialData.autoplay ?? true);
|
const [autoplay, setAutoplay] = React.useState<boolean>(initialData.autoplay ?? true);
|
||||||
const [loop, setLoop] = React.useState<boolean>(initialData.loop ?? false);
|
const [loop, setLoop] = React.useState<boolean>(initialData.loop ?? true);
|
||||||
const [volume, setVolume] = React.useState<number>(initialData.volume ?? 0.8);
|
const [volume, setVolume] = React.useState<number>(initialData.volume ?? 0.8);
|
||||||
|
const [muted, setMuted] = React.useState<boolean>(initialData.muted ?? false);
|
||||||
|
const [videoDefaultsLoaded, setVideoDefaultsLoaded] = React.useState<boolean>(false);
|
||||||
|
|
||||||
const [mediaModalOpen, setMediaModalOpen] = 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(() => {
|
React.useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
const isSingleOccurrence = initialData.isSingleOccurrence || false;
|
const isSingleOccurrence = initialData.isSingleOccurrence || false;
|
||||||
@@ -154,12 +192,16 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
setPageProgress(initialData.pageProgress ?? true);
|
setPageProgress(initialData.pageProgress ?? true);
|
||||||
setAutoProgress(initialData.autoProgress ?? true);
|
setAutoProgress(initialData.autoProgress ?? true);
|
||||||
setWebsiteUrl(initialData.websiteUrl ?? '');
|
setWebsiteUrl(initialData.websiteUrl ?? '');
|
||||||
// Video fields
|
|
||||||
setAutoplay(initialData.autoplay ?? true);
|
// Video fields - use initialData values when editing
|
||||||
setLoop(initialData.loop ?? false);
|
if (editMode) {
|
||||||
setVolume(initialData.volume ?? 0.8);
|
setAutoplay(initialData.autoplay ?? true);
|
||||||
|
setLoop(initialData.loop ?? true);
|
||||||
|
setVolume(initialData.volume ?? 0.8);
|
||||||
|
setMuted(initialData.muted ?? false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [open, initialData]);
|
}, [open, initialData, editMode]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!mediaModalOpen && pendingMedia) {
|
if (!mediaModalOpen && pendingMedia) {
|
||||||
@@ -296,6 +338,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
payload.autoplay = autoplay;
|
payload.autoplay = autoplay;
|
||||||
payload.loop = loop;
|
payload.loop = loop;
|
||||||
payload.volume = volume;
|
payload.volume = volume;
|
||||||
|
payload.muted = muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -664,13 +707,24 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<TextBoxComponent
|
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500, fontSize: '14px' }}>
|
||||||
placeholder="Lautstärke (0.0 - 1.0)"
|
Lautstärke
|
||||||
floatLabelType="Auto"
|
</label>
|
||||||
type="number"
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
value={String(volume)}
|
<TextBoxComponent
|
||||||
change={e => setVolume(Math.max(0, Math.min(1, Number(e.value))))}
|
placeholder="0.0 - 1.0"
|
||||||
/>
|
floatLabelType="Never"
|
||||||
|
type="number"
|
||||||
|
value={String(volume)}
|
||||||
|
change={e => setVolume(Math.max(0, Math.min(1, Number(e.value))))}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<CheckBoxComponent
|
||||||
|
label="Ton aus"
|
||||||
|
checked={muted}
|
||||||
|
change={e => setMuted(e.checked || false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { TabComponent, TabItemDirective, TabItemsDirective } from '@syncfusion/ej2-react-navigations';
|
import { TabComponent, TabItemDirective, TabItemsDirective } from '@syncfusion/ej2-react-navigations';
|
||||||
import { NumericTextBoxComponent } from '@syncfusion/ej2-react-inputs';
|
import { NumericTextBoxComponent, TextBoxComponent } from '@syncfusion/ej2-react-inputs';
|
||||||
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
|
import { ButtonComponent, CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
|
||||||
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
|
|
||||||
import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
|
|
||||||
import { ToastComponent } from '@syncfusion/ej2-react-notifications';
|
import { ToastComponent } from '@syncfusion/ej2-react-notifications';
|
||||||
import { listHolidays, uploadHolidaysCsv, type Holiday } from './apiHolidays';
|
import { listHolidays, uploadHolidaysCsv, type Holiday } from './apiHolidays';
|
||||||
import { getSupplementTableSettings, updateSupplementTableSettings } from './apiSystemSettings';
|
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 { listAcademicPeriods, getActiveAcademicPeriod, setActiveAcademicPeriod, type AcademicPeriod } from './apiAcademicPeriods';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
// Minimal event type for Syncfusion Tab 'selected' callback
|
||||||
|
type TabSelectedEvent = { selectedIndex?: number };
|
||||||
|
|
||||||
const Einstellungen: React.FC = () => {
|
const Einstellungen: React.FC = () => {
|
||||||
// Presentation settings state
|
// Presentation settings state
|
||||||
const [presentationInterval, setPresentationInterval] = React.useState(10);
|
const [presentationInterval, setPresentationInterval] = React.useState(10);
|
||||||
@@ -76,6 +77,13 @@ const Einstellungen: React.FC = () => {
|
|||||||
const [supplementEnabled, setSupplementEnabled] = React.useState(false);
|
const [supplementEnabled, setSupplementEnabled] = React.useState(false);
|
||||||
const [supplementBusy, setSupplementBusy] = React.useState(false);
|
const [supplementBusy, setSupplementBusy] = React.useState(false);
|
||||||
|
|
||||||
|
// Video defaults state (Admin+)
|
||||||
|
const [videoAutoplay, setVideoAutoplay] = React.useState<boolean>(true);
|
||||||
|
const [videoLoop, setVideoLoop] = React.useState<boolean>(true);
|
||||||
|
const [videoVolume, setVideoVolume] = React.useState<number>(0.8);
|
||||||
|
const [videoMuted, setVideoMuted] = React.useState<boolean>(false);
|
||||||
|
const [videoBusy, setVideoBusy] = React.useState<boolean>(false);
|
||||||
|
|
||||||
const showToast = (content: string, cssClass: string = 'e-toast-success') => {
|
const showToast = (content: string, cssClass: string = 'e-toast-success') => {
|
||||||
if (toastRef.current) {
|
if (toastRef.current) {
|
||||||
toastRef.current.show({
|
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 () => {
|
const loadAcademicPeriods = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [list, active] = await Promise.all([
|
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)
|
// System settings only for admin/superadmin (will render only if allowed)
|
||||||
if (['admin', 'superadmin'].includes(user.role)) {
|
if (['admin', 'superadmin'].includes(user.role)) {
|
||||||
loadSupplementSettings();
|
loadSupplementSettings();
|
||||||
|
loadPresentationSettings();
|
||||||
|
loadVideoSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [refresh, loadSupplementSettings, loadAcademicPeriods, user]);
|
}, [refresh, loadSupplementSettings, loadAcademicPeriods, loadPresentationSettings, loadVideoSettings, user]);
|
||||||
|
|
||||||
const onUpload = async () => {
|
const onUpload = async () => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -174,10 +208,548 @@ 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
|
// Determine which tabs to show based on role
|
||||||
const isAdmin = !!(user && ['admin', 'superadmin'].includes(user.role));
|
const isAdmin = !!(user && ['admin', 'superadmin'].includes(user.role));
|
||||||
const isSuperadmin = !!(user && user.role === 'superadmin');
|
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 = () => (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
{/* Import Card */}
|
||||||
|
<div className="e-card" style={{ marginBottom: 20 }}>
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">Schulferien importieren</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content">
|
||||||
|
<p style={{ marginBottom: 12, fontSize: '14px', color: '#666' }}>
|
||||||
|
Unterstützte Formate:
|
||||||
|
<br />• CSV mit Kopfzeile: <code>name</code>, <code>start_date</code>, <code>end_date</code>, optional <code>region</code>
|
||||||
|
<br />• TXT/CSV ohne Kopfzeile mit Spalten: interner Name, <strong>Name</strong>, <strong>Start (YYYYMMDD)</strong>, <strong>Ende (YYYYMMDD)</strong>, optional interne Info (ignoriert)
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||||
|
<input type="file" accept=".csv,text/csv,.txt,text/plain" onChange={e => setFile(e.target.files?.[0] ?? null)} />
|
||||||
|
<ButtonComponent cssClass="e-primary" onClick={onUpload} disabled={!file || busy}>
|
||||||
|
{busy ? 'Importiere…' : 'CSV/TXT importieren'}
|
||||||
|
</ButtonComponent>
|
||||||
|
</div>
|
||||||
|
{message && <div style={{ marginTop: 8, fontSize: '14px' }}>{message}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List Card */}
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">Importierte Ferien</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content">
|
||||||
|
{holidays.length === 0 ? (
|
||||||
|
<div style={{ fontSize: '14px', color: '#666' }}>Keine Einträge vorhanden.</div>
|
||||||
|
) : (
|
||||||
|
<ul style={{ fontSize: '14px', listStyle: 'disc', paddingLeft: 24 }}>
|
||||||
|
{holidays.slice(0, 20).map(h => (
|
||||||
|
<li key={h.id}>
|
||||||
|
{h.name}: {h.start_date} – {h.end_date}
|
||||||
|
{h.region ? ` (${h.region})` : ''}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AcademicPeriodsContent = () => (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">Akademische Perioden</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content">
|
||||||
|
{periods.length === 0 ? (
|
||||||
|
<div style={{ fontSize: '14px', color: '#666' }}>Keine Perioden gefunden.</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ minWidth: 260 }}>
|
||||||
|
<DropDownListComponent
|
||||||
|
dataSource={periodOptions}
|
||||||
|
fields={{ text: 'name', value: 'id' }}
|
||||||
|
value={activePeriodId ?? undefined}
|
||||||
|
change={(e) => setActivePeriodId(Number(e.value))}
|
||||||
|
placeholder="Aktive Periode wählen"
|
||||||
|
popupHeight="250px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass="e-primary"
|
||||||
|
disabled={activePeriodId == null}
|
||||||
|
onClick={async () => {
|
||||||
|
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
|
||||||
|
</ButtonComponent>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display & Clients
|
||||||
|
const DisplayDefaultsContent = () => (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">Standard-Einstellungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
|
||||||
|
Platzhalter für Anzeige-Defaults (z. B. Heartbeat-Timeout, Screenshot-Aufbewahrung, Standard-Gruppe).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ClientsConfigContent = () => (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">Client-Konfiguration</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content">
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<Link to="/clients"><ButtonComponent>Infoscreen-Clients öffnen</ButtonComponent></Link>
|
||||||
|
<Link to="/infoscr_groups"><ButtonComponent>Raumgruppen öffnen</ButtonComponent></Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const UploadSettingsContent = () => (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">Upload-Einstellungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
|
||||||
|
Platzhalter für Upload-Limits, erlaubte Dateitypen und Standardspeicherorte.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ConversionStatusContent = () => (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">Konvertierungsstatus</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
|
||||||
|
Platzhalter – Konversionsstatus-Übersicht kann hier integriert werden (siehe API /api/conversions/...).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Events
|
||||||
|
const WebUntisSettingsContent = () => (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">WebUntis / Vertretungsplan</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content">
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
|
||||||
|
Vertretungsplan URL
|
||||||
|
</label>
|
||||||
|
<TextBoxComponent
|
||||||
|
placeholder="https://example.com/vertretungsplan"
|
||||||
|
value={supplementUrl}
|
||||||
|
change={(e) => setSupplementUrl(e.value || '')}
|
||||||
|
cssClass="e-outline"
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666', marginTop: 4 }}>
|
||||||
|
Diese URL wird verwendet, um die Stundenplan-Änderungstabelle (Vertretungsplan) anzuzeigen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<CheckBoxComponent
|
||||||
|
label="Vertretungsplan aktiviert"
|
||||||
|
checked={supplementEnabled}
|
||||||
|
change={(e) => setSupplementEnabled(e.checked || false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass="e-primary"
|
||||||
|
onClick={onSaveSupplementSettings}
|
||||||
|
disabled={supplementBusy}
|
||||||
|
>
|
||||||
|
{supplementBusy ? 'Speichere…' : 'Einstellungen speichern'}
|
||||||
|
</ButtonComponent>
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass="e-outline"
|
||||||
|
onClick={onTestSupplementUrl}
|
||||||
|
disabled={!supplementUrl}
|
||||||
|
>
|
||||||
|
Vorschau öffnen
|
||||||
|
</ButtonComponent>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PresentationsDefaultsContent = () => (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">Präsentationen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content" style={{ fontSize: 14 }}>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
|
||||||
|
Slide-Show Intervall (Sekunden)
|
||||||
|
</label>
|
||||||
|
<NumericTextBoxComponent
|
||||||
|
min={1}
|
||||||
|
max={600}
|
||||||
|
step={1}
|
||||||
|
value={presentationInterval}
|
||||||
|
format="n0"
|
||||||
|
change={(e: { value: number }) => setPresentationInterval(Number(e.value) || 10)}
|
||||||
|
placeholder="Intervall in Sekunden"
|
||||||
|
width="120px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<CheckBoxComponent
|
||||||
|
label="Seitenfortschritt anzeigen (Fortschrittsbalken)"
|
||||||
|
checked={presentationPageProgress}
|
||||||
|
change={e => setPresentationPageProgress(e.checked || false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<CheckBoxComponent
|
||||||
|
label="Präsentationsfortschritt anzeigen (Fortschrittsbalken)"
|
||||||
|
checked={presentationAutoProgress}
|
||||||
|
change={e => setPresentationAutoProgress(e.checked || false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass="e-primary"
|
||||||
|
onClick={onSavePresentationSettings}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
disabled={presentationBusy}
|
||||||
|
>
|
||||||
|
{presentationBusy ? 'Speichere…' : 'Einstellungen speichern'}
|
||||||
|
</ButtonComponent>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const WebsitesDefaultsContent = () => (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">Webseiten</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
|
||||||
|
Platzhalter für Standardwerte (Lade-Timeout, Intervall, Sandbox/Embedding) für Webseiten.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const VideosDefaultsContent = () => (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">Videos</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content" style={{ fontSize: 14 }}>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<CheckBoxComponent
|
||||||
|
label="Automatisch abspielen"
|
||||||
|
checked={videoAutoplay}
|
||||||
|
change={e => setVideoAutoplay(!!e.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<CheckBoxComponent
|
||||||
|
label="In Schleife abspielen"
|
||||||
|
checked={videoLoop}
|
||||||
|
change={e => setVideoLoop(!!e.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>Lautstärke</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<NumericTextBoxComponent
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={videoVolume}
|
||||||
|
format="n2"
|
||||||
|
change={(e: { value: number }) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<CheckBoxComponent
|
||||||
|
label="Ton aus"
|
||||||
|
checked={videoMuted}
|
||||||
|
change={e => setVideoMuted(!!e.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ButtonComponent
|
||||||
|
cssClass="e-primary"
|
||||||
|
onClick={onSaveVideoSettings}
|
||||||
|
disabled={videoBusy}
|
||||||
|
>
|
||||||
|
{videoBusy ? 'Speichere…' : 'Einstellungen speichern'}
|
||||||
|
</ButtonComponent>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MessagesDefaultsContent = () => (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">Mitteilungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
|
||||||
|
Platzhalter für Standardwerte (Dauer, Layout/Styling) für Mitteilungen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const OtherDefaultsContent = () => (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">Sonstige</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
|
||||||
|
Platzhalter für sonstige Eventtypen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Users
|
||||||
|
const UsersQuickActionsContent = () => (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">Schnellaktionen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content">
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<Link to="/benutzer"><ButtonComponent cssClass="e-primary">Benutzerverwaltung öffnen</ButtonComponent></Link>
|
||||||
|
<ButtonComponent disabled title="Demnächst">Benutzer einladen</ButtonComponent>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// System
|
||||||
|
const OrganizationInfoContent = () => (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">Organisationsinformationen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
|
||||||
|
Platzhalter für Organisationsname, Branding, Standard-Lokalisierung.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AdvancedConfigContent = () => (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<div className="e-card">
|
||||||
|
<div className="e-card-header">
|
||||||
|
<div className="e-card-header-caption">
|
||||||
|
<div className="e-card-header-title">Erweiterte Konfiguration</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
|
||||||
|
Platzhalter für System-weit fortgeschrittene Optionen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- Nested Tab wrappers (first-level tabs -> second-level content) ----------
|
||||||
|
const AcademicCalendarTabs = () => (
|
||||||
|
<TabComponent
|
||||||
|
heightAdjustMode="Auto"
|
||||||
|
selectedItem={academicTabIndex}
|
||||||
|
selected={(e: TabSelectedEvent) => setAcademicTabIndex(e.selectedIndex ?? 0)}
|
||||||
|
>
|
||||||
|
<TabItemsDirective>
|
||||||
|
<TabItemDirective header={{ text: '📥 Import & Liste' }} content={HolidaysImportAndListContent} />
|
||||||
|
<TabItemDirective header={{ text: '🗂️ Perioden' }} content={AcademicPeriodsContent} />
|
||||||
|
</TabItemsDirective>
|
||||||
|
</TabComponent>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DisplayClientsTabs = () => (
|
||||||
|
<TabComponent
|
||||||
|
heightAdjustMode="Auto"
|
||||||
|
selectedItem={displayTabIndex}
|
||||||
|
selected={(e: TabSelectedEvent) => setDisplayTabIndex(e.selectedIndex ?? 0)}
|
||||||
|
>
|
||||||
|
<TabItemsDirective>
|
||||||
|
<TabItemDirective header={{ text: '⚙️ Defaults' }} content={DisplayDefaultsContent} />
|
||||||
|
<TabItemDirective header={{ text: '🖥️ Clients' }} content={ClientsConfigContent} />
|
||||||
|
</TabItemsDirective>
|
||||||
|
</TabComponent>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MediaFilesTabs = () => (
|
||||||
|
<TabComponent
|
||||||
|
heightAdjustMode="Auto"
|
||||||
|
selectedItem={mediaTabIndex}
|
||||||
|
selected={(e: TabSelectedEvent) => setMediaTabIndex(e.selectedIndex ?? 0)}
|
||||||
|
>
|
||||||
|
<TabItemsDirective>
|
||||||
|
<TabItemDirective header={{ text: '⬆️ Upload' }} content={UploadSettingsContent} />
|
||||||
|
<TabItemDirective header={{ text: '🔄 Konvertierung' }} content={ConversionStatusContent} />
|
||||||
|
</TabItemsDirective>
|
||||||
|
</TabComponent>
|
||||||
|
);
|
||||||
|
|
||||||
|
const EventsTabs = () => (
|
||||||
|
<TabComponent
|
||||||
|
heightAdjustMode="Auto"
|
||||||
|
selectedItem={eventsTabIndex}
|
||||||
|
selected={(e: TabSelectedEvent) => setEventsTabIndex(e.selectedIndex ?? 0)}
|
||||||
|
>
|
||||||
|
<TabItemsDirective>
|
||||||
|
<TabItemDirective header={{ text: '📘 WebUntis' }} content={WebUntisSettingsContent} />
|
||||||
|
<TabItemDirective header={{ text: '🖼️ Präsentation' }} content={PresentationsDefaultsContent} />
|
||||||
|
<TabItemDirective header={{ text: '🌐 Webseiten' }} content={WebsitesDefaultsContent} />
|
||||||
|
<TabItemDirective header={{ text: '🎬 Videos' }} content={VideosDefaultsContent} />
|
||||||
|
<TabItemDirective header={{ text: '💬 Mitteilungen' }} content={MessagesDefaultsContent} />
|
||||||
|
<TabItemDirective header={{ text: '🔧 Sonstige' }} content={OtherDefaultsContent} />
|
||||||
|
</TabItemsDirective>
|
||||||
|
</TabComponent>
|
||||||
|
);
|
||||||
|
|
||||||
|
const UsersTabs = () => (
|
||||||
|
<TabComponent
|
||||||
|
heightAdjustMode="Auto"
|
||||||
|
selectedItem={usersTabIndex}
|
||||||
|
selected={(e: TabSelectedEvent) => setUsersTabIndex(e.selectedIndex ?? 0)}
|
||||||
|
>
|
||||||
|
<TabItemsDirective>
|
||||||
|
<TabItemDirective header={{ text: '⚡ Schnellaktionen' }} content={UsersQuickActionsContent} />
|
||||||
|
</TabItemsDirective>
|
||||||
|
</TabComponent>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SystemTabs = () => (
|
||||||
|
<TabComponent
|
||||||
|
heightAdjustMode="Auto"
|
||||||
|
selectedItem={systemTabIndex}
|
||||||
|
selected={(e: TabSelectedEvent) => setSystemTabIndex(e.selectedIndex ?? 0)}
|
||||||
|
>
|
||||||
|
<TabItemsDirective>
|
||||||
|
<TabItemDirective header={{ text: '🏢 Organisation' }} content={OrganizationInfoContent} />
|
||||||
|
<TabItemDirective header={{ text: '🧩 Erweiterte Optionen' }} content={AdvancedConfigContent} />
|
||||||
|
</TabItemsDirective>
|
||||||
|
</TabComponent>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- Top-level (root) tabs ----------
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 20 }}>
|
<div style={{ padding: 20 }}>
|
||||||
<ToastComponent ref={toastRef} position={{ X: 'Right', Y: 'Top' }} />
|
<ToastComponent ref={toastRef} position={{ X: 'Right', Y: 'Top' }} />
|
||||||
@@ -186,362 +758,21 @@ const Einstellungen: React.FC = () => {
|
|||||||
|
|
||||||
<TabComponent heightAdjustMode="Auto">
|
<TabComponent heightAdjustMode="Auto">
|
||||||
<TabItemsDirective>
|
<TabItemsDirective>
|
||||||
{/* 📅 Academic Calendar */}
|
<TabItemDirective header={{ text: '📅 Akademischer Kalender' }} content={AcademicCalendarTabs} />
|
||||||
<TabItemDirective header={{ text: '📅 Akademischer Kalender' }} content={() => (
|
|
||||||
<div style={{ padding: 20 }}>
|
|
||||||
{/* Holidays Import */}
|
|
||||||
<div className="e-card" style={{ marginBottom: 20 }}>
|
|
||||||
<div className="e-card-header">
|
|
||||||
<div className="e-card-header-caption">
|
|
||||||
<div className="e-card-header-title">Schulferien importieren</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="e-card-content">
|
|
||||||
<p style={{ marginBottom: 12, fontSize: '14px', color: '#666' }}>
|
|
||||||
Unterstützte Formate:
|
|
||||||
<br />• CSV mit Kopfzeile: <code>name</code>, <code>start_date</code>, <code>end_date</code>, optional <code>region</code>
|
|
||||||
<br />• TXT/CSV ohne Kopfzeile mit Spalten: interner Name, <strong>Name</strong>, <strong>Start (YYYYMMDD)</strong>, <strong>Ende (YYYYMMDD)</strong>, optional interne Info (ignoriert)
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
|
||||||
<input type="file" accept=".csv,text/csv,.txt,text/plain" onChange={e => setFile(e.target.files?.[0] ?? null)} />
|
|
||||||
<ButtonComponent cssClass="e-primary" onClick={onUpload} disabled={!file || busy}>
|
|
||||||
{busy ? 'Importiere…' : 'CSV/TXT importieren'}
|
|
||||||
</ButtonComponent>
|
|
||||||
</div>
|
|
||||||
{message && <div style={{ marginTop: 8, fontSize: '14px' }}>{message}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Imported Holidays List */}
|
|
||||||
<div className="e-card" style={{ marginBottom: 20 }}>
|
|
||||||
<div className="e-card-header">
|
|
||||||
<div className="e-card-header-caption">
|
|
||||||
<div className="e-card-header-title">Importierte Ferien</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="e-card-content">
|
|
||||||
{holidays.length === 0 ? (
|
|
||||||
<div style={{ fontSize: '14px', color: '#666' }}>Keine Einträge vorhanden.</div>
|
|
||||||
) : (
|
|
||||||
<ul style={{ fontSize: '14px', listStyle: 'disc', paddingLeft: 24 }}>
|
|
||||||
{holidays.slice(0, 20).map(h => (
|
|
||||||
<li key={h.id}>
|
|
||||||
{h.name}: {h.start_date} – {h.end_date}
|
|
||||||
{h.region ? ` (${h.region})` : ''}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Academic Periods */}
|
|
||||||
<div className="e-card">
|
|
||||||
<div className="e-card-header">
|
|
||||||
<div className="e-card-header-caption">
|
|
||||||
<div className="e-card-header-title">Akademische Perioden</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="e-card-content">
|
|
||||||
{periods.length === 0 ? (
|
|
||||||
<div style={{ fontSize: '14px', color: '#666' }}>Keine Perioden gefunden.</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
||||||
<div style={{ minWidth: 260 }}>
|
|
||||||
<DropDownListComponent
|
|
||||||
dataSource={periodOptions}
|
|
||||||
fields={{ text: 'name', value: 'id' }}
|
|
||||||
value={activePeriodId ?? undefined}
|
|
||||||
change={(e) => setActivePeriodId(Number(e.value))}
|
|
||||||
placeholder="Aktive Periode wählen"
|
|
||||||
popupHeight="250px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ButtonComponent
|
|
||||||
cssClass="e-primary"
|
|
||||||
disabled={activePeriodId == null}
|
|
||||||
onClick={async () => {
|
|
||||||
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
|
|
||||||
</ButtonComponent>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)} />
|
|
||||||
|
|
||||||
{/* 🖥️ Display & Clients (Admin+) */}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<TabItemDirective header={{ text: '🖥️ Anzeige & Clients' }} content={() => (
|
<TabItemDirective header={{ text: '🖥️ Anzeige & Clients' }} content={DisplayClientsTabs} />
|
||||||
<div style={{ padding: 20 }}>
|
|
||||||
<div className="e-card" style={{ marginBottom: 20 }}>
|
|
||||||
<div className="e-card-header">
|
|
||||||
<div className="e-card-header-caption">
|
|
||||||
<div className="e-card-header-title">Standard-Einstellungen</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
|
|
||||||
Platzhalter für Anzeige-Defaults (z. B. Heartbeat-Timeout, Screenshot-Aufbewahrung, Standard-Gruppe).
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="e-card">
|
|
||||||
<div className="e-card-header">
|
|
||||||
<div className="e-card-header-caption">
|
|
||||||
<div className="e-card-header-title">Client-Konfiguration</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="e-card-content">
|
|
||||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
|
||||||
<Link to="/clients"><ButtonComponent>Infoscreen-Clients öffnen</ButtonComponent></Link>
|
|
||||||
<Link to="/infoscr_groups"><ButtonComponent>Raumgruppen öffnen</ButtonComponent></Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)} />
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 🎬 Media & Files (Admin+) */}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<TabItemDirective header={{ text: '🎬 Medien & Dateien' }} content={() => (
|
<TabItemDirective header={{ text: '🎬 Medien & Dateien' }} content={MediaFilesTabs} />
|
||||||
<div style={{ padding: 20 }}>
|
|
||||||
<div className="e-card" style={{ marginBottom: 20 }}>
|
|
||||||
<div className="e-card-header">
|
|
||||||
<div className="e-card-header-caption">
|
|
||||||
<div className="e-card-header-title">Upload-Einstellungen</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
|
|
||||||
Platzhalter für Upload-Limits, erlaubte Dateitypen und Standardspeicherorte.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="e-card">
|
|
||||||
<div className="e-card-header">
|
|
||||||
<div className="e-card-header-caption">
|
|
||||||
<div className="e-card-header-title">Konvertierungsstatus</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
|
|
||||||
Platzhalter – Konversionsstatus-Übersicht kann hier integriert werden (siehe API /api/conversions/...).
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)} />
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* <20>️ Events (Admin+): per-event-type defaults and WebUntis link settings */}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<TabItemDirective header={{ text: '<EFBFBD>️ Events' }} content={() => (
|
<TabItemDirective header={{ text: '🗓️ Events' }} content={EventsTabs} />
|
||||||
<div style={{ padding: 20 }}>
|
|
||||||
{/* WebUntis / Supplement table URL */}
|
|
||||||
<div className="e-card" style={{ marginBottom: 20 }}>
|
|
||||||
<div className="e-card-header">
|
|
||||||
<div className="e-card-header-caption">
|
|
||||||
<div className="e-card-header-title">WebUntis / Vertretungsplan</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="e-card-content">
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
|
|
||||||
Vertretungsplan URL
|
|
||||||
</label>
|
|
||||||
<TextBoxComponent
|
|
||||||
placeholder="https://example.com/vertretungsplan"
|
|
||||||
value={supplementUrl}
|
|
||||||
change={(e) => setSupplementUrl(e.value || '')}
|
|
||||||
cssClass="e-outline"
|
|
||||||
width="100%"
|
|
||||||
/>
|
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginTop: 4 }}>
|
|
||||||
Diese URL wird verwendet, um die Stundenplan-Änderungstabelle (Vertretungsplan) anzuzeigen.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<CheckBoxComponent
|
|
||||||
label="Vertretungsplan aktiviert"
|
|
||||||
checked={supplementEnabled}
|
|
||||||
change={(e) => setSupplementEnabled(e.checked || false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
|
||||||
<ButtonComponent
|
|
||||||
cssClass="e-primary"
|
|
||||||
onClick={onSaveSupplementSettings}
|
|
||||||
disabled={supplementBusy}
|
|
||||||
>
|
|
||||||
{supplementBusy ? 'Speichere…' : 'Einstellungen speichern'}
|
|
||||||
</ButtonComponent>
|
|
||||||
<ButtonComponent
|
|
||||||
cssClass="e-outline"
|
|
||||||
onClick={onTestSupplementUrl}
|
|
||||||
disabled={!supplementUrl}
|
|
||||||
>
|
|
||||||
Vorschau öffnen
|
|
||||||
</ButtonComponent>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Presentation defaults */}
|
|
||||||
<div className="e-card" style={{ marginBottom: 20 }}>
|
|
||||||
<div className="e-card-header">
|
|
||||||
<div className="e-card-header-caption">
|
|
||||||
<div className="e-card-header-title">Präsentationen</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="e-card-content" style={{ fontSize: 14 }}>
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
|
|
||||||
Slide-Show Intervall (Sekunden)
|
|
||||||
</label>
|
|
||||||
<NumericTextBoxComponent
|
|
||||||
min={1}
|
|
||||||
max={600}
|
|
||||||
step={1}
|
|
||||||
value={presentationInterval}
|
|
||||||
format="n0"
|
|
||||||
change={(e: { value: number }) => setPresentationInterval(Number(e.value) || 10)}
|
|
||||||
placeholder="Intervall in Sekunden"
|
|
||||||
width="120px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<CheckBoxComponent
|
|
||||||
label="Seitenfortschritt anzeigen (Fortschrittsbalken)"
|
|
||||||
checked={presentationPageProgress}
|
|
||||||
change={e => setPresentationPageProgress(e.checked || false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<CheckBoxComponent
|
|
||||||
label="Präsentationsfortschritt anzeigen (Fortschrittsbalken)"
|
|
||||||
checked={presentationAutoProgress}
|
|
||||||
change={e => setPresentationAutoProgress(e.checked || false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ButtonComponent
|
|
||||||
cssClass="e-primary"
|
|
||||||
onClick={onSavePresentationSettings}
|
|
||||||
style={{ marginTop: 8 }}
|
|
||||||
disabled={presentationBusy}
|
|
||||||
>
|
|
||||||
{presentationBusy ? 'Speichere…' : 'Einstellungen speichern'}
|
|
||||||
</ButtonComponent>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Website defaults */}
|
|
||||||
<div className="e-card" style={{ marginBottom: 20 }}>
|
|
||||||
<div className="e-card-header">
|
|
||||||
<div className="e-card-header-caption">
|
|
||||||
<div className="e-card-header-title">Webseiten</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
|
|
||||||
Platzhalter für Standardwerte (Lade-Timeout, Intervall, Sandbox/Embedding) für Webseiten.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Video defaults */}
|
|
||||||
<div className="e-card" style={{ marginBottom: 20 }}>
|
|
||||||
<div className="e-card-header">
|
|
||||||
<div className="e-card-header-caption">
|
|
||||||
<div className="e-card-header-title">Videos</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
|
|
||||||
Platzhalter für Standardwerte (Autoplay, Loop, Lautstärke) für Videos.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Message defaults */}
|
|
||||||
<div className="e-card" style={{ marginBottom: 20 }}>
|
|
||||||
<div className="e-card-header">
|
|
||||||
<div className="e-card-header-caption">
|
|
||||||
<div className="e-card-header-title">Mitteilungen</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
|
|
||||||
Platzhalter für Standardwerte (Dauer, Layout/Styling) für Mitteilungen.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Other defaults */}
|
|
||||||
<div className="e-card">
|
|
||||||
<div className="e-card-header">
|
|
||||||
<div className="e-card-header-caption">
|
|
||||||
<div className="e-card-header-title">Sonstige</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
|
|
||||||
Platzhalter für sonstige Eventtypen.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)} />
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 👥 Users (Admin+) */}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<TabItemDirective header={{ text: '👥 Benutzer' }} content={() => (
|
<TabItemDirective header={{ text: '👥 Benutzer' }} content={UsersTabs} />
|
||||||
<div style={{ padding: 20 }}>
|
|
||||||
<div className="e-card">
|
|
||||||
<div className="e-card-header">
|
|
||||||
<div className="e-card-header-caption">
|
|
||||||
<div className="e-card-header-title">Schnellaktionen</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="e-card-content">
|
|
||||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
|
||||||
<Link to="/benutzer"><ButtonComponent cssClass="e-primary">Benutzerverwaltung öffnen</ButtonComponent></Link>
|
|
||||||
<ButtonComponent disabled title="Demnächst">Benutzer einladen</ButtonComponent>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)} />
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ⚙️ System (Superadmin) */}
|
|
||||||
{isSuperadmin && (
|
{isSuperadmin && (
|
||||||
<TabItemDirective header={{ text: '⚙️ System' }} content={() => (
|
<TabItemDirective header={{ text: '⚙️ System' }} content={SystemTabs} />
|
||||||
<div style={{ padding: 20 }}>
|
|
||||||
<div className="e-card" style={{ marginBottom: 20 }}>
|
|
||||||
<div className="e-card-header">
|
|
||||||
<div className="e-card-header-caption">
|
|
||||||
<div className="e-card-header-title">Organisationsinformationen</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
|
|
||||||
Platzhalter für Organisationsname, Branding, Standard-Lokalisierung.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="e-card">
|
|
||||||
<div className="e-card-header">
|
|
||||||
<div className="e-card-header-caption">
|
|
||||||
<div className="e-card-header-title">Erweiterte Konfiguration</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
|
|
||||||
Platzhalter für System-weit fortgeschrittene Optionen.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)} />
|
|
||||||
)}
|
)}
|
||||||
</TabItemsDirective>
|
</TabItemsDirective>
|
||||||
</TabComponent>
|
</TabComponent>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export default defineConfig({
|
|||||||
'@syncfusion/ej2-react-navigations',
|
'@syncfusion/ej2-react-navigations',
|
||||||
'@syncfusion/ej2-react-buttons',
|
'@syncfusion/ej2-react-buttons',
|
||||||
'@syncfusion/ej2-react-splitbuttons',
|
'@syncfusion/ej2-react-splitbuttons',
|
||||||
|
'@syncfusion/ej2-react-grids',
|
||||||
|
'@syncfusion/ej2-react-schedule',
|
||||||
|
'@syncfusion/ej2-react-filemanager',
|
||||||
'@syncfusion/ej2-base',
|
'@syncfusion/ej2-base',
|
||||||
'@syncfusion/ej2-navigations',
|
'@syncfusion/ej2-navigations',
|
||||||
'@syncfusion/ej2-buttons',
|
'@syncfusion/ej2-buttons',
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ class Event(Base):
|
|||||||
autoplay = Column(Boolean, nullable=True) # NEU
|
autoplay = Column(Boolean, nullable=True) # NEU
|
||||||
loop = Column(Boolean, nullable=True) # NEU
|
loop = Column(Boolean, nullable=True) # NEU
|
||||||
volume = Column(Float, nullable=True) # NEU
|
volume = Column(Float, nullable=True) # NEU
|
||||||
|
muted = Column(Boolean, nullable=True) # NEU: Video mute
|
||||||
slideshow_interval = Column(Integer, nullable=True) # NEU
|
slideshow_interval = Column(Integer, nullable=True) # NEU
|
||||||
page_progress = Column(Boolean, nullable=True) # NEU: Seitenfortschritt (Page-Progress)
|
page_progress = Column(Boolean, nullable=True) # NEU: Seitenfortschritt (Page-Progress)
|
||||||
auto_progress = Column(Boolean, nullable=True) # NEU: Präsentationsfortschritt (Auto-Progress)
|
auto_progress = Column(Boolean, nullable=True) # NEU: Präsentationsfortschritt (Auto-Progress)
|
||||||
|
|||||||
30
server/alembic/versions/21226a449037_add_muted_to_events.py
Normal file
30
server/alembic/versions/21226a449037_add_muted_to_events.py
Normal file
@@ -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')
|
||||||
@@ -50,6 +50,9 @@ with engine.connect() as conn:
|
|||||||
('presentation_interval', '10', 'Standard Intervall für Präsentationen (Sekunden)'),
|
('presentation_interval', '10', 'Standard Intervall für Präsentationen (Sekunden)'),
|
||||||
('presentation_page_progress', 'true', 'Seitenfortschrift anzeigen (Page-Progress) für Präsentationen'),
|
('presentation_page_progress', 'true', 'Seitenfortschrift anzeigen (Page-Progress) für Präsentationen'),
|
||||||
('presentation_auto_progress', 'true', 'Automatischer Fortschritt (Auto-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:
|
for key, value, description in default_settings:
|
||||||
|
|||||||
@@ -138,7 +138,14 @@ def get_event(event_id):
|
|||||||
"IsAllDay": False, # Assuming events are not all-day by default
|
"IsAllDay": False, # Assuming events are not all-day by default
|
||||||
"MediaId": str(event.event_media_id) if event.event_media_id else None,
|
"MediaId": str(event.event_media_id) if event.event_media_id else None,
|
||||||
"SlideshowInterval": event.slideshow_interval,
|
"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,
|
"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,
|
"RecurrenceRule": event.recurrence_rule,
|
||||||
"RecurrenceEnd": event.recurrence_end.isoformat() if event.recurrence_end else None,
|
"RecurrenceEnd": event.recurrence_end.isoformat() if event.recurrence_end else None,
|
||||||
"SkipHolidays": event.skip_holidays,
|
"SkipHolidays": event.skip_holidays,
|
||||||
@@ -398,6 +405,7 @@ def create_event():
|
|||||||
autoplay = None
|
autoplay = None
|
||||||
loop = None
|
loop = None
|
||||||
volume = None
|
volume = None
|
||||||
|
muted = None
|
||||||
if event_type == "video":
|
if event_type == "video":
|
||||||
event_media_id = data.get("event_media_id")
|
event_media_id = data.get("event_media_id")
|
||||||
if not event_media_id:
|
if not event_media_id:
|
||||||
@@ -406,6 +414,7 @@ def create_event():
|
|||||||
autoplay = data.get("autoplay", True)
|
autoplay = data.get("autoplay", True)
|
||||||
loop = data.get("loop", False)
|
loop = data.get("loop", False)
|
||||||
volume = data.get("volume", 0.8)
|
volume = data.get("volume", 0.8)
|
||||||
|
muted = data.get("muted", False)
|
||||||
|
|
||||||
# created_by aus den Daten holen, Default: None
|
# created_by aus den Daten holen, Default: None
|
||||||
created_by = data.get("created_by")
|
created_by = data.get("created_by")
|
||||||
@@ -435,6 +444,7 @@ def create_event():
|
|||||||
autoplay=autoplay,
|
autoplay=autoplay,
|
||||||
loop=loop,
|
loop=loop,
|
||||||
volume=volume,
|
volume=volume,
|
||||||
|
muted=muted,
|
||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
# Recurrence
|
# Recurrence
|
||||||
recurrence_rule=data.get("recurrence_rule"),
|
recurrence_rule=data.get("recurrence_rule"),
|
||||||
@@ -514,6 +524,8 @@ def update_event(event_id):
|
|||||||
event.loop = data.get("loop")
|
event.loop = data.get("loop")
|
||||||
if "volume" in data:
|
if "volume" in data:
|
||||||
event.volume = data.get("volume")
|
event.volume = data.get("volume")
|
||||||
|
if "muted" in data:
|
||||||
|
event.muted = data.get("muted")
|
||||||
event.created_by = data.get("created_by", event.created_by)
|
event.created_by = data.get("created_by", event.created_by)
|
||||||
# Track previous values to decide on exception regeneration
|
# Track previous values to decide on exception regeneration
|
||||||
prev_rule = event.recurrence_rule
|
prev_rule = event.recurrence_rule
|
||||||
|
|||||||
Reference in New Issue
Block a user