Compare commits

..

112 Commits

Author SHA1 Message Date
admin 1102252e71 Merge branch 'main' into codex/fix-git-permission-denied-error-ipz1d6 2026-04-13 13:11:12 +02:00
admin 70f1802721 Fix MudBlazor analyzer issues and target x64 for HANA client 2026-04-13 13:10:01 +02:00
admin 2b9b40af93 Merge pull request #59 from metacube2/codex/fix-git-permission-denied-error-92qoc3
Add transformation rules UI and engine; add connection testing/status and Site SourceSystem
2026-04-13 12:20:43 +02:00
admin eb427ac608 Merge branch 'main' into codex/fix-git-permission-denied-error-92qoc3 2026-04-13 12:20:33 +02:00
admin 97e598fe3b Fix MudBlazor generic/value callback compile errors 2026-04-13 12:19:42 +02:00
admin 9406843988 Merge pull request #58 from metacube2/codex/fix-git-permission-denied-error
Add field transformation rules, UI, DB schema and integrate into export; improve HANA connection testing
2026-04-13 11:52:17 +02:00
admin ec827a4ce8 Add connection diagnostics and visual field transformation mapping 2026-04-13 11:52:05 +02:00
admin c4a93a7f15 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	TrafagSalesExporter/TrafagSalesExporter.csproj
2026-04-13 11:31:18 +02:00
admin 0d11315848 Merge pull request #57 from metacube2/codex/fix-git-permission-denied-error
Ignore Visual Studio workspace files in TrafagSalesExporter
2026-04-13 11:24:18 +02:00
admin c336c1c7f8 Ignore Visual Studio workspace files in TrafagSalesExporter 2026-04-13 11:24:04 +02:00
admin 3b6f66d0fb asdf 2026-04-13 11:22:40 +02:00
Claude af40d87213 Merge HANA SSL/MDC support and DLL reference fix from claude/blazor-sap-sales-exporter-9VrM0 2026-04-13 09:07:41 +00:00
Claude efcf7b180c Add SSL, MDC database, and custom HANA connection parameters
Fixes 'error while parsing protocol' HanaException by supporting
SSL/TLS encryption, Multi-Tenant Database Container (MDC) database
name, and arbitrary additional connection parameters.

- HanaServer model: added DatabaseName, UseSsl, ValidateCertificate,
  AdditionalParams fields + BuildConnectionString() helper
- HanaQueryService: accepts HanaServer directly, uses
  BuildConnectionString() for full parameter support
- AppDbContext: added EnsureSchema() method that uses PRAGMA table_info
  + ALTER TABLE ADD COLUMN to add the new fields to existing SQLite
  databases without losing data (EnsureCreated does not update schema)
- Program.cs: calls EnsureSchema on startup before seeding
- Standorte.razor: server dialog now exposes DatabaseName, UseSsl,
  ValidateCertificate, AdditionalParams with helper texts; test
  connection uses new signature

https://claude.ai/code/session_012heAXNMbbyxqYf2S2HrKLj
2026-04-13 08:52:10 +00:00
Claude f916c26fb4 Fix SAP HANA client reference - use direct DLL reference instead of missing NuGet package
Das Paket 'Sap.Data.Hana.v2' existiert nicht auf nuget.org. SAP liefert den
HANA .NET Client ausschliesslich ueber das SAP HANA Client Installationspaket
aus. Stattdessen wird nun Sap.Data.Hana.Core.v2.1.dll direkt aus dem
Standard-Installationspfad referenziert (via HanaClientDll MSBuild-Property
ueberschreibbar). Warnung beim Build wenn DLL nicht gefunden wird.

https://claude.ai/code/session_012heAXNMbbyxqYf2S2HrKLj
2026-04-10 06:23:59 +00:00
admin c198d362b1 Merge pull request #55 from metacube2/claude/blazor-sap-sales-exporter-9VrM0
Migrate from console app to Blazor web UI with database
2026-04-09 16:07:10 +02:00
Claude 8524631508 Convert TrafagSalesExporter from console app to Blazor Server app with MudBlazor UI
- Replaced console app with .NET 8 Blazor Server architecture
- Added EF Core SQLite database (trafag_exporter.db) with auto-seed data
- Models: HanaServer, Site, SharePointConfig, ExportSettings, ExportLog, SalesRecord
- Services: HanaQueryService (with configurable dateFilter), ExcelExportService,
  SharePointUploadService, ExportOrchestrationService (with live status events),
  TimerBackgroundService (scheduled daily export)
- MudBlazor UI pages: Dashboard (export status + manual trigger), Standorte
  (HANA server + site CRUD), Settings (SharePoint + timer config), Logs (filtered view)
- SAP HANA queries unchanged (INV + CRN with exact SAP B1 table joins)
- SharePoint upload via Microsoft Graph with app registration auth

https://claude.ai/code/session_012heAXNMbbyxqYf2S2HrKLj
2026-04-09 14:00:44 +00:00
admin 2f56082adc Merge pull request #53 from metacube2/codex/create-c#-console-app-for-sap-hana-export
Add Trafag SAP HANA → Excel → SharePoint exporter (.NET 8 console)
2026-04-09 15:48:14 +02:00
admin 673bba7298 Add Trafag SAP HANA to Excel SharePoint exporter console app 2026-04-09 15:47:55 +02:00
admin 8d8b62f1f5 Merge pull request #51 from metacube2/claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw
asdf
2026-02-09 10:47:23 +01:00
admin 99f40dd7ea Update fmt.Println message from 'Hello' to 'Goodbye' 2026-02-09 10:42:52 +01:00
admin bfcf018d33 Merge pull request #50 from metacube2/claude/php-video-converter-suite-SVNhO
Initial commit: Video Converter Suite application scaffold
2026-02-07 19:25:23 +01:00
Claude 6c56306873 Add PHP Video Converter Suite with live stream pipelines and nuclear control panel UI
Full-featured video conversion platform with:
- FFmpeg-based pipeline system with composable stages (transcode, scale, filter, audio, bitrate, framerate, trim, deinterlace, denoise, stabilize)
- Live stream management with real-time format switching (RTMP/RTSP/HTTP)
- Industrial/nuclear power plant control room themed UI with gauges, switches, LED indicators
- Format switchboard for instant conversion between 16+ video/audio formats
- Pipeline designer with visual flow editor and drag-and-drop stage composition
- Job queue with priority scheduling and batch conversion
- WebSocket server for real-time progress broadcasting
- REST API for all operations (upload, convert, streams, pipelines, queue)
- System monitoring (CPU, memory, disk) with animated gauge displays
- Docker Compose setup with web, websocket, and worker services

https://claude.ai/code/session_01WxmHGnVFXGm2bwbFREHkHb
2026-02-07 18:11:04 +00:00
admin 2cc77f5405 Merge pull request #49 from metacube2/claude/product-promotion-tool-jY9ZZ
Add PromoMaster - automatic product promotion tool in DE/EN
2026-02-07 19:06:56 +01:00
admin 47487c7bab Merge pull request #47 from metacube2/claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw
asdf
2026-02-07 19:04:02 +01:00
admin fe502fc4b3 Merge branch 'main' into claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw 2026-02-07 19:03:54 +01:00
admin df86a5f568 Merge pull request #48 from metacube2/claude/photography-homepage-tools-1bcI6
Add comprehensive styling for photography tools application
2026-02-07 19:02:10 +01:00
Claude 3f0662c49a feat: Add Turkish/Swedish translations and 6 photography simulation tools
- Added Turkish (TR) and Swedish (SV) language support to i18n system
- Added simulation tool i18n keys to all 8 languages
- New simulation section with 6 interactive canvas-based tools:
  Bokeh, Long Exposure, White Balance, ISO Noise, Perspective, Histogram
- Updated hero stats counter from 6 to 12 tools
- Added simulation nav link and footer link

https://claude.ai/code/session_016BnRMtz5yhf7n5ZPQCMfmN
2026-02-07 13:15:58 +00:00
Claude 3b6d0c4db5 Add PromoMaster - automatic product promotion tool in DE/EN
Full-featured web tool for generating product marketing materials:
- Social media posts for Twitter/X, Instagram, Facebook, LinkedIn, TikTok
- 6 promotion styles: Professional, Casual, Urgent/FOMO, Luxury, Fun, Minimal
- Email marketing templates
- SEO texts & metadata generator
- Press release generator
- Slogan & tagline generator
- Hashtag cloud
- Landing page HTML generator with live preview
- Export to TXT, HTML, JSON, CSV
- Complete DE/EN bilingual support with language toggle
- SEO meta tags, Open Graph, Twitter Cards, JSON-LD structured data
- Fully responsive dark-mode design

https://claude.ai/code/session_01BkvFWWTbZTBY6KafPCpXGF
2026-02-07 13:14:49 +00:00
Claude 0605aee88d feat: Add PhotoPro Tools - photography homepage with 6 languages
Complete photography toolkit with:
- 6 lens calculators (DOF, FOV, crop factor, hyperfocal, flash range, magnification)
- 8 interactive composition rules with canvas demos (thirds, golden ratio, leading lines, symmetry, framing, negative space, diagonals, color theory)
- Motif recognition guide for 8 genres (portrait, landscape, street, macro, night, sport, architecture, wildlife) with optimal camera settings
- Interactive exposure triangle visualization with real-time EV meter
- Quiz system with 50 questions across 5 categories with instant feedback
- Full i18n support: Deutsch, English, Français, Italiano, Srpski, Shqip
- Modern dark UI with glassmorphism, particles, animations
- Fully responsive design

https://claude.ai/code/session_016BnRMtz5yhf7n5ZPQCMfmN
2026-02-07 12:50:44 +00:00
admin 6fba9d938a Remove OpenWeatherMap API key input and update settings
Removed API key input for OpenWeatherMap and updated weather widget settings.
2026-02-05 11:03:08 +01:00
admin 5d9ebbbc3e Refactor weather settings and timelapse checks
Removed the weather API key retrieval method and updated the weekly timelapse check logic.
2026-02-05 11:02:36 +01:00
admin 282d8b70fc Merge branch 'main' into claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw 2026-02-05 11:01:33 +01:00
admin 814494f812 Add auto-screenshot and email sharing settings 2026-02-05 10:57:02 +01:00
admin 75e5566532 Update weather widget description and remove API key field 2026-02-05 10:56:21 +01:00
admin 5d382db42e Merge pull request #46 from metacube2/codex/implement-phase-locked-phase-vocoder
Add phase-locked vocoder timestretcher
2026-01-31 12:24:13 +01:00
admin 054717fff1 Add phase-locked vocoder timestretcher 2026-01-31 12:24:02 +01:00
admin 5218c064cb Merge pull request #45 from metacube2/claude/fix-aurora-api-key-VK588
Add 4 new features: timelapse toggle, auto-screenshot, video search, …
2026-01-30 19:42:09 +01:00
Claude 6a24b564a4 Add 4 new features: timelapse toggle, auto-screenshot, video search, email sharing
- Weekly timelapse button now toggleable via settings (zoom_timelapse.weekly_timelapse_enabled)
- Auto-screenshot API for cron-based gallery capture every 10 min
- Date/time video search with filter UI in archive section
- Email sharing with share links and PHPMailer integration
- New API endpoints: auto-screenshot.php, gallery.php, video-search.php, share.php
- New settings: auto_screenshot.*, sharing.* for feature configuration
2026-01-30 18:33:54 +00:00
admin cc85523c9c Merge pull request #44 from metacube2/claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw
a
2026-01-23 21:24:58 +01:00
admin bb27cb151e Merge pull request #43 from metacube2/claude/fix-aurora-api-key-VK588
Claude/fix aurora api key vk588
2026-01-23 21:24:00 +01:00
Claude 16673b91d3 Add Billing/Stripe integration and Landing Page (Phase 4+5)
Phase 4 - Billing/Stripe:
- src/Billing/StripeService.php: Stripe API wrapper
  - Checkout session creation
  - Customer management
  - Billing portal sessions
  - Webhook signature verification
- src/Billing/SubscriptionManager.php: Subscription logic
  - Plan management (CRUD)
  - Trial handling
  - Feature access checks
  - Invoice storage
- src/Billing/WebhookHandler.php: Stripe webhook processing
  - checkout.session.completed
  - customer.subscription.* events
  - invoice.paid / payment_failed
- api/stripe-webhook.php: Webhook endpoint
- dashboard/billing.php: Billing dashboard
  - Current plan display with features
  - Plan comparison grid
  - Upgrade buttons with Stripe Checkout
  - Invoice history

Phase 5 - Landing Page:
- landing/index.php: Marketing homepage
  - Hero section with CTA
  - Feature grid (6 features)
  - How it works (3 steps)
  - Final CTA section
  - Responsive design
- landing/pricing.php: Pricing page
  - Dynamic plan cards from DB
  - Monthly/yearly toggle (2 months free)
  - Feature comparison
  - FAQ accordion

All features respect saas_features toggles in settings.
2026-01-23 19:16:18 +00:00
Claude ac77e27089 Add automatic onboarding system (Phase 3)
Onboarding Wizard:
- register.php: User registration with validation
- verify.php: Email verification (with demo mode)
- stream.php: Stream URL configuration & validation
- branding.php: Quick branding setup with live preview
- complete.php: Success page with confetti animation

Backend Classes (src/Onboarding/):
- OnboardingManager.php: Orchestrates the onboarding flow
  - Registration with automatic subdomain generation
  - Email verification tokens
  - Step tracking in tenant_onboarding table
- StreamValidator.php: Validates stream URLs
  - HLS (.m3u8) validation with playlist check
  - RTMP format validation
  - iframe/embed URL detection (YouTube, Vimeo, Twitch)
  - Generic HTTP reachability check

Features:
- 4-step wizard with progress indicator
- Stream type auto-detection
- Live branding preview
- Skip options for optional steps
- Trial period display
2026-01-23 18:41:53 +00:00
Claude 7bd62b3527 Add tenant dashboard (Phase 2)
Dashboard Features:
- Login page with session-based auth
- Overview page with live stats (viewers, stream status)
- Stream settings (URL, type configuration)
- Branding editor (colors, texts, custom CSS)
- Settings page (weather, content toggles, UI options)

New Files:
- dashboard/index.php: Main overview with stats
- dashboard/login.php: Authentication page
- dashboard/logout.php: Session cleanup
- dashboard/stream.php: Stream configuration
- dashboard/branding.php: Visual customization
- dashboard/settings.php: Feature toggles
- dashboard/templates/layout.php: Shared layout
- dashboard/api/stats.php: Stats API endpoint
- dashboard/assets/dashboard.css: Modern dashboard UI
- dashboard/assets/dashboard.js: Client-side functionality
- src/Auth/AuthManager.php: Secure auth with Argon2, remember-me

Auth Features:
- Secure password hashing (Argon2ID)
- Remember-me tokens
- Role-based access (super_admin, tenant_admin, tenant_user)
- Legacy fallback for existing admin credentials
2026-01-23 17:09:38 +00:00
Claude 402604b4cc Add Multi-Tenant SaaS foundation for customer management
Phase 1 implementation includes:

Database:
- schema.sql with tables for tenants, domains, settings, branding,
  streams, users, subscriptions, plans, invoices, viewer_stats

Core Classes (src/Core/):
- Database.php: PDO wrapper with singleton pattern
- TenantResolver.php: Domain-to-tenant resolution with fallback

Tenant Classes (src/Tenant/):
- TenantManager.php: CRUD operations for tenants
- TenantSettingsManager.php: DB-based settings per tenant

Configuration:
- config.example.php: Template for database/stripe/mail config
- bootstrap.php: Initializes multi-tenant environment
- .gitignore: Excludes config.php and cache files

Integration:
- SettingsManager.php: Added saas_features toggles (all off by default)
- index.php: Uses getSiteConfig() from bootstrap when multi-tenant enabled,
  falls back to legacy hardcoded domains when disabled

All SaaS features are disabled by default (saas_features.multi_tenant_enabled = false),
ensuring zero breaking changes to existing installations.
2026-01-23 16:40:42 +00:00
admin 4acdf89588 Merge pull request #42 from metacube2/codex/fix-missing-api-key-in-index.php
Add optional OpenWeatherMap API key support for weather widget
2026-01-22 23:01:34 +01:00
admin 20c0569731 Add optional weather API key support 2026-01-22 23:00:41 +01:00
admin 144c813acf Merge pull request #41 from metacube2/claude/fix-aurora-api-key-VK588
Prevent caching of API errors in WeatherManager
2026-01-22 22:40:57 +01:00
Claude 328b5b5b15 Fix cached weather error being returned
The weather cache was returning old errors (like "API Key fehlt"
from the previous OpenWeatherMap implementation) even after
switching to Open-Meteo which doesn't require an API key.

Changes:
- Delete cache file if it contains an error
- Prevent errors from being cached in the first place
2026-01-22 21:40:09 +00:00
admin 6472bbf162 Merge pull request #40 from metacube2/claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw
asdf
2026-01-22 22:30:35 +01:00
admin c66a5b9f64 Merge pull request #39 from metacube2/claude/fix-layout-centering-cdX7d
Add error handling to weather widget
2026-01-22 22:29:27 +01:00
Claude 7e468d51ca Add error handling to weather widget
- Wrap getCurrentWeather() in try-catch block
- Prevents white screen if weather API fails
- Shows error message in widget instead
- Add test-weather.php for debugging
2026-01-22 21:06:50 +00:00
admin 7b3f99e837 Merge pull request #38 from metacube2/claude/fix-layout-centering-cdX7d
Switch weather API from OpenWeatherMap to Open-Meteo
2026-01-22 22:00:25 +01:00
Claude 20704b3cd8 Switch weather API from OpenWeatherMap to Open-Meteo
🎉 **100% KOSTENLOS - KEIN API KEY MEHR NÖTIG!**

**Warum Open-Meteo?**
-  Komplett kostenlos ohne Limits
-  KEIN API Key erforderlich
-  Keine Kreditkarte, keine Anmeldung
-  Open Source & Non-Profit
-  Sehr präzise Daten für die Schweiz
-  Schnell & zuverlässig

**Technische Änderungen:**

**WeatherManager.php:**
- API URL auf api.open-meteo.com gewechselt
- WMO Weather Codes (0-99) implementiert
- Deutsche Wetterbeschreibungen für alle Codes
- Temperatur, Wind, Luftdruck, Feuchtigkeit, Niederschlag
- Icon-Mapping von WMO Codes zu Emojis
- Timezone: Europe/Zurich

**index.php Admin-Panel:**
- API Key Feld entfernt (nicht mehr nötig!)
- Überschrift: "Open-Meteo - 100% kostenlos!"
- Event Listener für API Key entfernt
- Funktioniert sofort ohne Setup!

**API Endpoint:**
https://api.open-meteo.com/v1/forecast
- Keine Authentication nötig
- Parameter: lat, lon, current weather vars
- Response: JSON mit aktuellen Wetterdaten

**Unterstützte Wetter-Codes:**
- 0: Klar
- 1-3: Bewölkt (verschiedene Grade)
- 45-48: Nebel
- 51-65: Regen (Niesel bis stark)
- 71-77: Schnee
- 80-86: Schauer
- 95-99: Gewitter & Hagel

**Resultat:**
Widget funktioniert SOFORT ohne jegliches Setup!
Einfach aktivieren und fertig! 🌤️
2026-01-22 20:59:07 +00:00
admin 6fa64baa35 Merge pull request #37 from metacube2/claude/fix-layout-centering-cdX7d
Fix undefined siteConfig variable in admin panel
2026-01-22 21:52:18 +01:00
Claude 6bca898488 Fix undefined siteConfig variable in admin panel
- Add global $siteConfig declaration in displayAdminContent()
- Fixes warning in SEO settings placeholder
2026-01-22 20:50:53 +00:00
admin 8bd90629f9 Merge pull request #36 from metacube2/claude/fix-layout-centering-cdX7d
Claude/fix layout centering cd x7d
2026-01-22 21:39:19 +01:00
Claude 7eda2fbbe8 Add weather widget with OpenWeatherMap integration
Implementiert vollständiges Wetter-Widget oberhalb des Webcam-Videos:

**Features:**
🌤️ **Wetter-Anzeige:**
- Temperatur (°C)
- Windgeschwindigkeit & Richtung (km/h)
- Luftdruck (hPa)
- Luftfeuchtigkeit (%)
- Wetterbeschreibung mit Emoji-Icons
- Niederschlag (Regen/Schnee) wenn vorhanden

⚙️ **Technisch:**
- OpenWeatherMap API Integration
- 5 Minuten Cache (konfigurierbar)
- Auto-Update alle X Minuten (Frontend)
- WeatherManager Klasse für Backend
- Schönes Gradient-Design mit Hover-Effekten
- Responsive für Mobile

🎛️ **Admin-Settings:**
- Wetter-Widget ein/aus
- API Key Eingabefeld + Registrierungs-Link
- Standort konfigurierbar (Stadt,Land)
- GPS-Koordinaten (Lat/Lon)
- Update-Intervall (5-60 Minuten)
- Einheiten (Metrisch/Imperial)

**Dateien:**
- WeatherManager.php: Neue Klasse für API-Calls & Caching
- SettingsManager.php: Weather Settings Defaults & Helper
- index.php: Widget HTML, CSS, JavaScript Auto-Update
- settings.json: Weather Defaults initialisiert

**Koordinaten Oberdürnten:**
Lat: 47.2833, Lon: 8.7167

**Setup für User:**
1. Gratis Account auf openweathermap.org erstellen
2. API Key im Admin-Panel einfügen
3. Fertig! Widget zeigt Live-Wetter an
2026-01-22 18:50:16 +00:00
Claude 5b8200a4ff Add cache clear utility script
- Add clear-cache.php for clearing PHP OPcache and realpath cache
- Useful for debugging and ensuring latest code changes are visible
2026-01-22 17:57:17 +00:00
Claude ac6632e24f Initialize settings.json with all new defaults
- Add all new settings groups (ui_display, zoom_timelapse, content, technical, theme, seo)
- Set default values for all new admin settings
- Enable all features by default for smooth transition
2026-01-22 17:54:57 +00:00
Claude 0ce527c69e Add comprehensive admin settings control panel
Erweitere Admin-Bereich um umfangreiche Settings-Steuerung:

**Punkt 2 - UI Anzeige:**
- Empfehlungs-Banner ein/aus
- QR-Code Section ein/aus
- Social Media Links ein/aus
- Patrouille Suisse Section ein/aus

**Punkt 3 - Zoom & Timelapse:**
- Zoom-Controls anzeigen/verstecken
- Max Zoom-Level konfigurierbar (1.5x - 4.0x)
- Timelapse Rückwärts-Modus ein/aus

**Punkt 5 - Content Management:**
- Gästebuch aktivieren/deaktivieren
- Galerie aktivieren/deaktivieren
- KI-Events anzeigen/verstecken
- Max Gästebuch-Einträge limit

**Punkt 6 - Technische Settings:**
- Viewer Update-Intervall konfigurierbar
- Session Timeout einstellbar

**Punkt 7 - Theme & Design:**
- Standard-Theme auswählbar (Legacy/Alpine/Modern)
- Theme-Switcher anzeigen/verstecken (war auskommentiert)

**Punkt 8 - SEO & Meta:**
- Custom Title konfigurierbar
- Meta Description editierbar
- Meta Keywords verwaltbar

**Technische Änderungen:**
- SettingsManager.php: Neue Defaults und Helper-Methoden
- Admin-Panel: Neue Settings-Gruppen mit Toggle-Switches
- JavaScript: Live-Apply ohne Reload für alle Settings
- HTML: Sections mit PHP-Settings verbunden
- CSS: Admin-Panel Styling hinzugefügt
- TimelapseController: reverseEnabled Setting integriert
2026-01-22 17:47:56 +00:00
admin 8f46ffb695 Merge pull request #35 from metacube2/claude/fix-layout-centering-cdX7d
Center header layout and adjust navigation alignment
2026-01-22 18:26:26 +01:00
Claude 36558e97cb Fix layout centering in aurora-livecam
- Center header container and navigation
- Add padding-right to header to prevent overlap with language selector buttons
- Change nav ul justify-content from space-around to center for better alignment
2026-01-22 17:25:58 +00:00
admin 1cf30a0c8b Update fmt.Println message from 'Hello' to 'Goodbye' 2026-01-22 18:17:50 +01:00
admin ff4bda1e53 Merge pull request #34 from metacube2/codex/create-power-bi-training-manual-for-hr
Add HR Power BI consumer and trainer manuals
2026-01-20 13:11:19 +01:00
admin af87ef329b Merge branch 'main' into codex/create-power-bi-training-manual-for-hr 2026-01-20 13:11:10 +01:00
admin 9c1d820876 Add HR Power BI consumer and trainer manuals 2026-01-20 13:08:29 +01:00
admin cbe215cfc2 Merge pull request #33 from metacube2/codex/create-power-bi-training-manual-for-hr
Add HTML Power BI HR manual with embedded SVG diagram
2026-01-20 09:52:43 +01:00
admin 12d64bf009 Expand HTML Power BI HR manual 2026-01-20 09:02:03 +01:00
admin 0871068ff8 Add HTML Power BI HR manual 2026-01-20 08:08:49 +01:00
admin 3d7cee81da Merge pull request #32 from metacube2/codex/create-power-bi-training-manual-for-hr
Provide Power BI HR training manual (Markdown; omit Word binary)
2026-01-20 07:49:14 +01:00
admin 0aa5a62754 Provide Power BI HR training manual 2026-01-20 07:48:59 +01:00
admin f487713a92 Add Power BI training manual for HR
Created a comprehensive training manual for Power BI tailored for HR staff, including step-by-step instructions, target audience details, data sources, KPIs, and troubleshooting tips.
2026-01-20 07:41:01 +01:00
admin 5eb27d1c28 Merge pull request #31 from metacube2/codex/check-translations-in-index2.php
Complete missing translations for language switcher and page content (IT/FR/ZH)
2026-01-19 22:25:50 +01:00
admin 4060115749 Complete missing translations in index2 2026-01-19 22:25:15 +01:00
admin b98a6761c2 Merge pull request #30 from metacube2/claude/seecam-domain-config-njiwU
Add missing translations for Patrouille Suisse and Blog sections
2026-01-18 17:14:56 +01:00
Claude 6a8e879898 Add missing translations for Patrouille Suisse and Blog sections
- About section: additional paragraph translated
- Admin section: headings translated
- Patrouille Suisse: complete section with all texts, headings and lists
- Blog section: subtitle, all 3 articles with titles, dates and content
- Footer blog text translated

All elements now have data-en and data-de attributes for language switching
2026-01-18 13:23:33 +00:00
admin b9407a9f13 Merge pull request #28 from metacube2/claude/seecam-domain-config-njiwU
Add domain-based site configuration for seecam.ch
2026-01-18 10:08:40 +01:00
Claude 53ae1ba76f Add domain-based site configuration for seecam.ch
- Detect domain (seecam.ch vs aurora-weather-livecam.com) via PHP
- Load different logo (seecam.jpg vs logo.png) based on domain
- Dynamic meta tags (title, og:*, twitter:*, canonical URL)
- Dynamic Schema.org JSON-LD (WebSite, LocalBusiness, VideoObject)
- Dynamic site texts (welcome, about, blog title, footer, copyright)
- QR-code URL adapts to current domain
- Email sender name remains unchanged (Aurora Livecam)
2026-01-18 09:06:02 +00:00
admin 9a418aa213 Merge pull request #27 from metacube2/claude/add-video-download-sppLI
Claude/add video download spp li
2026-01-17 18:18:37 +01:00
Claude 9a88b5cad2 Add SEO-optimized index2.php with comprehensive improvements
- Add SEO meta tags (title, description, keywords, robots)
- Add Schema.org JSON-LD structured data (WebSite, LocalBusiness, VideoObject)
- Add local SEO geo tags for Zürich Oberland/Dürnten
- Improve Open Graph and Twitter Card meta tags
- Add optimized alt-texts for images
- Add social media links to footer (Instagram, Facebook, YouTube, TikTok)
- Add Patrouille Suisse dedicated section with training info
- Add Blog section with weather and webcam articles
- Add accessibility CSS for screen-readers
2026-01-17 17:13:46 +00:00
admin 3e6a584f4f Merge pull request #25 from metacube2/claude/add-macos-support-uYBaj
Add macOS Catalyst support for RollkofferSimulator
2026-01-15 15:24:17 +01:00
admin 6ad6167c52 Merge pull request #24 from metacube2/main-aurora
Main aurora
2026-01-15 15:10:46 +01:00
Claude f9b84e4d3c Add separate zoom wrapper layers for all video modes
- Added live-video-wrapper around webcam-player
- Added timelapse-wrapper inside timelapse-viewer
- Added daily-video-wrapper inside daily-video-player
- Zoom now applies to wrapper divs, not video elements directly
- Pan works by dragging inside video container when zoomed
- Double-click to reset zoom
- Cursor changes to grab when zoomed > 1x
- Touch support for mobile pan
2026-01-15 14:09:42 +00:00
admin 2e6fd332ac Merge pull request #23 from metacube2/claude/add-video-download-sppLI
Claude/add video download spp li
2026-01-15 14:54:33 +01:00
Claude b5376f46e5 Merge zoom & pan improvements from main-aurora 2026-01-15 13:51:42 +00:00
Claude 98f1fcae14 Add zoom & pan for all video modes
- Zoom now works for livestream, timelapse and daily videos
- Added pan function: drag to move zoomed area with mouse
- Added touch support for mobile pan
- Added +/- zoom buttons and reset button
- Reduced max zoom from 100x to 4x
- Dynamically detects active video element
- Pan limits based on zoom level
- Cursor changes to grab when zoomed
2026-01-15 13:51:25 +00:00
Claude 14c064de64 Fix video overlay issue - add z-index and disable zoom temporarily
- Added z-index: 10 to webcam-player (higher than overlays)
- Added z-index: 5 and display: none to timelapse/daily-video overlays
- Disabled zoom script temporarily for testing
2026-01-13 09:39:44 +00:00
Claude 313c2108a9 Fix live video player - add native HLS support and debugging
- Added display:block and background:#000 to video element
- Added native HLS detection for Safari (canPlayType check)
- Added console.log debugging for video loading
- Added error event handlers for better debugging
- Added fallback for browsers without HLS support
- Improved HLS.js error handling
2026-01-13 09:34:42 +00:00
admin c12ac16557 Merge pull request #22 from metacube2/claude/add-video-download-sppLI
Claude/add video download spp li
2026-01-13 10:27:17 +01:00
Claude b686d4506c Update aurora-livecam with new design and fixed zoom
- New design from main-aurora branch
- Fixed zoom: maxZoom reduced from 100 to 4
- Added zoom +/- buttons
- Added zoom slider with step 0.5
- Fixed video-zoom.js to not apply transform at 1x
2026-01-13 09:26:08 +00:00
admin c38bd130e5 Merge pull request #21 from metacube2/codex/fix-saving-changes-in-audora-project-tgc6un
Add 1–100x zoom controls for all video modes and wire video-zoom.js
2026-01-12 12:59:49 +01:00
admin de343364ad Merge branch 'main-aurora' into codex/fix-saving-changes-in-audora-project-tgc6un 2026-01-12 12:58:08 +01:00
admin e8385adb87 Add zoom controls for video modes 2026-01-12 12:40:22 +01:00
admin f7843e5e35 Merge pull request #20 from metacube2/codex/fix-saving-changes-in-audora-project-4bnojk
Add design switcher (Alpine/Modern) with Swiss-cross and sun overlay; harden settings save
2026-01-12 12:27:48 +01:00
admin 3a78d09399 Add design switcher themes 2026-01-12 12:27:32 +01:00
admin 28d2032f23 Merge pull request #19 from metacube2/codex/fix-saving-changes-in-audora-project
Fix settings save path and improve save reliability
2026-01-12 11:33:52 +01:00
admin 1ec8d734ee Fix settings save path 2026-01-12 11:33:33 +01:00
admin 60dab1e9df Add advertisement banner styles and functionality 2026-01-12 11:26:12 +01:00
Claude 9e175fdf56 Add indexmiau.php as copy of index.php 2026-01-11 03:23:53 +00:00
admin 13024c5ae8 Merge pull request #18 from metacube2/claude/add-video-download-sppLI
Claude/add video download spp li
2026-01-11 03:49:25 +01:00
Claude 42b12c5c36 Add zoom for all video modes and fix settings saving
- Zoom now works on live video, timelapse images, and archive video player
- Added zoom level indicator (shows percentage)
- Increased max zoom from 3x to 4x
- Fixed settings AJAX handler using FormData for reliable POST
- Settings event handlers now properly bound after DOM load
- Added error handling and visual feedback for settings changes
2026-01-11 02:48:33 +00:00
Claude a033d15912 Refactor index.php with cleaner AJAX handling and simplified code
- Inline AJAX settings handler for better control flow
- Simplify video download logic with condensed code
- Clean up domain redirect handling
- Remove redundant headers and verbose comments
2026-01-11 02:34:26 +00:00
admin 1f9bc08682 Merge pull request #17 from metacube2/main-aurora
Merge pull request #16 from metacube2/claude/mail-finetuning-webapp-0…
2026-01-10 11:49:50 +01:00
admin 191381ece4 Merge pull request #16 from metacube2/claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw
neue funktionen aurora index
2026-01-10 11:49:22 +01:00
admin fabdfb121a Merge pull request #15 from metacube2/claude/add-video-download-sppLI
Add complete index.php with all video player enhancements
2026-01-10 11:48:02 +01:00
Claude 4454adca59 Add complete index.php with all video player enhancements
Features integrated:
- Timelapse controls: slider, speed (1x/10x/100x), reverse playback
- Daily video player: plays videos in main player with controls
- Back to Live button for both timelapse and daily videos
- Admin settings panel: viewer display toggle, min viewers, video mode
- Conditional viewer count display based on admin settings
- AJAX settings updates without page reload
- SettingsManager integration throughout the application
2026-01-10 10:19:04 +00:00
admin 9ae417cb03 Merge pull request #14 from metacube2/claude/add-video-download-sppLI
Implement video download functionality
2026-01-10 11:10:29 +01:00
Claude 367aa4c67b Add Aurora Livecam video player enhancements
- Add SettingsManager class for admin settings (settings.json)
- Add timelapse controls with slider, speed (1x/10x/100x), and reverse playback
- Add daily video player to play videos in main player window
- Add admin settings panel for viewer display and video mode configuration
- Add CSS styles for new player controls
- Include integration guide for existing index.php
2026-01-10 10:08:17 +00:00
admin cac3768885 Merge pull request #13 from metacube2/claude/healthbridge-sync-app-XxRm8
Build intelligent health data synchronization app
2025-12-25 18:00:52 +01:00
Claude b953908f58 Add HealthBridge iOS app for intelligent health data synchronization
Complete implementation of a SwiftUI iOS app that serves as a "Single Source
of Truth" for health data. The app reads from all Apple Health sources,
detects conflicts between devices, merges data using configurable strategies,
and writes cleaned data back.

Features:
- Phase 1: HealthKit integration with automatic source discovery
- Phase 2: DataReader with conflict detection (time-window based)
- Phase 3: RuleEngine with 8 merge strategies (exclusive, priority, higher wins, etc.)
- Phase 4: MergeEngine for conflict resolution + DataWriter for HealthKit writes
- Phase 5: SwiftUI UI for dashboard, conflicts, rules, and sources management
- Phase 6: Background sync with configurable intervals and push notifications
- Phase 7: Complete rule editor and polished UI components

Supported data types:
- Steps, Heart Rate, Blood Pressure, SpO2, Sleep
- Distance, Floors Climbed, Active Energy, HRV, Respiratory Rate

Architecture: SourceManager -> DataReader -> RuleEngine -> MergeEngine -> DataWriter
2025-12-25 16:59:48 +00:00
admin 0ffb1c771e Merge pull request #12 from metacube2/claude/dctp-delta-transfer-swz4W
Build Delta Code Transfer Protocol tool
2025-12-25 14:51:10 +01:00
Claude 9363a2dd99 Add macOS Catalyst support for RollkofferSimulator
- Enable Mac Catalyst in Xcode project (SUPPORTS_MACCATALYST=YES)
- Set macOS deployment target to 13.0 (Ventura+)
- Add keyboard support for all scenes (Escape, Space, Enter)
- Add macOS menu bar with game commands (Cmd+P pause, Cmd+R restart)
- Configure window size restrictions for macOS
- Update Info.plist with macOS minimum version
2025-12-20 17:46:26 +00:00
155 changed files with 43975 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Ignore Visual Studio + build artifacts
.vs/
TrafagSalesExporter/.vs/
TrafagSalesExporter/bin/
TrafagSalesExporter/obj/
TrafagSalesExporter/*.user
TrafagSalesExporter/*.suo
TrafagSalesExporter/*.db
TrafagSalesExporter/*.db-shm
TrafagSalesExporter/*.db-wal
+91
View File
@@ -0,0 +1,91 @@
import SwiftUI
import HealthKit
import BackgroundTasks
@main
struct HealthBridgeApp: App {
@StateObject private var appState = AppState()
@StateObject private var healthKitManager = HealthKitManager.shared
@StateObject private var syncCoordinator = SyncCoordinator.shared
init() {
registerBackgroundTasks()
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(appState)
.environmentObject(healthKitManager)
.environmentObject(syncCoordinator)
.onAppear {
Task {
await requestHealthKitAuthorization()
}
}
}
}
private func registerBackgroundTasks() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.healthbridge.sync",
using: nil
) { task in
guard let bgTask = task as? BGAppRefreshTask else { return }
handleBackgroundSync(task: bgTask)
}
}
private func handleBackgroundSync(task: BGAppRefreshTask) {
scheduleNextBackgroundSync()
let syncTask = Task {
do {
try await syncCoordinator.performSync()
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
task.expirationHandler = {
syncTask.cancel()
}
}
private func scheduleNextBackgroundSync() {
let request = BGAppRefreshTaskRequest(identifier: "com.healthbridge.sync")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Failed to schedule background sync: \(error)")
}
}
private func requestHealthKitAuthorization() async {
do {
try await healthKitManager.requestAuthorization()
} catch {
print("HealthKit authorization failed: \(error)")
}
}
}
// MARK: - App State
@MainActor
class AppState: ObservableObject {
@Published var selectedTab: Tab = .dashboard
@Published var showingConflictDetail: Conflict?
@Published var isLoading = false
@Published var lastSyncDate: Date?
@Published var pendingConflicts: [Conflict] = []
enum Tab {
case dashboard
case conflicts
case rules
case sources
}
}
+315
View File
@@ -0,0 +1,315 @@
import Foundation
// MARK: - Conflict
struct Conflict: Identifiable, Codable {
let id: UUID
let dataType: HealthDataType
let timeWindow: TimeWindow
var readings: [SourceReading]
var status: ConflictStatus
var resolution: ConflictResolution?
var appliedStrategy: MergeStrategy?
let detectedAt: Date
var resolvedAt: Date?
init(
id: UUID = UUID(),
dataType: HealthDataType,
timeWindow: TimeWindow,
readings: [SourceReading],
status: ConflictStatus = .pending,
resolution: ConflictResolution? = nil,
appliedStrategy: MergeStrategy? = nil,
detectedAt: Date = Date(),
resolvedAt: Date? = nil
) {
self.id = id
self.dataType = dataType
self.timeWindow = timeWindow
self.readings = readings
self.status = status
self.resolution = resolution
self.appliedStrategy = appliedStrategy
self.detectedAt = detectedAt
self.resolvedAt = resolvedAt
}
var valueDifference: Double {
guard readings.count >= 2 else { return 0 }
let values = readings.map { $0.value }
return (values.max() ?? 0) - (values.min() ?? 0)
}
var percentageDifference: Double {
guard readings.count >= 2 else { return 0 }
let values = readings.map { $0.value }
guard let min = values.min(), min > 0 else { return 0 }
guard let max = values.max() else { return 0 }
return ((max - min) / min) * 100
}
var severity: ConflictSeverity {
let pctDiff = percentageDifference
if pctDiff < 5 { return .minor }
if pctDiff < 20 { return .moderate }
if pctDiff < 50 { return .significant }
return .major
}
var highestValueReading: SourceReading? {
readings.max(by: { $0.value < $1.value })
}
var lowestValueReading: SourceReading? {
readings.min(by: { $0.value < $1.value })
}
var primarySourceReading: SourceReading? {
readings.max(by: { $0.sourceCategory.priority < $1.sourceCategory.priority })
}
}
// MARK: - Conflict Status
enum ConflictStatus: String, Codable {
case pending = "pending"
case resolved = "resolved"
case manualReview = "manual_review"
case ignored = "ignored"
var displayName: String {
switch self {
case .pending: return "Offen"
case .resolved: return "Gelöst"
case .manualReview: return "Manuelle Prüfung"
case .ignored: return "Ignoriert"
}
}
var icon: String {
switch self {
case .pending: return "clock.fill"
case .resolved: return "checkmark.circle.fill"
case .manualReview: return "hand.raised.fill"
case .ignored: return "eye.slash.fill"
}
}
}
// MARK: - Conflict Severity
enum ConflictSeverity: String, Codable {
case minor = "minor"
case moderate = "moderate"
case significant = "significant"
case major = "major"
var displayName: String {
switch self {
case .minor: return "Gering"
case .moderate: return "Moderat"
case .significant: return "Erheblich"
case .major: return "Gross"
}
}
var color: String {
switch self {
case .minor: return "green"
case .moderate: return "yellow"
case .significant: return "orange"
case .major: return "red"
}
}
}
// MARK: - Conflict Resolution
struct ConflictResolution: Codable {
let resolvedValue: Double
let secondaryResolvedValue: Double? // For blood pressure
let winningSourceId: String
let strategy: MergeStrategy
let isManual: Bool
let resolvedAt: Date
let notes: String?
init(
resolvedValue: Double,
secondaryResolvedValue: Double? = nil,
winningSourceId: String,
strategy: MergeStrategy,
isManual: Bool = false,
resolvedAt: Date = Date(),
notes: String? = nil
) {
self.resolvedValue = resolvedValue
self.secondaryResolvedValue = secondaryResolvedValue
self.winningSourceId = winningSourceId
self.strategy = strategy
self.isManual = isManual
self.resolvedAt = resolvedAt
self.notes = notes
}
}
// MARK: - Merge Strategy
enum MergeStrategy: String, Codable, CaseIterable, Identifiable {
case exclusive = "exclusive"
case priority = "priority"
case higherWins = "higher_wins"
case lowerWins = "lower_wins"
case average = "average"
case coverage = "coverage"
case coverageThenHigher = "coverage_then_higher"
case manual = "manual"
case mostRecent = "most_recent"
var id: String { rawValue }
var displayName: String {
switch self {
case .exclusive: return "Exklusiv"
case .priority: return "Priorität"
case .higherWins: return "Höherer Wert"
case .lowerWins: return "Niedrigerer Wert"
case .average: return "Durchschnitt"
case .coverage: return "Abdeckung"
case .coverageThenHigher: return "Abdeckung + Höher"
case .manual: return "Manuell"
case .mostRecent: return "Neuester"
}
}
var description: String {
switch self {
case .exclusive:
return "Nur eine Quelle kann diesen Datentyp liefern"
case .priority:
return "Höchste Priorität gewinnt basierend auf Benutzereinstellungen"
case .higherWins:
return "Der grössere Wert wird verwendet (z.B. mehr Schritte = war aktiv)"
case .lowerWins:
return "Der kleinere Wert wird verwendet"
case .average:
return "Durchschnitt aller Quellen"
case .coverage:
return "Quelle mit Daten für dieses Zeitfenster"
case .coverageThenHigher:
return "Erst Abdeckung prüfen, dann höherer Wert bei Konflikt"
case .manual:
return "Benutzer entscheidet bei jedem Konflikt"
case .mostRecent:
return "Zuletzt erfasster Wert"
}
}
var icon: String {
switch self {
case .exclusive: return "1.circle.fill"
case .priority: return "list.number"
case .higherWins: return "arrow.up.circle.fill"
case .lowerWins: return "arrow.down.circle.fill"
case .average: return "divide.circle.fill"
case .coverage: return "square.fill.on.square.fill"
case .coverageThenHigher: return "square.stack.3d.up.fill"
case .manual: return "hand.raised.fill"
case .mostRecent: return "clock.arrow.circlepath"
}
}
}
// MARK: - Merge Rule
struct MergeRule: Identifiable, Codable {
let id: UUID
let dataType: HealthDataType
var strategy: MergeStrategy
var primarySourceId: String?
var fallbackSourceId: String?
var sourcePriorities: [String: Int]
var autoApply: Bool
var thresholdForManualReview: Double? // Percentage difference threshold
init(
id: UUID = UUID(),
dataType: HealthDataType,
strategy: MergeStrategy,
primarySourceId: String? = nil,
fallbackSourceId: String? = nil,
sourcePriorities: [String: Int] = [:],
autoApply: Bool = true,
thresholdForManualReview: Double? = nil
) {
self.id = id
self.dataType = dataType
self.strategy = strategy
self.primarySourceId = primarySourceId
self.fallbackSourceId = fallbackSourceId
self.sourcePriorities = sourcePriorities
self.autoApply = autoApply
self.thresholdForManualReview = thresholdForManualReview
}
static func defaultRule(for dataType: HealthDataType) -> MergeRule {
switch dataType {
case .bloodPressureSystolic, .bloodPressureDiastolic, .bloodOxygen,
.heartRate, .restingHeartRate, .heartRateVariability, .respiratoryRate:
return MergeRule(dataType: dataType, strategy: .exclusive)
case .floorsClimbed:
return MergeRule(dataType: dataType, strategy: .exclusive)
case .steps, .distance, .activeEnergy:
return MergeRule(dataType: dataType, strategy: .coverageThenHigher)
case .sleep:
return MergeRule(dataType: dataType, strategy: .priority)
}
}
}
// MARK: - Sync Record
struct SyncRecord: Identifiable, Codable {
let id: UUID
let dataType: HealthDataType
let timeWindow: TimeWindow
var readings: [SourceReading]
var mergedValue: Double?
var secondaryMergedValue: Double? // For blood pressure
var strategy: MergeStrategy
var status: SyncStatus
var hasConflict: Bool
var conflictId: UUID?
let createdAt: Date
var processedAt: Date?
enum SyncStatus: String, Codable {
case pending = "pending"
case processing = "processing"
case completed = "completed"
case failed = "failed"
case requiresManualReview = "requires_manual"
}
init(
id: UUID = UUID(),
dataType: HealthDataType,
timeWindow: TimeWindow,
readings: [SourceReading],
mergedValue: Double? = nil,
secondaryMergedValue: Double? = nil,
strategy: MergeStrategy = .priority,
status: SyncStatus = .pending,
hasConflict: Bool = false,
conflictId: UUID? = nil,
createdAt: Date = Date(),
processedAt: Date? = nil
) {
self.id = id
self.dataType = dataType
self.timeWindow = timeWindow
self.readings = readings
self.mergedValue = mergedValue
self.secondaryMergedValue = secondaryMergedValue
self.strategy = strategy
self.status = status
self.hasConflict = hasConflict
self.conflictId = conflictId
self.createdAt = createdAt
self.processedAt = processedAt
}
}
+269
View File
@@ -0,0 +1,269 @@
import Foundation
import HealthKit
// MARK: - Health Data Type
enum HealthDataType: String, CaseIterable, Codable, Identifiable {
case steps = "steps"
case heartRate = "heart_rate"
case bloodPressureSystolic = "blood_pressure_systolic"
case bloodPressureDiastolic = "blood_pressure_diastolic"
case bloodOxygen = "blood_oxygen"
case sleep = "sleep"
case distance = "distance"
case floorsClimbed = "floors_climbed"
case activeEnergy = "active_energy"
case restingHeartRate = "resting_heart_rate"
case heartRateVariability = "hrv"
case respiratoryRate = "respiratory_rate"
var id: String { rawValue }
var displayName: String {
switch self {
case .steps: return "Schritte"
case .heartRate: return "Herzfrequenz"
case .bloodPressureSystolic: return "Blutdruck (Systolisch)"
case .bloodPressureDiastolic: return "Blutdruck (Diastolisch)"
case .bloodOxygen: return "Blutsauerstoff (SpO2)"
case .sleep: return "Schlaf"
case .distance: return "Distanz"
case .floorsClimbed: return "Stockwerke"
case .activeEnergy: return "Aktive Energie"
case .restingHeartRate: return "Ruhepuls"
case .heartRateVariability: return "HRV"
case .respiratoryRate: return "Atemfrequenz"
}
}
var icon: String {
switch self {
case .steps: return "figure.walk"
case .heartRate, .restingHeartRate: return "heart.fill"
case .bloodPressureSystolic, .bloodPressureDiastolic: return "drop.fill"
case .bloodOxygen: return "lungs.fill"
case .sleep: return "bed.double.fill"
case .distance: return "map.fill"
case .floorsClimbed: return "stairs"
case .activeEnergy: return "flame.fill"
case .heartRateVariability: return "waveform.path.ecg"
case .respiratoryRate: return "wind"
}
}
var unit: String {
switch self {
case .steps: return "Schritte"
case .heartRate, .restingHeartRate: return "bpm"
case .bloodPressureSystolic, .bloodPressureDiastolic: return "mmHg"
case .bloodOxygen: return "%"
case .sleep: return "h"
case .distance: return "km"
case .floorsClimbed: return "Stockwerke"
case .activeEnergy: return "kcal"
case .heartRateVariability: return "ms"
case .respiratoryRate: return "/min"
}
}
var hkQuantityType: HKQuantityType? {
switch self {
case .steps:
return HKQuantityType.quantityType(forIdentifier: .stepCount)
case .heartRate:
return HKQuantityType.quantityType(forIdentifier: .heartRate)
case .bloodPressureSystolic:
return HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic)
case .bloodPressureDiastolic:
return HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic)
case .bloodOxygen:
return HKQuantityType.quantityType(forIdentifier: .oxygenSaturation)
case .distance:
return HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)
case .floorsClimbed:
return HKQuantityType.quantityType(forIdentifier: .flightsClimbed)
case .activeEnergy:
return HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)
case .restingHeartRate:
return HKQuantityType.quantityType(forIdentifier: .restingHeartRate)
case .heartRateVariability:
return HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN)
case .respiratoryRate:
return HKQuantityType.quantityType(forIdentifier: .respiratoryRate)
case .sleep:
return nil // Sleep uses category type
}
}
var hkCategoryType: HKCategoryType? {
switch self {
case .sleep:
return HKCategoryType.categoryType(forIdentifier: .sleepAnalysis)
default:
return nil
}
}
var hkUnit: HKUnit {
switch self {
case .steps, .floorsClimbed:
return .count()
case .heartRate, .restingHeartRate, .respiratoryRate:
return HKUnit.count().unitDivided(by: .minute())
case .bloodPressureSystolic, .bloodPressureDiastolic:
return .millimeterOfMercury()
case .bloodOxygen:
return .percent()
case .sleep:
return .hour()
case .distance:
return .meterUnit(with: .kilo)
case .activeEnergy:
return .kilocalorie()
case .heartRateVariability:
return .secondUnit(with: .milli)
}
}
/// Default primary source for this data type
var defaultPrimarySource: SourceCategory {
switch self {
case .floorsClimbed:
return .iPhone
case .steps, .heartRate, .bloodPressureSystolic, .bloodPressureDiastolic,
.bloodOxygen, .sleep, .distance, .activeEnergy, .restingHeartRate,
.heartRateVariability, .respiratoryRate:
return .watch
}
}
/// Whether this data type typically has only one source
var isExclusive: Bool {
switch self {
case .bloodPressureSystolic, .bloodPressureDiastolic, .bloodOxygen,
.heartRate, .restingHeartRate, .heartRateVariability, .respiratoryRate, .sleep:
return true
default:
return false
}
}
}
// MARK: - Source Category
enum SourceCategory: String, Codable, CaseIterable {
case iPhone = "iphone"
case watch = "watch"
case thirdPartyWatch = "third_party_watch"
case thirdPartyApp = "third_party_app"
case healthBridge = "health_bridge"
case unknown = "unknown"
var displayName: String {
switch self {
case .iPhone: return "iPhone"
case .watch: return "Apple Watch"
case .thirdPartyWatch: return "Drittanbieter-Watch"
case .thirdPartyApp: return "Drittanbieter-App"
case .healthBridge: return "HealthBridge"
case .unknown: return "Unbekannt"
}
}
var icon: String {
switch self {
case .iPhone: return "iphone"
case .watch: return "applewatch"
case .thirdPartyWatch: return "applewatch.side.right"
case .thirdPartyApp: return "app.badge"
case .healthBridge: return "arrow.triangle.2.circlepath"
case .unknown: return "questionmark.circle"
}
}
var priority: Int {
switch self {
case .healthBridge: return 100
case .watch: return 80
case .thirdPartyWatch: return 70
case .iPhone: return 50
case .thirdPartyApp: return 30
case .unknown: return 0
}
}
}
// MARK: - Data Quality
enum DataQuality: String, Codable {
case complete = "complete"
case partial = "partial"
case missing = "missing"
case invalid = "invalid"
var icon: String {
switch self {
case .complete: return "checkmark.circle.fill"
case .partial: return "circle.lefthalf.filled"
case .missing: return "circle.dashed"
case .invalid: return "xmark.circle.fill"
}
}
var color: String {
switch self {
case .complete: return "green"
case .partial: return "yellow"
case .missing: return "gray"
case .invalid: return "red"
}
}
}
// MARK: - Time Window
struct TimeWindow: Codable, Hashable, Identifiable {
let start: Date
let end: Date
var id: String { "\(start.timeIntervalSince1970)-\(end.timeIntervalSince1970)" }
var interval: DateInterval {
DateInterval(start: start, end: end)
}
var duration: TimeInterval {
end.timeIntervalSince(start)
}
var formattedRange: String {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return "\(formatter.string(from: start)) - \(formatter.string(from: end))"
}
var formattedDate: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter.string(from: start)
}
static func windows(for date: Date, intervalMinutes: Int = 15) -> [TimeWindow] {
let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: date)
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
var windows: [TimeWindow] = []
var current = startOfDay
while current < endOfDay {
let windowEnd = calendar.date(byAdding: .minute, value: intervalMinutes, to: current)!
windows.append(TimeWindow(start: current, end: min(windowEnd, endOfDay)))
current = windowEnd
}
return windows
}
static func hourlyWindows(for date: Date) -> [TimeWindow] {
windows(for: date, intervalMinutes: 60)
}
}
+183
View File
@@ -0,0 +1,183 @@
import Foundation
import HealthKit
// MARK: - Health Source
struct HealthSource: Identifiable, Codable, Hashable {
let id: String
let bundleIdentifier: String
let name: String
let category: SourceCategory
var supportedDataTypes: Set<HealthDataType>
var lastActivityDate: Date?
var userPriorities: [HealthDataType: Int]
var isEnabled: Bool
init(
id: String = UUID().uuidString,
bundleIdentifier: String,
name: String,
category: SourceCategory,
supportedDataTypes: Set<HealthDataType> = [],
lastActivityDate: Date? = nil,
userPriorities: [HealthDataType: Int] = [:],
isEnabled: Bool = true
) {
self.id = id
self.bundleIdentifier = bundleIdentifier
self.name = name
self.category = category
self.supportedDataTypes = supportedDataTypes
self.lastActivityDate = lastActivityDate
self.userPriorities = userPriorities
self.isEnabled = isEnabled
}
var displayName: String {
if name.isEmpty {
return bundleIdentifier.components(separatedBy: ".").last ?? bundleIdentifier
}
return name
}
var isHealthBridge: Bool {
bundleIdentifier == HealthBridgeConstants.bundleIdentifier
}
func priority(for dataType: HealthDataType) -> Int {
userPriorities[dataType] ?? category.priority
}
static func from(hkSource: HKSource) -> HealthSource {
let category = classifySource(bundleId: hkSource.bundleIdentifier)
return HealthSource(
id: hkSource.bundleIdentifier,
bundleIdentifier: hkSource.bundleIdentifier,
name: hkSource.name,
category: category
)
}
private static func classifySource(bundleId: String) -> SourceCategory {
let lowercased = bundleId.lowercased()
if lowercased.contains("healthbridge") {
return .healthBridge
} else if lowercased.contains("apple.health") {
return .iPhone
} else if lowercased.contains("watch") || lowercased.contains("applewatch") {
return .watch
} else if lowercased.contains("huawei") || lowercased.contains("samsung") ||
lowercased.contains("fitbit") || lowercased.contains("garmin") ||
lowercased.contains("polar") || lowercased.contains("withings") {
return .thirdPartyWatch
} else {
return .thirdPartyApp
}
}
}
// MARK: - Source Reading
struct SourceReading: Identifiable, Codable {
let id: UUID
let sourceId: String
let sourceName: String
let sourceCategory: SourceCategory
let value: Double
let secondaryValue: Double? // For blood pressure (diastolic)
let timestamp: Date
let originalRecordId: String?
let quality: DataQuality
init(
id: UUID = UUID(),
sourceId: String,
sourceName: String,
sourceCategory: SourceCategory,
value: Double,
secondaryValue: Double? = nil,
timestamp: Date,
originalRecordId: String? = nil,
quality: DataQuality = .complete
) {
self.id = id
self.sourceId = sourceId
self.sourceName = sourceName
self.sourceCategory = sourceCategory
self.value = value
self.secondaryValue = secondaryValue
self.timestamp = timestamp
self.originalRecordId = originalRecordId
self.quality = quality
}
var formattedValue: String {
if value == floor(value) {
return String(format: "%.0f", value)
}
return String(format: "%.1f", value)
}
}
// MARK: - Source Health Status
struct SourceHealthStatus: Identifiable {
let id: String
let source: HealthSource
let lastSync: Date?
let recordCount: Int
let dataGaps: [TimeWindow]
let overallQuality: DataQuality
var syncStatus: SyncStatus {
guard let lastSync = lastSync else {
return .neverSynced
}
let hoursSinceSync = Date().timeIntervalSince(lastSync) / 3600
if hoursSinceSync < 1 {
return .recentlySynced
} else if hoursSinceSync < 24 {
return .syncedToday
} else if hoursSinceSync < 72 {
return .stale
} else {
return .veryStale
}
}
enum SyncStatus {
case recentlySynced
case syncedToday
case stale
case veryStale
case neverSynced
var icon: String {
switch self {
case .recentlySynced: return "checkmark.circle.fill"
case .syncedToday: return "checkmark.circle"
case .stale: return "exclamationmark.circle"
case .veryStale: return "exclamationmark.triangle"
case .neverSynced: return "xmark.circle"
}
}
var description: String {
switch self {
case .recentlySynced: return "Kürzlich synchronisiert"
case .syncedToday: return "Heute synchronisiert"
case .stale: return "Sync überfällig"
case .veryStale: return "Lange nicht synchronisiert"
case .neverSynced: return "Nie synchronisiert"
}
}
}
}
// MARK: - Constants
enum HealthBridgeConstants {
static let bundleIdentifier = "com.healthbridge.merged"
static let displayName = "HealthBridge"
static let defaultSyncInterval: TimeInterval = 15 * 60 // 15 minutes
static let conflictThreshold: TimeInterval = 60 // 1 minute overlap tolerance
}
+140
View File
@@ -0,0 +1,140 @@
# HealthBridge
Intelligente Health-Daten-Synchronisation für iOS Eine "Single Source of Truth" für Gesundheitsdaten.
## Übersicht
HealthBridge liest alle Quellen aus Apple Health, erkennt Konflikte zwischen verschiedenen Geräten und Apps, merged intelligent basierend auf konfigurierbaren Regeln und schreibt bereinigte Daten zurück.
## Features
### Source Discovery
- Automatische Erkennung aller verbundenen Datenquellen
- Klassifizierung nach Gerätetyp (iPhone, Apple Watch, Drittanbieter-Watch, Apps)
- Übersicht über Fähigkeiten und unterstützte Datentypen pro Quelle
### Konflikt-Erkennung
- Automatische Erkennung von Datenkonflikten zwischen Quellen
- Zeitfenster-basierte Analyse (15-Minuten-Intervalle)
- Schweregrad-Klassifizierung (minor, moderate, significant, major)
### Merge-Strategien
- **Exclusive**: Nur eine Quelle möglich (z.B. Blutdruck, SpO2)
- **Priority**: Fixe Rangfolge basierend auf Benutzereinstellungen
- **Higher Wins**: Grösserer Wert gewinnt (ideal für Schritte)
- **Coverage**: Quelle mit Daten für Zeitfenster gewinnt
- **Coverage Then Higher**: Erst Abdeckung, dann höherer Wert
- **Average**: Durchschnitt aller Quellen
- **Manual**: Benutzer entscheidet bei jedem Konflikt
### UI-Komponenten
- **Dashboard**: Tagesübersicht aller Gesundheitsdaten mit Sync-Status
- **Konflikte**: Liste offener Konflikte mit One-Tap-Auflösung
- **Regeln**: Konfiguration der Merge-Strategien pro Datentyp
- **Quellen**: Übersicht aller erkannten Datenquellen
### Background Sync
- Automatische Synchronisierung im Hintergrund
- Konfigurierbares Intervall (15 Min bis 2 Stunden)
- Push-Benachrichtigungen bei neuen Konflikten
## Unterstützte Datentypen
| Datentyp | Primärquelle | Strategie |
|----------|--------------|-----------|
| Schritte | Watch | Coverage + Higher |
| Herzfrequenz | Watch | Exclusive |
| Blutdruck | Watch D2 | Exclusive |
| SpO2 | Watch | Exclusive |
| Schlaf | Watch | Priority |
| Distanz | Watch/iPhone | Coverage + Higher |
| Stockwerke | iPhone | Exclusive |
| Aktive Energie | Watch | Coverage + Higher |
## Architektur
```
┌─────────────────────────────────────────────────────────────────┐
│ Apple Health │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ HealthBridge App │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ DataReader │→ │ MergeEngine │→ │ DataWriter │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ▲ ▲ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │SourceManager│ │ RuleEngine │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SyncCoordinator │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## Projektstruktur
```
HealthBridge/
├── App/
│ └── HealthBridgeApp.swift # App-Entry, Background Tasks
├── Models/
│ ├── HealthDataTypes.swift # Datentypen, TimeWindow
│ ├── Source.swift # HealthSource, SourceReading
│ └── Conflict.swift # Conflict, MergeStrategy, MergeRule
├── Services/
│ ├── HealthKitManager.swift # HealthKit-Integration
│ ├── SourceManager.swift # Source Discovery & Management
│ ├── DataReader.swift # Daten lesen, Konflikte erkennen
│ ├── RuleEngine.swift # Merge-Regeln verwalten & anwenden
│ ├── MergeEngine.swift # Konflikte analysieren & lösen
│ ├── DataWriter.swift # Daten zurückschreiben
│ └── SyncCoordinator.swift # Orchestrierung aller Services
├── Views/
│ ├── ContentView.swift # Tab-Navigation
│ ├── DashboardView.swift # Hauptübersicht
│ ├── ConflictsView.swift # Konflikt-Liste & Detail
│ ├── RulesView.swift # Regelwerk-Editor
│ ├── SourcesView.swift # Quellen-Übersicht
│ ├── SettingsView.swift # Einstellungen
│ └── Components/
│ └── HealthChart.swift # Diagramm-Komponenten
├── ViewModels/
│ └── DashboardViewModel.swift # Dashboard-Logik
├── Utils/
│ ├── Extensions.swift # Swift-Erweiterungen
│ └── NotificationManager.swift # Push-Benachrichtigungen
└── Resources/
├── Info.plist # App-Konfiguration
└── HealthBridge.entitlements # HealthKit-Berechtigungen
```
## Voraussetzungen
- iOS 16.0+
- Xcode 15.0+
- Apple Developer Account (für HealthKit-Entitlements)
- Physisches Gerät (HealthKit nicht im Simulator verfügbar)
## Installation
1. Projekt in Xcode öffnen
2. Team für Code Signing auswählen
3. HealthKit-Capability aktivieren
4. Auf physischem Gerät ausführen
## Berechtigungen
Die App benötigt folgende Berechtigungen:
- **HealthKit Read**: Lesen aller Gesundheitsdaten
- **HealthKit Write**: Schreiben gemergter Daten
- **Background App Refresh**: Für automatische Synchronisierung
- **Notifications**: Für Konflikt-Benachrichtigungen
## Lizenz
MIT License
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array>
<string>health-records</string>
</array>
<key>com.apple.developer.healthkit.background-delivery</key>
<true/>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>
+74
View File
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>HealthBridge</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>LaunchScreenBackground</string>
<key>UIImageName</key>
<string>LaunchIcon</string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
<string>healthkit</string>
</array>
<!-- HealthKit -->
<key>NSHealthShareUsageDescription</key>
<string>HealthBridge benötigt Zugriff auf Ihre Gesundheitsdaten, um diese zwischen verschiedenen Quellen zu synchronisieren und Konflikte zu lösen.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>HealthBridge schreibt bereinigte Gesundheitsdaten zurück in Apple Health, um eine konsistente Datenbasis zu gewährleisten.</string>
<!-- Background Modes -->
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.healthbridge.sync</string>
<string>com.healthbridge.cleanup</string>
</array>
</dict>
</plist>
+488
View File
@@ -0,0 +1,488 @@
import Foundation
import HealthKit
import Combine
// MARK: - Data Reader
@MainActor
class DataReader: ObservableObject {
static let shared = DataReader()
private let healthKitManager = HealthKitManager.shared
private let sourceManager = SourceManager.shared
@Published var isReading = false
@Published var lastReadDate: Date?
@Published var detectedConflicts: [Conflict] = []
@Published var readingProgress: Double = 0
private init() {}
// MARK: - Fetch Data by Type and Date Range
func fetchData(
for dataType: HealthDataType,
from startDate: Date,
to endDate: Date,
groupByWindow intervalMinutes: Int = 15
) async throws -> [TimeWindowData] {
isReading = true
defer { isReading = false }
let samples = try await healthKitManager.fetchSamples(
for: dataType,
from: startDate,
to: endDate
)
// Group samples by source
let groupedBySource = groupBySource(samples: samples, dataType: dataType)
// Create time windows
let windows = generateTimeWindows(from: startDate, to: endDate, intervalMinutes: intervalMinutes)
// Assign samples to windows
var windowDataList: [TimeWindowData] = []
for window in windows {
let windowData = createWindowData(
window: window,
dataType: dataType,
groupedBySource: groupedBySource
)
windowDataList.append(windowData)
}
lastReadDate = Date()
return windowDataList
}
private func groupBySource(samples: [HKSample], dataType: HealthDataType) -> [String: [HKSample]] {
var grouped: [String: [HKSample]] = [:]
for sample in samples {
let sourceId = sample.sourceRevision.source.bundleIdentifier
if grouped[sourceId] == nil {
grouped[sourceId] = []
}
grouped[sourceId]?.append(sample)
}
return grouped
}
private func generateTimeWindows(from start: Date, to end: Date, intervalMinutes: Int) -> [TimeWindow] {
var windows: [TimeWindow] = []
var current = start
while current < end {
let windowEnd = min(
Calendar.current.date(byAdding: .minute, value: intervalMinutes, to: current)!,
end
)
windows.append(TimeWindow(start: current, end: windowEnd))
current = windowEnd
}
return windows
}
private func createWindowData(
window: TimeWindow,
dataType: HealthDataType,
groupedBySource: [String: [HKSample]]
) -> TimeWindowData {
var readings: [SourceReading] = []
for (sourceId, samples) in groupedBySource {
let windowSamples = samples.filter { sample in
sample.startDate < window.end && sample.endDate > window.start
}
if !windowSamples.isEmpty {
let reading = createReading(
from: windowSamples,
sourceId: sourceId,
dataType: dataType,
window: window
)
readings.append(reading)
}
}
let hasConflict = detectConflict(in: readings, dataType: dataType)
return TimeWindowData(
timeWindow: window,
dataType: dataType,
readings: readings,
hasConflict: hasConflict
)
}
private func createReading(
from samples: [HKSample],
sourceId: String,
dataType: HealthDataType,
window: TimeWindow
) -> SourceReading {
let value: Double
var secondaryValue: Double? = nil
switch dataType {
case .steps, .floorsClimbed, .activeEnergy, .distance:
// Sum up values for cumulative types
value = samples.compactMap { sample -> Double? in
guard let quantitySample = sample as? HKQuantitySample else { return nil }
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
}.reduce(0, +)
case .heartRate, .restingHeartRate, .respiratoryRate, .heartRateVariability, .bloodOxygen:
// Average for rate-based types
let values = samples.compactMap { sample -> Double? in
guard let quantitySample = sample as? HKQuantitySample else { return nil }
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
}
value = values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
case .bloodPressureSystolic, .bloodPressureDiastolic:
// For blood pressure, we need to handle correlations
let values = samples.compactMap { sample -> Double? in
guard let quantitySample = sample as? HKQuantitySample else { return nil }
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
}
value = values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
case .sleep:
// Sum up sleep duration
value = samples.reduce(0) { acc, sample in
acc + sample.endDate.timeIntervalSince(sample.startDate) / 3600
}
}
let source = sourceManager.sources.first { $0.bundleIdentifier == sourceId }
let category = source?.category ?? sourceManager.classifySource(sourceId)
return SourceReading(
sourceId: sourceId,
sourceName: source?.name ?? sourceId,
sourceCategory: category,
value: value,
secondaryValue: secondaryValue,
timestamp: window.start,
originalRecordId: samples.first?.uuid.uuidString,
quality: samples.isEmpty ? .missing : .complete
)
}
// MARK: - Conflict Detection
private func detectConflict(in readings: [SourceReading], dataType: HealthDataType) -> Bool {
// No conflict if less than 2 readings
guard readings.count >= 2 else { return false }
// Filter out zero values (device wasn't tracking)
let nonZeroReadings = readings.filter { $0.value > 0 }
guard nonZeroReadings.count >= 2 else { return false }
// Check if values differ significantly
let values = nonZeroReadings.map { $0.value }
guard let minVal = values.min(), let maxVal = values.max() else { return false }
// Threshold varies by data type
let threshold = conflictThreshold(for: dataType)
if minVal == 0 {
return maxVal > threshold.absoluteThreshold
}
let percentDiff = (maxVal - minVal) / minVal * 100
return percentDiff > threshold.percentageThreshold
}
private func conflictThreshold(for dataType: HealthDataType) -> ConflictThreshold {
switch dataType {
case .steps:
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 100)
case .distance:
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 0.1) // 100m
case .heartRate:
return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 10)
case .bloodPressureSystolic, .bloodPressureDiastolic:
return ConflictThreshold(percentageThreshold: 5, absoluteThreshold: 5)
case .bloodOxygen:
return ConflictThreshold(percentageThreshold: 2, absoluteThreshold: 2)
case .floorsClimbed:
return ConflictThreshold(percentageThreshold: 20, absoluteThreshold: 2)
case .activeEnergy:
return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 50)
case .sleep:
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 0.5) // 30 min
case .restingHeartRate:
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 5)
case .heartRateVariability:
return ConflictThreshold(percentageThreshold: 20, absoluteThreshold: 10)
case .respiratoryRate:
return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 2)
}
}
// MARK: - Detect All Conflicts
func detectConflicts(
for date: Date,
dataTypes: [HealthDataType] = HealthDataType.allCases
) async throws -> [Conflict] {
let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: date)
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
var allConflicts: [Conflict] = []
for (index, dataType) in dataTypes.enumerated() {
readingProgress = Double(index) / Double(dataTypes.count)
do {
let windowData = try await fetchData(
for: dataType,
from: startOfDay,
to: endOfDay
)
let conflicts = windowData
.filter { $0.hasConflict }
.map { data in
Conflict(
dataType: dataType,
timeWindow: data.timeWindow,
readings: data.readings,
status: .pending
)
}
allConflicts.append(contentsOf: conflicts)
} catch {
print("Failed to detect conflicts for \(dataType): \(error)")
}
}
readingProgress = 1.0
detectedConflicts = allConflicts
return allConflicts
}
// MARK: - Data Gaps Detection
func detectGaps(
for dataType: HealthDataType,
from startDate: Date,
to endDate: Date,
expectedIntervalMinutes: Int = 15
) async throws -> [DataGap] {
let samples = try await healthKitManager.fetchSamples(
for: dataType,
from: startDate,
to: endDate
)
guard !samples.isEmpty else {
return [DataGap(
dataType: dataType,
timeWindow: TimeWindow(start: startDate, end: endDate),
expectedRecordCount: 0,
actualRecordCount: 0
)]
}
let sortedSamples = samples.sorted { $0.startDate < $1.startDate }
var gaps: [DataGap] = []
let expectedInterval = TimeInterval(expectedIntervalMinutes * 60)
// Check gap at start
if let firstSample = sortedSamples.first,
firstSample.startDate.timeIntervalSince(startDate) > expectedInterval * 2 {
gaps.append(DataGap(
dataType: dataType,
timeWindow: TimeWindow(start: startDate, end: firstSample.startDate),
expectedRecordCount: Int(firstSample.startDate.timeIntervalSince(startDate) / expectedInterval),
actualRecordCount: 0
))
}
// Check gaps between samples
for i in 0..<(sortedSamples.count - 1) {
let current = sortedSamples[i]
let next = sortedSamples[i + 1]
let gap = next.startDate.timeIntervalSince(current.endDate)
if gap > expectedInterval * 2 {
gaps.append(DataGap(
dataType: dataType,
timeWindow: TimeWindow(start: current.endDate, end: next.startDate),
expectedRecordCount: Int(gap / expectedInterval),
actualRecordCount: 0
))
}
}
// Check gap at end
if let lastSample = sortedSamples.last,
endDate.timeIntervalSince(lastSample.endDate) > expectedInterval * 2 {
gaps.append(DataGap(
dataType: dataType,
timeWindow: TimeWindow(start: lastSample.endDate, end: endDate),
expectedRecordCount: Int(endDate.timeIntervalSince(lastSample.endDate) / expectedInterval),
actualRecordCount: 0
))
}
return gaps
}
// MARK: - Aggregated Data
func fetchDailySummary(for date: Date) async throws -> DailySummary {
let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: date)
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
var summary = DailySummary(date: date)
for dataType in HealthDataType.allCases {
do {
let samples = try await healthKitManager.fetchSamples(
for: dataType,
from: startOfDay,
to: endOfDay
)
let value = aggregateValue(samples: samples, dataType: dataType)
summary.values[dataType] = value
// Check for conflicts
let windowData = try await fetchData(
for: dataType,
from: startOfDay,
to: endOfDay
)
let conflictCount = windowData.filter { $0.hasConflict }.count
summary.conflictCounts[dataType] = conflictCount
} catch {
print("Failed to fetch \(dataType) for summary: \(error)")
}
}
return summary
}
private func aggregateValue(samples: [HKSample], dataType: HealthDataType) -> Double {
switch dataType {
case .steps, .floorsClimbed, .activeEnergy, .distance:
return samples.compactMap { sample -> Double? in
guard let quantitySample = sample as? HKQuantitySample else { return nil }
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
}.reduce(0, +)
case .heartRate, .restingHeartRate, .respiratoryRate, .heartRateVariability,
.bloodOxygen, .bloodPressureSystolic, .bloodPressureDiastolic:
let values = samples.compactMap { sample -> Double? in
guard let quantitySample = sample as? HKQuantitySample else { return nil }
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
}
return values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
case .sleep:
return samples.reduce(0) { acc, sample in
acc + sample.endDate.timeIntervalSince(sample.startDate) / 3600
}
}
}
}
// MARK: - Supporting Types
struct TimeWindowData: Identifiable {
let id = UUID()
let timeWindow: TimeWindow
let dataType: HealthDataType
let readings: [SourceReading]
let hasConflict: Bool
var primaryReading: SourceReading? {
readings.max { $0.sourceCategory.priority < $1.sourceCategory.priority }
}
var conflictSeverity: ConflictSeverity? {
guard hasConflict, readings.count >= 2 else { return nil }
let values = readings.map { $0.value }.filter { $0 > 0 }
guard let min = values.min(), let max = values.max(), min > 0 else { return nil }
let percentDiff = (max - min) / min * 100
if percentDiff < 5 { return .minor }
if percentDiff < 20 { return .moderate }
if percentDiff < 50 { return .significant }
return .major
}
}
struct ConflictThreshold {
let percentageThreshold: Double
let absoluteThreshold: Double
}
struct DataGap: Identifiable {
let id = UUID()
let dataType: HealthDataType
let timeWindow: TimeWindow
let expectedRecordCount: Int
let actualRecordCount: Int
var severity: GapSeverity {
let duration = timeWindow.duration
if duration < 3600 { return .minor } // < 1 hour
if duration < 4 * 3600 { return .moderate } // < 4 hours
if duration < 12 * 3600 { return .significant } // < 12 hours
return .major
}
enum GapSeverity {
case minor, moderate, significant, major
}
}
struct DailySummary {
let date: Date
var values: [HealthDataType: Double] = [:]
var conflictCounts: [HealthDataType: Int] = [:]
var lastUpdated = Date()
var totalConflicts: Int {
conflictCounts.values.reduce(0, +)
}
func formattedValue(for dataType: HealthDataType) -> String {
guard let value = values[dataType] else { return "" }
switch dataType {
case .steps, .floorsClimbed:
return String(format: "%.0f", value)
case .distance:
return String(format: "%.2f km", value)
case .heartRate, .restingHeartRate, .respiratoryRate:
return String(format: "%.0f %@", value, dataType.unit)
case .bloodPressureSystolic, .bloodPressureDiastolic:
return String(format: "%.0f mmHg", value)
case .bloodOxygen:
return String(format: "%.0f%%", value * 100)
case .activeEnergy:
return String(format: "%.0f kcal", value)
case .sleep:
let hours = Int(value)
let minutes = Int((value - Double(hours)) * 60)
return "\(hours)h \(minutes)min"
case .heartRateVariability:
return String(format: "%.0f ms", value)
}
}
}
+395
View File
@@ -0,0 +1,395 @@
import Foundation
import HealthKit
import Combine
// MARK: - Data Writer
@MainActor
class DataWriter: ObservableObject {
static let shared = DataWriter()
private let healthKitManager = HealthKitManager.shared
private let healthStore = HKHealthStore()
@Published var isWriting = false
@Published var writeProgress: Double = 0
@Published var lastWriteDate: Date?
@Published var writtenRecords: [WrittenRecord] = []
@Published var failedWrites: [FailedWrite] = []
private let processedRecordsKey = "healthbridge.processed.records"
private init() {
loadProcessedRecords()
}
// MARK: - Write Single Record
func writeRecord(_ mergedRecord: MergedRecord) async throws -> WrittenRecord {
isWriting = true
defer { isWriting = false }
// Check if already written
if isAlreadyWritten(mergedRecord) {
throw DataWriterError.duplicateRecord
}
let metadata = createMetadata(from: mergedRecord)
switch mergedRecord.dataType {
case .bloodPressureSystolic, .bloodPressureDiastolic:
// Blood pressure needs special handling
guard let diastolic = mergedRecord.secondaryValue else {
throw DataWriterError.missingSecondaryValue
}
try await writeBloodPressure(
systolic: mergedRecord.value,
diastolic: diastolic,
date: mergedRecord.timeWindow.start,
metadata: metadata
)
default:
try await writeSample(
dataType: mergedRecord.dataType,
value: mergedRecord.value,
date: mergedRecord.timeWindow.start,
metadata: metadata
)
}
let writtenRecord = WrittenRecord(
id: UUID(),
mergedRecordId: mergedRecord.id,
dataType: mergedRecord.dataType,
value: mergedRecord.value,
secondaryValue: mergedRecord.secondaryValue,
writtenAt: Date(),
timeWindow: mergedRecord.timeWindow
)
writtenRecords.append(writtenRecord)
markAsProcessed(mergedRecord)
lastWriteDate = Date()
return writtenRecord
}
// MARK: - Write Batch
func writeBatch(_ mergedRecords: [MergedRecord]) async -> BatchWriteResult {
isWriting = true
defer { isWriting = false }
var successful: [WrittenRecord] = []
var failed: [FailedWrite] = []
for (index, record) in mergedRecords.enumerated() {
writeProgress = Double(index) / Double(mergedRecords.count)
do {
let writtenRecord = try await writeRecord(record)
successful.append(writtenRecord)
} catch {
let failedWrite = FailedWrite(
mergedRecord: record,
error: error,
attemptedAt: Date()
)
failed.append(failedWrite)
failedWrites.append(failedWrite)
}
}
writeProgress = 1.0
return BatchWriteResult(
successful: successful,
failed: failed,
completedAt: Date()
)
}
// MARK: - Private Write Methods
private func writeSample(
dataType: HealthDataType,
value: Double,
date: Date,
metadata: [String: Any]
) async throws {
guard let quantityType = dataType.hkQuantityType else {
throw DataWriterError.unsupportedDataType
}
let quantity = HKQuantity(unit: dataType.hkUnit, doubleValue: value)
let sample = HKQuantitySample(
type: quantityType,
quantity: quantity,
start: date,
end: date,
metadata: metadata
)
try await healthStore.save(sample)
}
private func writeBloodPressure(
systolic: Double,
diastolic: Double,
date: Date,
metadata: [String: Any]
) async throws {
// Validate blood pressure values
let validation = BloodPressureHandler.shared.validate(
systolic: systolic,
diastolic: diastolic
)
if !validation.isValid {
throw DataWriterError.invalidValue(validation.issues.joined(separator: ", "))
}
guard let systolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic),
let diastolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic),
let correlationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure) else {
throw DataWriterError.unsupportedDataType
}
let systolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: systolic)
let diastolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: diastolic)
let systolicSample = HKQuantitySample(
type: systolicType,
quantity: systolicQuantity,
start: date,
end: date,
metadata: metadata
)
let diastolicSample = HKQuantitySample(
type: diastolicType,
quantity: diastolicQuantity,
start: date,
end: date,
metadata: metadata
)
let correlation = HKCorrelation(
type: correlationType,
start: date,
end: date,
objects: [systolicSample, diastolicSample],
metadata: metadata
)
try await healthStore.save(correlation)
}
// MARK: - Metadata
private func createMetadata(from record: MergedRecord) -> [String: Any] {
var metadata: [String: Any] = [
HKMetadataKeyWasUserEntered: false,
"HealthBridgeSource": HealthBridgeConstants.bundleIdentifier,
"OriginalSourceId": record.originalSourceId,
"MergeStrategy": record.strategy.rawValue,
"MergedRecordId": record.id.uuidString,
"MergedAt": ISO8601DateFormatter().string(from: record.createdAt)
]
for (key, value) in record.metadata {
metadata["HB_\(key)"] = value
}
return metadata
}
// MARK: - Duplicate Prevention
private var processedRecordIds: Set<String> = []
private func loadProcessedRecords() {
if let data = UserDefaults.standard.data(forKey: processedRecordsKey),
let ids = try? JSONDecoder().decode(Set<String>.self, from: data) {
processedRecordIds = ids
}
}
private func saveProcessedRecords() {
if let data = try? JSONEncoder().encode(processedRecordIds) {
UserDefaults.standard.set(data, forKey: processedRecordsKey)
}
}
private func isAlreadyWritten(_ record: MergedRecord) -> Bool {
let identifier = createRecordIdentifier(record)
return processedRecordIds.contains(identifier)
}
private func markAsProcessed(_ record: MergedRecord) {
let identifier = createRecordIdentifier(record)
processedRecordIds.insert(identifier)
saveProcessedRecords()
// Cleanup old records (keep last 7 days)
cleanupOldRecords()
}
private func createRecordIdentifier(_ record: MergedRecord) -> String {
let components = [
record.dataType.rawValue,
String(record.timeWindow.start.timeIntervalSince1970),
String(record.value)
]
return components.joined(separator: "-")
}
private func cleanupOldRecords() {
// Keep only identifiers that contain recent timestamps
let sevenDaysAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
let cutoffTimestamp = sevenDaysAgo.timeIntervalSince1970
processedRecordIds = processedRecordIds.filter { identifier in
guard let parts = identifier.split(separator: "-").dropFirst().first,
let timestamp = Double(parts) else {
return false
}
return timestamp > cutoffTimestamp
}
saveProcessedRecords()
}
// MARK: - Delete Records
func deleteHealthBridgeRecords(
for dataType: HealthDataType,
from startDate: Date,
to endDate: Date
) async throws -> Int {
guard let sampleType = dataType.hkQuantityType else {
throw DataWriterError.unsupportedDataType
}
let predicate = HKQuery.predicateForSamples(
withStart: startDate,
end: endDate,
options: .strictStartDate
)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: sampleType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: nil
) { [weak self] _, samplesOrNil, errorOrNil in
guard let self = self else {
continuation.resume(throwing: DataWriterError.unknownError)
return
}
if let error = errorOrNil {
continuation.resume(throwing: error)
return
}
guard let samples = samplesOrNil else {
continuation.resume(returning: 0)
return
}
// Filter to only HealthBridge records
let healthBridgeSamples = samples.filter { sample in
if let metadata = sample.metadata,
let source = metadata["HealthBridgeSource"] as? String {
return source == HealthBridgeConstants.bundleIdentifier
}
return false
}
guard !healthBridgeSamples.isEmpty else {
continuation.resume(returning: 0)
return
}
Task {
do {
try await self.healthStore.delete(healthBridgeSamples)
continuation.resume(returning: healthBridgeSamples.count)
} catch {
continuation.resume(throwing: error)
}
}
}
healthStore.execute(query)
}
}
}
// MARK: - Supporting Types
struct WrittenRecord: Identifiable, Codable {
let id: UUID
let mergedRecordId: UUID
let dataType: HealthDataType
let value: Double
let secondaryValue: Double?
let writtenAt: Date
let timeWindow: TimeWindow
}
struct FailedWrite: Identifiable {
let id = UUID()
let mergedRecord: MergedRecord
let error: Error
let attemptedAt: Date
var errorMessage: String {
error.localizedDescription
}
}
struct BatchWriteResult {
let successful: [WrittenRecord]
let failed: [FailedWrite]
let completedAt: Date
var successCount: Int { successful.count }
var failureCount: Int { failed.count }
var totalCount: Int { successCount + failureCount }
var successRate: Double {
guard totalCount > 0 else { return 1.0 }
return Double(successCount) / Double(totalCount)
}
}
// MARK: - Errors
enum DataWriterError: LocalizedError {
case unsupportedDataType
case duplicateRecord
case missingSecondaryValue
case invalidValue(String)
case writeFailed(String)
case unknownError
var errorDescription: String? {
switch self {
case .unsupportedDataType:
return "Dieser Datentyp wird nicht unterstützt"
case .duplicateRecord:
return "Dieser Datensatz wurde bereits geschrieben"
case .missingSecondaryValue:
return "Fehlender sekundärer Wert (z.B. diastolischer Blutdruck)"
case .invalidValue(let message):
return "Ungültiger Wert: \(message)"
case .writeFailed(let message):
return "Schreiben fehlgeschlagen: \(message)"
case .unknownError:
return "Unbekannter Fehler"
}
}
}
@@ -0,0 +1,391 @@
import Foundation
import HealthKit
import Combine
// MARK: - HealthKit Manager
@MainActor
class HealthKitManager: ObservableObject {
static let shared = HealthKitManager()
private let healthStore = HKHealthStore()
@Published var isAuthorized = false
@Published var authorizationStatus: [HealthDataType: HKAuthorizationStatus] = [:]
@Published var discoveredSources: [HealthSource] = []
@Published var sourceHealthStatus: [String: SourceHealthStatus] = [:]
@Published var lastError: Error?
private init() {}
// MARK: - Authorization
var allQuantityTypes: Set<HKQuantityType> {
var types = Set<HKQuantityType>()
for dataType in HealthDataType.allCases {
if let quantityType = dataType.hkQuantityType {
types.insert(quantityType)
}
}
return types
}
var allCategoryTypes: Set<HKCategoryType> {
var types = Set<HKCategoryType>()
for dataType in HealthDataType.allCases {
if let categoryType = dataType.hkCategoryType {
types.insert(categoryType)
}
}
return types
}
var allSampleTypes: Set<HKSampleType> {
var types = Set<HKSampleType>()
allQuantityTypes.forEach { types.insert($0) }
allCategoryTypes.forEach { types.insert($0) }
return types
}
func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthKitError.healthDataNotAvailable
}
let typesToRead = allSampleTypes
let typesToWrite = allQuantityTypes
try await healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead)
isAuthorized = true
await updateAuthorizationStatus()
await discoverSources()
}
func updateAuthorizationStatus() async {
for dataType in HealthDataType.allCases {
if let quantityType = dataType.hkQuantityType {
let status = healthStore.authorizationStatus(for: quantityType)
authorizationStatus[dataType] = status
} else if let categoryType = dataType.hkCategoryType {
let status = healthStore.authorizationStatus(for: categoryType)
authorizationStatus[dataType] = status
}
}
}
// MARK: - Source Discovery
func discoverSources() async {
var allSources: [String: HealthSource] = [:]
for dataType in HealthDataType.allCases {
guard let sampleType = dataType.hkQuantityType ?? dataType.hkCategoryType else {
continue
}
do {
let sources = try await fetchSources(for: sampleType)
for source in sources {
if var existingSource = allSources[source.bundleIdentifier] {
existingSource.supportedDataTypes.insert(dataType)
allSources[source.bundleIdentifier] = existingSource
} else {
var newSource = source
newSource.supportedDataTypes.insert(dataType)
allSources[source.bundleIdentifier] = newSource
}
}
} catch {
print("Failed to fetch sources for \(dataType): \(error)")
}
}
discoveredSources = Array(allSources.values).sorted { $0.category.priority > $1.category.priority }
// Update source health status
for source in discoveredSources {
await updateSourceHealth(source)
}
}
private func fetchSources(for sampleType: HKSampleType) async throws -> [HealthSource] {
let query = HKSourceQuery(sampleType: sampleType, samplePredicate: nil) { _, sourcesOrNil, errorOrNil in
// Handled via continuation
}
return try await withCheckedThrowingContinuation { continuation in
let query = HKSourceQuery(sampleType: sampleType, samplePredicate: nil) { _, sourcesOrNil, errorOrNil in
if let error = errorOrNil {
continuation.resume(throwing: error)
return
}
guard let sources = sourcesOrNil else {
continuation.resume(returning: [])
return
}
let healthSources = sources.map { HealthSource.from(hkSource: $0) }
continuation.resume(returning: healthSources)
}
healthStore.execute(query)
}
}
private func updateSourceHealth(_ source: HealthSource) async {
var recordCount = 0
var lastActivity: Date?
for dataType in source.supportedDataTypes {
if let quantityType = dataType.hkQuantityType {
let predicate = HKQuery.predicateForObjects(from: HKSource(bundleIdentifier: source.bundleIdentifier, name: source.name) )
// Simplified: just get count
if let count = try? await fetchRecordCount(for: quantityType, source: source) {
recordCount += count
}
if let date = try? await fetchLastActivityDate(for: quantityType, source: source) {
if lastActivity == nil || date > lastActivity! {
lastActivity = date
}
}
}
}
let status = SourceHealthStatus(
id: source.id,
source: source,
lastSync: lastActivity,
recordCount: recordCount,
dataGaps: [], // TODO: Implement gap detection
overallQuality: recordCount > 0 ? .complete : .missing
)
sourceHealthStatus[source.id] = status
}
private func fetchRecordCount(for sampleType: HKSampleType, source: HealthSource) async throws -> Int {
let calendar = Calendar.current
let now = Date()
let startOfDay = calendar.startOfDay(for: now)
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now, options: .strictStartDate)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: sampleType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: nil
) { _, samplesOrNil, errorOrNil in
if let error = errorOrNil {
continuation.resume(throwing: error)
return
}
let samples = samplesOrNil ?? []
let matchingSamples = samples.filter { $0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier }
continuation.resume(returning: matchingSamples.count)
}
self.healthStore.execute(query)
}
}
private func fetchLastActivityDate(for sampleType: HKSampleType, source: HealthSource) async throws -> Date? {
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: sampleType,
predicate: nil,
limit: 1,
sortDescriptors: [sortDescriptor]
) { _, samplesOrNil, errorOrNil in
if let error = errorOrNil {
continuation.resume(throwing: error)
return
}
let matchingSample = samplesOrNil?.first { $0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier }
continuation.resume(returning: matchingSample?.endDate)
}
self.healthStore.execute(query)
}
}
// MARK: - Source Classification
func classifySource(_ source: HKSource) -> SourceCategory {
let bundleId = source.bundleIdentifier.lowercased()
if bundleId.contains("healthbridge") {
return .healthBridge
} else if bundleId.contains("apple.health") && !bundleId.contains("watch") {
return .iPhone
} else if bundleId.contains("apple") && bundleId.contains("watch") {
return .watch
} else if bundleId.contains("huawei") {
return .thirdPartyWatch
} else if bundleId.contains("samsung") || bundleId.contains("galaxy") {
return .thirdPartyWatch
} else if bundleId.contains("fitbit") {
return .thirdPartyWatch
} else if bundleId.contains("garmin") {
return .thirdPartyWatch
} else if bundleId.contains("polar") {
return .thirdPartyWatch
} else if bundleId.contains("withings") {
return .thirdPartyWatch
} else {
return .thirdPartyApp
}
}
func getSourceCapabilities(_ source: HealthSource) -> Set<HealthDataType> {
return source.supportedDataTypes
}
// MARK: - Data Fetching (Basic)
func fetchSamples(
for dataType: HealthDataType,
from startDate: Date,
to endDate: Date
) async throws -> [HKSample] {
guard let sampleType = dataType.hkQuantityType ?? dataType.hkCategoryType else {
throw HealthKitError.unsupportedDataType
}
let predicate = HKQuery.predicateForSamples(
withStart: startDate,
end: endDate,
options: .strictStartDate
)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: sampleType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)]
) { _, samplesOrNil, errorOrNil in
if let error = errorOrNil {
continuation.resume(throwing: error)
return
}
continuation.resume(returning: samplesOrNil ?? [])
}
self.healthStore.execute(query)
}
}
// MARK: - Data Writing
func writeSample(
dataType: HealthDataType,
value: Double,
secondaryValue: Double? = nil,
date: Date,
metadata: [String: Any]? = nil
) async throws {
guard let quantityType = dataType.hkQuantityType else {
throw HealthKitError.unsupportedDataType
}
let quantity = HKQuantity(unit: dataType.hkUnit, doubleValue: value)
let sample = HKQuantitySample(
type: quantityType,
quantity: quantity,
start: date,
end: date,
metadata: metadata
)
try await healthStore.save(sample)
}
func writeBloodPressure(
systolic: Double,
diastolic: Double,
date: Date,
metadata: [String: Any]? = nil
) async throws {
guard let systolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic),
let diastolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic) else {
throw HealthKitError.unsupportedDataType
}
let systolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: systolic)
let diastolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: diastolic)
let systolicSample = HKQuantitySample(
type: systolicType,
quantity: systolicQuantity,
start: date,
end: date,
metadata: metadata
)
let diastolicSample = HKQuantitySample(
type: diastolicType,
quantity: diastolicQuantity,
start: date,
end: date,
metadata: metadata
)
// Create correlation for blood pressure
guard let correlationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure) else {
throw HealthKitError.unsupportedDataType
}
let correlation = HKCorrelation(
type: correlationType,
start: date,
end: date,
objects: [systolicSample, diastolicSample],
metadata: metadata
)
try await healthStore.save(correlation)
}
}
// MARK: - HealthKit Errors
enum HealthKitError: LocalizedError {
case healthDataNotAvailable
case authorizationDenied
case unsupportedDataType
case noDataFound
case writeFailed(Error)
case queryFailed(Error)
var errorDescription: String? {
switch self {
case .healthDataNotAvailable:
return "Health-Daten sind auf diesem Gerät nicht verfügbar"
case .authorizationDenied:
return "Zugriff auf Health-Daten wurde verweigert"
case .unsupportedDataType:
return "Dieser Datentyp wird nicht unterstützt"
case .noDataFound:
return "Keine Daten gefunden"
case .writeFailed(let error):
return "Schreiben fehlgeschlagen: \(error.localizedDescription)"
case .queryFailed(let error):
return "Abfrage fehlgeschlagen: \(error.localizedDescription)"
}
}
}
// MARK: - HKSource Extension
extension HKSource {
convenience init(bundleIdentifier: String, name: String) {
// Note: This is a workaround since HKSource doesn't have a public initializer
// In production, sources come from HealthKit queries
fatalError("HKSource cannot be initialized directly - use source from HKSample")
}
}
+287
View File
@@ -0,0 +1,287 @@
import Foundation
import Combine
// MARK: - Merge Engine
@MainActor
class MergeEngine: ObservableObject {
static let shared = MergeEngine()
private let ruleEngine = RuleEngine.shared
private let dataReader = DataReader.shared
@Published var pendingMerges: [MergeOperation] = []
@Published var completedMerges: [MergeOperation] = []
@Published var isMerging = false
@Published var mergeProgress: Double = 0
private init() {}
// MARK: - Analyze Window
func analyze(windowData: TimeWindowData) -> WindowAnalysis {
let readings = windowData.readings
let dataType = windowData.dataType
// No analysis needed for single reading
if readings.count <= 1 {
return WindowAnalysis(
windowData: windowData,
hasConflict: false,
conflictSeverity: nil,
recommendedReading: readings.first,
alternativeReadings: [],
confidence: .high,
analysisNotes: readings.isEmpty ? "Keine Daten" : "Einzelne Quelle"
)
}
// Apply rule to get recommendation
let result = ruleEngine.applyRule(to: readings, dataType: dataType)
// Calculate conflict severity
let values = readings.map { $0.value }.filter { $0 > 0 }
var severity: ConflictSeverity? = nil
if values.count >= 2, let min = values.min(), let max = values.max(), min > 0 {
let percentDiff = (max - min) / min * 100
if percentDiff >= 5 {
if percentDiff < 10 { severity = .minor }
else if percentDiff < 25 { severity = .moderate }
else if percentDiff < 50 { severity = .significant }
else { severity = .major }
}
}
let alternativeReadings = readings.filter { $0.id != result.selectedReading?.id }
return WindowAnalysis(
windowData: windowData,
hasConflict: windowData.hasConflict,
conflictSeverity: severity,
recommendedReading: result.selectedReading,
alternativeReadings: alternativeReadings,
confidence: result.confidence,
analysisNotes: result.reason
)
}
// MARK: - Resolve Conflict
func resolveConflict(_ conflict: Conflict, using result: RuleApplicationResult) -> ConflictResolution? {
guard let selectedReading = result.selectedReading else {
return nil
}
return ConflictResolution(
resolvedValue: selectedReading.value,
secondaryResolvedValue: selectedReading.secondaryValue,
winningSourceId: selectedReading.sourceId,
strategy: result.strategy,
isManual: result.strategy == .manual
)
}
func resolveConflictManually(
_ conflict: Conflict,
selectedReadingId: UUID
) -> ConflictResolution? {
guard let selectedReading = conflict.readings.first(where: { $0.id == selectedReadingId }) else {
return nil
}
return ConflictResolution(
resolvedValue: selectedReading.value,
secondaryResolvedValue: selectedReading.secondaryValue,
winningSourceId: selectedReading.sourceId,
strategy: .manual,
isManual: true
)
}
// MARK: - Create Merged Record
func createMergedRecord(from conflict: Conflict, resolution: ConflictResolution) -> MergedRecord {
return MergedRecord(
id: UUID(),
dataType: conflict.dataType,
timeWindow: conflict.timeWindow,
value: resolution.resolvedValue,
secondaryValue: resolution.secondaryResolvedValue,
originalSourceId: resolution.winningSourceId,
strategy: resolution.strategy,
createdAt: Date(),
metadata: [
"conflictId": conflict.id.uuidString,
"originalSourceCount": String(conflict.readings.count),
"isManualResolution": String(resolution.isManual)
]
)
}
// MARK: - Batch Processing
func processConflicts(_ conflicts: [Conflict]) async -> [MergeOperation] {
isMerging = true
defer { isMerging = false }
var operations: [MergeOperation] = []
for (index, conflict) in conflicts.enumerated() {
mergeProgress = Double(index) / Double(conflicts.count)
let result = ruleEngine.applyRule(to: conflict.readings, dataType: conflict.dataType)
if result.confidence == .requiresManual ||
ruleEngine.shouldRequestManualReview(readings: conflict.readings, dataType: conflict.dataType) {
// Add to pending for manual review
let operation = MergeOperation(
conflict: conflict,
status: .pendingManualReview,
result: result
)
pendingMerges.append(operation)
operations.append(operation)
} else if let resolution = resolveConflict(conflict, using: result) {
// Auto-resolve
let mergedRecord = createMergedRecord(from: conflict, resolution: resolution)
let operation = MergeOperation(
conflict: conflict,
status: .resolved,
result: result,
resolution: resolution,
mergedRecord: mergedRecord
)
completedMerges.append(operation)
operations.append(operation)
}
}
mergeProgress = 1.0
return operations
}
// MARK: - Daily Merge
func performDailyMerge(for date: Date) async throws -> DailyMergeReport {
let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: date)
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
var report = DailyMergeReport(date: date)
for dataType in HealthDataType.allCases {
do {
let windowData = try await dataReader.fetchData(
for: dataType,
from: startOfDay,
to: endOfDay
)
let conflictWindows = windowData.filter { $0.hasConflict }
report.totalConflicts += conflictWindows.count
for window in conflictWindows {
let analysis = analyze(windowData: window)
if analysis.confidence == .requiresManual {
report.pendingManualReview += 1
} else {
report.autoResolved += 1
}
report.analysesByType[dataType, default: []].append(analysis)
}
} catch {
report.errors.append("Fehler bei \(dataType.displayName): \(error.localizedDescription)")
}
}
return report
}
}
// MARK: - Supporting Types
struct WindowAnalysis {
let windowData: TimeWindowData
let hasConflict: Bool
let conflictSeverity: ConflictSeverity?
let recommendedReading: SourceReading?
let alternativeReadings: [SourceReading]
let confidence: RuleConfidence
let analysisNotes: String
var recommendedValue: Double? {
recommendedReading?.value
}
var valueDifference: Double? {
guard let recommended = recommendedReading?.value,
let alternative = alternativeReadings.first?.value else {
return nil
}
return abs(recommended - alternative)
}
}
struct MergeOperation: Identifiable {
let id = UUID()
let conflict: Conflict
var status: MergeStatus
let result: RuleApplicationResult
var resolution: ConflictResolution?
var mergedRecord: MergedRecord?
let createdAt = Date()
var processedAt: Date?
enum MergeStatus {
case pending
case pendingManualReview
case resolved
case written
case failed
}
}
struct MergedRecord: Identifiable, Codable {
let id: UUID
let dataType: HealthDataType
let timeWindow: TimeWindow
let value: Double
let secondaryValue: Double?
let originalSourceId: String
let strategy: MergeStrategy
let createdAt: Date
var writtenAt: Date?
var healthKitRecordId: String?
var metadata: [String: String]
var formattedValue: String {
if value == floor(value) {
return String(format: "%.0f", value)
}
return String(format: "%.1f", value)
}
}
struct DailyMergeReport {
let date: Date
var totalConflicts = 0
var autoResolved = 0
var pendingManualReview = 0
var analysesByType: [HealthDataType: [WindowAnalysis]] = [:]
var errors: [String] = []
let generatedAt = Date()
var successRate: Double {
guard totalConflicts > 0 else { return 1.0 }
return Double(autoResolved) / Double(totalConflicts)
}
var summary: String {
if totalConflicts == 0 {
return "Keine Konflikte gefunden"
}
return "\(autoResolved)/\(totalConflicts) automatisch gelöst, \(pendingManualReview) zur Prüfung"
}
}
+571
View File
@@ -0,0 +1,571 @@
import Foundation
import Combine
// MARK: - Rule Engine
@MainActor
class RuleEngine: ObservableObject {
static let shared = RuleEngine()
@Published var rules: [HealthDataType: MergeRule] = [:]
@Published var isLoaded = false
private let storage = RuleStorageManager()
private let sourceManager = SourceManager.shared
private init() {
loadRules()
}
// MARK: - Rule Loading
func loadRules() {
let savedRules = storage.loadRules()
if savedRules.isEmpty {
// Initialize with defaults
for dataType in HealthDataType.allCases {
rules[dataType] = MergeRule.defaultRule(for: dataType)
}
saveRules()
} else {
rules = savedRules
}
isLoaded = true
}
func saveRules() {
storage.saveRules(rules)
}
// MARK: - Rule Access
func getRule(for dataType: HealthDataType) -> MergeRule {
return rules[dataType] ?? MergeRule.defaultRule(for: dataType)
}
func setRule(_ rule: MergeRule, for dataType: HealthDataType) {
rules[dataType] = rule
saveRules()
}
func resetToDefault(for dataType: HealthDataType) {
rules[dataType] = MergeRule.defaultRule(for: dataType)
saveRules()
}
func resetAllToDefaults() {
for dataType in HealthDataType.allCases {
rules[dataType] = MergeRule.defaultRule(for: dataType)
}
saveRules()
}
// MARK: - Rule Application
func applyRule(
to readings: [SourceReading],
dataType: HealthDataType
) -> RuleApplicationResult {
let rule = getRule(for: dataType)
// Filter out empty/zero readings for most strategies
let validReadings = readings.filter { $0.value > 0 || $0.quality == .complete }
guard !validReadings.isEmpty else {
return RuleApplicationResult(
selectedReading: nil,
strategy: rule.strategy,
confidence: .low,
reason: "Keine gültigen Werte vorhanden"
)
}
// If only one valid reading, no conflict
if validReadings.count == 1 {
return RuleApplicationResult(
selectedReading: validReadings[0],
strategy: rule.strategy,
confidence: .high,
reason: "Nur eine Quelle verfügbar"
)
}
// Apply strategy
switch rule.strategy {
case .exclusive:
return applyExclusiveStrategy(readings: validReadings, rule: rule)
case .priority:
return applyPriorityStrategy(readings: validReadings, rule: rule, dataType: dataType)
case .higherWins:
return applyHigherWinsStrategy(readings: validReadings, rule: rule)
case .lowerWins:
return applyLowerWinsStrategy(readings: validReadings, rule: rule)
case .average:
return applyAverageStrategy(readings: validReadings, rule: rule)
case .coverage:
return applyCoverageStrategy(readings: validReadings, rule: rule)
case .coverageThenHigher:
return applyCoverageThenHigherStrategy(readings: validReadings, rule: rule)
case .mostRecent:
return applyMostRecentStrategy(readings: validReadings, rule: rule)
case .manual:
return RuleApplicationResult(
selectedReading: nil,
strategy: .manual,
confidence: .requiresManual,
reason: "Manuelle Entscheidung erforderlich"
)
}
}
// MARK: - Strategy Implementations
private func applyExclusiveStrategy(
readings: [SourceReading],
rule: MergeRule
) -> RuleApplicationResult {
// If primary source is specified, use it
if let primaryId = rule.primarySourceId,
let reading = readings.first(where: { $0.sourceId == primaryId }) {
return RuleApplicationResult(
selectedReading: reading,
strategy: .exclusive,
confidence: .high,
reason: "Exklusive Quelle: \(reading.sourceName)"
)
}
// Otherwise use highest priority source
let sorted = readings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority }
if let first = sorted.first {
return RuleApplicationResult(
selectedReading: first,
strategy: .exclusive,
confidence: .high,
reason: "Höchste Priorität: \(first.sourceName)"
)
}
return RuleApplicationResult(
selectedReading: nil,
strategy: .exclusive,
confidence: .low,
reason: "Keine geeignete Quelle gefunden"
)
}
private func applyPriorityStrategy(
readings: [SourceReading],
rule: MergeRule,
dataType: HealthDataType
) -> RuleApplicationResult {
// Sort by user-defined priority, then by category priority
let sorted = readings.sorted { r1, r2 in
let p1 = rule.sourcePriorities[r1.sourceId] ?? r1.sourceCategory.priority
let p2 = rule.sourcePriorities[r2.sourceId] ?? r2.sourceCategory.priority
return p1 > p2
}
if let first = sorted.first {
return RuleApplicationResult(
selectedReading: first,
strategy: .priority,
confidence: .high,
reason: "Höchste Priorität: \(first.sourceName)"
)
}
return RuleApplicationResult(
selectedReading: nil,
strategy: .priority,
confidence: .low,
reason: "Keine Quelle mit Priorität gefunden"
)
}
private func applyHigherWinsStrategy(
readings: [SourceReading],
rule: MergeRule
) -> RuleApplicationResult {
let sorted = readings.sorted { $0.value > $1.value }
if let highest = sorted.first {
// Check if there's a significant difference
let values = readings.map { $0.value }
let spread = (values.max() ?? 0) - (values.min() ?? 0)
let avgValue = values.reduce(0, +) / Double(values.count)
let spreadPercent = avgValue > 0 ? (spread / avgValue) * 100 : 0
let confidence: RuleConfidence = spreadPercent < 10 ? .high : .medium
return RuleApplicationResult(
selectedReading: highest,
strategy: .higherWins,
confidence: confidence,
reason: "Höchster Wert: \(highest.formattedValue) von \(highest.sourceName)"
)
}
return RuleApplicationResult(
selectedReading: nil,
strategy: .higherWins,
confidence: .low,
reason: "Keine Werte zum Vergleich"
)
}
private func applyLowerWinsStrategy(
readings: [SourceReading],
rule: MergeRule
) -> RuleApplicationResult {
let sorted = readings.sorted { $0.value < $1.value }
if let lowest = sorted.first {
return RuleApplicationResult(
selectedReading: lowest,
strategy: .lowerWins,
confidence: .medium,
reason: "Niedrigster Wert: \(lowest.formattedValue) von \(lowest.sourceName)"
)
}
return RuleApplicationResult(
selectedReading: nil,
strategy: .lowerWins,
confidence: .low,
reason: "Keine Werte zum Vergleich"
)
}
private func applyAverageStrategy(
readings: [SourceReading],
rule: MergeRule
) -> RuleApplicationResult {
let values = readings.map { $0.value }
let average = values.reduce(0, +) / Double(values.count)
// Create a synthetic reading for the average
let syntheticReading = SourceReading(
sourceId: HealthBridgeConstants.bundleIdentifier,
sourceName: "Durchschnitt",
sourceCategory: .healthBridge,
value: average,
timestamp: readings.first?.timestamp ?? Date(),
quality: .complete
)
return RuleApplicationResult(
selectedReading: syntheticReading,
strategy: .average,
confidence: .medium,
reason: "Durchschnitt aus \(readings.count) Quellen"
)
}
private func applyCoverageStrategy(
readings: [SourceReading],
rule: MergeRule
) -> RuleApplicationResult {
// Prefer readings with complete quality
let completeReadings = readings.filter { $0.quality == .complete }
if completeReadings.count == 1 {
return RuleApplicationResult(
selectedReading: completeReadings[0],
strategy: .coverage,
confidence: .high,
reason: "Einzige Quelle mit vollständigen Daten: \(completeReadings[0].sourceName)"
)
}
// If multiple complete readings, fall back to priority
if !completeReadings.isEmpty {
let sorted = completeReadings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority }
if let first = sorted.first {
return RuleApplicationResult(
selectedReading: first,
strategy: .coverage,
confidence: .medium,
reason: "Mehrere Quellen verfügbar, gewählt: \(first.sourceName)"
)
}
}
// No complete readings, use any reading with highest priority
let sorted = readings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority }
if let first = sorted.first {
return RuleApplicationResult(
selectedReading: first,
strategy: .coverage,
confidence: .low,
reason: "Keine vollständigen Daten, gewählt: \(first.sourceName)"
)
}
return RuleApplicationResult(
selectedReading: nil,
strategy: .coverage,
confidence: .low,
reason: "Keine Quelle mit Daten gefunden"
)
}
private func applyCoverageThenHigherStrategy(
readings: [SourceReading],
rule: MergeRule
) -> RuleApplicationResult {
// First check if one source has data and others don't (coverage)
let nonZeroReadings = readings.filter { $0.value > 0 }
let zeroReadings = readings.filter { $0.value == 0 }
// If only one source has data, it wins on coverage
if nonZeroReadings.count == 1 && !zeroReadings.isEmpty {
return RuleApplicationResult(
selectedReading: nonZeroReadings[0],
strategy: .coverageThenHigher,
confidence: .high,
reason: "Einzige Quelle mit Daten: \(nonZeroReadings[0].sourceName)"
)
}
// Multiple sources have data, use higher wins
if nonZeroReadings.count > 1 {
let sorted = nonZeroReadings.sorted { $0.value > $1.value }
if let highest = sorted.first {
return RuleApplicationResult(
selectedReading: highest,
strategy: .coverageThenHigher,
confidence: .medium,
reason: "Höherer Wert bei Konflikt: \(highest.formattedValue) von \(highest.sourceName)"
)
}
}
// Fallback
if let first = readings.first {
return RuleApplicationResult(
selectedReading: first,
strategy: .coverageThenHigher,
confidence: .low,
reason: "Fallback auf erste Quelle"
)
}
return RuleApplicationResult(
selectedReading: nil,
strategy: .coverageThenHigher,
confidence: .low,
reason: "Keine Daten verfügbar"
)
}
private func applyMostRecentStrategy(
readings: [SourceReading],
rule: MergeRule
) -> RuleApplicationResult {
let sorted = readings.sorted { $0.timestamp > $1.timestamp }
if let mostRecent = sorted.first {
return RuleApplicationResult(
selectedReading: mostRecent,
strategy: .mostRecent,
confidence: .high,
reason: "Neuester Wert von \(mostRecent.sourceName)"
)
}
return RuleApplicationResult(
selectedReading: nil,
strategy: .mostRecent,
confidence: .low,
reason: "Keine Zeitstempel verfügbar"
)
}
// MARK: - Threshold Check
func shouldRequestManualReview(
readings: [SourceReading],
dataType: HealthDataType
) -> Bool {
let rule = getRule(for: dataType)
guard let threshold = rule.thresholdForManualReview else {
return rule.strategy == .manual
}
let values = readings.map { $0.value }.filter { $0 > 0 }
guard values.count >= 2,
let min = values.min(),
let max = values.max(),
min > 0 else {
return false
}
let percentDiff = (max - min) / min * 100
return percentDiff > threshold
}
}
// MARK: - Rule Application Result
struct RuleApplicationResult {
let selectedReading: SourceReading?
let strategy: MergeStrategy
let confidence: RuleConfidence
let reason: String
var resolvedValue: Double? {
selectedReading?.value
}
var winningSourceId: String? {
selectedReading?.sourceId
}
}
enum RuleConfidence: String, Codable {
case high = "high"
case medium = "medium"
case low = "low"
case requiresManual = "requires_manual"
var displayName: String {
switch self {
case .high: return "Hohe Sicherheit"
case .medium: return "Mittlere Sicherheit"
case .low: return "Geringe Sicherheit"
case .requiresManual: return "Manuelle Prüfung"
}
}
var icon: String {
switch self {
case .high: return "checkmark.seal.fill"
case .medium: return "checkmark.seal"
case .low: return "questionmark.circle"
case .requiresManual: return "hand.raised.fill"
}
}
}
// MARK: - Rule Storage Manager
class RuleStorageManager {
private let userDefaults = UserDefaults.standard
private let rulesKey = "healthbridge.merge.rules"
func saveRules(_ rules: [HealthDataType: MergeRule]) {
do {
let data = try JSONEncoder().encode(rules)
userDefaults.set(data, forKey: rulesKey)
} catch {
print("Failed to save rules: \(error)")
}
}
func loadRules() -> [HealthDataType: MergeRule] {
guard let data = userDefaults.data(forKey: rulesKey) else {
return [:]
}
do {
return try JSONDecoder().decode([HealthDataType: MergeRule].self, from: data)
} catch {
print("Failed to load rules: \(error)")
return [:]
}
}
}
// MARK: - Blood Pressure Handler
class BloodPressureHandler {
static let shared = BloodPressureHandler()
struct ValidationResult {
let isValid: Bool
let issues: [String]
}
func validate(systolic: Double, diastolic: Double) -> ValidationResult {
var issues: [String] = []
// Range validation
if systolic < 70 || systolic > 200 {
issues.append("Systolischer Wert ausserhalb des Normalbereichs (70-200 mmHg)")
}
if diastolic < 40 || diastolic > 130 {
issues.append("Diastolischer Wert ausserhalb des Normalbereichs (40-130 mmHg)")
}
// Plausibility check
if diastolic >= systolic {
issues.append("Diastolischer Wert muss kleiner als systolischer Wert sein")
}
if systolic - diastolic < 20 {
issues.append("Pulsdruck zu gering (< 20 mmHg)")
}
if systolic - diastolic > 100 {
issues.append("Pulsdruck zu hoch (> 100 mmHg)")
}
return ValidationResult(isValid: issues.isEmpty, issues: issues)
}
func classifyBloodPressure(systolic: Double, diastolic: Double) -> BloodPressureClassification {
if systolic < 120 && diastolic < 80 {
return .normal
} else if systolic < 130 && diastolic < 80 {
return .elevated
} else if systolic < 140 || diastolic < 90 {
return .hypertensionStage1
} else if systolic < 180 || diastolic < 120 {
return .hypertensionStage2
} else {
return .hypertensiveCrisis
}
}
enum BloodPressureClassification: String {
case normal = "Normal"
case elevated = "Erhöht"
case hypertensionStage1 = "Bluthochdruck Stufe 1"
case hypertensionStage2 = "Bluthochdruck Stufe 2"
case hypertensiveCrisis = "Hypertensive Krise"
var color: String {
switch self {
case .normal: return "green"
case .elevated: return "yellow"
case .hypertensionStage1: return "orange"
case .hypertensionStage2: return "red"
case .hypertensiveCrisis: return "purple"
}
}
var recommendation: String {
switch self {
case .normal:
return "Weiter so! Regelmässige Kontrolle empfohlen."
case .elevated:
return "Lebensstiländerungen empfohlen. Mehr Bewegung, weniger Salz."
case .hypertensionStage1:
return "Arztbesuch empfohlen. Möglicherweise Medikation erforderlich."
case .hypertensionStage2:
return "Zeitnaher Arztbesuch erforderlich. Medikation wahrscheinlich notwendig."
case .hypertensiveCrisis:
return "SOFORT medizinische Hilfe aufsuchen!"
}
}
}
}
+409
View File
@@ -0,0 +1,409 @@
import Foundation
import HealthKit
import Combine
// MARK: - Source Manager
@MainActor
class SourceManager: ObservableObject {
static let shared = SourceManager()
@Published var sources: [HealthSource] = []
@Published var sourceProfiles: [String: SourceProfile] = [:]
@Published var isDiscovering = false
private let healthKitManager = HealthKitManager.shared
private let storage = SourceStorageManager()
private init() {}
// MARK: - Source Discovery
func discoverSources() async {
isDiscovering = true
defer { isDiscovering = false }
await healthKitManager.discoverSources()
sources = healthKitManager.discoveredSources
// Load saved source profiles and merge with discovered sources
let savedProfiles = storage.loadSourceProfiles()
for source in sources {
if let savedProfile = savedProfiles[source.bundleIdentifier] {
sourceProfiles[source.bundleIdentifier] = savedProfile
} else {
sourceProfiles[source.bundleIdentifier] = SourceProfile(source: source)
}
}
}
// MARK: - Source Classification
func classifySource(_ bundleIdentifier: String) -> SourceCategory {
let lowercased = bundleIdentifier.lowercased()
// HealthBridge
if lowercased.contains("healthbridge") {
return .healthBridge
}
// Apple Devices
if lowercased.contains("com.apple") {
if lowercased.contains("watch") || lowercased.contains("nano") {
return .watch
}
if lowercased.contains("health") {
return .iPhone
}
}
// Known Watch Brands
let watchBrands = ["huawei", "samsung", "galaxy", "fitbit", "garmin", "polar",
"withings", "amazfit", "xiaomi", "honor", "oppo", "suunto",
"coros", "whoop", "oura"]
for brand in watchBrands {
if lowercased.contains(brand) {
return .thirdPartyWatch
}
}
// Known Health Apps
let healthApps = ["strava", "nike", "adidas", "runtastic", "runkeeper",
"mapmyrun", "endomondo", "myfitnesspal", "flo", "clue"]
for app in healthApps {
if lowercased.contains(app) {
return .thirdPartyApp
}
}
return .unknown
}
// MARK: - Source Capabilities
func getSourceCapabilities(_ source: HealthSource) -> SourceCapabilities {
let category = source.category
switch category {
case .iPhone:
return SourceCapabilities(
canMeasureSteps: true,
canMeasureDistance: true,
canMeasureFloors: true,
canMeasureHeartRate: false,
canMeasureBloodPressure: false,
canMeasureBloodOxygen: false,
canMeasureSleep: false,
canMeasureWorkouts: true,
hasGPS: true,
hasBarometer: true,
hasAccelerometer: true
)
case .watch:
return SourceCapabilities(
canMeasureSteps: true,
canMeasureDistance: true,
canMeasureFloors: false,
canMeasureHeartRate: true,
canMeasureBloodPressure: false,
canMeasureBloodOxygen: true,
canMeasureSleep: true,
canMeasureWorkouts: true,
hasGPS: true,
hasBarometer: false,
hasAccelerometer: true
)
case .thirdPartyWatch:
// Check for specific features based on name
let name = source.name.lowercased()
let isHuaweiD2 = name.contains("huawei") && (name.contains("d2") || name.contains("watch d"))
return SourceCapabilities(
canMeasureSteps: true,
canMeasureDistance: true,
canMeasureFloors: false,
canMeasureHeartRate: true,
canMeasureBloodPressure: isHuaweiD2, // Huawei Watch D2 has BP sensor
canMeasureBloodOxygen: true,
canMeasureSleep: true,
canMeasureWorkouts: true,
hasGPS: true,
hasBarometer: false,
hasAccelerometer: true
)
case .thirdPartyApp:
return SourceCapabilities(
canMeasureSteps: true,
canMeasureDistance: true,
canMeasureFloors: false,
canMeasureHeartRate: false,
canMeasureBloodPressure: false,
canMeasureBloodOxygen: false,
canMeasureSleep: false,
canMeasureWorkouts: true,
hasGPS: true,
hasBarometer: false,
hasAccelerometer: false
)
case .healthBridge, .unknown:
return SourceCapabilities.empty
}
}
// MARK: - Source Health
func getSourceHealth(_ source: HealthSource) async -> SourceHealthReport {
var dataGaps: [HealthDataType: [TimeWindow]] = [:]
var lastActivityByType: [HealthDataType: Date] = [:]
var recordCountByType: [HealthDataType: Int] = [:]
let calendar = Calendar.current
let now = Date()
let yesterday = calendar.date(byAdding: .day, value: -1, to: now)!
for dataType in source.supportedDataTypes {
do {
let samples = try await healthKitManager.fetchSamples(
for: dataType,
from: yesterday,
to: now
)
let matchingSamples = samples.filter {
$0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier
}
recordCountByType[dataType] = matchingSamples.count
if let lastSample = matchingSamples.last {
lastActivityByType[dataType] = lastSample.endDate
}
// Detect gaps
let gaps = detectDataGaps(
in: matchingSamples,
from: yesterday,
to: now,
expectedInterval: 15 * 60 // 15 minutes
)
if !gaps.isEmpty {
dataGaps[dataType] = gaps
}
} catch {
print("Error checking health for \(dataType): \(error)")
}
}
let overallQuality: DataQuality
if recordCountByType.values.reduce(0, +) == 0 {
overallQuality = .missing
} else if dataGaps.isEmpty {
overallQuality = .complete
} else {
overallQuality = .partial
}
return SourceHealthReport(
source: source,
lastActivityByType: lastActivityByType,
recordCountByType: recordCountByType,
dataGaps: dataGaps,
overallQuality: overallQuality,
checkedAt: Date()
)
}
private func detectDataGaps(
in samples: [HKSample],
from start: Date,
to end: Date,
expectedInterval: TimeInterval
) -> [TimeWindow] {
guard !samples.isEmpty else {
return [TimeWindow(start: start, end: end)]
}
var gaps: [TimeWindow] = []
let sortedSamples = samples.sorted { $0.startDate < $1.startDate }
// Check gap at beginning
if let firstSample = sortedSamples.first,
firstSample.startDate.timeIntervalSince(start) > expectedInterval * 2 {
gaps.append(TimeWindow(start: start, end: firstSample.startDate))
}
// Check gaps between samples
for i in 0..<(sortedSamples.count - 1) {
let currentEnd = sortedSamples[i].endDate
let nextStart = sortedSamples[i + 1].startDate
let gap = nextStart.timeIntervalSince(currentEnd)
if gap > expectedInterval * 2 {
gaps.append(TimeWindow(start: currentEnd, end: nextStart))
}
}
// Check gap at end
if let lastSample = sortedSamples.last,
end.timeIntervalSince(lastSample.endDate) > expectedInterval * 2 {
gaps.append(TimeWindow(start: lastSample.endDate, end: end))
}
return gaps
}
// MARK: - Priority Management
func setPriority(_ priority: Int, for source: HealthSource, dataType: HealthDataType) {
guard var profile = sourceProfiles[source.bundleIdentifier] else { return }
profile.priorities[dataType] = priority
sourceProfiles[source.bundleIdentifier] = profile
storage.saveSourceProfiles(sourceProfiles)
}
func getPriority(for source: HealthSource, dataType: HealthDataType) -> Int {
if let profile = sourceProfiles[source.bundleIdentifier],
let priority = profile.priorities[dataType] {
return priority
}
return source.category.priority
}
func getSourcesByPriority(for dataType: HealthDataType) -> [HealthSource] {
return sources
.filter { $0.supportedDataTypes.contains(dataType) }
.sorted { getPriority(for: $0, dataType: dataType) > getPriority(for: $1, dataType: dataType) }
}
// MARK: - Source Enable/Disable
func setEnabled(_ enabled: Bool, for source: HealthSource) {
guard var profile = sourceProfiles[source.bundleIdentifier] else { return }
profile.isEnabled = enabled
sourceProfiles[source.bundleIdentifier] = profile
storage.saveSourceProfiles(sourceProfiles)
}
func isEnabled(_ source: HealthSource) -> Bool {
return sourceProfiles[source.bundleIdentifier]?.isEnabled ?? true
}
}
// MARK: - Source Profile
struct SourceProfile: Codable {
let bundleIdentifier: String
var priorities: [HealthDataType: Int]
var isEnabled: Bool
var customName: String?
var notes: String?
let addedAt: Date
init(source: HealthSource) {
self.bundleIdentifier = source.bundleIdentifier
self.priorities = [:]
self.isEnabled = true
self.customName = nil
self.notes = nil
self.addedAt = Date()
}
}
// MARK: - Source Capabilities
struct SourceCapabilities {
let canMeasureSteps: Bool
let canMeasureDistance: Bool
let canMeasureFloors: Bool
let canMeasureHeartRate: Bool
let canMeasureBloodPressure: Bool
let canMeasureBloodOxygen: Bool
let canMeasureSleep: Bool
let canMeasureWorkouts: Bool
let hasGPS: Bool
let hasBarometer: Bool
let hasAccelerometer: Bool
static let empty = SourceCapabilities(
canMeasureSteps: false,
canMeasureDistance: false,
canMeasureFloors: false,
canMeasureHeartRate: false,
canMeasureBloodPressure: false,
canMeasureBloodOxygen: false,
canMeasureSleep: false,
canMeasureWorkouts: false,
hasGPS: false,
hasBarometer: false,
hasAccelerometer: false
)
var supportedDataTypes: Set<HealthDataType> {
var types = Set<HealthDataType>()
if canMeasureSteps { types.insert(.steps) }
if canMeasureDistance { types.insert(.distance) }
if canMeasureFloors { types.insert(.floorsClimbed) }
if canMeasureHeartRate {
types.insert(.heartRate)
types.insert(.restingHeartRate)
}
if canMeasureBloodPressure {
types.insert(.bloodPressureSystolic)
types.insert(.bloodPressureDiastolic)
}
if canMeasureBloodOxygen { types.insert(.bloodOxygen) }
if canMeasureSleep { types.insert(.sleep) }
return types
}
}
// MARK: - Source Health Report
struct SourceHealthReport {
let source: HealthSource
let lastActivityByType: [HealthDataType: Date]
let recordCountByType: [HealthDataType: Int]
let dataGaps: [HealthDataType: [TimeWindow]]
let overallQuality: DataQuality
let checkedAt: Date
var lastOverallActivity: Date? {
lastActivityByType.values.max()
}
var totalRecordCount: Int {
recordCountByType.values.reduce(0, +)
}
var hasSignificantGaps: Bool {
dataGaps.values.flatMap { $0 }.contains { $0.duration > 3600 } // > 1 hour gap
}
}
// MARK: - Source Storage Manager
class SourceStorageManager {
private let userDefaults = UserDefaults.standard
private let profilesKey = "healthbridge.source.profiles"
func saveSourceProfiles(_ profiles: [String: SourceProfile]) {
do {
let data = try JSONEncoder().encode(profiles)
userDefaults.set(data, forKey: profilesKey)
} catch {
print("Failed to save source profiles: \(error)")
}
}
func loadSourceProfiles() -> [String: SourceProfile] {
guard let data = userDefaults.data(forKey: profilesKey) else {
return [:]
}
do {
return try JSONDecoder().decode([String: SourceProfile].self, from: data)
} catch {
print("Failed to load source profiles: \(error)")
return [:]
}
}
}
+301
View File
@@ -0,0 +1,301 @@
import Foundation
import Combine
import UserNotifications
// MARK: - Sync Coordinator
@MainActor
class SyncCoordinator: ObservableObject {
static let shared = SyncCoordinator()
private let healthKitManager = HealthKitManager.shared
private let sourceManager = SourceManager.shared
private let dataReader = DataReader.shared
private let ruleEngine = RuleEngine.shared
private let mergeEngine = MergeEngine.shared
private let dataWriter = DataWriter.shared
@Published var isSyncing = false
@Published var syncProgress: Double = 0
@Published var lastSyncDate: Date?
@Published var lastSyncResult: SyncResult?
@Published var pendingConflicts: [Conflict] = []
@Published var syncHistory: [SyncResult] = []
private let syncHistoryKey = "healthbridge.sync.history"
private let maxHistoryItems = 50
private init() {
loadSyncHistory()
}
// MARK: - Main Sync
func performSync(
for date: Date = Date(),
dataTypes: [HealthDataType] = HealthDataType.allCases
) async throws {
guard !isSyncing else { return }
isSyncing = true
syncProgress = 0
defer { isSyncing = false }
let startTime = Date()
var result = SyncResult(startedAt: startTime)
do {
// Step 1: Refresh sources (10%)
syncProgress = 0.05
await sourceManager.discoverSources()
syncProgress = 0.1
// Step 2: Detect conflicts (40%)
let conflicts = try await dataReader.detectConflicts(for: date, dataTypes: dataTypes)
result.totalConflicts = conflicts.count
syncProgress = 0.4
// Step 3: Process conflicts with merge engine (70%)
let operations = await mergeEngine.processConflicts(conflicts)
syncProgress = 0.7
let autoResolved = operations.filter { $0.status == .resolved }
let pendingManual = operations.filter { $0.status == .pendingManualReview }
result.autoResolved = autoResolved.count
result.pendingManualReview = pendingManual.count
// Update pending conflicts
pendingConflicts = pendingManual.map { $0.conflict }
// Step 4: Write resolved records (90%)
let recordsToWrite = autoResolved.compactMap { $0.mergedRecord }
if !recordsToWrite.isEmpty {
let writeResult = await dataWriter.writeBatch(recordsToWrite)
result.writtenRecords = writeResult.successCount
result.writeErrors = writeResult.failureCount
}
syncProgress = 0.9
// Step 5: Finalize (100%)
result.completedAt = Date()
result.status = .success
syncProgress = 1.0
} catch {
result.status = .failed
result.error = error.localizedDescription
result.completedAt = Date()
throw error
}
lastSyncDate = Date()
lastSyncResult = result
addToHistory(result)
// Send notification if there are pending conflicts
if result.pendingManualReview > 0 {
await sendConflictNotification(count: result.pendingManualReview)
}
}
// MARK: - Quick Sync
func performQuickSync() async throws {
try await performSync(
for: Date(),
dataTypes: [.steps, .heartRate, .activeEnergy]
)
}
// MARK: - Sync Specific Data Type
func syncDataType(_ dataType: HealthDataType, for date: Date = Date()) async throws {
try await performSync(for: date, dataTypes: [dataType])
}
// MARK: - Manual Conflict Resolution
func resolveConflict(_ conflict: Conflict, selectedReadingId: UUID) async throws {
guard let resolution = mergeEngine.resolveConflictManually(conflict, selectedReadingId: selectedReadingId) else {
throw SyncError.resolutionFailed
}
var resolvedConflict = conflict
resolvedConflict.status = .resolved
resolvedConflict.resolution = resolution
resolvedConflict.resolvedAt = Date()
let mergedRecord = mergeEngine.createMergedRecord(from: resolvedConflict, resolution: resolution)
// Write the record
_ = try await dataWriter.writeRecord(mergedRecord)
// Remove from pending
pendingConflicts.removeAll { $0.id == conflict.id }
}
func ignoreConflict(_ conflict: Conflict) {
pendingConflicts.removeAll { $0.id == conflict.id }
}
// MARK: - Sync History
private func loadSyncHistory() {
guard let data = UserDefaults.standard.data(forKey: syncHistoryKey),
let history = try? JSONDecoder().decode([SyncResult].self, from: data) else {
return
}
syncHistory = history
}
private func addToHistory(_ result: SyncResult) {
syncHistory.insert(result, at: 0)
if syncHistory.count > maxHistoryItems {
syncHistory = Array(syncHistory.prefix(maxHistoryItems))
}
saveSyncHistory()
}
private func saveSyncHistory() {
guard let data = try? JSONEncoder().encode(syncHistory) else { return }
UserDefaults.standard.set(data, forKey: syncHistoryKey)
}
func clearHistory() {
syncHistory.removeAll()
UserDefaults.standard.removeObject(forKey: syncHistoryKey)
}
// MARK: - Notifications
private func sendConflictNotification(count: Int) async {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
guard settings.authorizationStatus == .authorized else { return }
let content = UNMutableNotificationContent()
content.title = "HealthBridge"
content.body = count == 1
? "1 Konflikt erfordert Ihre Aufmerksamkeit"
: "\(count) Konflikte erfordern Ihre Aufmerksamkeit"
content.sound = .default
content.badge = NSNumber(value: count)
let request = UNNotificationRequest(
identifier: "healthbridge.conflicts",
content: content,
trigger: nil
)
try? await center.add(request)
}
func requestNotificationPermission() async -> Bool {
let center = UNUserNotificationCenter.current()
do {
return try await center.requestAuthorization(options: [.alert, .sound, .badge])
} catch {
return false
}
}
// MARK: - Statistics
var todayStats: TodayStats {
let today = Calendar.current.startOfDay(for: Date())
let todaySyncs = syncHistory.filter {
Calendar.current.isDate($0.startedAt, inSameDayAs: today)
}
return TodayStats(
syncCount: todaySyncs.count,
totalConflicts: todaySyncs.reduce(0) { $0 + $1.totalConflicts },
autoResolved: todaySyncs.reduce(0) { $0 + $1.autoResolved },
pendingManual: pendingConflicts.count,
lastSync: lastSyncDate
)
}
}
// MARK: - Supporting Types
struct SyncResult: Identifiable, Codable {
let id = UUID()
let startedAt: Date
var completedAt: Date?
var status: SyncStatus = .inProgress
var totalConflicts = 0
var autoResolved = 0
var pendingManualReview = 0
var writtenRecords = 0
var writeErrors = 0
var error: String?
enum SyncStatus: String, Codable {
case inProgress = "in_progress"
case success = "success"
case partialSuccess = "partial_success"
case failed = "failed"
}
var duration: TimeInterval? {
guard let completed = completedAt else { return nil }
return completed.timeIntervalSince(startedAt)
}
var formattedDuration: String {
guard let duration = duration else { return "" }
if duration < 1 {
return "< 1s"
}
return String(format: "%.1fs", duration)
}
var successRate: Double {
guard totalConflicts > 0 else { return 1.0 }
return Double(autoResolved) / Double(totalConflicts)
}
}
struct TodayStats {
let syncCount: Int
let totalConflicts: Int
let autoResolved: Int
let pendingManual: Int
let lastSync: Date?
var resolutionRate: Double {
guard totalConflicts > 0 else { return 1.0 }
return Double(autoResolved) / Double(totalConflicts)
}
var formattedLastSync: String {
guard let date = lastSync else { return "Nie" }
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: date, relativeTo: Date())
}
}
enum SyncError: LocalizedError {
case notAuthorized
case syncInProgress
case resolutionFailed
case writeFailed
var errorDescription: String? {
switch self {
case .notAuthorized:
return "Keine Berechtigung für HealthKit"
case .syncInProgress:
return "Synchronisierung läuft bereits"
case .resolutionFailed:
return "Konfliktauflösung fehlgeschlagen"
case .writeFailed:
return "Schreiben der Daten fehlgeschlagen"
}
}
}
+153
View File
@@ -0,0 +1,153 @@
import Foundation
import SwiftUI
// MARK: - Date Extensions
extension Date {
var startOfDay: Date {
Calendar.current.startOfDay(for: self)
}
var endOfDay: Date {
Calendar.current.date(byAdding: .day, value: 1, to: startOfDay)!.addingTimeInterval(-1)
}
var isToday: Bool {
Calendar.current.isDateInToday(self)
}
var isYesterday: Bool {
Calendar.current.isDateInYesterday(self)
}
func formatted(style: DateFormatter.Style) -> String {
let formatter = DateFormatter()
formatter.dateStyle = style
formatter.timeStyle = .none
return formatter.string(from: self)
}
func formattedTime() -> String {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter.string(from: self)
}
func formattedRelative() -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: self, relativeTo: Date())
}
func adding(days: Int) -> Date {
Calendar.current.date(byAdding: .day, value: days, to: self)!
}
func adding(hours: Int) -> Date {
Calendar.current.date(byAdding: .hour, value: hours, to: self)!
}
func adding(minutes: Int) -> Date {
Calendar.current.date(byAdding: .minute, value: minutes, to: self)!
}
}
// MARK: - Double Extensions
extension Double {
func formatted(decimals: Int = 1) -> String {
String(format: "%.\(decimals)f", self)
}
var formattedAsInteger: String {
String(format: "%.0f", self)
}
var formattedAsPercentage: String {
String(format: "%.1f%%", self * 100)
}
}
// MARK: - Array Extensions
extension Array {
func chunked(into size: Int) -> [[Element]] {
stride(from: 0, to: count, by: size).map {
Array(self[$0..<Swift.min($0 + size, count)])
}
}
}
// MARK: - Color Extensions
extension Color {
static let healthBridgePrimary = Color.blue
static let healthBridgeSecondary = Color.cyan
static let healthBridgeAccent = Color.orange
static func forSeverity(_ severity: ConflictSeverity) -> Color {
switch severity {
case .minor: return .green
case .moderate: return .yellow
case .significant: return .orange
case .major: return .red
}
}
static func forQuality(_ quality: DataQuality) -> Color {
switch quality {
case .complete: return .green
case .partial: return .yellow
case .missing: return .gray
case .invalid: return .red
}
}
}
// MARK: - View Extensions
extension View {
func cardStyle() -> some View {
self
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
}
func sectionHeader(_ title: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
.foregroundStyle(.primary)
self
}
}
}
// MARK: - Binding Extensions
extension Binding {
func onChange(_ handler: @escaping (Value) -> Void) -> Binding<Value> {
Binding(
get: { self.wrappedValue },
set: { newValue in
self.wrappedValue = newValue
handler(newValue)
}
)
}
}
// MARK: - Optional Extensions
extension Optional where Wrapped == String {
var orEmpty: String {
self ?? ""
}
var isNilOrEmpty: Bool {
self?.isEmpty ?? true
}
}
// MARK: - Collection Extensions
extension Collection {
var isNotEmpty: Bool {
!isEmpty
}
}
@@ -0,0 +1,185 @@
import Foundation
import UserNotifications
@MainActor
class NotificationManager: ObservableObject {
static let shared = NotificationManager()
@Published var isAuthorized = false
@Published var pendingNotifications: [String] = []
private let center = UNUserNotificationCenter.current()
private init() {
Task {
await checkAuthorization()
}
}
// MARK: - Authorization
func requestAuthorization() async -> Bool {
do {
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
isAuthorized = granted
return granted
} catch {
print("Notification authorization failed: \(error)")
return false
}
}
func checkAuthorization() async {
let settings = await center.notificationSettings()
isAuthorized = settings.authorizationStatus == .authorized
}
// MARK: - Conflict Notifications
func sendConflictNotification(count: Int) async {
guard isAuthorized else { return }
let content = UNMutableNotificationContent()
content.title = "HealthBridge"
if count == 1 {
content.body = "1 neuer Konflikt erfordert Ihre Aufmerksamkeit"
} else {
content.body = "\(count) neue Konflikte erfordern Ihre Aufmerksamkeit"
}
content.sound = .default
content.badge = NSNumber(value: count)
content.categoryIdentifier = "CONFLICT"
let request = UNNotificationRequest(
identifier: "healthbridge.conflict.\(Date().timeIntervalSince1970)",
content: content,
trigger: nil
)
do {
try await center.add(request)
} catch {
print("Failed to send notification: \(error)")
}
}
// MARK: - Sync Notifications
func sendSyncCompleteNotification(
conflictsResolved: Int,
pendingConflicts: Int
) async {
guard isAuthorized else { return }
let content = UNMutableNotificationContent()
content.title = "Sync abgeschlossen"
if pendingConflicts > 0 {
content.body = "\(conflictsResolved) Konflikte gelöst, \(pendingConflicts) offen"
} else {
content.body = "Alle \(conflictsResolved) Konflikte wurden gelöst"
}
content.sound = .default
content.categoryIdentifier = "SYNC_COMPLETE"
let request = UNNotificationRequest(
identifier: "healthbridge.sync.\(Date().timeIntervalSince1970)",
content: content,
trigger: nil
)
do {
try await center.add(request)
} catch {
print("Failed to send notification: \(error)")
}
}
// MARK: - Scheduled Notifications
func scheduleReminder(at hour: Int, minute: Int) async {
guard isAuthorized else { return }
let content = UNMutableNotificationContent()
content.title = "HealthBridge Erinnerung"
content.body = "Vergessen Sie nicht, Ihre Gesundheitsdaten zu synchronisieren"
content.sound = .default
var dateComponents = DateComponents()
dateComponents.hour = hour
dateComponents.minute = minute
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
let request = UNNotificationRequest(
identifier: "healthbridge.reminder.daily",
content: content,
trigger: trigger
)
do {
try await center.add(request)
} catch {
print("Failed to schedule reminder: \(error)")
}
}
func cancelReminder() {
center.removePendingNotificationRequests(withIdentifiers: ["healthbridge.reminder.daily"])
}
// MARK: - Badge Management
func clearBadge() async {
do {
try await center.setBadgeCount(0)
} catch {
print("Failed to clear badge: \(error)")
}
}
func updateBadge(count: Int) async {
do {
try await center.setBadgeCount(count)
} catch {
print("Failed to update badge: \(error)")
}
}
// MARK: - Notification Categories
func registerCategories() {
// Conflict category with actions
let resolveAction = UNNotificationAction(
identifier: "RESOLVE_AUTO",
title: "Automatisch lösen",
options: []
)
let viewAction = UNNotificationAction(
identifier: "VIEW_CONFLICTS",
title: "Anzeigen",
options: [.foreground]
)
let conflictCategory = UNNotificationCategory(
identifier: "CONFLICT",
actions: [resolveAction, viewAction],
intentIdentifiers: [],
options: []
)
// Sync complete category
let syncCategory = UNNotificationCategory(
identifier: "SYNC_COMPLETE",
actions: [viewAction],
intentIdentifiers: [],
options: []
)
center.setNotificationCategories([conflictCategory, syncCategory])
}
}
@@ -0,0 +1,103 @@
import Foundation
import Combine
@MainActor
class DashboardViewModel: ObservableObject {
private let syncCoordinator = SyncCoordinator.shared
private let dataReader = DataReader.shared
private let sourceManager = SourceManager.shared
@Published var dailySummary: DailySummary?
@Published var isLoading = false
@Published var selectedDate = Date()
@Published var errorMessage: String?
private var cancellables = Set<AnyCancellable>()
init() {
setupBindings()
}
private func setupBindings() {
$selectedDate
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { [weak self] date in
Task {
await self?.loadData(for: date)
}
}
.store(in: &cancellables)
}
func loadData(for date: Date = Date()) async {
isLoading = true
errorMessage = nil
do {
dailySummary = try await dataReader.fetchDailySummary(for: date)
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
func performSync() async {
do {
try await syncCoordinator.performSync(for: selectedDate)
await loadData(for: selectedDate)
} catch {
errorMessage = "Sync fehlgeschlagen: \(error.localizedDescription)"
}
}
func refresh() async {
await sourceManager.discoverSources()
await loadData(for: selectedDate)
}
var syncStatus: SyncStatus {
if syncCoordinator.isSyncing {
return .syncing
} else if let lastSync = syncCoordinator.lastSyncDate {
let hoursSinceSync = Date().timeIntervalSince(lastSync) / 3600
if hoursSinceSync < 1 {
return .synced
} else if hoursSinceSync < 24 {
return .stale
} else {
return .veryStale
}
} else {
return .neverSynced
}
}
enum SyncStatus {
case syncing
case synced
case stale
case veryStale
case neverSynced
var description: String {
switch self {
case .syncing: return "Synchronisiere..."
case .synced: return "Synchronisiert"
case .stale: return "Sync empfohlen"
case .veryStale: return "Sync überfällig"
case .neverSynced: return "Nie synchronisiert"
}
}
var icon: String {
switch self {
case .syncing: return "arrow.triangle.2.circlepath"
case .synced: return "checkmark.circle.fill"
case .stale: return "exclamationmark.circle"
case .veryStale: return "exclamationmark.triangle"
case .neverSynced: return "xmark.circle"
}
}
}
}
@@ -0,0 +1,241 @@
import SwiftUI
import Charts
struct HealthChart: View {
let dataType: HealthDataType
let data: [ChartDataPoint]
let showConflicts: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: dataType.icon)
.foregroundStyle(.blue)
Text(dataType.displayName)
.font(.headline)
Spacer()
if let latest = data.last {
Text(latest.formattedValue)
.font(.title3)
.fontWeight(.semibold)
Text(dataType.unit)
.font(.caption)
.foregroundStyle(.secondary)
}
}
if #available(iOS 16.0, *) {
Chart(data) { point in
LineMark(
x: .value("Zeit", point.date),
y: .value("Wert", point.value)
)
.foregroundStyle(.blue)
PointMark(
x: .value("Zeit", point.date),
y: .value("Wert", point.value)
)
.foregroundStyle(point.hasConflict && showConflicts ? .orange : .blue)
.symbolSize(point.hasConflict && showConflicts ? 100 : 50)
}
.frame(height: 150)
.chartXAxis {
AxisMarks(values: .stride(by: .hour, count: 4)) { value in
AxisValueLabel(format: .dateTime.hour())
}
}
.chartYAxis {
AxisMarks(position: .leading)
}
} else {
// Fallback for iOS 15
simpleChart
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private var simpleChart: some View {
GeometryReader { geometry in
let maxValue = data.map { $0.value }.max() ?? 1
let minValue = data.map { $0.value }.min() ?? 0
let range = maxValue - minValue
Path { path in
guard !data.isEmpty else { return }
let xStep = geometry.size.width / CGFloat(max(1, data.count - 1))
for (index, point) in data.enumerated() {
let x = CGFloat(index) * xStep
let normalizedY = range > 0 ? (point.value - minValue) / range : 0.5
let y = geometry.size.height * (1 - normalizedY)
if index == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}
}
.stroke(Color.blue, lineWidth: 2)
}
.frame(height: 150)
}
}
struct ChartDataPoint: Identifiable {
let id = UUID()
let date: Date
let value: Double
let hasConflict: Bool
let sourceId: String?
var formattedValue: String {
if value == floor(value) {
return String(format: "%.0f", value)
}
return String(format: "%.1f", value)
}
}
// MARK: - Blood Pressure Chart
struct BloodPressureChart: View {
let data: [BloodPressurePoint]
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "drop.fill")
.foregroundStyle(.red)
Text("Blutdruck")
.font(.headline)
Spacer()
if let latest = data.last {
Text("\(Int(latest.systolic))/\(Int(latest.diastolic))")
.font(.title3)
.fontWeight(.semibold)
Text("mmHg")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if #available(iOS 16.0, *) {
Chart(data) { point in
// Systolic
LineMark(
x: .value("Zeit", point.date),
y: .value("Systolisch", point.systolic)
)
.foregroundStyle(.red)
// Diastolic
LineMark(
x: .value("Zeit", point.date),
y: .value("Diastolisch", point.diastolic)
)
.foregroundStyle(.blue)
}
.frame(height: 150)
.chartYScale(domain: 40...180)
.chartLegend(position: .bottom)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
struct BloodPressurePoint: Identifiable {
let id = UUID()
let date: Date
let systolic: Double
let diastolic: Double
let classification: BloodPressureHandler.BloodPressureClassification
}
// MARK: - Summary Ring
struct SummaryRing: View {
let progress: Double
let color: Color
let icon: String
let value: String
let label: String
var body: some View {
VStack(spacing: 4) {
ZStack {
Circle()
.stroke(color.opacity(0.2), lineWidth: 8)
Circle()
.trim(from: 0, to: min(progress, 1.0))
.stroke(color, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: 2) {
Image(systemName: icon)
.font(.caption)
.foregroundStyle(color)
Text(value)
.font(.caption2)
.fontWeight(.semibold)
}
}
.frame(width: 60, height: 60)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
#Preview {
VStack(spacing: 20) {
HealthChart(
dataType: .steps,
data: (0..<24).map { hour in
ChartDataPoint(
date: Calendar.current.date(byAdding: .hour, value: hour, to: Calendar.current.startOfDay(for: Date()))!,
value: Double.random(in: 0...1000),
hasConflict: hour % 5 == 0,
sourceId: nil
)
},
showConflicts: true
)
HStack(spacing: 20) {
SummaryRing(
progress: 0.75,
color: .blue,
icon: "figure.walk",
value: "7.5k",
label: "Schritte"
)
SummaryRing(
progress: 0.5,
color: .red,
icon: "heart.fill",
value: "72",
label: "Puls"
)
SummaryRing(
progress: 1.0,
color: .green,
icon: "checkmark.circle",
value: "100%",
label: "Synced"
)
}
}
.padding()
}
+348
View File
@@ -0,0 +1,348 @@
import SwiftUI
struct ConflictsView: View {
@EnvironmentObject var syncCoordinator: SyncCoordinator
@State private var selectedConflict: Conflict?
@State private var showingDetail = false
var body: some View {
NavigationStack {
Group {
if syncCoordinator.pendingConflicts.isEmpty {
emptyState
} else {
conflictsList
}
}
.navigationTitle("Konflikte")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("Alle automatisch lösen") {
Task { await resolveAllAuto() }
}
Button("Alle ignorieren", role: .destructive) {
ignoreAll()
}
} label: {
Image(systemName: "ellipsis.circle")
}
.disabled(syncCoordinator.pendingConflicts.isEmpty)
}
}
.sheet(item: $selectedConflict) { conflict in
ConflictDetailView(conflict: conflict) { selectedReadingId in
Task {
await resolveConflict(conflict, selectedReadingId: selectedReadingId)
}
}
}
}
}
// MARK: - Empty State
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.green)
Text("Keine Konflikte")
.font(.title2)
.fontWeight(.semibold)
Text("Alle Ihre Gesundheitsdaten sind synchronisiert")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding()
}
// MARK: - Conflicts List
private var conflictsList: some View {
List {
ForEach(groupedConflicts.keys.sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
Section(dataType.displayName) {
ForEach(groupedConflicts[dataType] ?? []) { conflict in
ConflictRow(conflict: conflict)
.contentShape(Rectangle())
.onTapGesture {
selectedConflict = conflict
}
}
}
}
}
.listStyle(.insetGrouped)
}
private var groupedConflicts: [HealthDataType: [Conflict]] {
Dictionary(grouping: syncCoordinator.pendingConflicts, by: { $0.dataType })
}
// MARK: - Actions
private func resolveConflict(_ conflict: Conflict, selectedReadingId: UUID) async {
do {
try await syncCoordinator.resolveConflict(conflict, selectedReadingId: selectedReadingId)
selectedConflict = nil
} catch {
print("Failed to resolve conflict: \(error)")
}
}
private func resolveAllAuto() async {
for conflict in syncCoordinator.pendingConflicts {
if let primaryReading = conflict.primarySourceReading {
try? await syncCoordinator.resolveConflict(conflict, selectedReadingId: primaryReading.id)
}
}
}
private func ignoreAll() {
for conflict in syncCoordinator.pendingConflicts {
syncCoordinator.ignoreConflict(conflict)
}
}
}
// MARK: - Conflict Row
struct ConflictRow: View {
let conflict: Conflict
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: conflict.dataType.icon)
.foregroundStyle(.blue)
Text(conflict.timeWindow.formattedRange)
.font(.headline)
Spacer()
severityBadge
}
HStack(spacing: 16) {
ForEach(conflict.readings.prefix(3)) { reading in
VStack(alignment: .leading, spacing: 2) {
Text(reading.sourceName)
.font(.caption)
.foregroundStyle(.secondary)
Text(reading.formattedValue)
.font(.subheadline)
.fontWeight(.medium)
}
}
}
if conflict.readings.count > 3 {
Text("+\(conflict.readings.count - 3) weitere")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
private var severityBadge: some View {
Text(conflict.severity.displayName)
.font(.caption2)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(severityColor.opacity(0.2))
.foregroundStyle(severityColor)
.clipShape(Capsule())
}
private var severityColor: Color {
switch conflict.severity {
case .minor: return .green
case .moderate: return .yellow
case .significant: return .orange
case .major: return .red
}
}
}
// MARK: - Conflict Detail View
struct ConflictDetailView: View {
let conflict: Conflict
let onResolve: (UUID) -> Void
@Environment(\.dismiss) private var dismiss
@State private var selectedReadingId: UUID?
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Header
headerSection
// Readings
readingsSection
// Difference Info
differenceSection
Spacer()
}
.padding()
}
.navigationTitle("Konflikt lösen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Auswählen") {
if let id = selectedReadingId {
onResolve(id)
}
}
.disabled(selectedReadingId == nil)
}
}
}
}
// MARK: - Header
private var headerSection: some View {
VStack(spacing: 8) {
Image(systemName: conflict.dataType.icon)
.font(.largeTitle)
.foregroundStyle(.blue)
Text(conflict.dataType.displayName)
.font(.title2)
.fontWeight(.semibold)
Text(conflict.timeWindow.formattedDate)
.font(.subheadline)
.foregroundStyle(.secondary)
Text(conflict.timeWindow.formattedRange)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
}
// MARK: - Readings
private var readingsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Quellen")
.font(.headline)
ForEach(conflict.readings) { reading in
ReadingCard(
reading: reading,
isSelected: selectedReadingId == reading.id,
dataType: conflict.dataType
)
.onTapGesture {
selectedReadingId = reading.id
}
}
}
}
// MARK: - Difference
private var differenceSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Analyse")
.font(.headline)
HStack {
VStack(alignment: .leading) {
Text("Differenz")
.font(.caption)
.foregroundStyle(.secondary)
Text(String(format: "%.1f %@", conflict.valueDifference, conflict.dataType.unit))
.font(.title3)
.fontWeight(.medium)
}
Spacer()
VStack(alignment: .trailing) {
Text("Prozentual")
.font(.caption)
.foregroundStyle(.secondary)
Text(String(format: "%.1f%%", conflict.percentageDifference))
.font(.title3)
.fontWeight(.medium)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
// MARK: - Reading Card
struct ReadingCard: View {
let reading: SourceReading
let isSelected: Bool
let dataType: HealthDataType
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: reading.sourceCategory.icon)
.foregroundStyle(.blue)
Text(reading.sourceName)
.font(.headline)
}
Text(reading.sourceCategory.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text(reading.formattedValue)
.font(.title2)
.fontWeight(.semibold)
Text(dataType.unit)
.font(.caption)
.foregroundStyle(.secondary)
}
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.title2)
.foregroundStyle(isSelected ? .blue : .secondary)
}
.padding()
.background(isSelected ? Color.blue.opacity(0.1) : Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
)
}
}
#Preview {
ConflictsView()
.environmentObject(SyncCoordinator.shared)
}
+42
View File
@@ -0,0 +1,42 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var syncCoordinator: SyncCoordinator
var body: some View {
TabView(selection: $appState.selectedTab) {
DashboardView()
.tabItem {
Label("Dashboard", systemImage: "chart.bar.fill")
}
.tag(AppState.Tab.dashboard)
ConflictsView()
.tabItem {
Label("Konflikte", systemImage: "arrow.triangle.2.circlepath")
}
.tag(AppState.Tab.conflicts)
.badge(syncCoordinator.pendingConflicts.count)
RulesView()
.tabItem {
Label("Regeln", systemImage: "slider.horizontal.3")
}
.tag(AppState.Tab.rules)
SourcesView()
.tabItem {
Label("Quellen", systemImage: "antenna.radiowaves.left.and.right")
}
.tag(AppState.Tab.sources)
}
.tint(.blue)
}
}
#Preview {
ContentView()
.environmentObject(AppState())
.environmentObject(SyncCoordinator.shared)
}
+379
View File
@@ -0,0 +1,379 @@
import SwiftUI
struct DashboardView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var syncCoordinator: SyncCoordinator
@StateObject private var dataReader = DataReader.shared
@State private var dailySummary: DailySummary?
@State private var isLoading = false
@State private var selectedDate = Date()
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Sync Status Card
syncStatusCard
// Date Picker
datePicker
// Health Metrics
if let summary = dailySummary {
healthMetricsGrid(summary: summary)
} else if isLoading {
loadingView
} else {
emptyStateView
}
// Pending Conflicts Alert
if !syncCoordinator.pendingConflicts.isEmpty {
pendingConflictsCard
}
// Recent Sync History
recentSyncHistory
}
.padding()
}
.navigationTitle("HealthBridge")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task { await performSync() }
} label: {
if syncCoordinator.isSyncing {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.clockwise")
}
}
.disabled(syncCoordinator.isSyncing)
}
}
.refreshable {
await loadData()
}
.task {
await loadData()
}
.onChange(of: selectedDate) {
Task { await loadData() }
}
}
}
// MARK: - Sync Status Card
private var syncStatusCard: some View {
VStack(spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Heute")
.font(.headline)
Text(syncCoordinator.todayStats.formattedLastSync)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if syncCoordinator.isSyncing {
VStack(alignment: .trailing) {
ProgressView(value: syncCoordinator.syncProgress)
.frame(width: 60)
Text("Synchronisiere...")
.font(.caption2)
.foregroundStyle(.secondary)
}
} else {
Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundStyle(.green)
}
}
Divider()
HStack(spacing: 20) {
StatItem(
value: "\(syncCoordinator.todayStats.syncCount)",
label: "Syncs",
icon: "arrow.triangle.2.circlepath"
)
StatItem(
value: "\(syncCoordinator.todayStats.totalConflicts)",
label: "Konflikte",
icon: "exclamationmark.triangle"
)
StatItem(
value: "\(Int(syncCoordinator.todayStats.resolutionRate * 100))%",
label: "Gelöst",
icon: "checkmark.circle"
)
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.05), radius: 8, y: 4)
}
// MARK: - Date Picker
private var datePicker: some View {
DatePicker(
"Datum",
selection: $selectedDate,
in: ...Date(),
displayedComponents: .date
)
.datePickerStyle(.compact)
.padding(.horizontal)
}
// MARK: - Health Metrics Grid
private func healthMetricsGrid(summary: DailySummary) -> some View {
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 16) {
ForEach(HealthDataType.allCases) { dataType in
if let value = summary.values[dataType], value > 0 {
HealthMetricCard(
dataType: dataType,
value: summary.formattedValue(for: dataType),
conflictCount: summary.conflictCounts[dataType] ?? 0
)
}
}
}
}
// MARK: - Pending Conflicts Card
private var pendingConflictsCard: some View {
Button {
appState.selectedTab = .conflicts
} label: {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
VStack(alignment: .leading) {
Text("\(syncCoordinator.pendingConflicts.count) Konflikte zu prüfen")
.font(.headline)
Text("Tippen zum Anzeigen")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
}
.padding()
.background(Color.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
// MARK: - Recent Sync History
private var recentSyncHistory: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Letzte Synchronisierungen")
.font(.headline)
ForEach(syncCoordinator.syncHistory.prefix(5)) { result in
SyncHistoryRow(result: result)
}
if syncCoordinator.syncHistory.isEmpty {
Text("Keine Synchronisierungen")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding()
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.05), radius: 8, y: 4)
}
// MARK: - Loading & Empty States
private var loadingView: some View {
VStack(spacing: 12) {
ProgressView()
Text("Lade Daten...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
private var emptyStateView: some View {
VStack(spacing: 12) {
Image(systemName: "heart.text.square")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("Keine Daten verfügbar")
.font(.headline)
Text("Synchronisieren Sie, um Daten zu laden")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
// MARK: - Actions
private func loadData() async {
isLoading = true
defer { isLoading = false }
do {
dailySummary = try await dataReader.fetchDailySummary(for: selectedDate)
} catch {
print("Failed to load data: \(error)")
}
}
private func performSync() async {
do {
try await syncCoordinator.performSync(for: selectedDate)
await loadData()
} catch {
print("Sync failed: \(error)")
}
}
}
// MARK: - Supporting Views
struct StatItem: View {
let value: String
let label: String
let icon: String
var body: some View {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.title3)
.fontWeight(.semibold)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
struct HealthMetricCard: View {
let dataType: HealthDataType
let value: String
let conflictCount: Int
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: dataType.icon)
.foregroundStyle(.blue)
Spacer()
if conflictCount > 0 {
Text("\(conflictCount)")
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange.opacity(0.2))
.clipShape(Capsule())
}
}
Text(value)
.font(.title2)
.fontWeight(.semibold)
Text(dataType.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
}
}
struct SyncHistoryRow: View {
let result: SyncResult
var body: some View {
HStack {
Image(systemName: statusIcon)
.foregroundStyle(statusColor)
VStack(alignment: .leading, spacing: 2) {
Text(formattedDate)
.font(.subheadline)
Text("\(result.autoResolved)/\(result.totalConflicts) gelöst")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(result.formattedDuration)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
private var statusIcon: String {
switch result.status {
case .success: return "checkmark.circle.fill"
case .partialSuccess: return "exclamationmark.circle.fill"
case .failed: return "xmark.circle.fill"
case .inProgress: return "arrow.clockwise"
}
}
private var statusColor: Color {
switch result.status {
case .success: return .green
case .partialSuccess: return .orange
case .failed: return .red
case .inProgress: return .blue
}
}
private var formattedDate: String {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter.string(from: result.startedAt)
}
}
#Preview {
DashboardView()
.environmentObject(AppState())
.environmentObject(SyncCoordinator.shared)
}
+252
View File
@@ -0,0 +1,252 @@
import SwiftUI
struct RulesView: View {
@StateObject private var ruleEngine = RuleEngine.shared
@StateObject private var sourceManager = SourceManager.shared
@State private var selectedDataType: HealthDataType?
@State private var showingResetConfirmation = false
var body: some View {
NavigationStack {
List {
Section {
Text("Regeln bestimmen, wie Konflikte zwischen verschiedenen Datenquellen automatisch gelöst werden.")
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(HealthDataType.allCases) { dataType in
RuleRow(
dataType: dataType,
rule: ruleEngine.getRule(for: dataType)
)
.contentShape(Rectangle())
.onTapGesture {
selectedDataType = dataType
}
}
}
.listStyle(.insetGrouped)
.navigationTitle("Regeln")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("Alle zurücksetzen", role: .destructive) {
showingResetConfirmation = true
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.sheet(item: $selectedDataType) { dataType in
RuleEditorView(dataType: dataType)
}
.confirmationDialog(
"Alle Regeln zurücksetzen?",
isPresented: $showingResetConfirmation,
titleVisibility: .visible
) {
Button("Zurücksetzen", role: .destructive) {
ruleEngine.resetAllToDefaults()
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Alle Regeln werden auf die Standardwerte zurückgesetzt.")
}
}
}
}
// MARK: - Rule Row
struct RuleRow: View {
let dataType: HealthDataType
let rule: MergeRule
var body: some View {
HStack {
Image(systemName: dataType.icon)
.foregroundStyle(.blue)
.frame(width: 30)
VStack(alignment: .leading, spacing: 4) {
Text(dataType.displayName)
.font(.headline)
HStack(spacing: 8) {
Image(systemName: rule.strategy.icon)
.font(.caption)
Text(rule.strategy.displayName)
.font(.caption)
}
.foregroundStyle(.secondary)
}
Spacer()
if !rule.autoApply {
Image(systemName: "hand.raised.fill")
.foregroundStyle(.orange)
.font(.caption)
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
// MARK: - Rule Editor View
struct RuleEditorView: View {
let dataType: HealthDataType
@Environment(\.dismiss) private var dismiss
@StateObject private var ruleEngine = RuleEngine.shared
@StateObject private var sourceManager = SourceManager.shared
@State private var selectedStrategy: MergeStrategy
@State private var autoApply: Bool
@State private var primarySourceId: String?
@State private var thresholdForManualReview: Double?
@State private var useThreshold: Bool
init(dataType: HealthDataType) {
self.dataType = dataType
let rule = RuleEngine.shared.getRule(for: dataType)
_selectedStrategy = State(initialValue: rule.strategy)
_autoApply = State(initialValue: rule.autoApply)
_primarySourceId = State(initialValue: rule.primarySourceId)
_thresholdForManualReview = State(initialValue: rule.thresholdForManualReview)
_useThreshold = State(initialValue: rule.thresholdForManualReview != nil)
}
var body: some View {
NavigationStack {
Form {
// Data Type Info
Section {
HStack {
Image(systemName: dataType.icon)
.font(.title2)
.foregroundStyle(.blue)
VStack(alignment: .leading) {
Text(dataType.displayName)
.font(.headline)
Text(dataType.unit)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// Strategy Selection
Section("Strategie") {
Picker("Merge-Strategie", selection: $selectedStrategy) {
ForEach(MergeStrategy.allCases) { strategy in
Label(strategy.displayName, systemImage: strategy.icon)
.tag(strategy)
}
}
.pickerStyle(.navigationLink)
Text(selectedStrategy.description)
.font(.caption)
.foregroundStyle(.secondary)
}
// Primary Source (for exclusive/priority)
if selectedStrategy == .exclusive || selectedStrategy == .priority {
Section("Primäre Quelle") {
let sources = sourceManager.sources.filter {
$0.supportedDataTypes.contains(dataType)
}
if sources.isEmpty {
Text("Keine Quellen für diesen Datentyp")
.foregroundStyle(.secondary)
} else {
Picker("Quelle", selection: $primarySourceId) {
Text("Automatisch").tag(nil as String?)
ForEach(sources) { source in
Text(source.displayName).tag(source.bundleIdentifier as String?)
}
}
}
}
}
// Auto Apply
Section("Automatisierung") {
Toggle("Automatisch anwenden", isOn: $autoApply)
if autoApply {
Toggle("Schwellenwert für manuelle Prüfung", isOn: $useThreshold)
if useThreshold {
VStack(alignment: .leading) {
Text("Bei Differenz über \(Int(thresholdForManualReview ?? 20))% nachfragen")
.font(.caption)
.foregroundStyle(.secondary)
Slider(
value: Binding(
get: { thresholdForManualReview ?? 20 },
set: { thresholdForManualReview = $0 }
),
in: 5...50,
step: 5
)
}
}
}
}
// Reset
Section {
Button("Auf Standard zurücksetzen", role: .destructive) {
let defaultRule = MergeRule.defaultRule(for: dataType)
selectedStrategy = defaultRule.strategy
autoApply = defaultRule.autoApply
primarySourceId = defaultRule.primarySourceId
thresholdForManualReview = defaultRule.thresholdForManualReview
useThreshold = defaultRule.thresholdForManualReview != nil
}
}
}
.navigationTitle("Regel bearbeiten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Speichern") {
saveRule()
dismiss()
}
}
}
}
}
private func saveRule() {
let rule = MergeRule(
dataType: dataType,
strategy: selectedStrategy,
primarySourceId: primarySourceId,
autoApply: autoApply,
thresholdForManualReview: useThreshold ? thresholdForManualReview : nil
)
ruleEngine.setRule(rule, for: dataType)
}
}
#Preview {
RulesView()
}
+218
View File
@@ -0,0 +1,218 @@
import SwiftUI
struct SettingsView: View {
@AppStorage("backgroundSyncEnabled") private var backgroundSyncEnabled = true
@AppStorage("syncIntervalMinutes") private var syncIntervalMinutes = 15
@AppStorage("notificationsEnabled") private var notificationsEnabled = true
@AppStorage("notifyOnConflict") private var notifyOnConflict = true
@AppStorage("notifyOnSyncComplete") private var notifyOnSyncComplete = false
@AppStorage("autoResolveMinorConflicts") private var autoResolveMinorConflicts = true
@StateObject private var syncCoordinator = SyncCoordinator.shared
@StateObject private var healthKitManager = HealthKitManager.shared
@State private var showingClearDataConfirmation = false
@State private var showingExportSheet = false
var body: some View {
NavigationStack {
Form {
// Sync Settings
Section("Synchronisierung") {
Toggle("Hintergrund-Sync", isOn: $backgroundSyncEnabled)
if backgroundSyncEnabled {
Picker("Intervall", selection: $syncIntervalMinutes) {
Text("15 Minuten").tag(15)
Text("30 Minuten").tag(30)
Text("1 Stunde").tag(60)
Text("2 Stunden").tag(120)
}
}
Toggle("Kleine Konflikte automatisch lösen", isOn: $autoResolveMinorConflicts)
}
// Notification Settings
Section("Benachrichtigungen") {
Toggle("Benachrichtigungen aktivieren", isOn: $notificationsEnabled)
if notificationsEnabled {
Toggle("Bei neuen Konflikten", isOn: $notifyOnConflict)
Toggle("Nach Synchronisierung", isOn: $notifyOnSyncComplete)
}
}
// Health Status
Section("HealthKit Status") {
HStack {
Text("Autorisierung")
Spacer()
if healthKitManager.isAuthorized {
Label("Erteilt", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
} else {
Label("Ausstehend", systemImage: "exclamationmark.circle")
.foregroundStyle(.orange)
}
}
if !healthKitManager.isAuthorized {
Button("HealthKit-Zugriff anfordern") {
Task {
try? await healthKitManager.requestAuthorization()
}
}
}
}
// Statistics
Section("Statistiken") {
StatRow(label: "Syncs heute", value: "\(syncCoordinator.todayStats.syncCount)")
StatRow(label: "Konflikte heute", value: "\(syncCoordinator.todayStats.totalConflicts)")
StatRow(label: "Automatisch gelöst", value: "\(syncCoordinator.todayStats.autoResolved)")
StatRow(label: "Auflösungsrate", value: "\(Int(syncCoordinator.todayStats.resolutionRate * 100))%")
}
// Data Management
Section("Daten") {
Button("Sync-Verlauf exportieren") {
showingExportSheet = true
}
Button("Sync-Verlauf löschen", role: .destructive) {
showingClearDataConfirmation = true
}
}
// About
Section("Info") {
HStack {
Text("Version")
Spacer()
Text("1.0.0")
.foregroundStyle(.secondary)
}
Link(destination: URL(string: "https://apple.com/health")!) {
HStack {
Text("Apple Health")
Spacer()
Image(systemName: "arrow.up.right.square")
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Einstellungen")
.confirmationDialog(
"Sync-Verlauf löschen?",
isPresented: $showingClearDataConfirmation,
titleVisibility: .visible
) {
Button("Löschen", role: .destructive) {
syncCoordinator.clearHistory()
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Der gesamte Sync-Verlauf wird gelöscht. Dies kann nicht rückgängig gemacht werden.")
}
.sheet(isPresented: $showingExportSheet) {
ExportView()
}
}
}
}
// MARK: - Stat Row
struct StatRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
Spacer()
Text(value)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Export View
struct ExportView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var syncCoordinator = SyncCoordinator.shared
@State private var exportFormat: ExportFormat = .json
@State private var isExporting = false
enum ExportFormat: String, CaseIterable {
case json = "JSON"
case csv = "CSV"
}
var body: some View {
NavigationStack {
VStack(spacing: 20) {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 48))
.foregroundStyle(.blue)
Text("Daten exportieren")
.font(.title2)
.fontWeight(.semibold)
Picker("Format", selection: $exportFormat) {
ForEach(ExportFormat.allCases, id: \.self) { format in
Text(format.rawValue).tag(format)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
Text("\(syncCoordinator.syncHistory.count) Sync-Einträge")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
Button {
exportData()
} label: {
Label("Exportieren", systemImage: "square.and.arrow.up")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.padding()
.disabled(isExporting || syncCoordinator.syncHistory.isEmpty)
}
.padding()
.navigationTitle("Export")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") {
dismiss()
}
}
}
}
}
private func exportData() {
isExporting = true
// In a real app, this would create and share a file
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
isExporting = false
dismiss()
}
}
}
#Preview {
SettingsView()
}
+407
View File
@@ -0,0 +1,407 @@
import SwiftUI
struct SourcesView: View {
@StateObject private var sourceManager = SourceManager.shared
@State private var selectedSource: HealthSource?
@State private var isRefreshing = false
var body: some View {
NavigationStack {
Group {
if sourceManager.sources.isEmpty && !sourceManager.isDiscovering {
emptyState
} else {
sourcesList
}
}
.navigationTitle("Quellen")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task { await refreshSources() }
} label: {
if isRefreshing {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.clockwise")
}
}
.disabled(isRefreshing)
}
}
.sheet(item: $selectedSource) { source in
SourceDetailView(source: source)
}
.task {
if sourceManager.sources.isEmpty {
await refreshSources()
}
}
}
}
// MARK: - Empty State
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 64))
.foregroundStyle(.secondary)
Text("Keine Quellen gefunden")
.font(.title2)
.fontWeight(.semibold)
Text("Verbinden Sie Geräte mit Apple Health")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
Task { await refreshSources() }
} label: {
Label("Aktualisieren", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
}
.padding()
}
// MARK: - Sources List
private var sourcesList: some View {
List {
ForEach(groupedSources.keys.sorted(by: { $0.priority > $1.priority }), id: \.self) { category in
Section(category.displayName) {
ForEach(groupedSources[category] ?? []) { source in
SourceRow(source: source)
.contentShape(Rectangle())
.onTapGesture {
selectedSource = source
}
}
}
}
}
.listStyle(.insetGrouped)
.refreshable {
await refreshSources()
}
}
private var groupedSources: [SourceCategory: [HealthSource]] {
Dictionary(grouping: sourceManager.sources, by: { $0.category })
}
private func refreshSources() async {
isRefreshing = true
defer { isRefreshing = false }
await sourceManager.discoverSources()
}
}
// MARK: - Source Row
struct SourceRow: View {
let source: HealthSource
@StateObject private var sourceManager = SourceManager.shared
var body: some View {
HStack {
Image(systemName: source.category.icon)
.font(.title2)
.foregroundStyle(.blue)
.frame(width: 40)
VStack(alignment: .leading, spacing: 4) {
Text(source.displayName)
.font(.headline)
Text("\(source.supportedDataTypes.count) Datentypen")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if let status = sourceManager.sourceHealthStatus[source.id] {
Image(systemName: status.syncStatus.icon)
.foregroundStyle(statusColor(for: status.syncStatus))
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
private func statusColor(for status: SourceHealthStatus.SyncStatus) -> Color {
switch status {
case .recentlySynced: return .green
case .syncedToday: return .blue
case .stale: return .orange
case .veryStale, .neverSynced: return .red
}
}
}
// MARK: - Source Detail View
struct SourceDetailView: View {
let source: HealthSource
@Environment(\.dismiss) private var dismiss
@StateObject private var sourceManager = SourceManager.shared
@State private var healthReport: SourceHealthReport?
@State private var isLoading = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Header
headerSection
// Capabilities
capabilitiesSection
// Data Types
dataTypesSection
// Health Report
if let report = healthReport {
healthReportSection(report)
}
// Priority Settings
prioritySection
}
.padding()
}
.navigationTitle(source.displayName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Fertig") {
dismiss()
}
}
}
.task {
await loadHealthReport()
}
}
}
// MARK: - Header
private var headerSection: some View {
VStack(spacing: 12) {
Image(systemName: source.category.icon)
.font(.system(size: 48))
.foregroundStyle(.blue)
Text(source.displayName)
.font(.title2)
.fontWeight(.semibold)
Text(source.category.displayName)
.font(.subheadline)
.foregroundStyle(.secondary)
Text(source.bundleIdentifier)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
.padding()
}
// MARK: - Capabilities
private var capabilitiesSection: some View {
let capabilities = sourceManager.getSourceCapabilities(source)
return VStack(alignment: .leading, spacing: 12) {
Text("Fähigkeiten")
.font(.headline)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 8) {
CapabilityBadge(name: "Schritte", available: capabilities.canMeasureSteps)
CapabilityBadge(name: "Herzfrequenz", available: capabilities.canMeasureHeartRate)
CapabilityBadge(name: "Blutdruck", available: capabilities.canMeasureBloodPressure)
CapabilityBadge(name: "SpO2", available: capabilities.canMeasureBloodOxygen)
CapabilityBadge(name: "Schlaf", available: capabilities.canMeasureSleep)
CapabilityBadge(name: "GPS", available: capabilities.hasGPS)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Data Types
private var dataTypesSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Unterstützte Datentypen")
.font(.headline)
ForEach(Array(source.supportedDataTypes).sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
HStack {
Image(systemName: dataType.icon)
.foregroundStyle(.blue)
.frame(width: 24)
Text(dataType.displayName)
Spacer()
Text(dataType.unit)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Health Report
private func healthReportSection(_ report: SourceHealthReport) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Zustand")
.font(.headline)
HStack {
VStack(alignment: .leading) {
Text("Datensätze (24h)")
.font(.caption)
.foregroundStyle(.secondary)
Text("\(report.totalRecordCount)")
.font(.title3)
.fontWeight(.medium)
}
Spacer()
VStack(alignment: .trailing) {
Text("Qualität")
.font(.caption)
.foregroundStyle(.secondary)
Image(systemName: report.overallQuality.icon)
.font(.title3)
.foregroundStyle(qualityColor(report.overallQuality))
}
}
if let lastActivity = report.lastOverallActivity {
HStack {
Text("Letzte Aktivität")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(formattedDate(lastActivity))
.font(.subheadline)
}
}
if report.hasSignificantGaps {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Datenlücken erkannt")
.font(.subheadline)
}
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Priority Section
private var prioritySection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Priorität")
.font(.headline)
Text("Höhere Priorität bedeutet, dass Daten dieser Quelle bevorzugt werden")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(Array(source.supportedDataTypes).sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
HStack {
Text(dataType.displayName)
Spacer()
Stepper(
"\(sourceManager.getPriority(for: source, dataType: dataType))",
value: Binding(
get: { sourceManager.getPriority(for: source, dataType: dataType) },
set: { sourceManager.setPriority($0, for: source, dataType: dataType) }
),
in: 0...100,
step: 10
)
.frame(width: 150)
}
.padding(.vertical, 4)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Helpers
private func loadHealthReport() async {
isLoading = true
defer { isLoading = false }
healthReport = await sourceManager.getSourceHealth(source)
}
private func qualityColor(_ quality: DataQuality) -> Color {
switch quality {
case .complete: return .green
case .partial: return .yellow
case .missing: return .gray
case .invalid: return .red
}
}
private func formattedDate(_ date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: date, relativeTo: Date())
}
}
// MARK: - Capability Badge
struct CapabilityBadge: View {
let name: String
let available: Bool
var body: some View {
HStack(spacing: 4) {
Image(systemName: available ? "checkmark.circle.fill" : "xmark.circle")
.foregroundStyle(available ? .green : .secondary)
Text(name)
.font(.caption)
.foregroundStyle(available ? .primary : .secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(available ? Color.green.opacity(0.1) : Color.gray.opacity(0.1))
.clipShape(Capsule())
}
}
#Preview {
SourcesView()
}
+379
View File
@@ -0,0 +1,379 @@
<!DOCTYPE html>
<html lang="de" data-lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PromoMaster - Produkt-Promotion-Tool | Product Promotion Tool</title>
<!-- SEO Meta Tags -->
<meta name="description" content="Erstellen Sie automatisch professionelle Produktwerbung in Deutsch und Englisch. Generate professional product promotions in German and English.">
<meta name="keywords" content="Produktwerbung, Product Promotion, Marketing, Werbung, Advertising, Social Media, SEO, Product Launch">
<meta name="author" content="PromoMaster">
<meta name="robots" content="index, follow">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:title" content="PromoMaster - Automatische Produktwerbung">
<meta property="og:description" content="Erstellen Sie automatisch professionelle Produktwerbung in Deutsch und Englisch.">
<meta property="og:locale" content="de_DE">
<meta property="og:locale:alternate" content="en_US">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="PromoMaster - Product Promotion Tool">
<meta name="twitter:description" content="Generate professional product promotions in German and English automatically.">
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "PromoMaster",
"description": "Automatisches Produkt-Promotion-Tool in Deutsch und Englisch",
"applicationCategory": "Marketing",
"operatingSystem": "Web Browser",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "EUR"
}
}
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Language Toggle -->
<div class="lang-toggle">
<button id="btn-de" class="lang-btn active" onclick="setLanguage('de')">DE</button>
<button id="btn-en" class="lang-btn" onclick="setLanguage('en')">EN</button>
</div>
<!-- Hero Section -->
<header class="hero">
<div class="hero-bg"></div>
<div class="container">
<h1 class="hero-title">
<span class="hero-icon">&#9889;</span>
PromoMaster
</h1>
<p class="hero-subtitle" data-de="Dein Produkt. Weltweit bekannt. Vollautomatisch." data-en="Your Product. Worldwide Fame. Fully Automatic.">Dein Produkt. Weltweit bekannt. Vollautomatisch.</p>
</div>
</header>
<!-- Main App -->
<main class="container">
<!-- Step 1: Product Input -->
<section class="card step-card" id="step1">
<div class="step-header">
<span class="step-number">1</span>
<h2 data-de="Produkt-Informationen" data-en="Product Information"></h2>
</div>
<div class="form-grid">
<div class="form-group">
<label for="productName" data-de="Produktname *" data-en="Product Name *"></label>
<input type="text" id="productName" data-de-placeholder="z.B. SuperWidget Pro" data-en-placeholder="e.g. SuperWidget Pro">
</div>
<div class="form-group">
<label for="productCategory" data-de="Kategorie" data-en="Category"></label>
<select id="productCategory">
<option value="tech" data-de="Technologie" data-en="Technology"></option>
<option value="fashion" data-de="Mode & Bekleidung" data-en="Fashion & Apparel"></option>
<option value="food" data-de="Lebensmittel & Getr&auml;nke" data-en="Food & Beverages"></option>
<option value="health" data-de="Gesundheit & Wellness" data-en="Health & Wellness"></option>
<option value="home" data-de="Haus & Garten" data-en="Home & Garden"></option>
<option value="sport" data-de="Sport & Fitness" data-en="Sports & Fitness"></option>
<option value="beauty" data-de="Sch&ouml;nheit & Pflege" data-en="Beauty & Care"></option>
<option value="education" data-de="Bildung & Kurse" data-en="Education & Courses"></option>
<option value="software" data-de="Software & Apps" data-en="Software & Apps"></option>
<option value="other" data-de="Sonstiges" data-en="Other"></option>
</select>
</div>
<div class="form-group full-width">
<label for="productDescription" data-de="Kurzbeschreibung *" data-en="Short Description *"></label>
<textarea id="productDescription" rows="3" data-de-placeholder="Was macht dein Produkt besonders? (max. 200 Zeichen)" data-en-placeholder="What makes your product special? (max. 200 characters)" maxlength="200"></textarea>
<span class="char-count"><span id="charCount">0</span>/200</span>
</div>
<div class="form-group">
<label for="productPrice" data-de="Preis (optional)" data-en="Price (optional)"></label>
<input type="text" id="productPrice" data-de-placeholder="z.B. 29,99 EUR" data-en-placeholder="e.g. $29.99">
</div>
<div class="form-group">
<label for="productUrl" data-de="Website / Link (optional)" data-en="Website / Link (optional)"></label>
<input type="url" id="productUrl" data-de-placeholder="https://dein-produkt.de" data-en-placeholder="https://your-product.com">
</div>
<div class="form-group full-width">
<label for="productFeatures" data-de="Top-Features (kommagetrennt)" data-en="Top Features (comma-separated)"></label>
<input type="text" id="productFeatures" data-de-placeholder="z.B. Schnell, Zuverl&auml;ssig, Einfach zu bedienen" data-en-placeholder="e.g. Fast, Reliable, Easy to use">
</div>
<div class="form-group full-width">
<label for="targetAudience" data-de="Zielgruppe" data-en="Target Audience"></label>
<input type="text" id="targetAudience" data-de-placeholder="z.B. Unternehmer, Studenten, Eltern" data-en-placeholder="e.g. Entrepreneurs, Students, Parents">
</div>
</div>
</section>
<!-- Step 2: Promotion Style -->
<section class="card step-card" id="step2">
<div class="step-header">
<span class="step-number">2</span>
<h2 data-de="Werbestil w&auml;hlen" data-en="Choose Promotion Style"></h2>
</div>
<div class="style-grid">
<div class="style-option selected" data-style="professional" onclick="selectStyle(this)">
<span class="style-icon">&#128188;</span>
<span class="style-label" data-de="Professionell" data-en="Professional"></span>
</div>
<div class="style-option" data-style="casual" onclick="selectStyle(this)">
<span class="style-icon">&#128075;</span>
<span class="style-label" data-de="Locker & Freundlich" data-en="Casual & Friendly"></span>
</div>
<div class="style-option" data-style="urgent" onclick="selectStyle(this)">
<span class="style-icon">&#9889;</span>
<span class="style-label" data-de="Dringend & FOMO" data-en="Urgent & FOMO"></span>
</div>
<div class="style-option" data-style="luxury" onclick="selectStyle(this)">
<span class="style-icon">&#10024;</span>
<span class="style-label" data-de="Premium & Luxus" data-en="Premium & Luxury"></span>
</div>
<div class="style-option" data-style="fun" onclick="selectStyle(this)">
<span class="style-icon">&#127881;</span>
<span class="style-label" data-de="Spa&szlig;ig & Kreativ" data-en="Fun & Creative"></span>
</div>
<div class="style-option" data-style="minimal" onclick="selectStyle(this)">
<span class="style-icon">&#9711;</span>
<span class="style-label" data-de="Minimalistisch" data-en="Minimalist"></span>
</div>
</div>
</section>
<!-- Generate Button -->
<div class="generate-section">
<button class="btn-generate" onclick="generateAll()" id="generateBtn">
<span class="btn-icon">&#9889;</span>
<span data-de="Alle Werbematerialien generieren" data-en="Generate All Promotion Materials"></span>
</button>
</div>
<!-- Results Section -->
<section id="results" class="results-section hidden">
<!-- Social Media Posts -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#128241; Social Media Posts" data-en="&#128241; Social Media Posts"></h3>
<button class="btn-copy-all" onclick="copyAll('social')" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="result-tabs">
<button class="tab-btn active" onclick="switchTab(this, 'twitter')">Twitter/X</button>
<button class="tab-btn" onclick="switchTab(this, 'instagram')">Instagram</button>
<button class="tab-btn" onclick="switchTab(this, 'facebook')">Facebook</button>
<button class="tab-btn" onclick="switchTab(this, 'linkedin')">LinkedIn</button>
<button class="tab-btn" onclick="switchTab(this, 'tiktok')">TikTok</button>
</div>
<div class="tab-content" id="tab-twitter">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="twitter-de"></div>
<button class="btn-copy" onclick="copyText('twitter-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="twitter-en"></div>
<button class="btn-copy" onclick="copyText('twitter-en')">&#128203;</button>
</div>
</div>
</div>
<div class="tab-content hidden" id="tab-instagram">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="instagram-de"></div>
<button class="btn-copy" onclick="copyText('instagram-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="instagram-en"></div>
<button class="btn-copy" onclick="copyText('instagram-en')">&#128203;</button>
</div>
</div>
</div>
<div class="tab-content hidden" id="tab-facebook">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="facebook-de"></div>
<button class="btn-copy" onclick="copyText('facebook-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="facebook-en"></div>
<button class="btn-copy" onclick="copyText('facebook-en')">&#128203;</button>
</div>
</div>
</div>
<div class="tab-content hidden" id="tab-linkedin">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="linkedin-de"></div>
<button class="btn-copy" onclick="copyText('linkedin-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="linkedin-en"></div>
<button class="btn-copy" onclick="copyText('linkedin-en')">&#128203;</button>
</div>
</div>
</div>
<div class="tab-content hidden" id="tab-tiktok">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="tiktok-de"></div>
<button class="btn-copy" onclick="copyText('tiktok-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="tiktok-en"></div>
<button class="btn-copy" onclick="copyText('tiktok-en')">&#128203;</button>
</div>
</div>
</div>
</div>
<!-- Email Marketing -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#9993; E-Mail Marketing" data-en="&#9993; Email Marketing"></h3>
<button class="btn-copy-all" onclick="copyAll('email')" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="email-de"></div>
<button class="btn-copy" onclick="copyText('email-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="email-en"></div>
<button class="btn-copy" onclick="copyText('email-en')">&#128203;</button>
</div>
</div>
</div>
<!-- SEO Texts -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#128270; SEO-Texte & Metadaten" data-en="&#128270; SEO Texts & Metadata"></h3>
<button class="btn-copy-all" onclick="copyAll('seo')" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="seo-de"></div>
<button class="btn-copy" onclick="copyText('seo-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="seo-en"></div>
<button class="btn-copy" onclick="copyText('seo-en')">&#128203;</button>
</div>
</div>
</div>
<!-- Press Release -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#128240; Pressemitteilung" data-en="&#128240; Press Release"></h3>
<button class="btn-copy-all" onclick="copyAll('press')" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="press-de"></div>
<button class="btn-copy" onclick="copyText('press-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="press-en"></div>
<button class="btn-copy" onclick="copyText('press-en')">&#128203;</button>
</div>
</div>
</div>
<!-- Slogan Generator -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#128161; Slogans & Taglines" data-en="&#128161; Slogans & Taglines"></h3>
</div>
<div class="slogan-grid" id="sloganGrid"></div>
</div>
<!-- Hashtag Cloud -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="# Hashtag-Wolke" data-en="# Hashtag Cloud"></h3>
<button class="btn-copy-all" onclick="copyHashtags()" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="hashtag-cloud" id="hashtagCloud"></div>
</div>
<!-- Landing Page Preview -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#127760; Landing Page HTML" data-en="&#127760; Landing Page HTML"></h3>
<button class="btn-copy-all" onclick="copyText('landing-code')" data-de="HTML kopieren" data-en="Copy HTML"></button>
</div>
<div class="landing-preview-container">
<div class="preview-toggle">
<button class="preview-btn active" onclick="togglePreview('preview')" data-de="Vorschau" data-en="Preview"></button>
<button class="preview-btn" onclick="togglePreview('code')" data-de="HTML-Code" data-en="HTML Code"></button>
</div>
<div id="landing-preview" class="landing-preview"></div>
<pre id="landing-code" class="landing-code hidden"></pre>
</div>
</div>
<!-- Export Section -->
<div class="card result-card export-card">
<div class="result-header">
<h3 data-de="&#128229; Alles exportieren" data-en="&#128229; Export Everything"></h3>
</div>
<div class="export-grid">
<button class="btn-export" onclick="exportAs('txt')">
<span class="export-icon">&#128196;</span>
<span>TXT</span>
</button>
<button class="btn-export" onclick="exportAs('html')">
<span class="export-icon">&#127760;</span>
<span>HTML</span>
</button>
<button class="btn-export" onclick="exportAs('json')">
<span class="export-icon">&#128218;</span>
<span>JSON</span>
</button>
<button class="btn-export" onclick="exportAs('csv')">
<span class="export-icon">&#128202;</span>
<span>CSV</span>
</button>
</div>
</div>
</section>
</main>
<!-- Toast Notification -->
<div class="toast hidden" id="toast"></div>
<!-- Footer -->
<footer class="footer">
<p data-de="PromoMaster - Vollautomatisches Produkt-Promotion-Tool" data-en="PromoMaster - Fully Automatic Product Promotion Tool"></p>
</footer>
<script src="promo.js"></script>
</body>
</html>
+813
View File
@@ -0,0 +1,813 @@
// === PromoMaster - Product Promotion Tool ===
// Vollautomatisches Produkt-Promotion-Tool in Deutsch und Englisch
(function () {
'use strict';
// --- State ---
let currentLang = 'de';
let selectedStyle = 'professional';
let generatedData = null;
// --- Language System ---
function setLanguage(lang) {
currentLang = lang;
document.documentElement.setAttribute('data-lang', lang);
document.getElementById('btn-de').classList.toggle('active', lang === 'de');
document.getElementById('btn-en').classList.toggle('active', lang === 'en');
document.querySelectorAll('[data-de]').forEach(function (el) {
el.textContent = el.getAttribute('data-' + lang);
});
document.querySelectorAll('[data-de-placeholder]').forEach(function (el) {
el.placeholder = el.getAttribute('data-' + lang + '-placeholder');
});
document.querySelectorAll('select option').forEach(function (opt) {
var val = opt.getAttribute('data-' + lang);
if (val) opt.textContent = val;
});
}
window.setLanguage = setLanguage;
// --- Style Selection ---
function selectStyle(el) {
document.querySelectorAll('.style-option').forEach(function (s) {
s.classList.remove('selected');
});
el.classList.add('selected');
selectedStyle = el.getAttribute('data-style');
}
window.selectStyle = selectStyle;
// --- Character Counter ---
var descInput = document.getElementById('productDescription');
var charCount = document.getElementById('charCount');
if (descInput && charCount) {
descInput.addEventListener('input', function () {
charCount.textContent = descInput.value.length;
});
}
// --- Text Templates ---
var templates = {
professional: {
twitter: {
de: function (p) {
return 'Entdecken Sie ' + p.name + ' \u2013 ' + p.desc + (p.features.length ? '\n\n\u2705 ' + p.features.slice(0, 3).join('\n\u2705 ') : '') + (p.url ? '\n\n\ud83d\udc49 ' + p.url : '') + (p.price ? '\n\ud83d\udcb0 ' + p.price : '') + '\n\n' + p.hashtags.slice(0, 4).join(' ');
},
en: function (p) {
return 'Discover ' + p.name + ' \u2013 ' + p.descEn + (p.features.length ? '\n\n\u2705 ' + p.featuresEn.slice(0, 3).join('\n\u2705 ') : '') + (p.url ? '\n\n\ud83d\udc49 ' + p.url : '') + (p.price ? '\n\ud83d\udcb0 ' + p.price : '') + '\n\n' + p.hashtagsEn.slice(0, 4).join(' ');
}
},
instagram: {
de: function (p) {
return '\u2728 ' + p.name + ' \u2013 Die Zukunft beginnt jetzt!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Was ' + p.name + ' besonders macht:\n' + p.features.map(function (f) { return '\ud83d\udd39 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? '\ud83c\udfaf Perfekt f\u00fcr: ' + p.audience + '\n\n' : '') + (p.price ? '\ud83d\udcb0 Jetzt f\u00fcr nur ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in Bio \u2b06\ufe0f\n\n' : '') + p.hashtags.join(' ');
},
en: function (p) {
return '\u2728 ' + p.name + ' \u2013 The future starts now!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'What makes ' + p.name + ' special:\n' + p.featuresEn.map(function (f) { return '\ud83d\udd39 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? '\ud83c\udfaf Perfect for: ' + p.audienceEn + '\n\n' : '') + (p.price ? '\ud83d\udcb0 Now only ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in bio \u2b06\ufe0f\n\n' : '') + p.hashtagsEn.join(' ');
}
},
facebook: {
de: function (p) {
return '\ud83d\ude80 Neu: ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.features.length ? '\ud83d\udc47 Das sind die Highlights:\n\n' + p.features.map(function (f) { return '\u2714\ufe0f ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Perfekt f\u00fcr alle, die ' + p.audience + ' sind.\n\n' : '') + (p.price ? '\ud83d\udcb5 Preis: ' + p.price + '\n\n' : '') + (p.url ? '\ud83c\udf10 Mehr erfahren: ' + p.url + '\n\n' : '') + 'Was denkt ihr? Lasst es uns in den Kommentaren wissen! \ud83d\udc47\n\n' + p.hashtags.slice(0, 5).join(' ');
},
en: function (p) {
return '\ud83d\ude80 New: ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? '\ud83d\udc47 Here are the highlights:\n\n' + p.featuresEn.map(function (f) { return '\u2714\ufe0f ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Perfect for everyone who is ' + p.audienceEn + '.\n\n' : '') + (p.price ? '\ud83d\udcb5 Price: ' + p.price + '\n\n' : '') + (p.url ? '\ud83c\udf10 Learn more: ' + p.url + '\n\n' : '') + 'What do you think? Let us know in the comments! \ud83d\udc47\n\n' + p.hashtagsEn.slice(0, 5).join(' ');
}
},
linkedin: {
de: function (p) {
return '\ud83d\udca1 ' + p.name + ' \u2013 Innovation trifft Effizienz\n\nIch freue mich, Ihnen ' + p.name + ' vorzustellen.\n\n' + p.desc + '\n\n' + (p.features.length ? 'Die wichtigsten Vorteile:\n\n' + p.features.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Zielgruppe: ' + p.audience + '\n\n' : '') + (p.price ? 'Investition: ' + p.price + '\n\n' : '') + (p.url ? 'Erfahren Sie mehr: ' + p.url + '\n\n' : '') + '#Innovation #Business ' + p.hashtags.slice(0, 3).join(' ');
},
en: function (p) {
return '\ud83d\udca1 ' + p.name + ' \u2013 Innovation meets Efficiency\n\nI\'m excited to introduce ' + p.name + ' to you.\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Key benefits:\n\n' + p.featuresEn.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Target audience: ' + p.audienceEn + '\n\n' : '') + (p.price ? 'Investment: ' + p.price + '\n\n' : '') + (p.url ? 'Learn more: ' + p.url + '\n\n' : '') + '#Innovation #Business ' + p.hashtagsEn.slice(0, 3).join(' ');
}
},
tiktok: {
de: function (p) {
return '\ud83d\udd25 POV: Du entdeckst gerade ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.slice(0, 3).map(function (f) { return '\u2728 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in Bio!\n\n' : '') + p.hashtags.join(' ') + ' #fyp #viral #musthave';
},
en: function (p) {
return '\ud83d\udd25 POV: You just discovered ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.slice(0, 3).map(function (f) { return '\u2728 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in bio!\n\n' : '') + p.hashtagsEn.join(' ') + ' #fyp #viral #musthave';
}
}
},
casual: {
twitter: {
de: function (p) { return 'Hey Leute! \ud83d\udc4b Kennt ihr schon ' + p.name + '? ' + p.desc + (p.features.length ? '\n\nDas Beste daran:\n' + p.features.slice(0, 3).map(function (f) { return '\ud83d\udc4d ' + f; }).join('\n') : '') + (p.url ? '\n\nSchaut mal vorbei: ' + p.url : '') + '\n\n' + p.hashtags.slice(0, 4).join(' '); },
en: function (p) { return 'Hey everyone! \ud83d\udc4b Have you heard of ' + p.name + '? ' + p.descEn + (p.features.length ? '\n\nBest thing about it:\n' + p.featuresEn.slice(0, 3).map(function (f) { return '\ud83d\udc4d ' + f; }).join('\n') : '') + (p.url ? '\n\nCheck it out: ' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 4).join(' '); }
},
instagram: {
de: function (p) { return 'Schaut mal was ich gefunden habe! \ud83e\udd29\n\n' + p.name + ' \u2013 ' + p.desc + '\n\n' + (p.features.length ? 'Warum ich es liebe:\n' + p.features.map(function (f) { return '\u2764\ufe0f ' + f; }).join('\n') + '\n\n' : '') + 'Wer will es auch haben? \ud83d\ude4b\u200d\u2640\ufe0f\n\n' + p.hashtags.join(' '); },
en: function (p) { return 'Look what I found! \ud83e\udd29\n\n' + p.name + ' \u2013 ' + p.descEn + '\n\n' + (p.features.length ? 'Why I love it:\n' + p.featuresEn.map(function (f) { return '\u2764\ufe0f ' + f; }).join('\n') + '\n\n' : '') + 'Who else wants this? \ud83d\ude4b\u200d\u2640\ufe0f\n\n' + p.hashtagsEn.join(' '); }
},
facebook: {
de: function (p) { return 'Hey Freunde! \ud83d\udc4b\n\nIch muss euch unbedingt von ' + p.name + ' erz\u00e4hlen!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Was mich \u00fcberzeugt hat:\n' + p.features.map(function (f) { return '\ud83d\udc49 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'Und das f\u00fcr ' + p.price + ' \u2013 richtig fair!\n\n' : '') + (p.url ? 'Hier gehts lang: ' + p.url + '\n\n' : '') + 'Kennt ihr das schon? \ud83d\ude0d'; },
en: function (p) { return 'Hey friends! \ud83d\udc4b\n\nI have to tell you about ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'What convinced me:\n' + p.featuresEn.map(function (f) { return '\ud83d\udc49 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'And all that for ' + p.price + ' \u2013 such a great deal!\n\n' : '') + (p.url ? 'Check it here: ' + p.url + '\n\n' : '') + 'Have you heard of this? \ud83d\ude0d'; }
},
linkedin: {
de: function (p) { return 'Moin zusammen! \ud83d\ude4c\n\nDarf ich vorstellen: ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Das macht es so cool:\n' + p.features.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'Wer hat Lust, das mal auszuprobieren?\n\n' + p.hashtags.slice(0, 3).join(' '); },
en: function (p) { return 'Hey everyone! \ud83d\ude4c\n\nLet me introduce: ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'What makes it awesome:\n' + p.featuresEn.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'Who wants to give it a try?\n\n' + p.hashtagsEn.slice(0, 3).join(' '); }
},
tiktok: {
de: function (p) { return 'OK das m\u00fcsst ihr sehen!! \ud83d\ude31\n\n' + p.name + ' ist einfach WILD!\n' + p.desc + '\n\n' + (p.features.length ? p.features.slice(0, 2).map(function (f) { return '\ud83e\udd2f ' + f; }).join('\n') + '\n\n' : '') + 'Kommentiert wenn ihrs auch braucht! \ud83d\udc47\n\n' + p.hashtags.join(' ') + ' #fyp #musthave'; },
en: function (p) { return 'OK you NEED to see this!! \ud83d\ude31\n\n' + p.name + ' is absolutely WILD!\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.slice(0, 2).map(function (f) { return '\ud83e\udd2f ' + f; }).join('\n') + '\n\n' : '') + 'Comment if you need this too! \ud83d\udc47\n\n' + p.hashtagsEn.join(' ') + ' #fyp #musthave'; }
}
},
urgent: {
twitter: {
de: function (p) { return '\u26a0\ufe0f NUR F\u00dcR KURZE ZEIT: ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.price ? '\ud83d\udcb0 Jetzt zuschlagen: ' + p.price + '\n' : '') + '\u23f0 Begrenztes Angebot \u2013 nicht verpassen!\n\n' + (p.url ? '\ud83d\udc49 Sofort sichern: ' + p.url + '\n\n' : '') + p.hashtags.slice(0, 4).join(' '); },
en: function (p) { return '\u26a0\ufe0f LIMITED TIME ONLY: ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.price ? '\ud83d\udcb0 Get it now: ' + p.price + '\n' : '') + '\u23f0 Limited offer \u2013 don\'t miss out!\n\n' + (p.url ? '\ud83d\udc49 Grab yours: ' + p.url + '\n\n' : '') + p.hashtagsEn.slice(0, 4).join(' '); }
},
instagram: {
de: function (p) { return '\ud83d\udea8 ACHTUNG! \ud83d\udea8\n\n' + p.name + ' ist DA!\n\n' + p.desc + '\n\n' + (p.features.length ? '\ud83d\udd25 ' + p.features.join(' \u2022 ') + '\n\n' : '') + '\u23f3 Nur solange der Vorrat reicht!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + 'JETZT HANDELN bevor es zu sp\u00e4t ist! \ud83d\udc47\n\n' + p.hashtags.join(' '); },
en: function (p) { return '\ud83d\udea8 ATTENTION! \ud83d\udea8\n\n' + p.name + ' is HERE!\n\n' + p.descEn + '\n\n' + (p.features.length ? '\ud83d\udd25 ' + p.featuresEn.join(' \u2022 ') + '\n\n' : '') + '\u23f3 Only while supplies last!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + 'ACT NOW before it\'s too late! \ud83d\udc47\n\n' + p.hashtagsEn.join(' '); }
},
facebook: {
de: function (p) { return '\ud83d\udea8\ud83d\udea8\ud83d\udea8 EILMELDUNG \ud83d\udea8\ud83d\udea8\ud83d\udea8\n\n' + p.name + ' ist endlich verf\u00fcgbar!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Die Fakten:\n' + p.features.map(function (f) { return '\u26a1 ' + f; }).join('\n') + '\n\n' : '') + '\u23f0 ACHTUNG: Angebot endet bald!\n' + (p.price ? '\ud83d\udcb5 Nur ' + p.price + '\n' : '') + (p.url ? '\n\ud83d\udc49 SOFORT ZUSCHLAGEN: ' + p.url : ''); },
en: function (p) { return '\ud83d\udea8\ud83d\udea8\ud83d\udea8 BREAKING \ud83d\udea8\ud83d\udea8\ud83d\udea8\n\n' + p.name + ' is finally available!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'The facts:\n' + p.featuresEn.map(function (f) { return '\u26a1 ' + f; }).join('\n') + '\n\n' : '') + '\u23f0 WARNING: Offer ends soon!\n' + (p.price ? '\ud83d\udcb5 Only ' + p.price + '\n' : '') + (p.url ? '\n\ud83d\udc49 GRAB IT NOW: ' + p.url : ''); }
},
linkedin: {
de: function (p) { return '\ud83d\udea8 Dringende Marktchance: ' + p.name + '\n\n' + p.desc + '\n\n' + (p.features.length ? 'Schl\u00fcsselvorteile:\n' + p.features.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + 'Dieses Angebot ist zeitlich begrenzt. Wer jetzt nicht handelt, verpasst eine einmalige Gelegenheit.\n\n' + (p.url ? p.url + '\n\n' : '') + p.hashtags.slice(0, 3).join(' '); },
en: function (p) { return '\ud83d\udea8 Urgent Market Opportunity: ' + p.name + '\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Key advantages:\n' + p.featuresEn.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + 'This offer is time-limited. Those who don\'t act now will miss a unique opportunity.\n\n' + (p.url ? p.url + '\n\n' : '') + p.hashtagsEn.slice(0, 3).join(' '); }
},
tiktok: {
de: function (p) { return '\ud83d\udea8 STOP SCROLLING! \ud83d\udea8\n\n' + p.name + ' \u2013 ' + p.desc + '\n\n' + '\u23f0 Letzte Chance!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n' : '') + '\nLINK IN BIO BEVOR ES WEG IST!\n\n' + p.hashtags.join(' ') + ' #fyp #limitedoffer'; },
en: function (p) { return '\ud83d\udea8 STOP SCROLLING! \ud83d\udea8\n\n' + p.name + ' \u2013 ' + p.descEn + '\n\n' + '\u23f0 Last chance!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n' : '') + '\nLINK IN BIO BEFORE IT\'S GONE!\n\n' + p.hashtagsEn.join(' ') + ' #fyp #limitedoffer'; }
}
},
luxury: {
twitter: {
de: function (p) { return '\u2728 ' + p.name + '\n\nExklusivit\u00e4t neu definiert.\n' + p.desc + '\n\n' + (p.price ? 'Ab ' + p.price + '\n' : '') + (p.url ? '\n' + p.url : '') + '\n\n' + p.hashtags.slice(0, 3).join(' ') + ' #Luxus #Premium'; },
en: function (p) { return '\u2728 ' + p.name + '\n\nRedefining exclusivity.\n' + p.descEn + '\n\n' + (p.price ? 'From ' + p.price + '\n' : '') + (p.url ? '\n' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 3).join(' ') + ' #Luxury #Premium'; }
},
instagram: {
de: function (p) { return '\u2726 ' + p.name.toUpperCase() + ' \u2726\n\n' + p.desc + '\n\n' + (p.features.length ? 'Exklusive Merkmale:\n' + p.features.map(function (f) { return '\u2726 ' + f; }).join('\n') + '\n\n' : '') + 'F\u00fcr alle, die das Beste verdienen.\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtags.join(' ') + ' #Luxury #Exclusive'; },
en: function (p) { return '\u2726 ' + p.name.toUpperCase() + ' \u2726\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Exclusive features:\n' + p.featuresEn.map(function (f) { return '\u2726 ' + f; }).join('\n') + '\n\n' : '') + 'For those who deserve the finest.\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtagsEn.join(' ') + ' #Luxury #Exclusive'; }
},
facebook: {
de: function (p) { return '\u2014\u2014\u2014 ' + p.name.toUpperCase() + ' \u2014\u2014\u2014\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.map(function (f) { return '\u25c7 ' + f; }).join('\n') + '\n\n' : '') + 'Perfektion kennt keine Kompromisse.\n\n' + (p.price ? 'Ab ' + p.price + '\n' : '') + (p.url ? '\nEntdecken Sie mehr: ' + p.url : ''); },
en: function (p) { return '\u2014\u2014\u2014 ' + p.name.toUpperCase() + ' \u2014\u2014\u2014\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.map(function (f) { return '\u25c7 ' + f; }).join('\n') + '\n\n' : '') + 'Perfection knows no compromise.\n\n' + (p.price ? 'From ' + p.price + '\n' : '') + (p.url ? '\nDiscover more: ' + p.url : ''); }
},
linkedin: {
de: function (p) { return p.name + ' \u2013 Exzellenz in jeder Hinsicht\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'Wir setzen Ma\u00dfst\u00e4be f\u00fcr Premium-Qualit\u00e4t.\n\n' + (p.url ? p.url + '\n\n' : '') + '#Premium #Excellence ' + p.hashtags.slice(0, 2).join(' '); },
en: function (p) { return p.name + ' \u2013 Excellence in every way\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'We set the standard for premium quality.\n\n' + (p.url ? p.url + '\n\n' : '') + '#Premium #Excellence ' + p.hashtagsEn.slice(0, 2).join(' '); }
},
tiktok: {
de: function (p) { return '\u2728 ' + p.name + ' \u2013 Luxus der n\u00e4chsten Generation\n\n' + p.desc + '\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtags.join(' ') + ' #luxury #aesthetic #premium'; },
en: function (p) { return '\u2728 ' + p.name + ' \u2013 Next generation luxury\n\n' + p.descEn + '\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtagsEn.join(' ') + ' #luxury #aesthetic #premium'; }
}
},
fun: {
twitter: {
de: function (p) { return '\ud83c\udf89 YOOO! ' + p.name + ' ist da und es ist der HAMMER! \ud83d\udd28\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.slice(0, 3).map(function (f) { return '\ud83c\udf1f ' + f; }).join('\n') + '\n' : '') + (p.url ? '\n\ud83d\ude80 Ab gehts: ' + p.url : '') + '\n\n' + p.hashtags.slice(0, 4).join(' '); },
en: function (p) { return '\ud83c\udf89 YOOO! ' + p.name + ' is here and it\'s AMAZING! \ud83d\udd28\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.slice(0, 3).map(function (f) { return '\ud83c\udf1f ' + f; }).join('\n') + '\n' : '') + (p.url ? '\n\ud83d\ude80 Let\'s go: ' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 4).join(' '); }
},
instagram: {
de: function (p) { return '\ud83e\udd2f OKAY WOW \ud83e\udd2f\n\n' + p.name + ' hat mein Leben ver\u00e4ndert und ich bin NICHT dramatisch! \ud83d\ude02\n\n' + p.desc + '\n\n' + (p.features.length ? 'Reasons to love it:\n' + p.features.map(function (f) { return '\ud83d\udcab ' + f; }).join('\n') + '\n\n' : '') + 'Wer ist dabei?! \ud83d\ude4b\u200d\u2642\ufe0f\n\n' + p.hashtags.join(' '); },
en: function (p) { return '\ud83e\udd2f OKAY WOW \ud83e\udd2f\n\n' + p.name + ' changed my life and I\'m NOT being dramatic! \ud83d\ude02\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Reasons to love it:\n' + p.featuresEn.map(function (f) { return '\ud83d\udcab ' + f; }).join('\n') + '\n\n' : '') + 'Who\'s in?! \ud83d\ude4b\u200d\u2642\ufe0f\n\n' + p.hashtagsEn.join(' '); }
},
facebook: {
de: function (p) { return '\ud83c\udf89\ud83c\udf89\ud83c\udf89 ES IST SOWEIT! \ud83c\udf89\ud83c\udf89\ud83c\udf89\n\n' + p.name + ' ist gelandet und wir k\u00f6nnen nicht aufh\u00f6ren dar\u00fcber zu reden!\n\n' + p.desc + '\n\n' + (p.features.length ? '\ud83d\ude0d Das ist alles drin:\n' + p.features.map(function (f) { return '\ud83d\udca5 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'Und das Beste? Nur ' + p.price + '! \ud83e\udd11\n\n' : '') + 'TAGGT jemanden der das braucht! \ud83d\udc47\ud83d\udc47\ud83d\udc47'; },
en: function (p) { return '\ud83c\udf89\ud83c\udf89\ud83c\udf89 IT\'S HERE! \ud83c\udf89\ud83c\udf89\ud83c\udf89\n\n' + p.name + ' has landed and we can\'t stop talking about it!\n\n' + p.descEn + '\n\n' + (p.features.length ? '\ud83d\ude0d Here\'s what you get:\n' + p.featuresEn.map(function (f) { return '\ud83d\udca5 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'Best part? Only ' + p.price + '! \ud83e\udd11\n\n' : '') + 'TAG someone who needs this! \ud83d\udc47\ud83d\udc47\ud83d\udc47'; }
},
linkedin: {
de: function (p) { return '\ud83d\ude80 Plot Twist: ' + p.name + ' existiert jetzt!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Die Highlights (ja, es wird noch besser):\n' + p.features.map(function (f) { return '\ud83d\udcaa ' + f; }).join('\n') + '\n\n' : '') + 'Wer will mitmachen? Schreibt mir! \ud83d\ude0e\n\n' + p.hashtags.slice(0, 3).join(' '); },
en: function (p) { return '\ud83d\ude80 Plot Twist: ' + p.name + ' now exists!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'The highlights (yes, it gets even better):\n' + p.featuresEn.map(function (f) { return '\ud83d\udcaa ' + f; }).join('\n') + '\n\n' : '') + 'Who\'s in? DM me! \ud83d\ude0e\n\n' + p.hashtagsEn.slice(0, 3).join(' '); }
},
tiktok: {
de: function (p) { return '\ud83e\udee3 Wenn du ' + p.name + ' noch nicht kennst, lebst du unter einem Stein!\n\n' + p.desc + '\n\n' + (p.price ? '\ud83d\udcb0 ' + p.price + ' \u2013 SCHNAPPER!\n' : '') + '\nSpeichern & Teilen nicht vergessen! \ud83d\ude4f\n\n' + p.hashtags.join(' ') + ' #fyp #gamechanger'; },
en: function (p) { return '\ud83e\udee3 If you don\'t know ' + p.name + ' yet, you\'re living under a rock!\n\n' + p.descEn + '\n\n' + (p.price ? '\ud83d\udcb0 ' + p.price + ' \u2013 STEAL!\n' : '') + '\nSave & Share! \ud83d\ude4f\n\n' + p.hashtagsEn.join(' ') + ' #fyp #gamechanger'; }
}
},
minimal: {
twitter: {
de: function (p) { return p.name + '.\n' + p.desc + (p.url ? '\n\n' + p.url : '') + '\n\n' + p.hashtags.slice(0, 3).join(' '); },
en: function (p) { return p.name + '.\n' + p.descEn + (p.url ? '\n\n' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 3).join(' '); }
},
instagram: {
de: function (p) { return p.name + '\n\n' + p.desc + (p.features.length ? '\n\n' + p.features.join(' / ') : '') + '\n\n' + p.hashtags.join(' '); },
en: function (p) { return p.name + '\n\n' + p.descEn + (p.features.length ? '\n\n' + p.featuresEn.join(' / ') : '') + '\n\n' + p.hashtagsEn.join(' '); }
},
facebook: {
de: function (p) { return p.name + '\n\n' + p.desc + (p.features.length ? '\n\n' + p.features.join(' \u2022 ') : '') + (p.price ? '\n\n' + p.price : '') + (p.url ? '\n\n' + p.url : ''); },
en: function (p) { return p.name + '\n\n' + p.descEn + (p.features.length ? '\n\n' + p.featuresEn.join(' \u2022 ') : '') + (p.price ? '\n\n' + p.price : '') + (p.url ? '\n\n' + p.url : ''); }
},
linkedin: {
de: function (p) { return p.name + '\n\n' + p.desc + (p.features.length ? '\n\n' + p.features.map(function (f) { return '\u2192 ' + f; }).join('\n') : '') + (p.url ? '\n\n' + p.url : ''); },
en: function (p) { return p.name + '\n\n' + p.descEn + (p.features.length ? '\n\n' + p.featuresEn.map(function (f) { return '\u2192 ' + f; }).join('\n') : '') + (p.url ? '\n\n' + p.url : ''); }
},
tiktok: {
de: function (p) { return p.name + '\n' + p.desc + '\n\n' + p.hashtags.slice(0, 5).join(' ') + ' #minimal'; },
en: function (p) { return p.name + '\n' + p.descEn + '\n\n' + p.hashtagsEn.slice(0, 5).join(' ') + ' #minimal'; }
}
}
};
// --- Email Templates ---
var emailTemplates = {
de: function (p) {
return 'Betreff: Entdecken Sie ' + p.name + ' \u2013 ' + p.desc.substring(0, 60) + '...\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
'Sehr geehrte Damen und Herren,\n\n' +
'wir freuen uns, Ihnen ' + p.name + ' vorzustellen \u2013 ' + p.desc + '\n\n' +
(p.features.length ?
'Die wichtigsten Vorteile auf einen Blick:\n\n' +
p.features.map(function (f) { return ' \u2714 ' + f; }).join('\n') + '\n\n' : '') +
(p.audience ? 'Ideal f\u00fcr: ' + p.audience + '\n\n' : '') +
(p.price ? 'Unser Angebot: ' + p.price + '\n\n' : '') +
(p.url ? '\u27a1 Jetzt mehr erfahren: ' + p.url + '\n\n' : '') +
'Haben Sie Fragen? Antworten Sie einfach auf diese E-Mail \u2013 wir helfen Ihnen gerne weiter.\n\n' +
'Mit freundlichen Gr\u00fc\u00dfen,\n' +
'Ihr ' + p.name + ' Team\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n' +
'Sie erhalten diese E-Mail, weil Sie sich f\u00fcr ' + p.name + ' interessieren.\n' +
'Abmelden | Datenschutz | Impressum';
},
en: function (p) {
return 'Subject: Discover ' + p.name + ' \u2013 ' + p.descEn.substring(0, 60) + '...\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
'Dear Customer,\n\n' +
'We are excited to introduce ' + p.name + ' \u2013 ' + p.descEn + '\n\n' +
(p.features.length ?
'Key benefits at a glance:\n\n' +
p.featuresEn.map(function (f) { return ' \u2714 ' + f; }).join('\n') + '\n\n' : '') +
(p.audience ? 'Ideal for: ' + p.audienceEn + '\n\n' : '') +
(p.price ? 'Our offer: ' + p.price + '\n\n' : '') +
(p.url ? '\u27a1 Learn more: ' + p.url + '\n\n' : '') +
'Have questions? Simply reply to this email \u2013 we\'re happy to help.\n\n' +
'Best regards,\n' +
'The ' + p.name + ' Team\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n' +
'You received this email because you showed interest in ' + p.name + '.\n' +
'Unsubscribe | Privacy Policy | Legal';
}
};
// --- SEO Templates ---
var seoTemplates = {
de: function (p) {
return 'META TITLE:\n' + p.name + ' \u2013 ' + p.desc.substring(0, 50) + ' | Jetzt entdecken\n\n' +
'META DESCRIPTION:\n' + p.desc + (p.features.length ? ' \u2714 ' + p.features.slice(0, 3).join(' \u2714 ') : '') + (p.price ? ' Ab ' + p.price + '.' : '') + ' Jetzt informieren!\n\n' +
'SEO KEYWORDS:\n' + p.name + ', ' + p.name.toLowerCase() + ' kaufen, ' + p.name.toLowerCase() + ' test, ' + p.name.toLowerCase() + ' erfahrungen, ' +
(p.category ? p.categoryDe + ', ' : '') + 'beste ' + p.name.toLowerCase() + ', ' + p.name.toLowerCase() + ' vergleich, ' + p.name.toLowerCase() + ' angebot\n\n' +
'H1 \u00dcBERSCHRIFT:\n' + p.name + ' \u2013 ' + p.desc + '\n\n' +
'H2 \u00dcBERSCHRIFTEN:\n' +
'Warum ' + p.name + '?\n' +
'Funktionen & Vorteile\n' +
'F\u00fcr wen ist ' + p.name + ' geeignet?\n' +
'Jetzt ' + p.name + ' bestellen\n\n' +
'ALT-TEXT F\u00dcR BILDER:\n' +
p.name + ' Produktbild \u2013 ' + p.desc.substring(0, 60);
},
en: function (p) {
return 'META TITLE:\n' + p.name + ' \u2013 ' + p.descEn.substring(0, 50) + ' | Discover Now\n\n' +
'META DESCRIPTION:\n' + p.descEn + (p.features.length ? ' \u2714 ' + p.featuresEn.slice(0, 3).join(' \u2714 ') : '') + (p.price ? ' From ' + p.price + '.' : '') + ' Learn more now!\n\n' +
'SEO KEYWORDS:\n' + p.name + ', buy ' + p.name.toLowerCase() + ', ' + p.name.toLowerCase() + ' review, ' + p.name.toLowerCase() + ' features, ' +
(p.category ? p.categoryEn + ', ' : '') + 'best ' + p.name.toLowerCase() + ', ' + p.name.toLowerCase() + ' comparison, ' + p.name.toLowerCase() + ' deal\n\n' +
'H1 HEADING:\n' + p.name + ' \u2013 ' + p.descEn + '\n\n' +
'H2 HEADINGS:\n' +
'Why ' + p.name + '?\n' +
'Features & Benefits\n' +
'Who is ' + p.name + ' for?\n' +
'Order ' + p.name + ' Now\n\n' +
'IMAGE ALT TEXT:\n' +
p.name + ' product image \u2013 ' + p.descEn.substring(0, 60);
}
};
// --- Press Release Templates ---
var pressTemplates = {
de: function (p) {
var today = new Date().toLocaleDateString('de-DE', { year: 'numeric', month: 'long', day: 'numeric' });
return 'PRESSEMITTEILUNG\n' +
'Datum: ' + today + '\n' +
'Zur sofortigen Ver\u00f6ffentlichung\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
p.name + ': ' + p.desc + '\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
'Wir freuen uns, die Verf\u00fcgbarkeit von ' + p.name + ' bekannt zu geben. ' + p.desc + '\n\n' +
(p.features.length ?
'Hauptmerkmale von ' + p.name + ':\n\n' +
p.features.map(function (f) { return ' \u2022 ' + f; }).join('\n') + '\n\n' : '') +
(p.audience ? '"' + p.name + ' wurde speziell f\u00fcr ' + p.audience + ' entwickelt", erkl\u00e4rt das Entwicklerteam.\n\n' : '') +
(p.price ? 'Verf\u00fcgbarkeit & Preis:\n' + p.name + ' ist ab sofort zum Preis von ' + p.price + ' erh\u00e4ltlich.\n\n' : '') +
(p.url ? 'Weitere Informationen finden Sie unter: ' + p.url + '\n\n' : '') +
'Pressekontakt:\n' +
'E-Mail: presse@' + p.name.toLowerCase().replace(/\s+/g, '') + '.de\n' +
'Web: ' + (p.url || 'www.' + p.name.toLowerCase().replace(/\s+/g, '') + '.de') + '\n\n' +
'###';
},
en: function (p) {
var today = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
return 'PRESS RELEASE\n' +
'Date: ' + today + '\n' +
'For Immediate Release\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
p.name + ': ' + p.descEn + '\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
'We are pleased to announce the availability of ' + p.name + '. ' + p.descEn + '\n\n' +
(p.features.length ?
'Key Features of ' + p.name + ':\n\n' +
p.featuresEn.map(function (f) { return ' \u2022 ' + f; }).join('\n') + '\n\n' : '') +
(p.audience ? '"' + p.name + ' was specifically designed for ' + p.audienceEn + '," says the development team.\n\n' : '') +
(p.price ? 'Availability & Pricing:\n' + p.name + ' is available now at ' + p.price + '.\n\n' : '') +
(p.url ? 'For more information, visit: ' + p.url + '\n\n' : '') +
'Press Contact:\n' +
'Email: press@' + p.name.toLowerCase().replace(/\s+/g, '') + '.com\n' +
'Web: ' + (p.url || 'www.' + p.name.toLowerCase().replace(/\s+/g, '') + '.com') + '\n\n' +
'###';
}
};
// --- Category Mapping ---
var categoryNames = {
tech: { de: 'Technologie', en: 'Technology' },
fashion: { de: 'Mode & Bekleidung', en: 'Fashion & Apparel' },
food: { de: 'Lebensmittel & Getr\u00e4nke', en: 'Food & Beverages' },
health: { de: 'Gesundheit & Wellness', en: 'Health & Wellness' },
home: { de: 'Haus & Garten', en: 'Home & Garden' },
sport: { de: 'Sport & Fitness', en: 'Sports & Fitness' },
beauty: { de: 'Sch\u00f6nheit & Pflege', en: 'Beauty & Care' },
education: { de: 'Bildung & Kurse', en: 'Education & Courses' },
software: { de: 'Software & Apps', en: 'Software & Apps' },
other: { de: 'Sonstiges', en: 'Other' }
};
// --- Gather Product Data ---
function getProductData() {
var name = document.getElementById('productName').value.trim();
var desc = document.getElementById('productDescription').value.trim();
var price = document.getElementById('productPrice').value.trim();
var url = document.getElementById('productUrl').value.trim();
var features = document.getElementById('productFeatures').value.trim();
var audience = document.getElementById('targetAudience').value.trim();
var category = document.getElementById('productCategory').value;
if (!name || !desc) {
showToast(currentLang === 'de' ? 'Bitte Produktname und Beschreibung eingeben!' : 'Please enter product name and description!');
return null;
}
var featureList = features ? features.split(',').map(function (f) { return f.trim(); }).filter(Boolean) : [];
var catInfo = categoryNames[category] || { de: '', en: '' };
return {
name: name,
desc: desc,
descEn: desc, // User provides in their language; used as-is
price: price,
url: url,
features: featureList,
featuresEn: featureList,
audience: audience,
audienceEn: audience,
category: category,
categoryDe: catInfo.de,
categoryEn: catInfo.en,
hashtags: generateHashtags(name, featureList, category, 'de'),
hashtagsEn: generateHashtags(name, featureList, category, 'en')
};
}
// --- Generate Hashtags ---
function generateHashtags(name, features, category, lang) {
var tags = [];
tags.push('#' + name.replace(/\s+/g, ''));
var catTags = {
tech: { de: ['#Technologie', '#Innovation', '#TechNews', '#Digital', '#Gadget'], en: ['#Technology', '#Innovation', '#TechNews', '#Digital', '#Gadget'] },
fashion: { de: ['#Mode', '#Fashion', '#Style', '#OOTD', '#Trend'], en: ['#Fashion', '#Style', '#OOTD', '#Trend', '#Outfit'] },
food: { de: ['#Foodie', '#Lecker', '#Essen', '#Kochen', '#Genuss'], en: ['#Foodie', '#Delicious', '#FoodLover', '#Cooking', '#Yummy'] },
health: { de: ['#Gesundheit', '#Wellness', '#Fitness', '#Wohlbefinden'], en: ['#Health', '#Wellness', '#Fitness', '#Wellbeing'] },
home: { de: ['#Zuhause', '#Wohnen', '#Interior', '#HomeDecor'], en: ['#Home', '#Living', '#Interior', '#HomeDecor'] },
sport: { de: ['#Sport', '#Fitness', '#Training', '#Motivation'], en: ['#Sports', '#Fitness', '#Training', '#Motivation'] },
beauty: { de: ['#Beauty', '#Pflege', '#Skincare', '#Sch\u00f6nheit'], en: ['#Beauty', '#Skincare', '#SelfCare', '#Glow'] },
education: { de: ['#Bildung', '#Lernen', '#Wissen', '#Weiterbildung'], en: ['#Education', '#Learning', '#Knowledge', '#Growth'] },
software: { de: ['#Software', '#App', '#Digital', '#SaaS', '#Produktivit\u00e4t'], en: ['#Software', '#App', '#Digital', '#SaaS', '#Productivity'] },
other: { de: ['#Neu', '#MustHave', '#Empfehlung'], en: ['#New', '#MustHave', '#Recommended'] }
};
var ct = catTags[category];
if (ct) {
tags = tags.concat(ct[lang] || ct.en);
}
features.slice(0, 2).forEach(function (f) {
tags.push('#' + f.replace(/\s+/g, '').replace(/[^a-zA-Z0-9\u00c0-\u017e]/g, ''));
});
return tags.filter(function (t, i, arr) { return arr.indexOf(t) === i; });
}
// --- Generate Slogans ---
function generateSlogans(p) {
var slogans = [];
var sloganTemplatesDe = [
p.name + ' \u2013 Weil du das Beste verdienst.',
p.name + '. Einfach. Besser. Anders.',
'Die Zukunft hei\u00dft ' + p.name + '.',
p.name + ' \u2013 Dein n\u00e4chster Schritt nach vorn.',
'Erlebe den Unterschied mit ' + p.name + '.',
p.name + '. Mehr als du erwartest.'
];
var sloganTemplatesEn = [
p.name + ' \u2013 Because you deserve the best.',
p.name + '. Simple. Better. Different.',
'The future is called ' + p.name + '.',
p.name + ' \u2013 Your next step forward.',
'Experience the difference with ' + p.name + '.',
p.name + '. More than you expect.'
];
for (var i = 0; i < sloganTemplatesDe.length; i++) {
slogans.push({ de: sloganTemplatesDe[i], en: sloganTemplatesEn[i] });
}
return slogans;
}
// --- Generate Landing Page HTML ---
function generateLandingPage(p) {
var accentColor = '#6C5CE7';
return '<!DOCTYPE html>\n' +
'<html lang="de">\n<head>\n' +
' <meta charset="UTF-8">\n' +
' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n' +
' <title>' + p.name + ' \u2013 ' + p.desc.substring(0, 60) + '</title>\n' +
' <meta name="description" content="' + p.desc + '">\n' +
' <meta property="og:title" content="' + p.name + '">\n' +
' <meta property="og:description" content="' + p.desc + '">\n' +
' <style>\n' +
' * { margin: 0; padding: 0; box-sizing: border-box; }\n' +
' body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #333; }\n' +
' .hero { background: linear-gradient(135deg, ' + accentColor + ', #00CEC9); color: #fff; padding: 80px 20px; text-align: center; }\n' +
' .hero h1 { font-size: 3rem; margin-bottom: 16px; }\n' +
' .hero p { font-size: 1.3rem; opacity: 0.9; max-width: 600px; margin: 0 auto 32px; }\n' +
' .cta-btn { display: inline-block; padding: 16px 40px; background: #fff; color: ' + accentColor + '; font-size: 18px; font-weight: 700; border-radius: 50px; text-decoration: none; transition: transform 0.3s; }\n' +
' .cta-btn:hover { transform: scale(1.05); }\n' +
' .features { padding: 60px 20px; max-width: 800px; margin: 0 auto; }\n' +
' .features h2 { text-align: center; font-size: 2rem; margin-bottom: 40px; }\n' +
' .feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 30px; }\n' +
' .feature { text-align: center; padding: 24px; }\n' +
' .feature h3 { color: ' + accentColor + '; margin-bottom: 8px; }\n' +
(p.price ? ' .pricing { text-align: center; padding: 60px 20px; background: #f8f9fa; }\n' +
' .pricing h2 { font-size: 2rem; margin-bottom: 16px; }\n' +
' .price-tag { font-size: 3rem; font-weight: 900; color: ' + accentColor + '; }\n' : '') +
' .footer { text-align: center; padding: 30px; color: #888; font-size: 14px; }\n' +
' </style>\n' +
'</head>\n<body>\n' +
' <section class="hero">\n' +
' <h1>' + p.name + '</h1>\n' +
' <p>' + p.desc + '</p>\n' +
(p.url ? ' <a href="' + escapeHtml(p.url) + '" class="cta-btn">Jetzt entdecken / Discover Now</a>\n' : ' <a href="#features" class="cta-btn">Mehr erfahren / Learn More</a>\n') +
' </section>\n' +
(p.features.length ? ' <section class="features" id="features">\n' +
' <h2>Features</h2>\n' +
' <div class="feature-grid">\n' +
p.features.map(function (f) { return ' <div class="feature">\n <h3>' + escapeHtml(f) + '</h3>\n </div>'; }).join('\n') + '\n' +
' </div>\n' +
' </section>\n' : '') +
(p.price ? ' <section class="pricing">\n' +
' <h2>Preis / Price</h2>\n' +
' <div class="price-tag">' + escapeHtml(p.price) + '</div>\n' +
' </section>\n' : '') +
' <footer class="footer">\n' +
' &copy; ' + new Date().getFullYear() + ' ' + escapeHtml(p.name) + '. All rights reserved.\n' +
' </footer>\n' +
'</body>\n</html>';
}
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
// --- Main Generate Function ---
function generateAll() {
var p = getProductData();
if (!p) return;
var btn = document.getElementById('generateBtn');
btn.classList.add('loading');
setTimeout(function () {
var style = templates[selectedStyle] || templates.professional;
var platforms = ['twitter', 'instagram', 'facebook', 'linkedin', 'tiktok'];
platforms.forEach(function (platform) {
var tpl = style[platform];
if (tpl) {
setText(platform + '-de', tpl.de(p));
setText(platform + '-en', tpl.en(p));
}
});
// Email
setText('email-de', emailTemplates.de(p));
setText('email-en', emailTemplates.en(p));
// SEO
setText('seo-de', seoTemplates.de(p));
setText('seo-en', seoTemplates.en(p));
// Press Release
setText('press-de', pressTemplates.de(p));
setText('press-en', pressTemplates.en(p));
// Slogans
var slogans = generateSlogans(p);
var sloganGrid = document.getElementById('sloganGrid');
sloganGrid.innerHTML = '';
slogans.forEach(function (s) {
var div = document.createElement('div');
div.className = 'slogan-item';
div.innerHTML = '<span class="slogan-lang">DE</span>' + escapeHtml(s.de);
div.onclick = function () { copyToClipboard(s.de); };
sloganGrid.appendChild(div);
var divEn = document.createElement('div');
divEn.className = 'slogan-item';
divEn.innerHTML = '<span class="slogan-lang">EN</span>' + escapeHtml(s.en);
divEn.onclick = function () { copyToClipboard(s.en); };
sloganGrid.appendChild(divEn);
});
// Hashtags
var hashtagCloud = document.getElementById('hashtagCloud');
hashtagCloud.innerHTML = '';
var allTags = p.hashtags.concat(p.hashtagsEn).filter(function (t, i, arr) { return arr.indexOf(t) === i; });
allTags.forEach(function (tag) {
var span = document.createElement('span');
span.className = 'hashtag';
span.textContent = tag;
span.onclick = function () { copyToClipboard(tag); };
hashtagCloud.appendChild(span);
});
// Landing Page
var landingHtml = generateLandingPage(p);
document.getElementById('landing-code').textContent = landingHtml;
var iframe = document.createElement('iframe');
iframe.srcdoc = landingHtml;
var previewDiv = document.getElementById('landing-preview');
previewDiv.innerHTML = '';
previewDiv.appendChild(iframe);
// Store data for export
generatedData = {
product: p,
style: selectedStyle,
social: {},
email: { de: emailTemplates.de(p), en: emailTemplates.en(p) },
seo: { de: seoTemplates.de(p), en: seoTemplates.en(p) },
press: { de: pressTemplates.de(p), en: pressTemplates.en(p) },
slogans: slogans,
hashtags: allTags,
landingPage: landingHtml
};
platforms.forEach(function (platform) {
var tpl = style[platform];
if (tpl) {
generatedData.social[platform] = { de: tpl.de(p), en: tpl.en(p) };
}
});
// Show results
document.getElementById('results').classList.remove('hidden');
document.getElementById('results').scrollIntoView({ behavior: 'smooth', block: 'start' });
btn.classList.remove('loading');
showToast(currentLang === 'de' ? 'Alle Werbematerialien wurden generiert!' : 'All promotion materials generated!');
}, 600);
}
window.generateAll = generateAll;
// --- Helper Functions ---
function setText(id, text) {
var el = document.getElementById(id);
if (el) el.textContent = text;
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function () {
showToast(currentLang === 'de' ? 'Kopiert!' : 'Copied!');
}).catch(function () {
fallbackCopy(text);
});
}
function fallbackCopy(text) {
var textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast(currentLang === 'de' ? 'Kopiert!' : 'Copied!');
}
function copyText(id) {
var el = document.getElementById(id);
if (el) copyToClipboard(el.textContent);
}
window.copyText = copyText;
function copyAll(section) {
if (!generatedData) return;
var text = '';
if (section === 'social') {
Object.keys(generatedData.social).forEach(function (platform) {
text += '=== ' + platform.toUpperCase() + ' (DE) ===\n' + generatedData.social[platform].de + '\n\n';
text += '=== ' + platform.toUpperCase() + ' (EN) ===\n' + generatedData.social[platform].en + '\n\n';
});
} else if (section === 'email') {
text = '=== EMAIL (DE) ===\n' + generatedData.email.de + '\n\n=== EMAIL (EN) ===\n' + generatedData.email.en;
} else if (section === 'seo') {
text = '=== SEO (DE) ===\n' + generatedData.seo.de + '\n\n=== SEO (EN) ===\n' + generatedData.seo.en;
} else if (section === 'press') {
text = '=== PRESS (DE) ===\n' + generatedData.press.de + '\n\n=== PRESS (EN) ===\n' + generatedData.press.en;
}
copyToClipboard(text);
}
window.copyAll = copyAll;
function copyHashtags() {
if (generatedData) copyToClipboard(generatedData.hashtags.join(' '));
}
window.copyHashtags = copyHashtags;
// --- Tabs ---
function switchTab(btn, tab) {
var parent = btn.closest('.result-card');
parent.querySelectorAll('.tab-btn').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
parent.querySelectorAll('.tab-content').forEach(function (c) { c.classList.add('hidden'); });
document.getElementById('tab-' + tab).classList.remove('hidden');
}
window.switchTab = switchTab;
// --- Landing Page Preview Toggle ---
function togglePreview(mode) {
var preview = document.getElementById('landing-preview');
var code = document.getElementById('landing-code');
var buttons = document.querySelectorAll('.preview-btn');
buttons.forEach(function (b) { b.classList.remove('active'); });
if (mode === 'preview') {
preview.classList.remove('hidden');
code.classList.add('hidden');
buttons[0].classList.add('active');
} else {
preview.classList.add('hidden');
code.classList.remove('hidden');
buttons[1].classList.add('active');
}
}
window.togglePreview = togglePreview;
// --- Export Functions ---
function exportAs(format) {
if (!generatedData) {
showToast(currentLang === 'de' ? 'Bitte zuerst generieren!' : 'Please generate first!');
return;
}
var content = '';
var filename = 'promo-' + generatedData.product.name.replace(/\s+/g, '-').toLowerCase();
var mimeType = 'text/plain';
if (format === 'txt') {
content = buildTextExport();
filename += '.txt';
} else if (format === 'html') {
content = generatedData.landingPage;
filename += '-landingpage.html';
mimeType = 'text/html';
} else if (format === 'json') {
content = JSON.stringify(generatedData, null, 2);
filename += '.json';
mimeType = 'application/json';
} else if (format === 'csv') {
content = buildCsvExport();
filename += '.csv';
mimeType = 'text/csv';
}
downloadFile(content, filename, mimeType);
showToast((currentLang === 'de' ? 'Export als ' : 'Exported as ') + format.toUpperCase() + '!');
}
window.exportAs = exportAs;
function buildTextExport() {
var d = generatedData;
var lines = [];
lines.push('========================================');
lines.push('PROMOMASTER - WERBEMATERIALIEN / PROMOTION MATERIALS');
lines.push('Produkt / Product: ' + d.product.name);
lines.push('Erstellt am / Generated: ' + new Date().toLocaleString());
lines.push('========================================\n');
Object.keys(d.social).forEach(function (platform) {
lines.push('\n--- ' + platform.toUpperCase() + ' (DE) ---');
lines.push(d.social[platform].de);
lines.push('\n--- ' + platform.toUpperCase() + ' (EN) ---');
lines.push(d.social[platform].en);
});
lines.push('\n\n--- E-MAIL MARKETING (DE) ---');
lines.push(d.email.de);
lines.push('\n--- E-MAIL MARKETING (EN) ---');
lines.push(d.email.en);
lines.push('\n\n--- SEO (DE) ---');
lines.push(d.seo.de);
lines.push('\n--- SEO (EN) ---');
lines.push(d.seo.en);
lines.push('\n\n--- PRESSEMITTEILUNG / PRESS RELEASE (DE) ---');
lines.push(d.press.de);
lines.push('\n--- PRESS RELEASE (EN) ---');
lines.push(d.press.en);
lines.push('\n\n--- SLOGANS ---');
d.slogans.forEach(function (s) {
lines.push('DE: ' + s.de);
lines.push('EN: ' + s.en);
});
lines.push('\n\n--- HASHTAGS ---');
lines.push(d.hashtags.join(' '));
return lines.join('\n');
}
function buildCsvExport() {
var d = generatedData;
var rows = [['Platform', 'Language', 'Content']];
Object.keys(d.social).forEach(function (platform) {
rows.push([platform, 'DE', '"' + d.social[platform].de.replace(/"/g, '""') + '"']);
rows.push([platform, 'EN', '"' + d.social[platform].en.replace(/"/g, '""') + '"']);
});
rows.push(['email', 'DE', '"' + d.email.de.replace(/"/g, '""') + '"']);
rows.push(['email', 'EN', '"' + d.email.en.replace(/"/g, '""') + '"']);
rows.push(['seo', 'DE', '"' + d.seo.de.replace(/"/g, '""') + '"']);
rows.push(['seo', 'EN', '"' + d.seo.en.replace(/"/g, '""') + '"']);
rows.push(['press', 'DE', '"' + d.press.de.replace(/"/g, '""') + '"']);
rows.push(['press', 'EN', '"' + d.press.en.replace(/"/g, '""') + '"']);
d.slogans.forEach(function (s, i) {
rows.push(['slogan_' + (i + 1), 'DE', '"' + s.de.replace(/"/g, '""') + '"']);
rows.push(['slogan_' + (i + 1), 'EN', '"' + s.en.replace(/"/g, '""') + '"']);
});
return rows.map(function (r) { return r.join(','); }).join('\n');
}
function downloadFile(content, filename, mimeType) {
var blob = new Blob([content], { type: mimeType + ';charset=utf-8' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// --- Toast ---
function showToast(msg) {
var toast = document.getElementById('toast');
toast.textContent = msg;
toast.classList.remove('hidden');
toast.classList.add('show');
setTimeout(function () {
toast.classList.remove('show');
setTimeout(function () { toast.classList.add('hidden'); }, 400);
}, 2500);
}
// --- Initialize ---
setLanguage('de');
})();
+724
View File
@@ -0,0 +1,724 @@
/* === PromoMaster - Product Promotion Tool === */
:root {
--primary: #6C5CE7;
--primary-dark: #5A4BD1;
--primary-light: #A29BFE;
--accent: #00CEC9;
--accent-dark: #00B5B0;
--bg: #0F0F1A;
--bg-card: #1A1A2E;
--bg-card-hover: #222240;
--text: #EAEAEA;
--text-muted: #8B8BA3;
--border: #2D2D4A;
--success: #00E676;
--warning: #FFD93D;
--danger: #FF6B6B;
--radius: 16px;
--radius-sm: 10px;
--shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.4);
--glow: 0 0 30px rgba(108, 92, 231, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
overflow-x: hidden;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 0 20px;
}
/* === Language Toggle === */
.lang-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 100;
display: flex;
gap: 4px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 30px;
padding: 4px;
box-shadow: var(--shadow);
}
.lang-btn {
padding: 8px 16px;
border: none;
border-radius: 26px;
background: transparent;
color: var(--text-muted);
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
}
.lang-btn.active {
background: var(--primary);
color: #fff;
}
.lang-btn:hover:not(.active) {
color: var(--text);
background: var(--border);
}
/* === Hero === */
.hero {
position: relative;
padding: 80px 0 50px;
text-align: center;
overflow: hidden;
}
.hero-bg {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(ellipse at 50% 50%, rgba(108, 92, 231, 0.15) 0%, transparent 60%),
radial-gradient(ellipse at 80% 20%, rgba(0, 206, 201, 0.1) 0%, transparent 40%);
animation: heroPulse 8s ease-in-out infinite;
}
@keyframes heroPulse {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.05); opacity: 1; }
}
.hero-title {
font-size: clamp(2.5rem, 6vw, 4rem);
font-weight: 900;
letter-spacing: -2px;
position: relative;
background: linear-gradient(135deg, var(--primary-light), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-icon {
font-size: 0.8em;
-webkit-text-fill-color: initial;
}
.hero-subtitle {
font-size: clamp(1rem, 2.5vw, 1.3rem);
color: var(--text-muted);
margin-top: 12px;
position: relative;
font-weight: 300;
}
/* === Cards === */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 32px;
margin-bottom: 24px;
box-shadow: var(--shadow);
transition: border-color 0.3s ease;
}
.card:hover {
border-color: var(--primary);
}
/* === Step Header === */
.step-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 28px;
}
.step-number {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary), var(--accent));
border-radius: 12px;
font-weight: 800;
font-size: 20px;
color: #fff;
flex-shrink: 0;
}
.step-header h2 {
font-size: 1.4rem;
font-weight: 700;
}
/* === Form === */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-group label {
font-size: 14px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-group input,
.form-group textarea,
.form-group select {
padding: 14px 18px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: 15px;
transition: all 0.3s ease;
outline: none;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.2);
}
.form-group select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238B8BA3' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 16px center;
padding-right: 40px;
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.char-count {
font-size: 12px;
color: var(--text-muted);
text-align: right;
}
/* === Style Options === */
.style-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.style-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 20px 14px;
background: var(--bg);
border: 2px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
}
.style-option:hover {
border-color: var(--primary-light);
background: var(--bg-card-hover);
}
.style-option.selected {
border-color: var(--primary);
background: rgba(108, 92, 231, 0.1);
box-shadow: var(--glow);
}
.style-icon {
font-size: 32px;
}
.style-label {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
}
.style-option.selected .style-label {
color: var(--text);
}
/* === Generate Button === */
.generate-section {
text-align: center;
margin: 40px 0;
}
.btn-generate {
padding: 18px 48px;
font-size: 18px;
font-weight: 700;
font-family: inherit;
color: #fff;
background: linear-gradient(135deg, var(--primary), var(--accent));
border: none;
border-radius: 50px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 8px 30px rgba(108, 92, 231, 0.4);
display: inline-flex;
align-items: center;
gap: 10px;
}
.btn-generate:hover {
transform: translateY(-3px);
box-shadow: 0 12px 40px rgba(108, 92, 231, 0.5);
}
.btn-generate:active {
transform: translateY(0);
}
.btn-generate.loading {
pointer-events: none;
opacity: 0.8;
}
.btn-generate.loading .btn-icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.btn-icon {
font-size: 22px;
display: inline-block;
}
/* === Results === */
.results-section {
animation: fadeInUp 0.6s ease;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.result-card {
position: relative;
}
.result-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 10px;
}
.result-header h3 {
font-size: 1.2rem;
font-weight: 700;
}
.btn-copy-all {
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: var(--primary-light);
background: rgba(108, 92, 231, 0.1);
border: 1px solid var(--primary);
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-copy-all:hover {
background: var(--primary);
color: #fff;
}
/* === Tabs === */
.result-tabs {
display: flex;
gap: 6px;
margin-bottom: 20px;
overflow-x: auto;
padding-bottom: 4px;
}
.tab-btn {
padding: 10px 20px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: var(--text-muted);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
}
.tab-btn:hover {
color: var(--text);
border-color: var(--primary-light);
}
.tab-btn.active {
color: #fff;
background: var(--primary);
border-color: var(--primary);
}
.tab-content {
transition: all 0.3s ease;
}
/* === Language Results === */
.lang-results {
display: flex;
flex-direction: column;
gap: 16px;
}
.lang-result {
position: relative;
padding: 20px;
padding-left: 56px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.lang-badge {
position: absolute;
top: 20px;
left: 16px;
padding: 3px 10px;
font-size: 11px;
font-weight: 800;
color: #fff;
background: linear-gradient(135deg, #E74C3C, #C0392B);
border-radius: 6px;
letter-spacing: 1px;
}
.lang-badge.en {
background: linear-gradient(135deg, #2980B9, #2471A3);
}
.result-text {
font-size: 14px;
line-height: 1.8;
white-space: pre-wrap;
color: var(--text);
padding-right: 40px;
}
.btn-copy {
position: absolute;
top: 16px;
right: 16px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s ease;
color: var(--text);
}
.btn-copy:hover {
background: var(--primary);
border-color: var(--primary);
}
/* === Slogans === */
.slogan-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.slogan-item {
padding: 18px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
text-align: center;
font-weight: 600;
font-size: 15px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.slogan-item:hover {
border-color: var(--accent);
background: var(--bg-card-hover);
}
.slogan-item .slogan-lang {
font-size: 10px;
font-weight: 800;
color: var(--text-muted);
letter-spacing: 1px;
display: block;
margin-bottom: 6px;
}
/* === Hashtags === */
.hashtag-cloud {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.hashtag {
padding: 8px 16px;
background: rgba(108, 92, 231, 0.1);
border: 1px solid var(--primary);
border-radius: 20px;
font-size: 14px;
font-weight: 600;
color: var(--primary-light);
cursor: pointer;
transition: all 0.3s ease;
}
.hashtag:hover {
background: var(--primary);
color: #fff;
}
/* === Landing Page Preview === */
.landing-preview-container {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.preview-toggle {
display: flex;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.preview-btn {
flex: 1;
padding: 12px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: var(--text-muted);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.3s ease;
}
.preview-btn.active {
color: var(--text);
background: var(--bg-card);
border-bottom: 2px solid var(--primary);
}
.landing-preview {
min-height: 300px;
background: #fff;
}
.landing-preview iframe {
width: 100%;
height: 500px;
border: none;
}
.landing-code {
padding: 20px;
background: var(--bg);
color: var(--accent);
font-size: 13px;
line-height: 1.6;
overflow-x: auto;
max-height: 400px;
white-space: pre;
margin: 0;
cursor: pointer;
}
/* === Export === */
.export-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
.btn-export {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 24px 16px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-export:hover {
border-color: var(--accent);
background: var(--bg-card-hover);
transform: translateY(-2px);
}
.export-icon {
font-size: 28px;
}
/* === Toast === */
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%) translateY(20px);
padding: 14px 28px;
background: var(--success);
color: #000;
font-weight: 600;
font-size: 14px;
border-radius: 30px;
z-index: 1000;
opacity: 0;
transition: all 0.4s ease;
box-shadow: 0 8px 30px rgba(0, 230, 118, 0.3);
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* === Footer === */
.footer {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
font-size: 14px;
border-top: 1px solid var(--border);
margin-top: 60px;
}
/* === Hidden === */
.hidden {
display: none !important;
}
/* === Responsive === */
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
}
.style-grid {
grid-template-columns: repeat(2, 1fr);
}
.slogan-grid {
grid-template-columns: 1fr;
}
.export-grid {
grid-template-columns: repeat(2, 1fr);
}
.card {
padding: 24px 18px;
}
.hero {
padding: 60px 0 30px;
}
.lang-toggle {
top: 12px;
right: 12px;
}
.result-tabs {
gap: 4px;
}
.tab-btn {
padding: 8px 14px;
font-size: 12px;
}
}
@media (max-width: 480px) {
.style-grid {
grid-template-columns: 1fr;
}
.export-grid {
grid-template-columns: 1fr 1fr;
}
.btn-generate {
padding: 16px 32px;
font-size: 16px;
}
}
+54
View File
@@ -14,6 +14,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
#if targetEnvironment(macCatalyst)
// Configure for macOS
configureMacOS()
#endif
return true
}
@@ -33,10 +37,60 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidBecomeActive(_ application: UIApplication) {
// Resume game if needed
}
#if targetEnvironment(macCatalyst)
// MARK: - macOS Configuration
private func configureMacOS() {
// Set minimum window size for macOS
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.forEach { windowScene in
windowScene.sizeRestrictions?.minimumSize = CGSize(width: 400, height: 600)
windowScene.sizeRestrictions?.maximumSize = CGSize(width: 600, height: 900)
}
}
override func buildMenu(with builder: UIMenuBuilder) {
super.buildMenu(with: builder)
// Remove unnecessary menus for a game
builder.remove(menu: .format)
builder.remove(menu: .edit)
// Add Game menu
let pauseCommand = UIKeyCommand(
title: "Pause",
action: #selector(handlePauseCommand),
input: "p",
modifierFlags: .command
)
let restartCommand = UIKeyCommand(
title: "Neustart",
action: #selector(handleRestartCommand),
input: "r",
modifierFlags: .command
)
let gameMenu = UIMenu(
title: "Spiel",
children: [pauseCommand, restartCommand]
)
builder.insertSibling(gameMenu, afterMenu: .file)
}
@objc private func handlePauseCommand() {
NotificationCenter.default.post(name: .pauseGame, object: nil)
}
@objc private func handleRestartCommand() {
NotificationCenter.default.post(name: .restartGame, object: nil)
}
#endif
}
// MARK: - Notification Names
extension Notification.Name {
static let pauseGame = Notification.Name("pauseGame")
static let resumeGame = Notification.Name("resumeGame")
static let restartGame = Notification.Name("restartGame")
}
@@ -36,6 +36,10 @@ class GameViewController: UIViewController {
// Setup notification observers
setupNotificationObservers()
#if targetEnvironment(macCatalyst)
setupMacCatalyst()
#endif
}
private func setupNotificationObservers() {
@@ -45,6 +49,13 @@ class GameViewController: UIViewController {
name: .pauseGame,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRestartNotification),
name: .restartGame,
object: nil
)
}
@objc private func handlePauseNotification() {
@@ -57,12 +68,46 @@ class GameViewController: UIViewController {
// This is just a notification that the app is going to background
}
@objc private func handleRestartNotification() {
guard let skView = self.view as? SKView else { return }
let menuScene = MenuScene(size: skView.bounds.size)
menuScene.scaleMode = .aspectFill
let transition = SKTransition.fade(withDuration: 0.5)
skView.presentScene(menuScene, transition: transition)
}
#if targetEnvironment(macCatalyst)
private func setupMacCatalyst() {
// Configure window appearance for macOS
if let windowScene = view.window?.windowScene {
windowScene.title = "Rollkoffer Simulator"
// Set window style
if let titlebar = windowScene.titlebar {
titlebar.titleVisibility = .visible
titlebar.toolbarStyle = .unified
}
}
}
// Enable keyboard input
override var canBecomeFirstResponder: Bool {
return true
}
#endif
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
#if targetEnvironment(macCatalyst)
return .all
#else
if UIDevice.current.userInterfaceIdiom == .phone {
return .portrait
} else {
return .all
}
#endif
}
override var prefersStatusBarHidden: Bool {
+4
View File
@@ -50,5 +50,9 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright 2024 Ingo K. All rights reserved.</string>
</dict>
</plist>
@@ -381,6 +381,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -393,9 +394,12 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -409,6 +413,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -421,9 +426,12 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -223,6 +223,27 @@ class GameOverScene: SKScene {
}
}
// MARK: - Keyboard Handling (macOS)
#if targetEnvironment(macCatalyst)
override var canBecomeFirstResponder: Bool { true }
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let key = presses.first?.key else {
super.pressesBegan(presses, with: event)
return
}
switch key.keyCode {
case .keyboardSpacebar, .keyboardReturnOrEnter:
retryGame()
case .keyboardEscape:
returnToMenu()
default:
super.pressesBegan(presses, with: event)
}
}
#endif
private func retryGame() {
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
@@ -318,6 +318,29 @@ class GameScene: SKScene {
isDragging = false
}
// MARK: - Keyboard Handling (macOS)
#if targetEnvironment(macCatalyst)
override var canBecomeFirstResponder: Bool { true }
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let key = presses.first?.key else {
super.pressesBegan(presses, with: event)
return
}
switch key.keyCode {
case .keyboardEscape:
togglePause()
case .keyboardSpacebar:
if gameState.currentState == .paused {
resumeGame()
}
default:
super.pressesBegan(presses, with: event)
}
}
#endif
// MARK: - Pause Handling
private func togglePause() {
if gameState.currentState == .playing {
@@ -245,6 +245,25 @@ class MenuScene: SKScene {
}
}
// MARK: - Keyboard Handling (macOS)
#if targetEnvironment(macCatalyst)
override var canBecomeFirstResponder: Bool { true }
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let key = presses.first?.key else {
super.pressesBegan(presses, with: event)
return
}
switch key.keyCode {
case .keyboardSpacebar, .keyboardReturnOrEnter:
startGame()
default:
super.pressesBegan(presses, with: event)
}
}
#endif
private func startGame() {
// Button press effect
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
@@ -280,6 +280,27 @@ class VictoryScene: SKScene {
}
}
// MARK: - Keyboard Handling (macOS)
#if targetEnvironment(macCatalyst)
override var canBecomeFirstResponder: Bool { true }
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let key = presses.first?.key else {
super.pressesBegan(presses, with: event)
return
}
switch key.keyCode {
case .keyboardSpacebar, .keyboardReturnOrEnter:
playAgain()
case .keyboardEscape:
returnToMenu()
default:
super.pressesBegan(presses, with: event)
}
}
#endif
private func playAgain() {
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
+8
View File
@@ -0,0 +1,8 @@
# Build artifacts
bin/
obj/
# Visual Studio user/IDE files
.vs/
*.user
*.suo
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trafag Sales Exporter</title>
<base href="/" />
<link href="css/app.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<HeadOutlet @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
</head>
<body>
<Routes @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
<script src="_framework/blazor.web.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
</body>
</html>
@@ -0,0 +1,38 @@
@inherits LayoutComponentBase
<MudThemeProvider Theme="_theme" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<MudLayout>
<MudAppBar Elevation="1" Color="Color.Primary">
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
OnClick="ToggleDrawer" />
<MudText Typo="Typo.h6" Class="ml-3">Trafag Sales Exporter</MudText>
</MudAppBar>
<MudDrawer @bind-Open="_drawerOpen" Elevation="2" ClipMode="DrawerClipMode.Always">
<NavMenu />
</MudDrawer>
<MudMainContent Class="pa-4">
@Body
</MudMainContent>
</MudLayout>
@code {
private bool _drawerOpen = true;
private readonly MudTheme _theme = new()
{
PaletteLight = new PaletteLight
{
Primary = "#1565C0",
Secondary = "#00897B",
AppbarBackground = "#1565C0"
}
};
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
}
@@ -0,0 +1,17 @@
<MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
Dashboard
</MudNavLink>
<MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn">
Standorte
</MudNavLink>
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
Transformationen
</MudNavLink>
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
Settings
</MudNavLink>
<MudNavLink Href="/logs" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">
Logs
</MudNavLink>
</MudNavMenu>
@@ -0,0 +1,193 @@
@page "/"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject ExportOrchestrationService Orchestrator
@inject TimerBackgroundService TimerService
@inject ISnackbar Snackbar
@implements IDisposable
<PageTitle>Dashboard</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Dashboard</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Spacing="4">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow"
OnClick="ExportAll" Disabled="_anyRunning">
Alle exportieren
</MudButton>
<MudText Typo="Typo.body1">
@if (TimerService.NextRun < DateTime.MaxValue)
{
<MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Small" Class="mr-1" />
@($"Nächster automatischer Lauf: {TimerService.NextRun:dd.MM.yyyy HH:mm}")
}
else
{
<MudIcon Icon="@Icons.Material.Filled.TimerOff" Size="Size.Small" Class="mr-1" />
@("Timer deaktiviert")
}
</MudText>
</MudStack>
</MudPaper>
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>Land</MudTh>
<MudTh>TSC</MudTh>
<MudTh>Schema</MudTh>
<MudTh>Server</MudTh>
<MudTh>Status</MudTh>
<MudTh>Zeilen</MudTh>
<MudTh>Letzter Lauf</MudTh>
<MudTh>Dauer</MudTh>
<MudTh>Aktion</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.ServerName</MudTd>
<MudTd>
@if (Orchestrator.IsExporting(context.SiteId))
{
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
<MudText Typo="Typo.caption">@Orchestrator.GetExportStatus(context.SiteId)</MudText>
}
else if (context.LastStatus == "OK")
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else if (context.LastStatus == "Error")
{
<MudTooltip Text="@context.ErrorMessage">
<MudIcon Icon="@Icons.Material.Filled.Error" Color="Color.Error" Size="Size.Small" />
</MudTooltip>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
<MudTd>@(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
<MudTd>@(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-")</MudTd>
<MudTd>
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.FileDownload"
OnClick="() => ExportSingle(context.SiteId)"
Disabled="Orchestrator.IsExporting(context.SiteId)">
Export
</MudButton>
</MudTd>
</RowTemplate>
</MudTable>
@code {
private List<DashboardRow> _dashboardRows = new();
private bool _loading = true;
private bool _anyRunning;
protected override async Task OnInitializedAsync()
{
Orchestrator.OnExportStatusChanged += HandleStatusChanged;
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
using var db = await DbFactory.CreateDbContextAsync();
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
var logs = await db.ExportLogs
.GroupBy(l => l.SiteId)
.Select(g => g.OrderByDescending(l => l.Timestamp).First())
.ToListAsync();
_dashboardRows = sites.Select(s =>
{
var log = logs.FirstOrDefault(l => l.SiteId == s.Id);
return new DashboardRow
{
SiteId = s.Id,
Land = s.Land,
TSC = s.TSC,
Schema = s.Schema,
ServerName = s.HanaServer?.Name ?? "",
LastStatus = log?.Status ?? "",
RowCount = log?.RowCount ?? 0,
LastRun = log?.Timestamp,
DurationSeconds = log?.DurationSeconds ?? 0,
ErrorMessage = log?.ErrorMessage ?? ""
};
}).ToList();
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId));
_loading = false;
}
private async Task ExportAll()
{
_anyRunning = true;
_ = Task.Run(async () =>
{
await Orchestrator.ExportAllAsync();
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
});
Snackbar.Add("Export für alle Standorte gestartet", Severity.Info);
}
private void ExportSingle(int siteId)
{
_ = Task.Run(async () =>
{
await Orchestrator.ExportSiteByIdAsync(siteId);
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
});
Snackbar.Add("Export gestartet", Severity.Info);
}
private async void HandleStatusChanged()
{
await InvokeAsync(async () =>
{
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId));
StateHasChanged();
if (!_anyRunning)
{
await LoadDataAsync();
StateHasChanged();
}
});
}
public void Dispose()
{
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
}
private class DashboardRow
{
public int SiteId { get; set; }
public string Land { get; set; } = "";
public string TSC { get; set; } = "";
public string Schema { get; set; } = "";
public string ServerName { get; set; } = "";
public string LastStatus { get; set; } = "";
public int RowCount { get; set; }
public DateTime? LastRun { get; set; }
public double DurationSeconds { get; set; }
public string ErrorMessage { get; set; } = "";
}
}
@@ -0,0 +1,134 @@
@page "/logs"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@inject IDbContextFactory<AppDbContext> DbFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<PageTitle>Logs</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Export Logs</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Spacing="3">
<MudSelect @bind-Value="_filterLand" Label="Land" Clearable Style="max-width:200px;">
@foreach (var land in _availableLands)
{
<MudSelectItem Value="@land">@land</MudSelectItem>
}
</MudSelect>
<MudSelect @bind-Value="_filterStatus" Label="Status" Clearable Style="max-width:150px;">
<MudSelectItem Value="@("OK")">OK</MudSelectItem>
<MudSelectItem Value="@("Error")">Error</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="_filterDate" Label="Datum" Clearable Style="max-width:200px;" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ApplyFilter"
StartIcon="@Icons.Material.Filled.FilterAlt">
Filtern
</MudButton>
<MudSpacer />
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteOldLogs"
StartIcon="@Icons.Material.Filled.DeleteSweep">
Alte Logs löschen
</MudButton>
</MudStack>
</MudPaper>
<MudTable Items="_logs" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>Zeitpunkt</MudTh>
<MudTh>Land</MudTh>
<MudTh>TSC</MudTh>
<MudTh>Status</MudTh>
<MudTh>Zeilen</MudTh>
<MudTh>Dauer</MudTh>
<MudTh>Dateiname</MudTh>
<MudTh>Fehler</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Timestamp.ToString("dd.MM.yyyy HH:mm:ss")</MudTd>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>
@if (context.Status == "OK")
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">OK</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Error">Error</MudChip>
}
</MudTd>
<MudTd>@context.RowCount.ToString("N0")</MudTd>
<MudTd>@($"{context.DurationSeconds:F1}s")</MudTd>
<MudTd>@context.FileName</MudTd>
<MudTd>
@if (!string.IsNullOrEmpty(context.ErrorMessage))
{
<MudTooltip Text="@context.ErrorMessage">
<MudText Typo="Typo.caption" Color="Color.Error" Style="max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
@context.ErrorMessage
</MudText>
</MudTooltip>
}
</MudTd>
</RowTemplate>
</MudTable>
@code {
private List<ExportLog> _logs = new();
private List<string> _availableLands = new();
private string? _filterLand;
private string? _filterStatus;
private DateTime? _filterDate;
private bool _loading = true;
protected override async Task OnInitializedAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_availableLands = await db.ExportLogs.Select(l => l.Land).Distinct().OrderBy(l => l).ToListAsync();
await LoadLogsAsync();
}
private async Task LoadLogsAsync()
{
_loading = true;
using var db = await DbFactory.CreateDbContextAsync();
IQueryable<ExportLog> query = db.ExportLogs.OrderByDescending(l => l.Timestamp);
if (!string.IsNullOrEmpty(_filterLand))
query = query.Where(l => l.Land == _filterLand);
if (!string.IsNullOrEmpty(_filterStatus))
query = query.Where(l => l.Status == _filterStatus);
if (_filterDate.HasValue)
query = query.Where(l => l.Timestamp.Date == _filterDate.Value.Date);
_logs = await query.Take(500).ToListAsync();
_loading = false;
}
private async Task ApplyFilter()
{
await LoadLogsAsync();
}
private async Task DeleteOldLogs()
{
var result = await DialogService.ShowMessageBox(
"Alte Logs löschen",
"Logs älter als 90 Tage löschen?",
yesText: "Löschen", cancelText: "Abbrechen");
if (result != true) return;
using var db = await DbFactory.CreateDbContextAsync();
var cutoff = DateTime.Now.AddDays(-90);
var oldLogs = await db.ExportLogs.Where(l => l.Timestamp < cutoff).ToListAsync();
db.ExportLogs.RemoveRange(oldLogs);
var count = await db.SaveChangesAsync();
await LoadLogsAsync();
Snackbar.Add($"{oldLogs.Count} alte Logs gelöscht", Severity.Info);
}
}
@@ -0,0 +1,164 @@
@page "/settings"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject SharePointUploadService SpService
@inject TimerBackgroundService TimerService
@inject ISnackbar Snackbar
<PageTitle>Settings</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Settings</MudText>
@* SharePoint Config *@
<MudText Typo="Typo.h5" Class="mb-2">SharePoint Konfiguration</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_spConfig.SiteUrl" Label="Site URL" />
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_spConfig.ExportFolder" Label="Export Folder" />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_spConfig.TenantId" Label="Tenant ID" />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_spConfig.ClientId" Label="Client ID" />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_spConfig.ClientSecret" Label="Client Secret" InputType="InputType.Password" />
</MudItem>
<MudItem xs="12">
<MudStack Row Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSharePoint"
StartIcon="@Icons.Material.Filled.Save">
Speichern
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="TestSharePoint"
StartIcon="@Icons.Material.Filled.NetworkCheck" Disabled="_testingSp">
@if (_testingSp)
{
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
@("Teste...")
}
else
{
@("SharePoint Verbindung testen")
}
</MudButton>
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
@* Export Settings *@
<MudText Typo="Typo.h5" Class="mb-2">Export Einstellungen</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_exportSettings.DateFilter" Label="Datum-Filter (ab)"
HelperText="Format: yyyy-MM-dd" />
</MudItem>
<MudItem xs="12" md="2">
<MudNumericField @bind-Value="_exportSettings.TimerHour" Label="Timer Stunde" Min="0" Max="23" />
</MudItem>
<MudItem xs="12" md="2">
<MudNumericField @bind-Value="_exportSettings.TimerMinute" Label="Timer Minute" Min="0" Max="59" />
</MudItem>
<MudItem xs="12" md="4">
<MudSwitch @bind-Value="_exportSettings.TimerEnabled" Label="Timer aktiviert" Color="Color.Primary" />
</MudItem>
<MudItem xs="12">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExportSettings"
StartIcon="@Icons.Material.Filled.Save">
Speichern
</MudButton>
</MudItem>
</MudGrid>
</MudPaper>
@* Filename Preview *@
<MudText Typo="Typo.h5" Class="mb-2">Dateiname Vorschau</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.body1">
<MudIcon Icon="@Icons.Material.Filled.InsertDriveFile" Size="Size.Small" Class="mr-1" />
Sales_{"{TSC}"}_{DateTime.Now:yyyy-MM-dd}.xlsx
</MudText>
<MudText Typo="Typo.caption" Class="mt-1">
Beispiel: Sales_TRFR_@(DateTime.Now.ToString("yyyy-MM-dd")).xlsx
</MudText>
</MudPaper>
@code {
private SharePointConfig _spConfig = new();
private ExportSettings _exportSettings = new();
private bool _testingSp;
protected override async Task OnInitializedAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig();
_exportSettings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
}
private async Task SaveSharePoint()
{
using var db = await DbFactory.CreateDbContextAsync();
var existing = await db.SharePointConfigs.FirstOrDefaultAsync();
if (existing is null)
{
db.SharePointConfigs.Add(_spConfig);
}
else
{
existing.SiteUrl = _spConfig.SiteUrl;
existing.ExportFolder = _spConfig.ExportFolder;
existing.TenantId = _spConfig.TenantId;
existing.ClientId = _spConfig.ClientId;
existing.ClientSecret = _spConfig.ClientSecret;
}
await db.SaveChangesAsync();
Snackbar.Add("SharePoint Konfiguration gespeichert", Severity.Success);
}
private async Task TestSharePoint()
{
_testingSp = true;
try
{
await SpService.TestConnectionAsync(
_spConfig.TenantId, _spConfig.ClientId, _spConfig.ClientSecret, _spConfig.SiteUrl);
Snackbar.Add("SharePoint Verbindung erfolgreich!", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Verbindung fehlgeschlagen: {ex.Message}", Severity.Error);
}
finally
{
_testingSp = false;
}
}
private async Task SaveExportSettings()
{
using var db = await DbFactory.CreateDbContextAsync();
var existing = await db.ExportSettings.FirstOrDefaultAsync();
if (existing is null)
{
db.ExportSettings.Add(_exportSettings);
}
else
{
existing.DateFilter = _exportSettings.DateFilter;
existing.TimerHour = _exportSettings.TimerHour;
existing.TimerMinute = _exportSettings.TimerMinute;
existing.TimerEnabled = _exportSettings.TimerEnabled;
}
await db.SaveChangesAsync();
TimerService.Recalculate();
Snackbar.Add("Export Einstellungen gespeichert", Severity.Success);
}
}
@@ -0,0 +1,353 @@
@page "/standorte"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject HanaQueryService HanaService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<PageTitle>Standorte</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Standorte</MudText>
<MudText Typo="Typo.h5" Class="mb-2">HANA Server</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
OnClick="AddServer" Class="mb-3">
Server hinzufügen
</MudButton>
<MudTable Items="_servers" Dense Hover Striped>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Host</MudTh>
<MudTh>Port</MudTh>
<MudTh>Username</MudTh>
<MudTh>Verbindungsstatus</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Name</MudTd>
<MudTd>@context.Host</MudTd>
<MudTd>@context.Port</MudTd>
<MudTd>@context.Username</MudTd>
<MudTd>
@if (_connectionStatus.TryGetValue(context.Id, out var status))
{
<MudTooltip Text="@BuildStatusTooltip(status)">
<MudChip Color="@(status.Success ? Color.Success : Color.Error)" Variant="Variant.Filled" Size="Size.Small">
@(status.Success ? "OK" : "Fehler") - @status.Stage
</MudChip>
</MudTooltip>
}
else
{
<MudChip Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small">Nicht getestet</MudChip>
}
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small"
OnClick="() => EditServer(context)" />
<MudIconButton Icon="@Icons.Material.Filled.NetworkCheck" Size="Size.Small" Color="Color.Info"
OnClick="() => TestServerConnection(context)" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="() => DeleteServer(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
<MudText Typo="Typo.h5" Class="mb-2">Standorte (Sites)</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
OnClick="AddSite" Class="mb-3">
Neuen Standort hinzufügen
</MudButton>
<MudTable Items="_sites" Dense Hover Striped>
<HeaderContent>
<MudTh>Land</MudTh>
<MudTh>TSC</MudTh>
<MudTh>Schema</MudTh>
<MudTh>Quellsystem</MudTh>
<MudTh>Server</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.SourceSystem</MudTd>
<MudTd>@(context.HanaServer?.Name ?? "-")</MudTd>
<MudTd>
@if (context.IsActive)
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else
{
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Default" Size="Size.Small" />
}
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small"
OnClick="() => EditSite(context)" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="() => DeleteSite(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
<MudDialog @bind-Visible="_serverDialogVisible" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_editingServer.Id == 0 ? "Server hinzufügen" : "Server bearbeiten")</MudText>
</TitleContent>
<DialogContent>
<MudTextField @bind-Value="_editingServer.Name" Label="Name" Required />
<MudTextField @bind-Value="_editingServer.Host" Label="Host" Required
HelperText="IP oder Hostname (ohne Protokoll)" />
<MudNumericField @bind-Value="_editingServer.Port" Label="Port"
HelperText="Typisch 30015 (Tenant), 30013 (SystemDB), 3xx15 für Instanz xx" />
<MudTextField @bind-Value="_editingServer.Username" Label="Username" />
<MudTextField @bind-Value="_editingServer.Password" Label="Password" InputType="InputType.Password" />
<MudTextField @bind-Value="_editingServer.DatabaseName" Label="Database Name (MDC)"
HelperText="Nur bei Multi-Tenant Setup angeben, sonst leer lassen" />
<MudSwitch @bind-Value="_editingServer.UseSsl" Label="SSL/TLS verwenden (encrypt=true)" Color="Color.Primary" />
<MudSwitch @bind-Value="_editingServer.ValidateCertificate" Label="SSL-Zertifikat validieren" Color="Color.Primary"
Disabled="!_editingServer.UseSsl" />
<MudTextField @bind-Value="_editingServer.AdditionalParams" Label="Zusätzliche Parameter"
HelperText="Optional, z.B. sslCryptoProvider=openssl;communicationTimeout=0" />
</DialogContent>
<DialogActions>
<MudButton OnClick="() => _serverDialogVisible = false">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveServer">Speichern</MudButton>
</DialogActions>
</MudDialog>
<MudDialog @bind-Visible="_siteDialogVisible" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_editingSite.Id == 0 ? "Standort hinzufügen" : "Standort bearbeiten")</MudText>
</TitleContent>
<DialogContent>
<MudSelect @bind-Value="_editingSite.HanaServerId" Label="Server" Required>
@foreach (var s in _servers)
{
<MudSelectItem Value="@s.Id">@s.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="_editingSite.Schema" Label="Schema" Required />
<MudTextField @bind-Value="_editingSite.TSC" Label="TSC" Required />
<MudTextField @bind-Value="_editingSite.Land" Label="Land" Required />
<MudSelect @bind-Value="_editingSite.SourceSystem" Label="Quellsystem" Required>
@foreach (var system in _sourceSystems)
{
<MudSelectItem Value="system">@system</MudSelectItem>
}
</MudSelect>
<MudCheckBox @bind-Value="_editingSite.IsActive" Label="Aktiv" />
</DialogContent>
<DialogActions>
<MudButton OnClick="() => _siteDialogVisible = false">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite">Speichern</MudButton>
</DialogActions>
</MudDialog>
@code {
private readonly string[] _sourceSystems = ["SAP", "BI1", "SAGE"];
private readonly Dictionary<int, ConnectionTestResult> _connectionStatus = new();
private List<HanaServer> _servers = new();
private List<Site> _sites = new();
private HanaServer _editingServer = new();
private Site _editingSite = new();
private bool _serverDialogVisible;
private bool _siteDialogVisible;
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_servers = await db.HanaServers.OrderBy(s => s.Name).ToListAsync();
_sites = await db.Sites.Include(s => s.HanaServer).OrderBy(s => s.Land).ToListAsync();
}
private void AddServer()
{
_editingServer = new HanaServer { Port = 30015 };
_serverDialogVisible = true;
}
private void EditServer(HanaServer server)
{
_editingServer = new HanaServer
{
Id = server.Id,
Name = server.Name,
Host = server.Host,
Port = server.Port,
Username = server.Username,
Password = server.Password,
DatabaseName = server.DatabaseName,
UseSsl = server.UseSsl,
ValidateCertificate = server.ValidateCertificate,
AdditionalParams = server.AdditionalParams
};
_serverDialogVisible = true;
}
private async Task SaveServer()
{
using var db = await DbFactory.CreateDbContextAsync();
if (_editingServer.Id == 0)
{
db.HanaServers.Add(_editingServer);
}
else
{
var existing = await db.HanaServers.FindAsync(_editingServer.Id);
if (existing is not null)
{
existing.Name = _editingServer.Name;
existing.Host = _editingServer.Host;
existing.Port = _editingServer.Port;
existing.Username = _editingServer.Username;
existing.Password = _editingServer.Password;
existing.DatabaseName = _editingServer.DatabaseName;
existing.UseSsl = _editingServer.UseSsl;
existing.ValidateCertificate = _editingServer.ValidateCertificate;
existing.AdditionalParams = _editingServer.AdditionalParams;
}
}
await db.SaveChangesAsync();
_serverDialogVisible = false;
await LoadDataAsync();
Snackbar.Add("Server gespeichert", Severity.Success);
}
private async Task DeleteServer(HanaServer server)
{
var result = await DialogService.ShowMessageBox(
"Server löschen",
$"Server '{server.Name}' wirklich löschen?",
yesText: "Löschen", cancelText: "Abbrechen");
if (result != true) return;
using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.HanaServers.FindAsync(server.Id);
if (entity is not null)
{
db.HanaServers.Remove(entity);
await db.SaveChangesAsync();
}
await LoadDataAsync();
Snackbar.Add("Server gelöscht", Severity.Info);
}
private async Task TestServerConnection(HanaServer server)
{
var result = await Task.Run(() => HanaService.TestConnectionDetailed(server));
_connectionStatus[server.Id] = result;
if (result.Success)
{
Snackbar.Add($"Verbindung zu '{server.Name}' erfolgreich.", Severity.Success);
}
else
{
Snackbar.Add($"{server.Name}: {result.ExceptionType} - {result.ErrorMessage}", Severity.Error);
}
}
private static string BuildStatusTooltip(ConnectionTestResult status)
{
var stamp = status.TestedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss");
if (status.Success)
return $"Letzter Test: {stamp}\nStage: {status.Stage}\n{status.ConnectionStringPreview}";
return $"Letzter Test: {stamp}\nStage: {status.Stage}\nFehler: {status.ErrorMessage}\n{status.ConnectionStringPreview}";
}
private void AddSite()
{
_editingSite = new Site
{
IsActive = true,
SourceSystem = "SAP",
HanaServerId = _servers.FirstOrDefault()?.Id ?? 0
};
_siteDialogVisible = true;
}
private void EditSite(Site site)
{
_editingSite = new Site
{
Id = site.Id,
HanaServerId = site.HanaServerId,
Schema = site.Schema,
TSC = site.TSC,
Land = site.Land,
SourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem,
IsActive = site.IsActive
};
_siteDialogVisible = true;
}
private async Task SaveSite()
{
using var db = await DbFactory.CreateDbContextAsync();
if (_editingSite.Id == 0)
{
db.Sites.Add(_editingSite);
}
else
{
var existing = await db.Sites.FindAsync(_editingSite.Id);
if (existing is not null)
{
existing.HanaServerId = _editingSite.HanaServerId;
existing.Schema = _editingSite.Schema;
existing.TSC = _editingSite.TSC;
existing.Land = _editingSite.Land;
existing.SourceSystem = _editingSite.SourceSystem;
existing.IsActive = _editingSite.IsActive;
}
}
await db.SaveChangesAsync();
_siteDialogVisible = false;
await LoadDataAsync();
Snackbar.Add("Standort gespeichert", Severity.Success);
}
private async Task DeleteSite(Site site)
{
var result = await DialogService.ShowMessageBox(
"Standort löschen",
$"Standort '{site.Land}' wirklich löschen?",
yesText: "Löschen", cancelText: "Abbrechen");
if (result != true) return;
using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.Sites.FindAsync(site.Id);
if (entity is not null)
{
db.Sites.Remove(entity);
await db.SaveChangesAsync();
}
await LoadDataAsync();
Snackbar.Add("Standort gelöscht", Severity.Info);
}
}
@@ -0,0 +1,137 @@
@page "/transformations"
@using Microsoft.EntityFrameworkCore
@using System.Reflection
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Models
@inject IDbContextFactory<AppDbContext> DbFactory
@inject ISnackbar Snackbar
<PageTitle>Transformationen</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Transformer Ansicht</MudText>
<MudText Typo="Typo.body1" Class="mb-4">Definiere pro Quellsystem (SAP, BI1, SAGE) Feld-Remapping und Transformationen.</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudStack Row="true" Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="AddRule">
Regel hinzufügen
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAllAsync">
Alle speichern
</MudButton>
</MudStack>
<MudTable Items="_rules" Dense Hover Striped>
<HeaderContent>
<MudTh>Aktiv</MudTh>
<MudTh>System</MudTh>
<MudTh>Source</MudTh>
<MudTh>Target</MudTh>
<MudTh>Typ</MudTh>
<MudTh>Argument</MudTh>
<MudTh>Sort</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudCheckBox @bind-Value="context.IsActive" /></MudTd>
<MudTd>
<MudSelect T="string" Value="@context.SourceSystem" ValueChanged="@(v => context.SourceSystem = v)" Dense>
@foreach (var system in _systems)
{
<MudSelectItem Value="system">@system</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" Value="@context.SourceField" ValueChanged="@(v => context.SourceField = v)" Dense>
@foreach (var field in _recordFields)
{
<MudSelectItem Value="field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" Value="@context.TargetField" ValueChanged="@(v => context.TargetField = v)" Dense>
@foreach (var field in _recordFields)
{
<MudSelectItem Value="field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" Value="@context.TransformationType" ValueChanged="@(v => context.TransformationType = v)" Dense>
@foreach (var type in _types)
{
<MudSelectItem Value="type">@type</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudTextField Value="@context.Argument" ValueChanged="@(v => context.Argument = v)" Dense
HelperText="Replace: alt=>neu" />
</MudTd>
<MudTd>
<MudNumericField T="int" Value="@context.SortOrder" ValueChanged="@(v => context.SortOrder = v)" Dense />
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
OnClick="() => RemoveRule(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
@code {
private readonly string[] _systems = ["SAP", "BI1", "SAGE"];
private readonly string[] _types = ["Copy", "Uppercase", "Lowercase", "Prefix", "Suffix", "Replace", "Constant"];
private readonly string[] _recordFields = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name)
.OrderBy(n => n)
.ToArray();
private List<FieldTransformationRule> _rules = new();
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_rules = await db.FieldTransformationRules.OrderBy(r => r.SortOrder).ThenBy(r => r.Id).ToListAsync();
}
private void AddRule()
{
var nextSort = _rules.Count == 0 ? 10 : _rules.Max(r => r.SortOrder) + 10;
_rules.Add(new FieldTransformationRule
{
SourceSystem = "SAP",
SourceField = nameof(SalesRecord.Material),
TargetField = nameof(SalesRecord.Material),
TransformationType = "Copy",
SortOrder = nextSort,
IsActive = true
});
}
private void RemoveRule(FieldTransformationRule rule)
{
_rules.Remove(rule);
}
private async Task SaveAllAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
db.FieldTransformationRules.RemoveRange(db.FieldTransformationRules);
await db.SaveChangesAsync();
db.FieldTransformationRules.AddRange(_rules);
await db.SaveChangesAsync();
Snackbar.Add("Transformationsregeln gespeichert.", Severity.Success);
await LoadAsync();
}
}
@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@@ -0,0 +1,9 @@
@using System.Net.Http
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@using MudBlazor
@using TrafagSalesExporter.Components
@using TrafagSalesExporter.Components.Layout
@using TrafagSalesExporter.Models
+115
View File
@@ -0,0 +1,115 @@
using System.Data;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<HanaServer> HanaServers => Set<HanaServer>();
public DbSet<Site> Sites => Set<Site>();
public DbSet<SharePointConfig> SharePointConfigs => Set<SharePointConfig>();
public DbSet<ExportSettings> ExportSettings => Set<ExportSettings>();
public DbSet<ExportLog> ExportLogs => Set<ExportLog>();
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
/// <summary>
/// Fügt Spalten zu existierenden Tabellen hinzu, die bei neueren Versionen
/// hinzugekommen sind. EnsureCreated aktualisiert das Schema nicht automatisch.
/// </summary>
public static void EnsureSchema(AppDbContext db)
{
AddColumnIfMissing(db, "HanaServers", "DatabaseName", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "HanaServers", "UseSsl", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "HanaServers", "ValidateCertificate", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "HanaServers", "AdditionalParams", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SourceSystem", "TEXT NOT NULL DEFAULT 'SAP'");
EnsureTransformationTable(db);
}
private static void AddColumnIfMissing(AppDbContext db, string table, string column, string type)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) conn.Open();
bool exists = false;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"PRAGMA table_info({table})";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
if (string.Equals(reader["name"]?.ToString(), column, StringComparison.OrdinalIgnoreCase))
{
exists = true;
break;
}
}
}
if (!exists)
{
using var alter = conn.CreateCommand();
alter.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {type}";
alter.ExecuteNonQuery();
}
}
private static void EnsureTransformationTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS FieldTransformationRules (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SourceSystem TEXT NOT NULL DEFAULT 'SAP',
SourceField TEXT NOT NULL,
TargetField TEXT NOT NULL,
TransformationType TEXT NOT NULL,
Argument TEXT NOT NULL DEFAULT '',
SortOrder INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1
);";
cmd.ExecuteNonQuery();
}
public static void SeedIfEmpty(AppDbContext db)
{
if (db.HanaServers.Any()) return;
var serverInternal = new HanaServer { Name = "Internal", Host = "travtrp0", Port = 30015, Username = "", Password = "" };
var serverIndia = new HanaServer { Name = "India", Host = "20.197.20.60", Port = 30015, Username = "", Password = "" };
db.HanaServers.AddRange(serverInternal, serverIndia);
db.SaveChanges();
db.Sites.AddRange(
new Site { HanaServerId = serverInternal.Id, Schema = "fr01_p", TSC = "TRFR", Land = "Frankreich", IsActive = true },
new Site { HanaServerId = serverInternal.Id, Schema = "it01_p", TSC = "TRIT", Land = "Italien", IsActive = true },
new Site { HanaServerId = serverInternal.Id, Schema = "us01_p", TSC = "TRUS", Land = "USA", IsActive = true },
new Site { HanaServerId = serverIndia.Id, Schema = "TRAFAG_LIVE", TSC = "TRIN", Land = "Indien", IsActive = true }
);
db.SharePointConfigs.Add(new SharePointConfig
{
SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform",
ExportFolder = "/Shared Documents/Exports/",
TenantId = "",
ClientId = "",
ClientSecret = ""
});
db.ExportSettings.Add(new ExportSettings
{
DateFilter = "2025-01-01",
TimerHour = 3,
TimerMinute = 0,
TimerEnabled = true
});
db.SaveChanges();
}
}
+21
View File
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class ExportLog
{
public int Id { get; set; }
public DateTime Timestamp { get; set; }
public int SiteId { get; set; }
[ForeignKey(nameof(SiteId))]
public Site? Site { get; set; }
public string Land { get; set; } = string.Empty;
public string TSC { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public int RowCount { get; set; }
public string? ErrorMessage { get; set; }
public string FileName { get; set; } = string.Empty;
public double DurationSeconds { get; set; }
}
@@ -0,0 +1,10 @@
namespace TrafagSalesExporter.Models;
public class ExportSettings
{
public int Id { get; set; }
public string DateFilter { get; set; } = "2025-01-01";
public int TimerHour { get; set; } = 3;
public int TimerMinute { get; set; }
public bool TimerEnabled { get; set; } = true;
}
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace TrafagSalesExporter.Models;
public class FieldTransformationRule
{
public int Id { get; set; }
[Required]
public string SourceSystem { get; set; } = "SAP";
[Required]
public string SourceField { get; set; } = nameof(SalesRecord.Material);
[Required]
public string TargetField { get; set; } = nameof(SalesRecord.Material);
[Required]
public string TransformationType { get; set; } = "Copy";
public string Argument { get; set; } = string.Empty;
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
+84
View File
@@ -0,0 +1,84 @@
using System.ComponentModel.DataAnnotations;
namespace TrafagSalesExporter.Models;
public class HanaServer
{
public int Id { get; set; }
[Required]
public string Name { get; set; } = string.Empty;
[Required]
public string Host { get; set; } = string.Empty;
public int Port { get; set; } = 30015;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
/// <summary>
/// Name der Tenant-Datenbank bei Multi-Tenant Database Container (MDC) Setups.
/// Leer lassen, wenn direkt auf einen Tenant-Port verbunden wird.
/// </summary>
public string DatabaseName { get; set; } = string.Empty;
/// <summary>
/// SSL/TLS Verschlüsselung aktivieren (encrypt=true).
/// </summary>
public bool UseSsl { get; set; }
/// <summary>
/// SSL-Zertifikat validieren. Bei self-signed Zertifikaten auf false setzen.
/// </summary>
public bool ValidateCertificate { get; set; }
/// <summary>
/// Zusätzliche Verbindungsparameter (Semikolon-getrennt), z.B. "sslCryptoProvider=openssl".
/// </summary>
public string AdditionalParams { get; set; } = string.Empty;
public string BuildConnectionString()
{
var parts = new List<string>
{
$"ServerNode={Host}:{Port}",
$"UserName={Username}",
$"Password={Password}"
};
if (!string.IsNullOrWhiteSpace(DatabaseName))
parts.Add($"DatabaseName={DatabaseName}");
if (UseSsl)
{
parts.Add("encrypt=true");
parts.Add($"sslValidateCertificate={(ValidateCertificate ? "true" : "false")}");
}
if (!string.IsNullOrWhiteSpace(AdditionalParams))
parts.Add(AdditionalParams.Trim().Trim(';'));
return string.Join(";", parts);
}
public string GetConnectionStringPreview()
{
var pwdMasked = string.IsNullOrEmpty(Password) ? "" : "***";
var copy = new HanaServer
{
Host = Host,
Port = Port,
Username = Username,
Password = pwdMasked,
DatabaseName = DatabaseName,
UseSsl = UseSsl,
ValidateCertificate = ValidateCertificate,
AdditionalParams = AdditionalParams
};
return copy.BuildConnectionString();
}
}
+31
View File
@@ -0,0 +1,31 @@
namespace TrafagSalesExporter.Models;
public class SalesRecord
{
public DateTime ExtractionDate { get; set; }
public string Tsc { get; set; } = string.Empty;
public string InvoiceNumber { get; set; } = string.Empty;
public int PositionOnInvoice { get; set; }
public string Material { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string ProductGroup { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public string SupplierNumber { get; set; } = string.Empty;
public string SupplierName { get; set; } = string.Empty;
public string SupplierCountry { get; set; } = string.Empty;
public string CustomerNumber { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string CustomerCountry { get; set; } = string.Empty;
public string CustomerIndustry { get; set; } = string.Empty;
public decimal StandardCost { get; set; }
public string StandardCostCurrency { get; set; } = string.Empty;
public string PurchaseOrderNumber { get; set; } = string.Empty;
public decimal SalesPriceValue { get; set; }
public string SalesCurrency { get; set; } = string.Empty;
public string Incoterms2020 { get; set; } = string.Empty;
public string SalesResponsibleEmployee { get; set; } = string.Empty;
public DateTime? InvoiceDate { get; set; }
public DateTime? OrderDate { get; set; }
public string Land { get; set; } = string.Empty;
public string DocumentType { get; set; } = string.Empty;
}
@@ -0,0 +1,11 @@
namespace TrafagSalesExporter.Models;
public class SharePointConfig
{
public int Id { get; set; }
public string SiteUrl { get; set; } = string.Empty;
public string ExportFolder { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
}
+28
View File
@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class Site
{
public int Id { get; set; }
public int HanaServerId { get; set; }
[ForeignKey(nameof(HanaServerId))]
public HanaServer? HanaServer { get; set; }
[Required]
public string Schema { get; set; } = string.Empty;
[Required]
public string TSC { get; set; } = string.Empty;
[Required]
public string Land { get; set; } = string.Empty;
[Required]
public string SourceSystem { get; set; } = "SAP";
public bool IsActive { get; set; } = true;
}
+46
View File
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore;
using MudBlazor.Services;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddMudServices();
builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite("Data Source=trafag_exporter.db"));
builder.Services.AddSingleton<HanaQueryService>();
builder.Services.AddSingleton<ExcelExportService>();
builder.Services.AddSingleton<SharePointUploadService>();
builder.Services.AddSingleton<RecordTransformationService>();
builder.Services.AddSingleton<ExportOrchestrationService>();
builder.Services.AddSingleton<TimerBackgroundService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<TimerBackgroundService>());
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
using var db = await dbFactory.CreateDbContextAsync();
await db.Database.EnsureCreatedAsync();
AppDbContext.EnsureSchema(db);
AppDbContext.SeedIfEmpty(db);
}
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<TrafagSalesExporter.Components.App>()
.AddInteractiveServerRenderMode();
app.Run();
@@ -0,0 +1,12 @@
{
"profiles": {
"TrafagSalesExporter": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:55415;http://localhost:55416"
}
}
}
@@ -0,0 +1,89 @@
using ClosedXML.Excel;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class ExcelExportService
{
public string CreateExcelFile(string outputDirectory, string tsc, DateTime fileDate, List<SalesRecord> records)
{
Directory.CreateDirectory(outputDirectory);
var fileName = $"Sales_{tsc}_{fileDate:yyyy-MM-dd}.xlsx";
var fullPath = Path.Combine(outputDirectory, fileName);
using var workbook = new XLWorkbook();
var ws = workbook.Worksheets.Add("Sales");
var headers = new[]
{
"extraction date",
"TSC",
"Invoice Number",
"Position on invoice",
"Material",
"Name",
"Product Group",
"Quantity",
"Supplier number",
"Supplier name",
"Supplier country",
"Customer number",
"Customer name",
"Customer country",
"Customer Industry",
"Standard cost",
"Standard Cost Currency",
"Purchase Order number",
"Sales Price/Value",
"Sales Currency",
"Incoterms 2020",
"Sales responsible employee",
"invoice date",
"order date",
"Land",
"Document Type"
};
for (var i = 0; i < headers.Length; i++)
{
ws.Cell(1, i + 1).Value = headers[i];
ws.Cell(1, i + 1).Style.Font.Bold = true;
}
var row = 2;
foreach (var record in records)
{
ws.Cell(row, 1).Value = record.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss");
ws.Cell(row, 2).Value = record.Tsc;
ws.Cell(row, 3).Value = record.InvoiceNumber;
ws.Cell(row, 4).Value = record.PositionOnInvoice;
ws.Cell(row, 5).Value = record.Material;
ws.Cell(row, 6).Value = record.Name;
ws.Cell(row, 7).Value = record.ProductGroup;
ws.Cell(row, 8).Value = record.Quantity;
ws.Cell(row, 9).Value = record.SupplierNumber;
ws.Cell(row, 10).Value = record.SupplierName;
ws.Cell(row, 11).Value = record.SupplierCountry;
ws.Cell(row, 12).Value = record.CustomerNumber;
ws.Cell(row, 13).Value = record.CustomerName;
ws.Cell(row, 14).Value = record.CustomerCountry;
ws.Cell(row, 15).Value = record.CustomerIndustry;
ws.Cell(row, 16).Value = record.StandardCost;
ws.Cell(row, 17).Value = record.StandardCostCurrency;
ws.Cell(row, 18).Value = record.PurchaseOrderNumber;
ws.Cell(row, 19).Value = record.SalesPriceValue;
ws.Cell(row, 20).Value = record.SalesCurrency;
ws.Cell(row, 21).Value = record.Incoterms2020;
ws.Cell(row, 22).Value = record.SalesResponsibleEmployee;
ws.Cell(row, 23).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 24).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 25).Value = record.Land;
ws.Cell(row, 26).Value = record.DocumentType;
row++;
}
ws.Columns().AdjustToContents();
workbook.SaveAs(fullPath);
return fullPath;
}
}
@@ -0,0 +1,171 @@
using Microsoft.EntityFrameworkCore;
using System.Diagnostics;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class ExportOrchestrationService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly HanaQueryService _hanaService;
private readonly ExcelExportService _excelService;
private readonly SharePointUploadService _sharePointService;
private readonly RecordTransformationService _transformationService;
private readonly ILogger<ExportOrchestrationService> _logger;
public event Action? OnExportStatusChanged;
private readonly Dictionary<int, string> _runningExports = new();
private readonly object _lock = new();
public ExportOrchestrationService(
IDbContextFactory<AppDbContext> dbFactory,
HanaQueryService hanaService,
ExcelExportService excelService,
SharePointUploadService sharePointService,
RecordTransformationService transformationService,
ILogger<ExportOrchestrationService> logger)
{
_dbFactory = dbFactory;
_hanaService = hanaService;
_excelService = excelService;
_sharePointService = sharePointService;
_transformationService = transformationService;
_logger = logger;
}
public bool IsExporting(int siteId)
{
lock (_lock)
{
return _runningExports.ContainsKey(siteId);
}
}
public string GetExportStatus(int siteId)
{
lock (_lock)
{
return _runningExports.TryGetValue(siteId, out var status) ? status : string.Empty;
}
}
public async Task ExportAllAsync()
{
using var db = await _dbFactory.CreateDbContextAsync();
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
foreach (var site in sites)
{
await ExportSiteAsync(site);
}
}
public async Task ExportSiteByIdAsync(int siteId)
{
using var db = await _dbFactory.CreateDbContextAsync();
var site = await db.Sites.Include(s => s.HanaServer).FirstOrDefaultAsync(s => s.Id == siteId);
if (site is null) return;
await ExportSiteAsync(site);
}
private async Task ExportSiteAsync(Site site)
{
if (site.HanaServer is null) return;
lock (_lock)
{
if (_runningExports.ContainsKey(site.Id)) return;
_runningExports[site.Id] = "HANA Abfrage...";
}
NotifyChanged();
var sw = Stopwatch.StartNew();
var log = new ExportLog
{
Timestamp = DateTime.Now,
SiteId = site.Id,
Land = site.Land,
TSC = site.TSC
};
try
{
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
UpdateStatus(site.Id, "HANA Abfrage...");
var records = await Task.Run(() => _hanaService.GetSalesRecords(
site.HanaServer, site.Schema, site.TSC, site.Land, settings.DateFilter));
UpdateStatus(site.Id, "Transformationen anwenden...");
var rules = await db.FieldTransformationRules
.Where(r => r.IsActive && r.SourceSystem == (string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem))
.OrderBy(r => r.SortOrder)
.ToListAsync();
_transformationService.Apply(records, rules);
UpdateStatus(site.Id, "Excel erstellen...");
var outputDir = Path.Combine(AppContext.BaseDirectory, "output");
var filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
var fileName = Path.GetFileName(filePath);
if (spConfig is not null &&
!string.IsNullOrWhiteSpace(spConfig.TenantId) &&
!string.IsNullOrWhiteSpace(spConfig.ClientId) &&
!string.IsNullOrWhiteSpace(spConfig.ClientSecret))
{
UpdateStatus(site.Id, "SharePoint Upload...");
await _sharePointService.UploadAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, spConfig.ExportFolder, site.Land, filePath);
}
sw.Stop();
log.Status = "OK";
log.RowCount = records.Count;
log.FileName = fileName;
log.DurationSeconds = sw.Elapsed.TotalSeconds;
_logger.LogInformation("Export OK: {Land} ({TSC}) - {Rows} Zeilen in {Duration:F1}s",
site.Land, site.TSC, records.Count, sw.Elapsed.TotalSeconds);
}
catch (Exception ex)
{
sw.Stop();
log.Status = "Error";
log.ErrorMessage = ex.Message;
log.FileName = string.Empty;
log.DurationSeconds = sw.Elapsed.TotalSeconds;
_logger.LogError(ex, "Export Fehler: {Land} ({TSC})", site.Land, site.TSC);
}
finally
{
using var db = await _dbFactory.CreateDbContextAsync();
db.ExportLogs.Add(log);
await db.SaveChangesAsync();
lock (_lock)
{
_runningExports.Remove(site.Id);
}
NotifyChanged();
}
}
private void UpdateStatus(int siteId, string status)
{
lock (_lock)
{
_runningExports[siteId] = status;
}
NotifyChanged();
}
private void NotifyChanged()
{
OnExportStatusChanged?.Invoke();
}
}
@@ -0,0 +1,217 @@
using Sap.Data.Hana;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class HanaQueryService
{
public List<SalesRecord> GetSalesRecords(HanaServer server,
string schema, string tsc, string land, string dateFilter)
{
var connectionString = server.BuildConnectionString();
var result = new List<SalesRecord>();
using var connection = new HanaConnection(connectionString);
connection.Open();
var invoiceQuery = GetInvoiceQuery(schema, tsc, dateFilter);
var creditNoteQuery = GetCreditNoteQuery(schema, tsc, dateFilter);
result.AddRange(ReadRecords(connection, invoiceQuery, land));
result.AddRange(ReadRecords(connection, creditNoteQuery, land));
foreach (var record in result)
{
if (record.Material.Contains('/'))
{
var parts = record.Material.Split('/');
record.Material = parts[^1];
}
}
return result;
}
public ConnectionTestResult TestConnectionDetailed(HanaServer server)
{
var testResult = new ConnectionTestResult
{
TestedAtUtc = DateTime.UtcNow,
ConnectionStringPreview = server.GetConnectionStringPreview(),
Stage = "Verbindungsaufbau"
};
try
{
var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString);
connection.Open();
testResult.Stage = "Ping-Query";
using var command = new HanaCommand("SELECT 1 FROM DUMMY", connection);
command.ExecuteScalar();
testResult.Success = true;
testResult.Stage = "OK";
return testResult;
}
catch (Exception ex)
{
testResult.Success = false;
testResult.ErrorMessage = ex.Message;
testResult.ExceptionType = ex.GetType().Name;
return testResult;
}
}
public void TestConnection(HanaServer server)
{
var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString);
connection.Open();
}
private static List<SalesRecord> ReadRecords(HanaConnection connection, string query, string land)
{
var records = new List<SalesRecord>();
using var command = new HanaCommand(query, connection);
using var reader = command.ExecuteReader();
while (reader.Read())
{
records.Add(new SalesRecord
{
ExtractionDate = reader.GetDateTime(reader.GetOrdinal("extraction_date")),
Tsc = reader.GetString(reader.GetOrdinal("tsc")),
InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty,
PositionOnInvoice = Convert.ToInt32(reader["invoice_position"]),
InvoiceDate = reader.IsDBNull(reader.GetOrdinal("invoice_date")) ? null : reader.GetDateTime(reader.GetOrdinal("invoice_date")),
Material = reader["material"]?.ToString() ?? string.Empty,
Name = reader["material_name"]?.ToString() ?? string.Empty,
ProductGroup = reader["product_group"]?.ToString() ?? string.Empty,
Quantity = Convert.ToDecimal(reader["quantity"]),
SupplierNumber = reader["supplier_number"]?.ToString() ?? string.Empty,
SupplierName = reader["supplier_name"]?.ToString() ?? string.Empty,
SupplierCountry = reader["supplier_country"]?.ToString() ?? string.Empty,
CustomerNumber = reader["customer_number"]?.ToString() ?? string.Empty,
CustomerName = reader["customer_name"]?.ToString() ?? string.Empty,
CustomerCountry = reader["customer_country"]?.ToString() ?? string.Empty,
CustomerIndustry = reader["customer_industry"]?.ToString() ?? string.Empty,
StandardCost = Convert.ToDecimal(reader["standard_cost"]),
StandardCostCurrency = reader["standard_cost_currency"]?.ToString() ?? string.Empty,
PurchaseOrderNumber = reader["purchase_order_number"]?.ToString() ?? string.Empty,
SalesPriceValue = Convert.ToDecimal(reader["sales_value"]),
SalesCurrency = reader["sales_currency"]?.ToString() ?? string.Empty,
Incoterms2020 = reader["incoterms_2020"]?.ToString() ?? string.Empty,
SalesResponsibleEmployee = reader["sales_responsible"]?.ToString() ?? string.Empty,
OrderDate = reader.IsDBNull(reader.GetOrdinal("order_date")) ? null : reader.GetDateTime(reader.GetOrdinal("order_date")),
Land = land,
DocumentType = reader["doc_type"]?.ToString() ?? string.Empty
});
}
return records;
}
private static string GetInvoiceQuery(string schema, string tsc, string dateFilter) => $@"
SELECT
CURRENT_TIMESTAMP AS extraction_date,
'{tsc}' AS tsc,
h.""DocNum"" AS invoice_number,
p.""LineNum"" AS invoice_position,
h.""DocDate"" AS invoice_date,
p.""ItemCode"" AS material,
p.""Dscription"" AS material_name,
COALESCE(grp.""ItmsGrpNam"", '') AS product_group,
p.""Quantity"" AS quantity,
COALESCE(itm.""CardCode"", '') AS supplier_number,
COALESCE(sup.""CardName"", '') AS supplier_name,
COALESCE(sup_adr.""Country"", '') AS supplier_country,
h.""CardCode"" AS customer_number,
h.""CardName"" AS customer_name,
COALESCE(cust_adr.""Country"", '') AS customer_country,
COALESCE(ind.""IndName"", '') AS customer_industry,
p.""StockPrice"" AS standard_cost,
COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency,
CASE WHEN p.""BaseType"" = 22
THEN CAST(p.""BaseRef"" AS NVARCHAR(20))
ELSE '' END AS purchase_order_number,
p.""LineTotal"" AS sales_value,
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency,
'' AS incoterms_2020,
COALESCE(emp.""SlpName"", '') AS sales_responsible,
CASE WHEN p.""BaseType"" = 17
THEN (SELECT o.""DocDate"" FROM {schema}.""ORDR"" o
WHERE o.""DocEntry"" = p.""BaseEntry"")
ELSE NULL END AS order_date,
'INV' AS doc_type
FROM {schema}.""OINV"" h
INNER JOIN {schema}.""INV1"" p ON h.""DocEntry"" = p.""DocEntry""
LEFT JOIN {schema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
LEFT JOIN {schema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
LEFT JOIN {schema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
LEFT JOIN {schema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode""
LEFT JOIN {schema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
AND sup.""CardType"" = 'S'
LEFT JOIN {schema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode""
AND sup_adr.""AdresType"" = 'B'
LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}'
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
private static string GetCreditNoteQuery(string schema, string tsc, string dateFilter) => $@"
SELECT
CURRENT_TIMESTAMP AS extraction_date,
'{tsc}' AS tsc,
h.""DocNum"" AS invoice_number,
p.""LineNum"" AS invoice_position,
h.""DocDate"" AS invoice_date,
p.""ItemCode"" AS material,
p.""Dscription"" AS material_name,
COALESCE(grp.""ItmsGrpNam"", '') AS product_group,
p.""Quantity"" * -1 AS quantity,
COALESCE(itm.""CardCode"", '') AS supplier_number,
COALESCE(sup.""CardName"", '') AS supplier_name,
COALESCE(sup_adr.""Country"", '') AS supplier_country,
h.""CardCode"" AS customer_number,
h.""CardName"" AS customer_name,
COALESCE(cust_adr.""Country"", '') AS customer_country,
COALESCE(ind.""IndName"", '') AS customer_industry,
p.""StockPrice"" AS standard_cost,
COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency,
'' AS purchase_order_number,
p.""LineTotal"" * -1 AS sales_value,
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency,
'' AS incoterms_2020,
COALESCE(emp.""SlpName"", '') AS sales_responsible,
NULL AS order_date,
'CRN' AS doc_type
FROM {schema}.""ORIN"" h
INNER JOIN {schema}.""RIN1"" p ON h.""DocEntry"" = p.""DocEntry""
LEFT JOIN {schema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
LEFT JOIN {schema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
LEFT JOIN {schema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
LEFT JOIN {schema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode""
LEFT JOIN {schema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
AND sup.""CardType"" = 'S'
LEFT JOIN {schema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode""
AND sup_adr.""AdresType"" = 'B'
LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}'
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
}
public class ConnectionTestResult
{
public bool Success { get; set; }
public DateTime TestedAtUtc { get; set; }
public string Stage { get; set; } = string.Empty;
public string ErrorMessage { get; set; } = string.Empty;
public string ExceptionType { get; set; } = string.Empty;
public string ConnectionStringPreview { get; set; } = string.Empty;
}
@@ -0,0 +1,92 @@
using System.Reflection;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class RecordTransformationService
{
private static readonly Dictionary<string, PropertyInfo> PropertyMap = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.ToDictionary(p => p.Name, p => p, StringComparer.OrdinalIgnoreCase);
public void Apply(List<SalesRecord> records, IEnumerable<FieldTransformationRule> rules)
{
var orderedRules = rules.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToList();
if (orderedRules.Count == 0 || records.Count == 0) return;
foreach (var record in records)
{
foreach (var rule in orderedRules)
{
ApplyRule(record, rule);
}
}
}
private static void ApplyRule(SalesRecord record, FieldTransformationRule rule)
{
if (!PropertyMap.TryGetValue(rule.SourceField, out var sourceProp)) return;
if (!PropertyMap.TryGetValue(rule.TargetField, out var targetProp)) return;
var sourceValue = sourceProp.GetValue(record);
object? result = rule.TransformationType switch
{
"Copy" => sourceValue,
"Uppercase" => sourceValue?.ToString()?.ToUpperInvariant(),
"Lowercase" => sourceValue?.ToString()?.ToLowerInvariant(),
"Prefix" => $"{rule.Argument}{sourceValue}",
"Suffix" => $"{sourceValue}{rule.Argument}",
"Replace" => ApplyReplace(sourceValue?.ToString(), rule.Argument),
"Constant" => rule.Argument,
_ => sourceValue
};
SetPropertyValue(record, targetProp, result);
}
private static string ApplyReplace(string? input, string? argument)
{
if (string.IsNullOrEmpty(input)) return string.Empty;
if (string.IsNullOrWhiteSpace(argument)) return input;
var parts = argument.Split("=>", 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2) return input;
return input.Replace(parts[0], parts[1], StringComparison.OrdinalIgnoreCase);
}
private static void SetPropertyValue(SalesRecord record, PropertyInfo property, object? value)
{
try
{
if (property.PropertyType == typeof(string))
{
property.SetValue(record, value?.ToString() ?? string.Empty);
return;
}
if (property.PropertyType == typeof(int))
{
if (int.TryParse(value?.ToString(), out var parsedInt)) property.SetValue(record, parsedInt);
return;
}
if (property.PropertyType == typeof(decimal))
{
if (decimal.TryParse(value?.ToString(), out var parsedDecimal)) property.SetValue(record, parsedDecimal);
return;
}
if (property.PropertyType == typeof(DateTime?) || property.PropertyType == typeof(DateTime))
{
if (DateTime.TryParse(value?.ToString(), out var parsedDate)) property.SetValue(record, parsedDate);
return;
}
property.SetValue(record, value);
}
catch
{
// skip invalid conversion to keep export running
}
}
}
@@ -0,0 +1,45 @@
using Azure.Identity;
using Microsoft.Graph;
namespace TrafagSalesExporter.Services;
public class SharePointUploadService
{
public async Task UploadAsync(string tenantId, string clientId, string clientSecret,
string siteUrl, string exportFolder, string land, string localFilePath)
{
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
var uri = new Uri(siteUrl);
var sitePath = uri.AbsolutePath;
var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
if (site?.Id is null)
throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden.");
var drive = await graphClient.Sites[site.Id].Drive.GetAsync();
if (drive?.Id is null)
throw new InvalidOperationException("SharePoint Dokumentenbibliothek konnte nicht gefunden werden.");
var fileName = Path.GetFileName(localFilePath);
var folderPath = exportFolder.Trim('/').Trim();
var remotePath = $"{folderPath}/{land}/{fileName}";
await using var stream = File.OpenRead(localFilePath);
await graphClient.Drives[drive.Id].Root.ItemWithPath(remotePath).Content.PutAsync(stream);
}
public async Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl)
{
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
var uri = new Uri(siteUrl);
var sitePath = uri.AbsolutePath;
var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
if (site?.Id is null)
throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden.");
}
}
@@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
namespace TrafagSalesExporter.Services;
public class TimerBackgroundService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<TimerBackgroundService> _logger;
private DateTime _nextRun = DateTime.MaxValue;
public DateTime NextRun => _nextRun;
public TimerBackgroundService(IServiceProvider serviceProvider, ILogger<TimerBackgroundService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
public void Recalculate()
{
_ = RecalculateNextRunAsync();
}
private async Task RecalculateNextRunAsync()
{
var dbFactory = _serviceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
using var db = await dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync();
if (settings is null || !settings.TimerEnabled)
{
_nextRun = DateTime.MaxValue;
return;
}
var now = DateTime.Now;
var todayRun = new DateTime(now.Year, now.Month, now.Day, settings.TimerHour, settings.TimerMinute, 0);
_nextRun = todayRun <= now ? todayRun.AddDays(1) : todayRun;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await RecalculateNextRunAsync();
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
if (DateTime.Now < _nextRun) continue;
_logger.LogInformation("Timer-Export gestartet um {Time}", DateTime.Now);
try
{
var orchestrator = _serviceProvider.GetRequiredService<ExportOrchestrationService>();
await orchestrator.ExportAllAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Fehler beim Timer-Export");
}
await RecalculateNextRunAsync();
}
}
}
@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>
<Prefer32Bit>false</Prefer32Bit>
<!--
Pfad zur SAP HANA Client DLL (wird mit dem SAP HANA Client installiert).
Standard-Pfad nach Installation: C:\Program Files\sap\hdbclient\dotnetcore\v2.1\
Kann bei Bedarf via MSBuild-Property überschrieben werden:
dotnet build /p:HanaClientDll="D:\pfad\zu\Sap.Data.Hana.Core.v2.1.dll"
-->
<HanaClientDll Condition="'$(HanaClientDll)' == ''">C:\Program Files\sap\hdbclient\dotnetcore\v2.1\Sap.Data.Hana.Core.v2.1.dll</HanaClientDll>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.104.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Graph" Version="5.80.0" />
<PackageReference Include="Azure.Identity" Version="1.13.1" />
<PackageReference Include="MudBlazor" Version="7.15.0" />
</ItemGroup>
<ItemGroup>
<Reference Include="Sap.Data.Hana.Core.v2.1">
<HintPath>$(HanaClientDll)</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
<Target Name="CheckHanaClient" BeforeTargets="ResolveAssemblyReferences">
<Warning Condition="!Exists('$(HanaClientDll)')"
Text="SAP HANA Client DLL nicht gefunden: $(HanaClientDll). Bitte SAP HANA Client installieren (https://tools.hana.ondemand.com) oder MSBuild-Property 'HanaClientDll' setzen." />
</Target>
</Project>
@@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.37012.4 d17.14
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrafagSalesExporter", "TrafagSalesExporter.csproj", "{49B56D6D-731C-6482-4A5C-82EAEEBCE593}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Debug|Any CPU.Build.0 = Debug|Any CPU
{49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Release|Any CPU.ActiveCfg = Release|Any CPU
{49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {DC174EA0-ECCB-4957-9D97-E7ABED992867}
EndGlobalSection
EndGlobal
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+3
View File
@@ -0,0 +1,3 @@
html, body {
font-family: 'Roboto', sans-serif;
}
+26
View File
@@ -0,0 +1,26 @@
# Configuration (contains secrets)
config.php
# Cache files
weather_cache.json
active_viewers.json
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Vendor (if using composer)
# vendor/
# Uploads (user content)
uploads/
+240
View File
@@ -0,0 +1,240 @@
# Integration Guide für Aurora Livecam Erweiterungen
## Übersicht der neuen Dateien
```
aurora-livecam/
├── SettingsManager.php # Admin-Einstellungen Klasse
├── settings.json # Einstellungen Datei
├── js/
│ ├── timelapse-controls.js # Timelapse mit Slider
│ ├── video-player.js # Tagesvideos im Player
│ └── admin-settings.js # Admin AJAX
├── css/
│ └── player-controls.css # Styles für Controls
└── INTEGRATION.md # Diese Anleitung
```
## Änderungen in index.php
### 1. Am Anfang der Datei (nach den requires)
```php
<?php
// ... bestehende requires ...
// NEU: Settings Manager einbinden
require_once 'SettingsManager.php';
$settingsManager = new SettingsManager();
// AJAX-Handler für Settings (VOR session_start!)
$settingsManager->handleAjax();
```
### 2. Im HEAD-Bereich (CSS einbinden)
```html
<link rel="stylesheet" href="css/player-controls.css">
```
### 3. Vor </body> (JavaScript einbinden)
```html
<script src="js/timelapse-controls.js"></script>
<script src="js/video-player.js"></script>
<?php if ($adminManager->isAdmin()): ?>
<script src="js/admin-settings.js"></script>
<?php endif; ?>
```
### 4. Video-Container anpassen
Ersetze den bestehenden video-container:
```html
<div class="video-container">
<?php echo $webcamManager->displayWebcam(); ?>
<!-- Timelapse Overlay -->
<div id="timelapse-viewer" style="display: none;">
<img id="timelapse-image" src="" alt="Timelapse">
</div>
<!-- NEU: Daily Video Player (wird dynamisch befüllt) -->
</div>
<!-- NEU: Timelapse Controls (außerhalb des Containers) -->
<div id="timelapse-controls"></div>
```
### 5. Zuschauer-Anzeige konditionell machen
Ersetze die Viewer-Stat Anzeige:
```php
<?php
$viewerCount = $viewerCounter->getInitialCount();
$showViewers = $settingsManager->shouldShowViewers($viewerCount);
?>
<?php if ($showViewers): ?>
<div class="info-badge viewer-stat">
<span class="live-dot"></span>
<strong id="viewer-count-display"><?php echo $viewerCount; ?></strong>
<span>Zuschauer</span>
</div>
<?php endif; ?>
```
### 6. Kalender Links anpassen
In der `VisualCalendarManager::displayVisualCalendar()` Methode:
```php
// Für Tagesvideos
$playInPlayer = $settingsManager->shouldPlayInPlayer();
$allowDownload = $settingsManager->shouldAllowDownload();
if ($playInPlayer) {
// Im Player abspielen
$output .= '<a href="#" onclick="DailyVideoPlayer.playVideo(\'' . $video['path'] . '\', ' . ($allowDownload ? 'true' : 'false') . '); return false;" class="play-link">';
$output .= '▶️ Abspielen';
$output .= '</a>';
}
if ($allowDownload) {
// Download Link
$output .= '<a href="?download_specific_video=..." class="download-link">⬇️ Download</a>';
}
```
### 7. Admin-Panel erweitern
Füge im Admin-Bereich hinzu:
```php
<?php if ($adminManager->isAdmin()): ?>
<section id="admin" class="section">
<div class="container">
<h2>Admin-Bereich</h2>
<!-- NEU: Settings Panel -->
<div id="admin-settings-panel">
<h3>⚙️ Anzeige-Einstellungen</h3>
<div class="settings-group">
<h4>👥 Zuschauer-Anzeige</h4>
<div class="setting-row">
<span class="setting-label">Zuschauer-Anzahl anzeigen</span>
<div class="setting-input">
<label class="toggle-switch">
<input type="checkbox" id="setting-viewer-enabled"
<?php echo $settingsManager->get('viewer_display.enabled') ? 'checked' : ''; ?>>
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-row">
<span class="setting-label">Mindestanzahl für Anzeige</span>
<div class="setting-input">
<input type="number" id="setting-min-viewers" class="number-input"
min="1" max="100"
value="<?php echo $settingsManager->get('viewer_display.min_viewers'); ?>">
</div>
</div>
</div>
<div class="settings-group">
<h4>🎬 Video-Modus</h4>
<div class="setting-row">
<span class="setting-label">Videos im Player abspielen</span>
<div class="setting-input">
<label class="toggle-switch">
<input type="checkbox" id="setting-play-in-player"
<?php echo $settingsManager->get('video_mode.play_in_player') ? 'checked' : ''; ?>>
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-row">
<span class="setting-label">Download erlauben</span>
<div class="setting-input">
<label class="toggle-switch">
<input type="checkbox" id="setting-allow-download"
<?php echo $settingsManager->get('video_mode.allow_download') ? 'checked' : ''; ?>>
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
</div>
<!-- Bestehender Admin-Content -->
<?php echo $adminManager->displayAdminContent(); ?>
</div>
</section>
<?php endif; ?>
```
### 8. Timelapse Button Event anpassen
Im bestehenden JavaScript:
```javascript
timelapseButton.addEventListener('click', function(e) {
e.preventDefault();
if (timelapseViewer.style.display === 'none') {
// NEU: TimelapseController verwenden
TimelapseController.init(imageFiles);
TimelapseController.show();
timelapseButton.textContent = 'Zurück zur Live-Webcam';
} else {
TimelapseController.backToLive();
}
});
```
### 9. Viewer Heartbeat anpassen
Im JavaScript für den Viewer-Counter:
```javascript
function updateViewerCount() {
fetch(window.location.href, {
method: 'POST',
body: new URLSearchParams({action: 'viewer_heartbeat'})
})
.then(r => r.json())
.then(data => {
const display = document.getElementById('viewer-count-display');
const container = document.querySelector('.viewer-stat');
if (data.count && display) {
display.textContent = data.count;
// Mindestanzahl prüfen (aus Settings)
const minViewers = window.minViewersToShow || 1;
if (container) {
container.style.display = data.count >= minViewers ? 'inline-flex' : 'none';
}
}
});
}
```
## Fertig!
Nach diesen Änderungen hast du:
- ✅ Timelapse mit Slider und 1x/10x/100x Geschwindigkeit
- ✅ Rückwärts-Spulen im Timelapse
- ✅ Tagesvideos im Player abspielen statt nur Download
- ✅ "Zurück zu Live" Button
- ✅ Admin-Einstellungen für Zuschauer-Anzeige
- ✅ Mindestanzahl für Zuschauer-Anzeige
- ✅ Video-Modus wählbar (Player/Download)
- ✅ Alles ohne Seiten-Reload
+394
View File
@@ -0,0 +1,394 @@
<?php
/**
* SettingsManager - Verwaltet Admin-Einstellungen
* Speichert in settings.json, lädt ohne Reload
*/
class SettingsManager {
private $settingsFile;
private $settings = [];
public function __construct($file = null) {
$this->settingsFile = $file ?: (__DIR__ . '/settings.json');
$this->load();
}
private function load() {
if (file_exists($this->settingsFile)) {
$content = file_get_contents($this->settingsFile);
$this->settings = json_decode($content, true) ?? $this->getDefaults();
} else {
$this->settings = $this->getDefaults();
$this->save();
}
}
private function getDefaults() {
return [
'viewer_display' => [
'enabled' => true,
'min_viewers' => 1,
'update_interval' => 5 // Sekunden
],
'video_mode' => [
'play_in_player' => true,
'allow_download' => true
],
'timelapse' => [
'default_speed' => 1,
'available_speeds' => [1, 10, 100]
],
// Punkt 2: UI-Anzeige Features
'ui_display' => [
'show_recommendation_banner' => true,
'show_qr_code' => true,
'show_social_media' => true,
'show_patrouille_suisse' => true
],
// Punkt 3: Zoom & Timelapse
'zoom_timelapse' => [
'show_zoom_controls' => true,
'max_zoom_level' => 4.0,
'timelapse_reverse_enabled' => true,
'weekly_timelapse_enabled' => true // Wochenzeitraffer Button
],
// Auto-Screenshot für Galerie
'auto_screenshot' => [
'enabled' => false,
'interval_minutes' => 10,
'max_images' => 144, // 24h bei 10min Intervall
'save_to_gallery' => true
],
// Email-Sharing
'sharing' => [
'email_enabled' => false,
'share_link_expiry_hours' => 24
],
// Punkt 5: Content Management
'content' => [
'guestbook_enabled' => true,
'gallery_enabled' => true,
'ai_events_enabled' => true,
'max_guestbook_entries' => 50
],
// Punkt 6: Technische Settings
'technical' => [
'viewer_update_interval' => 5, // Sekunden
'session_timeout' => 30 // Sekunden
],
// Punkt 7: Theme & Design
'theme' => [
'default_theme' => 'theme-legacy',
'show_theme_switcher' => false
],
// Punkt 8: SEO & Meta
'seo' => [
'custom_title' => '',
'meta_description' => '',
'meta_keywords' => ''
],
// Weather Widget
'weather' => [
'enabled' => true,
'location' => 'Oberdürnten,CH',
'lat' => '47.2833',
'lon' => '8.7167',
'update_interval' => 5, // Minuten
'units' => 'metric' // metric (Celsius) oder imperial (Fahrenheit)
],
// SaaS Features - alle aktivierbar/deaktivierbar
'saas_features' => [
// Multi-Tenant
'multi_tenant_enabled' => false, // Aktiviert DB-basierte Tenant-Verwaltung
'customer_management_enabled' => false,
// Onboarding
'self_registration_enabled' => false,
'email_verification_required' => true,
'trial_enabled' => true,
'trial_days' => 14,
// Billing
'billing_enabled' => false,
'stripe_enabled' => false,
'free_plan_available' => true,
// Dashboard
'tenant_dashboard_enabled' => false,
'analytics_enabled' => false,
'custom_domain_enabled' => false,
'custom_branding_enabled' => false,
// Landing
'landing_page_enabled' => false,
'demo_mode_enabled' => false,
// Limits (Default für Free-Plan)
'default_max_viewers' => 50,
'default_storage_mb' => 500,
'default_retention_days' => 7
],
'last_updated' => null,
'updated_by' => null
];
}
public function get($key = null) {
if ($key === null) return $this->settings;
$keys = explode('.', $key);
$value = $this->settings;
foreach ($keys as $k) {
if (!isset($value[$k])) return null;
$value = $value[$k];
}
return $value;
}
public function set($key, $value) {
$keys = explode('.', $key);
$ref = &$this->settings;
foreach ($keys as $i => $k) {
if ($i === count($keys) - 1) {
$ref[$k] = $value;
} else {
if (!isset($ref[$k])) $ref[$k] = [];
$ref = &$ref[$k];
}
}
$this->settings['last_updated'] = date('Y-m-d H:i:s');
return $this->save();
}
private function save() {
$payload = json_encode($this->settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
if ($payload === false) {
return false;
}
return file_put_contents($this->settingsFile, $payload, LOCK_EX) !== false;
}
// Für AJAX-Anfragen
public function handleAjax() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
if (!isset($_POST['settings_action'])) return;
header('Content-Type: application/json');
switch ($_POST['settings_action']) {
case 'get':
echo json_encode(['success' => true, 'settings' => $this->settings]);
exit;
case 'update':
$key = $_POST['key'] ?? null;
$value = $_POST['value'] ?? null;
// Boolean-Werte konvertieren
if ($value === 'true') $value = true;
if ($value === 'false') $value = false;
if (is_numeric($value)) $value = intval($value);
if ($key && $this->set($key, $value)) {
echo json_encode(['success' => true, 'message' => 'Einstellung gespeichert']);
} else {
echo json_encode([
'success' => false,
'message' => 'Fehler beim Speichern. Bitte Dateirechte prüfen.'
]);
}
exit;
}
}
// Viewer-Anzeige prüfen
public function shouldShowViewers($currentCount) {
if (!$this->get('viewer_display.enabled')) return false;
return $currentCount >= $this->get('viewer_display.min_viewers');
}
// Video-Modus prüfen
public function shouldPlayInPlayer() {
return $this->get('video_mode.play_in_player') === true;
}
public function shouldAllowDownload() {
return $this->get('video_mode.allow_download') === true;
}
// UI Display Helper
public function shouldShowRecommendationBanner() {
return $this->get('ui_display.show_recommendation_banner') === true;
}
public function shouldShowQRCode() {
return $this->get('ui_display.show_qr_code') === true;
}
public function shouldShowSocialMedia() {
return $this->get('ui_display.show_social_media') === true;
}
public function shouldShowPatrouillesuisse() {
return $this->get('ui_display.show_patrouille_suisse') === true;
}
// Content Management Helper
public function isGuestbookEnabled() {
return $this->get('content.guestbook_enabled') === true;
}
public function isGalleryEnabled() {
return $this->get('content.gallery_enabled') === true;
}
public function isAIEventsEnabled() {
return $this->get('content.ai_events_enabled') === true;
}
public function getMaxGuestbookEntries() {
return $this->get('content.max_guestbook_entries') ?? 50;
}
// Theme Helper
public function getDefaultTheme() {
return $this->get('theme.default_theme') ?? 'theme-legacy';
}
public function shouldShowThemeSwitcher() {
return $this->get('theme.show_theme_switcher') === true;
}
// Technical Helper
public function getViewerUpdateInterval() {
return $this->get('technical.viewer_update_interval') ?? 5;
}
public function getSessionTimeout() {
return $this->get('technical.session_timeout') ?? 30;
}
// Zoom & Timelapse Helper
public function shouldShowZoomControls() {
return $this->get('zoom_timelapse.show_zoom_controls') === true;
}
public function getMaxZoomLevel() {
return $this->get('zoom_timelapse.max_zoom_level') ?? 4.0;
}
public function isTimelapseReverseEnabled() {
return $this->get('zoom_timelapse.timelapse_reverse_enabled') === true;
}
public function isWeeklyTimelapseEnabled() {
return $this->get('zoom_timelapse.weekly_timelapse_enabled') !== true;
}
// Auto-Screenshot Helper
public function isAutoScreenshotEnabled() {
return $this->get('auto_screenshot.enabled') === true;
}
public function getAutoScreenshotInterval() {
return $this->get('auto_screenshot.interval_minutes') ?? 10;
}
public function getAutoScreenshotMaxImages() {
return $this->get('auto_screenshot.max_images') ?? 144;
}
// Sharing Helper
public function isEmailSharingEnabled() {
return $this->get('sharing.email_enabled') === true;
}
public function getShareLinkExpiryHours() {
return $this->get('sharing.share_link_expiry_hours') ?? 24;
}
// SEO Helper
public function getCustomTitle() {
$title = $this->get('seo.custom_title');
return !empty($title) ? $title : null;
}
public function getMetaDescription() {
return $this->get('seo.meta_description') ?? '';
}
public function getMetaKeywords() {
return $this->get('seo.meta_keywords') ?? '';
}
// Weather Helper
public function isWeatherEnabled() {
return $this->get('weather.enabled') === true;
}
public function getWeatherLocation() {
return $this->get('weather.location') ?? 'Oberdürnten,CH';
}
public function getWeatherCoords() {
return [
'lat' => $this->get('weather.lat') ?? '47.2833',
'lon' => $this->get('weather.lon') ?? '8.7167'
];
}
public function getWeatherUpdateInterval() {
return $this->get('weather.update_interval') ?? 5;
}
public function getWeatherUnits() {
return $this->get('weather.units') ?? 'metric';
}
// SaaS Feature Helper
public function isMultiTenantEnabled() {
return $this->get('saas_features.multi_tenant_enabled') === true;
}
public function isSelfRegistrationEnabled() {
return $this->get('saas_features.self_registration_enabled') === true;
}
public function isBillingEnabled() {
return $this->get('saas_features.billing_enabled') === true;
}
public function isStripeEnabled() {
return $this->get('saas_features.stripe_enabled') === true;
}
public function isTenantDashboardEnabled() {
return $this->get('saas_features.tenant_dashboard_enabled') === true;
}
public function isAnalyticsEnabled() {
return $this->get('saas_features.analytics_enabled') === true;
}
public function isCustomDomainEnabled() {
return $this->get('saas_features.custom_domain_enabled') === true;
}
public function isCustomBrandingEnabled() {
return $this->get('saas_features.custom_branding_enabled') === true;
}
public function isLandingPageEnabled() {
return $this->get('saas_features.landing_page_enabled') === true;
}
public function getTrialDays() {
return $this->get('saas_features.trial_days') ?? 14;
}
public function getDefaultMaxViewers() {
return $this->get('saas_features.default_max_viewers') ?? 50;
}
}
+286
View File
@@ -0,0 +1,286 @@
<?php
/**
* WeatherManager - Holt und cached Wetterdaten von Open-Meteo (kostenlos!)
* Keine API Key nötig!
*/
class WeatherManager {
private $settingsManager;
private $cacheFile = 'weather_cache.json';
private $cacheTime = 300; // 5 Minuten in Sekunden
public function __construct($settingsManager) {
$this->settingsManager = $settingsManager;
}
/**
* Holt aktuelle Wetterdaten (cached)
*/
public function getCurrentWeather() {
// Prüfe ob Weather aktiviert ist
if (!$this->settingsManager->isWeatherEnabled()) {
return null;
}
// Prüfe Cache
$cached = $this->getCache();
if ($cached !== null) {
return $cached;
}
$coords = $this->settingsManager->getWeatherCoords();
$apiKey = trim($this->settingsManager->getWeatherApiKey());
$weather = $apiKey !== ''
? $this->fetchOpenWeather($coords, $apiKey)
: $this->fetchOpenMeteo($coords);
if (isset($weather['error'])) {
return $weather;
}
// Cache speichern
$this->saveCache($weather);
return $weather;
}
private function fetchOpenMeteo($coords) {
// Open-Meteo API URL - komplett kostenlos, kein API Key!
$url = "https://api.open-meteo.com/v1/forecast?" . http_build_query([
'latitude' => $coords['lat'],
'longitude' => $coords['lon'],
'current' => 'temperature_2m,relative_humidity_2m,precipitation,weather_code,wind_speed_10m,wind_direction_10m,pressure_msl,cloud_cover',
'timezone' => 'Europe/Zurich'
]);
$response = $this->fetchUrl($url);
if ($response === null) {
return ['error' => 'API Fehler'];
}
$data = json_decode($response, true);
if (!$data || !isset($data['current'])) {
return ['error' => 'Ungültige API Antwort'];
}
$current = $data['current'];
return [
'temp' => round($current['temperature_2m'], 1),
'feels_like' => round($current['temperature_2m'], 1), // Open-Meteo hat keine "feels like"
'humidity' => $current['relative_humidity_2m'],
'pressure' => round($current['pressure_msl'], 0),
'wind_speed' => round($current['wind_speed_10m'], 1), // Schon in km/h!
'wind_deg' => $current['wind_direction_10m'],
'wind_direction' => $this->getWindDirection($current['wind_direction_10m']),
'clouds' => $current['cloud_cover'] ?? 0,
'description' => $this->getWeatherDescription($current['weather_code']),
'icon' => $this->getWeatherIcon($current['weather_code']),
'rain_1h' => $current['precipitation'] ?? 0,
'snow_1h' => 0, // Open-Meteo gibt Niederschlag gesamt
'location' => $this->settingsManager->getWeatherLocation(),
'timestamp' => time()
];
}
private function fetchOpenWeather($coords, $apiKey) {
$units = $this->settingsManager->getWeatherUnits();
$url = "https://api.openweathermap.org/data/2.5/weather?" . http_build_query([
'lat' => $coords['lat'],
'lon' => $coords['lon'],
'appid' => $apiKey,
'units' => $units,
'lang' => 'de'
]);
$response = $this->fetchUrl($url);
if ($response === null) {
return ['error' => 'API Fehler'];
}
$data = json_decode($response, true);
if (!$data || !isset($data['main'], $data['weather'][0], $data['wind'])) {
return ['error' => 'Ungültige API Antwort'];
}
$windSpeed = $data['wind']['speed'];
if ($units === 'metric') {
$windSpeed = $windSpeed * 3.6; // m/s -> km/h
}
return [
'temp' => round($data['main']['temp'], 1),
'feels_like' => round($data['main']['feels_like'], 1),
'humidity' => $data['main']['humidity'],
'pressure' => round($data['main']['pressure'], 0),
'wind_speed' => round($windSpeed, 1),
'wind_deg' => $data['wind']['deg'] ?? 0,
'wind_direction' => $this->getWindDirection($data['wind']['deg'] ?? 0),
'clouds' => $data['clouds']['all'] ?? 0,
'description' => ucfirst($data['weather'][0]['description']),
'icon' => $data['weather'][0]['icon'] ?? '01d',
'rain_1h' => $data['rain']['1h'] ?? 0,
'snow_1h' => $data['snow']['1h'] ?? 0,
'location' => $data['name'] ?? $this->settingsManager->getWeatherLocation(),
'timestamp' => time()
];
}
private function fetchUrl($url) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || !$response) {
return null;
}
return $response;
}
/**
* Wandelt WMO Weather Code in Beschreibung um
* https://open-meteo.com/en/docs
*/
private function getWeatherDescription($code) {
$descriptions = [
0 => 'Klar',
1 => 'Überwiegend klar',
2 => 'Teilweise bewölkt',
3 => 'Bewölkt',
45 => 'Neblig',
48 => 'Nebel mit Reifablagerung',
51 => 'Leichter Nieselregen',
53 => 'Mäßiger Nieselregen',
55 => 'Dichter Nieselregen',
61 => 'Leichter Regen',
63 => 'Mäßiger Regen',
65 => 'Starker Regen',
71 => 'Leichter Schneefall',
73 => 'Mäßiger Schneefall',
75 => 'Starker Schneefall',
77 => 'Schneegraupeln',
80 => 'Leichte Regenschauer',
81 => 'Mäßige Regenschauer',
82 => 'Starke Regenschauer',
85 => 'Leichte Schneeschauer',
86 => 'Starke Schneeschauer',
95 => 'Gewitter',
96 => 'Gewitter mit leichtem Hagel',
99 => 'Gewitter mit starkem Hagel'
];
return $descriptions[$code] ?? 'Unbekannt';
}
/**
* Wandelt WMO Weather Code in Icon-Code um (OpenWeatherMap kompatibel)
*/
private function getWeatherIcon($code) {
if ($code == 0) return '01d'; // Klar
if ($code >= 1 && $code <= 2) return '02d'; // Teilweise bewölkt
if ($code == 3) return '04d'; // Bewölkt
if ($code >= 45 && $code <= 48) return '50d'; // Nebel
if ($code >= 51 && $code <= 55) return '09d'; // Nieselregen
if ($code >= 61 && $code <= 65) return '10d'; // Regen
if ($code >= 71 && $code <= 77) return '13d'; // Schnee
if ($code >= 80 && $code <= 82) return '09d'; // Regenschauer
if ($code >= 85 && $code <= 86) return '13d'; // Schneeschauer
if ($code >= 95 && $code <= 99) return '11d'; // Gewitter
return '01d'; // Default
}
/**
* Wandelt Windrichtung (Grad) in Kompassrichtung um
*/
private function getWindDirection($deg) {
$directions = ['N', 'NNO', 'NO', 'ONO', 'O', 'OSO', 'SO', 'SSO', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
$index = round($deg / 22.5) % 16;
return $directions[$index];
}
/**
* Holt Daten aus Cache (wenn noch gültig)
*/
private function getCache() {
if (!file_exists($this->cacheFile)) {
return null;
}
$content = file_get_contents($this->cacheFile);
$data = json_decode($content, true);
if (!$data || !isset($data['timestamp'])) {
return null;
}
// Fehler nicht aus Cache zurückgeben (z.B. alter "API Key fehlt" Error)
if (isset($data['error'])) {
@unlink($this->cacheFile); // Cache mit Fehler löschen
return null;
}
// Update-Intervall aus Settings holen (in Minuten)
$updateInterval = $this->settingsManager->getWeatherUpdateInterval() * 60; // Minuten -> Sekunden
// Prüfe ob Cache noch gültig
if (time() - $data['timestamp'] < $updateInterval) {
return $data;
}
return null;
}
/**
* Speichert Daten im Cache (nur wenn kein Fehler)
*/
private function saveCache($data) {
// Fehler nicht cachen
if (isset($data['error'])) {
return;
}
$json = json_encode($data, JSON_PRETTY_PRINT);
file_put_contents($this->cacheFile, $json, LOCK_EX);
}
/**
* Gibt Wetter-Icon-Emoji zurück
*/
public function getWeatherEmoji($iconCode) {
$map = [
'01d' => '☀️', '01n' => '🌙',
'02d' => '⛅', '02n' => '☁️',
'03d' => '☁️', '03n' => '☁️',
'04d' => '☁️', '04n' => '☁️',
'09d' => '🌧️', '09n' => '🌧️',
'10d' => '🌦️', '10n' => '🌧️',
'11d' => '⛈️', '11n' => '⛈️',
'13d' => '❄️', '13n' => '❄️',
'50d' => '🌫️', '50n' => '🌫️'
];
return $map[$iconCode] ?? '🌤️';
}
/**
* AJAX Handler für Wetter-Updates
*/
public function handleAjax() {
if ($_SERVER['REQUEST_METHOD'] !== 'GET') return;
if (!isset($_GET['weather_action'])) return;
header('Content-Type: application/json');
if ($_GET['weather_action'] === 'get') {
$weather = $this->getCurrentWeather();
echo json_encode(['success' => true, 'data' => $weather]);
exit;
}
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
/**
* Auto-Screenshot API
*
* Kann als Cron-Job aufgerufen werden:
* */10 * * * * curl -s http://localhost/api/auto-screenshot.php?key=YOUR_SECRET_KEY
*
* Oder via Webhook/Timer
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
header('Content-Type: application/json');
$settingsManager = new SettingsManager();
// Prüfe ob Feature aktiviert
if (!$settingsManager->isAutoScreenshotEnabled()) {
echo json_encode(['success' => false, 'error' => 'Auto-Screenshot deaktiviert']);
exit;
}
// Optionale API-Key Validierung
$configFile = dirname(__DIR__) . '/config.php';
if (file_exists($configFile)) {
$config = require $configFile;
$apiKey = $config['auto_screenshot_key'] ?? '';
if (!empty($apiKey) && ($_GET['key'] ?? '') !== $apiKey) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Ungültiger API-Key']);
exit;
}
}
// Galerie-Verzeichnis erstellen
$galleryDir = dirname(__DIR__) . '/gallery/auto/';
if (!is_dir($galleryDir)) {
mkdir($galleryDir, 0755, true);
}
// Screenshot-Dateiname
$filename = 'auto_' . date('Y-m-d_H-i-s') . '.jpg';
$filepath = $galleryDir . $filename;
// Video-Stream URL
$streamUrl = 'test_video.m3u8';
$logoPath = dirname(__DIR__) . '/logo.png';
// FFmpeg-Befehl zum Erstellen des Screenshots
$command = sprintf(
'ffmpeg -i %s -vframes 1 -q:v 2 %s 2>&1',
escapeshellarg($streamUrl),
escapeshellarg($filepath)
);
exec($command, $output, $returnVar);
if ($returnVar !== 0 || !file_exists($filepath)) {
echo json_encode([
'success' => false,
'error' => 'Screenshot fehlgeschlagen',
'command' => $command,
'output' => implode("\n", $output)
]);
exit;
}
// Alte Screenshots aufräumen (max. Anzahl einhalten)
$maxImages = $settingsManager->getAutoScreenshotMaxImages();
$existingFiles = glob($galleryDir . 'auto_*.jpg');
rsort($existingFiles); // Neueste zuerst
if (count($existingFiles) > $maxImages) {
$filesToDelete = array_slice($existingFiles, $maxImages);
foreach ($filesToDelete as $file) {
@unlink($file);
}
}
// Metadaten speichern
$metaFile = $galleryDir . 'metadata.json';
$metadata = [];
if (file_exists($metaFile)) {
$metadata = json_decode(file_get_contents($metaFile), true) ?? [];
}
$metadata[$filename] = [
'created_at' => date('Y-m-d H:i:s'),
'timestamp' => time(),
'size' => filesize($filepath)
];
// Nur die letzten maxImages behalten
$metadata = array_slice($metadata, -$maxImages, null, true);
file_put_contents($metaFile, json_encode($metadata, JSON_PRETTY_PRINT));
echo json_encode([
'success' => true,
'file' => $filename,
'path' => '/gallery/auto/' . $filename,
'total_images' => count(glob($galleryDir . 'auto_*.jpg'))
]);
+97
View File
@@ -0,0 +1,97 @@
<?php
/**
* Gallery API
*
* GET /api/gallery.php - Liste alle Galerie-Bilder
* GET /api/gallery.php?date=2024-01-30 - Bilder eines bestimmten Datums
* GET /api/gallery.php?from=2024-01-01&to=2024-01-31 - Bilder in einem Zeitraum
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
$settingsManager = new SettingsManager();
$galleryDir = dirname(__DIR__) . '/gallery/auto/';
// Prüfe ob Galerie existiert
if (!is_dir($galleryDir)) {
echo json_encode(['success' => true, 'images' => [], 'total' => 0]);
exit;
}
// Parameter
$date = $_GET['date'] ?? null;
$from = $_GET['from'] ?? null;
$to = $_GET['to'] ?? null;
$limit = min(100, (int)($_GET['limit'] ?? 50));
$offset = max(0, (int)($_GET['offset'] ?? 0));
// Alle Bilder holen
$allFiles = glob($galleryDir . 'auto_*.jpg');
rsort($allFiles); // Neueste zuerst
$images = [];
foreach ($allFiles as $file) {
$filename = basename($file);
// Extrahiere Datum aus Dateinamen: auto_2024-01-30_14-30-00.jpg
if (preg_match('/auto_(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})\.jpg/', $filename, $matches)) {
$fileDate = $matches[1];
$fileTime = str_replace('-', ':', $matches[2]);
// Datumsfilter
if ($date !== null && $fileDate !== $date) {
continue;
}
if ($from !== null && $fileDate < $from) {
continue;
}
if ($to !== null && $fileDate > $to) {
continue;
}
$images[] = [
'filename' => $filename,
'path' => '/gallery/auto/' . $filename,
'date' => $fileDate,
'time' => $fileTime,
'datetime' => $fileDate . ' ' . $fileTime,
'timestamp' => strtotime($fileDate . ' ' . $fileTime),
'size' => filesize($file)
];
}
}
$total = count($images);
// Pagination
$images = array_slice($images, $offset, $limit);
// Verfügbare Daten (für Kalender/Filter)
$availableDates = [];
foreach (glob($galleryDir . 'auto_*.jpg') as $file) {
if (preg_match('/auto_(\d{4}-\d{2}-\d{2})/', basename($file), $m)) {
$availableDates[$m[1]] = ($availableDates[$m[1]] ?? 0) + 1;
}
}
krsort($availableDates);
echo json_encode([
'success' => true,
'images' => $images,
'total' => $total,
'offset' => $offset,
'limit' => $limit,
'available_dates' => $availableDates,
'filters' => [
'date' => $date,
'from' => $from,
'to' => $to
]
]);
+315
View File
@@ -0,0 +1,315 @@
<?php
/**
* Share API - Teilen von Bildern/Videos per E-Mail
*
* POST /api/share.php
* Body: { email: "friend@example.com", type: "video|image", path: "/videos/...", message: "Schau dir das an!" }
*/
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
$settingsManager = new SettingsManager();
// Prüfe ob Feature aktiviert
if (!$settingsManager->isEmailSharingEnabled()) {
echo json_encode(['success' => false, 'error' => 'E-Mail-Sharing ist deaktiviert']);
exit;
}
// Config laden
$configFile = dirname(__DIR__) . '/config.php';
$config = file_exists($configFile) ? require $configFile : [];
$mailConfig = $config['mail'] ?? [];
if (empty($mailConfig['host']) || empty($mailConfig['username'])) {
echo json_encode(['success' => false, 'error' => 'E-Mail-Server nicht konfiguriert']);
exit;
}
// === GET: Share-Link generieren ===
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['generate'])) {
$path = $_GET['path'] ?? '';
$type = $_GET['type'] ?? 'video';
if (empty($path)) {
echo json_encode(['success' => false, 'error' => 'Kein Pfad angegeben']);
exit;
}
// Token generieren
$expiryHours = $settingsManager->getShareLinkExpiryHours();
$expiry = time() + ($expiryHours * 3600);
$token = hash_hmac('sha256', $path . $expiry, session_id() . 'share_secret');
// Share-Link speichern
$shareDir = dirname(__DIR__) . '/data/shares/';
if (!is_dir($shareDir)) {
mkdir($shareDir, 0755, true);
}
$shareId = bin2hex(random_bytes(16));
$shareData = [
'id' => $shareId,
'path' => $path,
'type' => $type,
'token' => $token,
'expiry' => $expiry,
'created_at' => date('Y-m-d H:i:s')
];
file_put_contents($shareDir . $shareId . '.json', json_encode($shareData));
// URL generieren
$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST'];
$shareUrl = $baseUrl . '/api/share.php?view=' . $shareId;
echo json_encode([
'success' => true,
'share_url' => $shareUrl,
'share_id' => $shareId,
'expires_at' => date('Y-m-d H:i:s', $expiry)
]);
exit;
}
// === GET: Share-Link anzeigen ===
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['view'])) {
$shareId = preg_replace('/[^a-f0-9]/', '', $_GET['view']);
$shareFile = dirname(__DIR__) . '/data/shares/' . $shareId . '.json';
if (!file_exists($shareFile)) {
header('Content-Type: text/html; charset=utf-8');
echo '<!DOCTYPE html><html><head><title>Link ungültig</title></head><body style="font-family:sans-serif;text-align:center;padding:50px;"><h1>❌ Link nicht gefunden</h1><p>Dieser Share-Link existiert nicht oder wurde bereits gelöscht.</p></body></html>';
exit;
}
$shareData = json_decode(file_get_contents($shareFile), true);
// Ablauf prüfen
if (time() > $shareData['expiry']) {
@unlink($shareFile);
header('Content-Type: text/html; charset=utf-8');
echo '<!DOCTYPE html><html><head><title>Link abgelaufen</title></head><body style="font-family:sans-serif;text-align:center;padding:50px;"><h1>⏰ Link abgelaufen</h1><p>Dieser Share-Link ist abgelaufen. Bitte fordere einen neuen Link an.</p></body></html>';
exit;
}
// Datei existiert?
$filePath = dirname(__DIR__) . $shareData['path'];
if (!file_exists($filePath)) {
header('Content-Type: text/html; charset=utf-8');
echo '<!DOCTYPE html><html><head><title>Datei nicht gefunden</title></head><body style="font-family:sans-serif;text-align:center;padding:50px;"><h1>📭 Datei nicht gefunden</h1><p>Die geteilte Datei existiert nicht mehr.</p></body></html>';
exit;
}
// Redirect zur Datei oder HTML-Seite mit eingebettetem Player
$isVideo = in_array(pathinfo($filePath, PATHINFO_EXTENSION), ['mp4', 'webm', 'mov']);
$isImage = in_array(pathinfo($filePath, PATHINFO_EXTENSION), ['jpg', 'jpeg', 'png', 'gif', 'webp']);
$siteName = $config['app']['name'] ?? 'Aurora Livecam';
$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST'];
header('Content-Type: text/html; charset=utf-8');
echo '<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Geteilte ' . ($isVideo ? 'Video' : 'Bild') . ' - ' . htmlspecialchars($siteName) . '</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 16px;
padding: 30px;
max-width: 900px;
width: 100%;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
h1 { font-size: 1.5rem; margin-bottom: 20px; color: #333; }
video, img {
width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 8px;
background: #000;
}
.download-btn {
display: inline-block;
margin-top: 20px;
padding: 12px 30px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
}
.download-btn:hover { opacity: 0.9; }
.footer {
margin-top: 20px;
color: rgba(255,255,255,0.8);
font-size: 0.9rem;
}
.footer a { color: white; }
</style>
</head>
<body>
<div class="container">
<h1>📤 Geteilte' . ($isVideo ? 's Video' : 's Bild') . '</h1>';
if ($isVideo) {
echo '<video controls autoplay><source src="' . htmlspecialchars($shareData['path']) . '" type="video/mp4">Ihr Browser unterstützt kein Video.</video>';
} else {
echo '<img src="' . htmlspecialchars($shareData['path']) . '" alt="Geteiltes Bild">';
}
echo '
<a href="' . htmlspecialchars($shareData['path']) . '" download class="download-btn">⬇️ Herunterladen</a>
</div>
<div class="footer">
Geteilt von <a href="' . $baseUrl . '">' . htmlspecialchars($siteName) . '</a>
</div>
</body>
</html>';
exit;
}
// === POST: E-Mail senden ===
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Nur POST erlaubt']);
exit;
}
// JSON-Body parsen
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
$input = $_POST;
}
$email = filter_var($input['email'] ?? '', FILTER_VALIDATE_EMAIL);
$path = $input['path'] ?? '';
$type = $input['type'] ?? 'video';
$message = htmlspecialchars($input['message'] ?? '');
$senderName = htmlspecialchars($input['sender_name'] ?? 'Ein Freund');
if (!$email) {
echo json_encode(['success' => false, 'error' => 'Ungültige E-Mail-Adresse']);
exit;
}
if (empty($path)) {
echo json_encode(['success' => false, 'error' => 'Kein Pfad angegeben']);
exit;
}
// Share-Link generieren
$expiryHours = $settingsManager->getShareLinkExpiryHours();
$expiry = time() + ($expiryHours * 3600);
$shareDir = dirname(__DIR__) . '/data/shares/';
if (!is_dir($shareDir)) {
mkdir($shareDir, 0755, true);
}
$shareId = bin2hex(random_bytes(16));
$shareData = [
'id' => $shareId,
'path' => $path,
'type' => $type,
'expiry' => $expiry,
'created_at' => date('Y-m-d H:i:s'),
'shared_to' => $email
];
file_put_contents($shareDir . $shareId . '.json', json_encode($shareData));
$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST'];
$shareUrl = $baseUrl . '/api/share.php?view=' . $shareId;
$siteName = $config['app']['name'] ?? 'Aurora Livecam';
// E-Mail senden
try {
$mail = new PHPMailer(true);
// SMTP Konfiguration
$mail->isSMTP();
$mail->Host = $mailConfig['host'];
$mail->SMTPAuth = true;
$mail->Username = $mailConfig['username'];
$mail->Password = $mailConfig['password'];
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = $mailConfig['port'] ?? 587;
$mail->CharSet = 'UTF-8';
// Absender/Empfänger
$mail->setFrom($mailConfig['from_address'], $mailConfig['from_name'] ?? $siteName);
$mail->addAddress($email);
// Inhalt
$mail->isHTML(true);
$mail->Subject = $senderName . ' hat ' . ($type === 'video' ? 'ein Video' : 'ein Bild') . ' mit dir geteilt';
$mail->Body = '
<div style="font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 12px 12px 0 0; text-align: center;">
<h1 style="color: white; margin: 0; font-size: 24px;">📤 ' . htmlspecialchars($siteName) . '</h1>
</div>
<div style="background: #f7f7f7; padding: 30px; border-radius: 0 0 12px 12px;">
<p style="font-size: 18px; color: #333; margin-bottom: 20px;">
<strong>' . htmlspecialchars($senderName) . '</strong> hat ' . ($type === 'video' ? 'ein Video' : 'ein Bild') . ' mit dir geteilt!
</p>
' . (!empty($message) ? '<div style="background: white; padding: 15px; border-radius: 8px; border-left: 4px solid #667eea; margin-bottom: 20px;"><em>"' . nl2br($message) . '"</em></div>' : '') . '
<a href="' . htmlspecialchars($shareUrl) . '" style="display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px 30px; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 16px;">
▶️ Jetzt ansehen
</a>
<p style="margin-top: 20px; color: #888; font-size: 12px;">
Dieser Link ist ' . $expiryHours . ' Stunden gültig.
</p>
</div>
</div>';
$mail->AltBody = $senderName . ' hat ' . ($type === 'video' ? 'ein Video' : 'ein Bild') . ' mit dir geteilt: ' . $shareUrl;
$mail->send();
echo json_encode([
'success' => true,
'message' => 'E-Mail wurde gesendet',
'share_url' => $shareUrl
]);
} catch (Exception $e) {
error_log('Share email error: ' . $e->getMessage());
echo json_encode([
'success' => false,
'error' => 'E-Mail konnte nicht gesendet werden',
'share_url' => $shareUrl // URL trotzdem zurückgeben
]);
}
+56
View File
@@ -0,0 +1,56 @@
<?php
/**
* Stripe Webhook Endpoint
*
* URL: /api/stripe-webhook.php
* Konfigurieren Sie diesen Endpoint in Ihrem Stripe Dashboard
*/
// Keine Session, keine Ausgabe vor JSON
error_reporting(0);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/vendor/autoload.php';
if (file_exists(dirname(__DIR__) . '/src/bootstrap.php')) {
require_once dirname(__DIR__) . '/src/bootstrap.php';
}
use AuroraLivecam\Billing\WebhookHandler;
// Nur POST erlaubt
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
// Payload lesen
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
if (empty($payload)) {
http_response_code(400);
echo json_encode(['error' => 'Empty payload']);
exit;
}
// Webhook verarbeiten
try {
$handler = new WebhookHandler();
$result = $handler->handle($payload, $signature);
if ($result['success']) {
http_response_code(200);
} else {
http_response_code(400);
}
header('Content-Type: application/json');
echo json_encode($result);
} catch (\Exception $e) {
error_log('Stripe Webhook Error: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => 'Internal server error']);
}
+192
View File
@@ -0,0 +1,192 @@
<?php
/**
* Video Search API
*
* Suche nach Videos nach Datum und Uhrzeit
*
* GET /api/video-search.php?date=2024-01-30
* GET /api/video-search.php?date=2024-01-30&time=14:30
* GET /api/video-search.php?from=2024-01-01&to=2024-01-31
* GET /api/video-search.php?time_from=08:00&time_to=18:00
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
$settingsManager = new SettingsManager();
$videoDir = dirname(__DIR__) . '/videos/';
$aiDir = dirname(__DIR__) . '/ai/';
// Parameter
$date = $_GET['date'] ?? null; // Format: YYYY-MM-DD
$time = $_GET['time'] ?? null; // Format: HH:MM
$fromDate = $_GET['from'] ?? null;
$toDate = $_GET['to'] ?? null;
$timeFrom = $_GET['time_from'] ?? null;
$timeTo = $_GET['time_to'] ?? null;
$type = $_GET['type'] ?? 'all'; // all, daily, ai
$aiCategory = $_GET['ai_category'] ?? null;
$limit = min(100, (int)($_GET['limit'] ?? 50));
$results = [
'daily_videos' => [],
'ai_videos' => [],
'gallery_images' => []
];
// AI-Kategorien
$aiCategories = ['sunny', 'rainy', 'snowy', 'planes', 'birds', 'sunset', 'sunrise', 'rainbow'];
// === TAGESVIDEOS SUCHEN ===
if ($type === 'all' || $type === 'daily') {
$pattern = $videoDir . 'daily_video_*.mp4';
$dailyVideos = glob($pattern);
foreach ($dailyVideos as $video) {
$filename = basename($video);
// Extrahiere Datum aus Dateinamen: daily_video_YYYYMMDD_HHMMSS.mp4
if (preg_match('/daily_video_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})\.mp4/', $filename, $matches)) {
$videoDate = $matches[1] . '-' . $matches[2] . '-' . $matches[3];
$videoTime = $matches[4] . ':' . $matches[5];
$videoDateTime = $videoDate . ' ' . $videoTime . ':' . $matches[6];
// Datumsfilter
if ($date !== null && $videoDate !== $date) {
continue;
}
if ($fromDate !== null && $videoDate < $fromDate) {
continue;
}
if ($toDate !== null && $videoDate > $toDate) {
continue;
}
// Uhrzeitfilter
if ($timeFrom !== null && $videoTime < $timeFrom) {
continue;
}
if ($timeTo !== null && $videoTime > $timeTo) {
continue;
}
// Spezifische Uhrzeit (mit 30 Min Toleranz)
if ($time !== null) {
$searchMinutes = intval(substr($time, 0, 2)) * 60 + intval(substr($time, 3, 2));
$videoMinutes = intval($matches[4]) * 60 + intval($matches[5]);
if (abs($searchMinutes - $videoMinutes) > 30) {
continue;
}
}
$results['daily_videos'][] = [
'type' => 'daily',
'filename' => $filename,
'path' => '/videos/' . $filename,
'date' => $videoDate,
'time' => $videoTime,
'datetime' => $videoDateTime,
'timestamp' => strtotime($videoDateTime),
'size' => filesize($video),
'size_mb' => round(filesize($video) / (1024 * 1024), 2)
];
}
}
}
// === AI-VIDEOS SUCHEN ===
if ($type === 'all' || $type === 'ai') {
$searchCategories = $aiCategory ? [$aiCategory] : $aiCategories;
foreach ($searchCategories as $category) {
$categoryDir = $aiDir . $category . '/';
if (!is_dir($categoryDir)) continue;
$pattern = $categoryDir . $category . '_*.mp4';
$aiVideos = glob($pattern);
foreach ($aiVideos as $video) {
$filename = basename($video);
// Extrahiere Datum aus Dateinamen: category_YYYYMMDD_HHMMSS.mp4
if (preg_match('/' . $category . '_(\d{4})(\d{2})(\d{2})_?(\d{2})?(\d{2})?(\d{2})?\.mp4/', $filename, $matches)) {
$videoDate = $matches[1] . '-' . $matches[2] . '-' . $matches[3];
$videoTime = isset($matches[4]) ? ($matches[4] . ':' . ($matches[5] ?? '00')) : '00:00';
$videoDateTime = $videoDate . ' ' . $videoTime;
// Datumsfilter
if ($date !== null && $videoDate !== $date) {
continue;
}
if ($fromDate !== null && $videoDate < $fromDate) {
continue;
}
if ($toDate !== null && $videoDate > $toDate) {
continue;
}
// Uhrzeitfilter
if ($timeFrom !== null && $videoTime < $timeFrom) {
continue;
}
if ($timeTo !== null && $videoTime > $timeTo) {
continue;
}
$results['ai_videos'][] = [
'type' => 'ai',
'category' => $category,
'filename' => $filename,
'path' => '/ai/' . $category . '/' . $filename,
'date' => $videoDate,
'time' => $videoTime,
'datetime' => $videoDateTime,
'timestamp' => strtotime($videoDateTime),
'size' => filesize($video),
'size_mb' => round(filesize($video) / (1024 * 1024), 2)
];
}
}
}
}
// Sortieren nach Datum/Zeit (neueste zuerst)
usort($results['daily_videos'], fn($a, $b) => $b['timestamp'] - $a['timestamp']);
usort($results['ai_videos'], fn($a, $b) => $b['timestamp'] - $a['timestamp']);
// Limit anwenden
$results['daily_videos'] = array_slice($results['daily_videos'], 0, $limit);
$results['ai_videos'] = array_slice($results['ai_videos'], 0, $limit);
// Statistiken
$results['stats'] = [
'total_daily' => count($results['daily_videos']),
'total_ai' => count($results['ai_videos']),
'total' => count($results['daily_videos']) + count($results['ai_videos'])
];
$results['filters'] = [
'date' => $date,
'time' => $time,
'from' => $fromDate,
'to' => $toDate,
'time_from' => $timeFrom,
'time_to' => $timeTo,
'type' => $type,
'ai_category' => $aiCategory
];
$results['success'] = true;
echo json_encode($results, JSON_PRETTY_PRINT);
+15
View File
@@ -0,0 +1,15 @@
<?php
// Clear PHP OPcache
if (function_exists('opcache_reset')) {
opcache_reset();
echo "OPcache cleared successfully!\n";
} else {
echo "OPcache not available\n";
}
// Clear realpath cache
clearstatcache(true);
echo "Realpath cache cleared!\n";
echo "\nNow reload the page with CTRL+F5 (hard refresh)\n";
?>
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* Aurora Livecam - Konfigurationsdatei
*
* Kopiere diese Datei zu config.php und passe die Werte an.
* WICHTIG: config.php niemals in Git committen!
*/
return [
// Datenbank-Konfiguration
'database' => [
'host' => 'localhost',
'port' => 3306,
'database' => 'aurora_livecam',
'username' => 'root',
'password' => '',
'charset' => 'utf8mb4',
],
// Anwendungs-Einstellungen
'app' => [
'name' => 'Aurora Livecam',
'url' => 'https://aurora-weather-livecam.com',
'debug' => false,
'timezone' => 'Europe/Zurich',
],
// Multi-Tenant Einstellungen
'tenant' => [
'default_subdomain_suffix' => '.aurora-livecam.com',
'allow_custom_domains' => true,
'trial_days' => 14,
],
// Stripe (für Billing)
'stripe' => [
'public_key' => '',
'secret_key' => '',
'webhook_secret' => '',
'currency' => 'chf',
],
// E-Mail Einstellungen (für Onboarding)
'mail' => [
'host' => 'smtp.example.com',
'port' => 587,
'username' => '',
'password' => '',
'from_address' => 'noreply@aurora-livecam.com',
'from_name' => 'Aurora Livecam',
],
// Sicherheit
'security' => [
'session_lifetime' => 7200, // 2 Stunden
'remember_me_days' => 30,
'password_min_length' => 8,
],
];
+274
View File
@@ -0,0 +1,274 @@
/* ========== TIMELAPSE CONTROLS ========== */
#timelapse-controls {
display: none;
margin-top: 15px;
}
.timelapse-control-bar {
display: flex;
align-items: center;
gap: 10px;
background: rgba(255, 255, 255, 0.95);
padding: 12px 20px;
border-radius: 50px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
flex-wrap: wrap;
justify-content: center;
}
.tl-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
width: 44px;
height: 44px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
}
.tl-btn:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.5);
}
.tl-btn.active {
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
}
.tl-slider-container {
flex: 1;
min-width: 200px;
max-width: 400px;
display: flex;
align-items: center;
gap: 15px;
}
#tl-slider {
flex: 1;
height: 8px;
border-radius: 4px;
background: #e0e0e0;
outline: none;
-webkit-appearance: none;
}
#tl-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
cursor: pointer;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
}
#tl-slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
cursor: pointer;
border: none;
}
#tl-time-display {
font-family: monospace;
font-size: 14px;
color: #333;
background: #f5f5f5;
padding: 6px 12px;
border-radius: 20px;
min-width: 140px;
text-align: center;
}
.tl-speed-btn {
width: auto !important;
padding: 0 20px !important;
border-radius: 22px !important;
font-weight: bold;
font-size: 14px;
}
.tl-back-btn {
width: auto !important;
padding: 0 20px !important;
border-radius: 22px !important;
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%) !important;
gap: 8px;
}
/* ========== DAILY VIDEO PLAYER ========== */
#daily-video-player {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #000;
z-index: 50;
}
#daily-video {
width: 100%;
height: 100%;
object-fit: contain;
}
.video-player-controls {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 15px;
z-index: 60;
}
/* ========== ADMIN SETTINGS PANEL ========== */
#admin-settings-panel {
background: white;
padding: 25px;
border-radius: 12px;
margin-bottom: 30px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
#admin-settings-panel h3 {
color: #667eea;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
margin-bottom: 20px;
}
.settings-group {
margin-bottom: 25px;
padding: 20px;
background: #f9f9f9;
border-radius: 8px;
}
.settings-group h4 {
margin-bottom: 15px;
color: #333;
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #eee;
}
.setting-row:last-child {
border-bottom: none;
}
.setting-label {
font-weight: 500;
color: #555;
}
.setting-input {
display: flex;
align-items: center;
gap: 10px;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
width: 50px;
height: 26px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.3s;
border-radius: 26px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
input:checked + .toggle-slider:before {
transform: translateX(24px);
}
/* Number Input */
.number-input {
width: 70px;
padding: 8px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 16px;
text-align: center;
}
.number-input:focus {
border-color: #667eea;
outline: none;
}
/* ========== MOBILE RESPONSIVE ========== */
@media (max-width: 600px) {
.timelapse-control-bar {
padding: 10px 15px;
gap: 8px;
}
.tl-btn {
width: 38px;
height: 38px;
font-size: 14px;
}
.tl-slider-container {
width: 100%;
order: 10;
margin-top: 10px;
}
#tl-time-display {
font-size: 12px;
min-width: 120px;
}
.video-player-controls {
flex-direction: column;
bottom: 10px;
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* Dashboard API - Stats
*/
header('Content-Type: application/json');
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/SettingsManager.php';
if (file_exists(dirname(__DIR__, 2) . '/src/bootstrap.php')) {
require_once dirname(__DIR__, 2) . '/src/bootstrap.php';
}
use AuroraLivecam\Auth\AuthManager;
use AuroraLivecam\Core\Database;
$auth = new AuthManager();
// Auth check
if (!$auth->isLoggedIn()) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
$user = $auth->getUser();
$tenantId = $user['tenant_id'] ?? 0;
$stats = [
'viewers_current' => 0,
'viewers_today' => 0,
'viewers_peak' => 0,
'stream_status' => 'unknown',
];
// Aktuelle Zuschauer aus Datei
$viewerFile = dirname(__DIR__, 2) . '/active_viewers.json';
if (file_exists($viewerFile)) {
$viewers = json_decode(file_get_contents($viewerFile), true);
$stats['viewers_current'] = count($viewers ?? []);
}
// DB Stats falls verfügbar
try {
$db = Database::getInstance();
if ($tenantId > 0) {
$todayStats = $db->fetchOne(
"SELECT SUM(viewer_count) as total, MAX(viewer_count) as peak
FROM viewer_stats
WHERE tenant_id = ? AND DATE(recorded_at) = CURDATE()",
[$tenantId]
);
if ($todayStats) {
$stats['viewers_today'] = (int)($todayStats['total'] ?? 0);
$stats['viewers_peak'] = (int)($todayStats['peak'] ?? 0);
}
$stream = $db->fetchOne(
"SELECT last_status FROM tenant_streams WHERE tenant_id = ? AND is_primary = 1",
[$tenantId]
);
$stats['stream_status'] = $stream['last_status'] ?? 'unknown';
}
} catch (\Exception $e) {
// DB nicht verfügbar - Stats bleiben auf Defaults
}
echo json_encode([
'success' => true,
'stats' => $stats,
'timestamp' => time(),
]);
@@ -0,0 +1,536 @@
/* Dashboard CSS */
:root {
--primary: #667eea;
--primary-dark: #5a67d8;
--secondary: #764ba2;
--accent: #f093fb;
--success: #48bb78;
--warning: #ed8936;
--danger: #f56565;
--dark: #1a202c;
--gray-900: #1a202c;
--gray-800: #2d3748;
--gray-700: #4a5568;
--gray-600: #718096;
--gray-500: #a0aec0;
--gray-400: #cbd5e0;
--gray-300: #e2e8f0;
--gray-200: #edf2f7;
--gray-100: #f7fafc;
--white: #ffffff;
--sidebar-width: 260px;
--header-height: 60px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--gray-100);
color: var(--gray-800);
line-height: 1.6;
}
/* Dashboard Container */
.dashboard-container {
display: flex;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
background: linear-gradient(180deg, var(--gray-900) 0%, var(--gray-800) 100%);
color: var(--white);
display: flex;
flex-direction: column;
position: fixed;
height: 100vh;
z-index: 100;
}
.sidebar-header {
padding: 1.5rem;
border-bottom: 1px solid var(--gray-700);
}
.sidebar-header h2 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.role-badge {
font-size: 0.75rem;
background: var(--primary);
padding: 0.125rem 0.5rem;
border-radius: 9999px;
text-transform: capitalize;
}
/* Navigation */
.sidebar-nav {
flex: 1;
padding: 1rem 0;
overflow-y: auto;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.5rem;
color: var(--gray-400);
text-decoration: none;
transition: all 0.2s;
}
.nav-item:hover {
background: var(--gray-700);
color: var(--white);
}
.nav-item.active {
background: linear-gradient(90deg, var(--primary) 0%, var(--secondary) 100%);
color: var(--white);
}
.nav-icon {
font-size: 1.25rem;
width: 1.5rem;
text-align: center;
}
.nav-divider {
height: 1px;
background: var(--gray-700);
margin: 1rem 0;
}
.nav-label {
display: block;
padding: 0.5rem 1.5rem;
font-size: 0.75rem;
text-transform: uppercase;
color: var(--gray-500);
letter-spacing: 0.05em;
}
.sidebar-footer {
border-top: 1px solid var(--gray-700);
padding: 0.5rem 0;
}
.nav-item.logout:hover {
background: var(--danger);
}
/* Main Content */
.main-content {
flex: 1;
margin-left: var(--sidebar-width);
min-height: 100vh;
}
.main-header {
height: var(--header-height);
background: var(--white);
border-bottom: 1px solid var(--gray-300);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
position: sticky;
top: 0;
z-index: 50;
}
.main-header h1 {
font-size: 1.5rem;
font-weight: 600;
}
.user-info {
color: var(--gray-600);
font-size: 0.875rem;
}
.content-wrapper {
padding: 2rem;
}
/* Cards */
.card {
background: var(--white);
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 1.5rem;
}
.card-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--gray-200);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
font-size: 1rem;
font-weight: 600;
}
.card-body {
padding: 1.5rem;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--white);
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.stat-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--gray-900);
}
.stat-label {
color: var(--gray-600);
font-size: 0.875rem;
}
.stat-change {
font-size: 0.875rem;
margin-top: 0.25rem;
}
.stat-change.positive { color: var(--success); }
.stat-change.negative { color: var(--danger); }
/* Forms */
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--gray-700);
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--gray-300);
border-radius: 0.375rem;
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
}
.form-help {
font-size: 0.875rem;
color: var(--gray-500);
margin-top: 0.25rem;
}
.form-textarea {
min-height: 100px;
resize: vertical;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
color: var(--white);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: var(--gray-200);
color: var(--gray-700);
}
.btn-secondary:hover {
background: var(--gray-300);
}
.btn-danger {
background: var(--danger);
color: var(--white);
}
.btn-success {
background: var(--success);
color: var(--white);
}
.btn-sm {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
/* Alerts */
.alert {
padding: 1rem 1.5rem;
border-radius: 0.375rem;
margin-bottom: 1.5rem;
}
.alert-success {
background: #c6f6d5;
color: #22543d;
border: 1px solid #9ae6b4;
}
.alert-error {
background: #fed7d7;
color: #742a2a;
border: 1px solid #feb2b2;
}
.alert-warning {
background: #feebc8;
color: #744210;
border: 1px solid #fbd38d;
}
.alert-info {
background: #bee3f8;
color: #2a4365;
border: 1px solid #90cdf4;
}
/* Tables */
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid var(--gray-200);
}
.table th {
font-weight: 600;
color: var(--gray-600);
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.table tbody tr:hover {
background: var(--gray-50);
}
/* Status Badges */
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-success { background: #c6f6d5; color: #22543d; }
.badge-warning { background: #feebc8; color: #744210; }
.badge-danger { background: #fed7d7; color: #742a2a; }
.badge-info { background: #bee3f8; color: #2a4365; }
/* Grid */
.grid {
display: grid;
gap: 1.5rem;
}
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
/* Color Picker */
.color-picker-wrapper {
display: flex;
align-items: center;
gap: 1rem;
}
.color-picker {
width: 50px;
height: 40px;
border: none;
border-radius: 0.375rem;
cursor: pointer;
}
.color-value {
font-family: monospace;
color: var(--gray-600);
}
/* Preview Box */
.preview-box {
border: 2px dashed var(--gray-300);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
background: var(--gray-50);
}
/* Toggle Switch */
.toggle-wrapper {
display: flex;
align-items: center;
gap: 0.75rem;
}
.toggle {
position: relative;
width: 48px;
height: 24px;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--gray-300);
border-radius: 24px;
transition: 0.3s;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background: white;
border-radius: 50%;
transition: 0.3s;
}
.toggle input:checked + .toggle-slider {
background: var(--primary);
}
.toggle input:checked + .toggle-slider:before {
transform: translateX(24px);
}
/* Login Page */
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
}
.login-box {
background: var(--white);
padding: 2.5rem;
border-radius: 1rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
width: 100%;
max-width: 400px;
}
.login-title {
text-align: center;
margin-bottom: 2rem;
}
.login-title h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.login-title p {
color: var(--gray-500);
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s;
}
.sidebar.open {
transform: translateX(0);
}
.main-content {
margin-left: 0;
}
.stats-grid {
grid-template-columns: 1fr;
}
.grid-2,
.grid-3 {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,131 @@
/**
* Dashboard JavaScript
*/
document.addEventListener('DOMContentLoaded', function() {
// Auto-dismiss alerts after 5 seconds
const alerts = document.querySelectorAll('.alert');
alerts.forEach(alert => {
setTimeout(() => {
alert.style.transition = 'opacity 0.3s';
alert.style.opacity = '0';
setTimeout(() => alert.remove(), 300);
}, 5000);
});
// Mobile sidebar toggle
const sidebar = document.querySelector('.sidebar');
const mainContent = document.querySelector('.main-content');
if (window.innerWidth <= 768) {
// Add menu button
const menuBtn = document.createElement('button');
menuBtn.className = 'btn btn-secondary';
menuBtn.style.cssText = 'position: fixed; top: 10px; left: 10px; z-index: 200; padding: 0.5rem;';
menuBtn.innerHTML = '☰';
menuBtn.onclick = () => sidebar.classList.toggle('open');
document.body.appendChild(menuBtn);
// Close sidebar on content click
mainContent.addEventListener('click', () => {
sidebar.classList.remove('open');
});
}
// Color picker live preview
document.querySelectorAll('.color-picker').forEach(picker => {
picker.addEventListener('input', function() {
const wrapper = this.closest('.color-picker-wrapper');
if (wrapper) {
const valueDisplay = wrapper.querySelector('.color-value');
if (valueDisplay) {
valueDisplay.textContent = this.value;
}
}
});
});
// Form unsaved changes warning
const forms = document.querySelectorAll('form');
let formChanged = false;
forms.forEach(form => {
form.addEventListener('change', () => {
formChanged = true;
});
form.addEventListener('submit', () => {
formChanged = false;
});
});
window.addEventListener('beforeunload', (e) => {
if (formChanged) {
e.preventDefault();
e.returnValue = '';
}
});
// Stats refresh (every 30 seconds on overview page)
if (document.querySelector('.stats-grid')) {
setInterval(refreshStats, 30000);
}
});
/**
* Refresh stats via AJAX
*/
function refreshStats() {
fetch('/dashboard/api/stats.php')
.then(response => response.json())
.then(data => {
if (data.success) {
updateStatCard('viewers_current', data.stats.viewers_current);
updateStatCard('viewers_today', data.stats.viewers_today);
updateStatCard('viewers_peak', data.stats.viewers_peak);
}
})
.catch(err => console.log('Stats refresh failed:', err));
}
/**
* Update a stat card value
*/
function updateStatCard(id, value) {
const cards = document.querySelectorAll('.stat-card');
cards.forEach(card => {
const label = card.querySelector('.stat-label');
if (label) {
// Match by label text (simplified)
const valueEl = card.querySelector('.stat-value');
if (valueEl && typeof value !== 'undefined') {
valueEl.textContent = value;
}
}
});
}
/**
* Show notification toast
*/
function showNotification(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `alert alert-${type}`;
toast.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 1000; min-width: 300px;';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.transition = 'opacity 0.3s';
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
/**
* Confirm dangerous actions
*/
function confirmAction(message) {
return confirm(message || 'Sind Sie sicher?');
}
+282
View File
@@ -0,0 +1,282 @@
<?php
/**
* Dashboard - Abrechnung
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
if (file_exists(dirname(__DIR__) . '/src/bootstrap.php')) {
require_once dirname(__DIR__) . '/src/bootstrap.php';
}
use AuroraLivecam\Auth\AuthManager;
use AuroraLivecam\Billing\StripeService;
use AuroraLivecam\Billing\SubscriptionManager;
$settingsManager = new SettingsManager();
$auth = new AuthManager();
$auth->requireLogin();
// Prüfe ob Billing aktiviert
if (!$settingsManager->isBillingEnabled()) {
header('Location: /dashboard/');
exit;
}
$user = $auth->getUser();
$tenantId = $user['tenant_id'] ?? 0;
$flashMessage = null;
$flashType = 'info';
$stripe = new StripeService();
$subscriptions = new SubscriptionManager();
// Aktuelle Subscription
$currentSub = null;
$plans = [];
$invoices = [];
$trialDays = 0;
try {
$currentSub = $subscriptions->getSubscription($tenantId);
$plans = $subscriptions->getPlans();
$invoices = $subscriptions->getInvoices($tenantId, 5);
$trialDays = $subscriptions->getTrialDaysRemaining($tenantId);
} catch (\Exception $e) {
$flashMessage = 'Fehler beim Laden der Abrechnungsdaten';
$flashType = 'error';
}
// Checkout starten
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['plan_id'])) {
$planId = (int)$_POST['plan_id'];
$plan = $subscriptions->getPlan($planId);
if ($plan && !empty($plan['stripe_price_id'])) {
$baseUrl = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'];
$session = $stripe->createCheckoutSession(
$tenantId,
$plan['stripe_price_id'],
$baseUrl . '/dashboard/billing.php?success=1',
$baseUrl . '/dashboard/billing.php?canceled=1'
);
if ($session && isset($session['url'])) {
header('Location: ' . $session['url']);
exit;
} else {
$flashMessage = 'Fehler beim Erstellen der Checkout-Session';
$flashType = 'error';
}
}
}
// Billing Portal öffnen
if (isset($_GET['portal'])) {
$baseUrl = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'];
$session = $stripe->createPortalSession($tenantId, $baseUrl . '/dashboard/billing.php');
if ($session && isset($session['url'])) {
header('Location: ' . $session['url']);
exit;
}
}
// Success/Cancel Messages
if (isset($_GET['success'])) {
$flashMessage = 'Zahlung erfolgreich! Ihr Abo ist jetzt aktiv.';
$flashType = 'success';
}
if (isset($_GET['canceled'])) {
$flashMessage = 'Checkout abgebrochen.';
$flashType = 'warning';
}
$pageTitle = 'Abrechnung';
$currentPage = 'billing';
ob_start();
?>
<!-- Aktueller Plan -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Aktueller Plan</h3>
<?php if ($currentSub): ?>
<span class="badge badge-<?php echo $currentSub['status'] === 'active' ? 'success' : ($currentSub['status'] === 'trialing' ? 'warning' : 'danger'); ?>">
<?php echo ucfirst($currentSub['status']); ?>
</span>
<?php endif; ?>
</div>
<div class="card-body">
<?php if ($currentSub): ?>
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
<div>
<h2 style="margin: 0; font-size: 1.75rem;"><?php echo htmlspecialchars($currentSub['plan_name'] ?? 'Free'); ?></h2>
<?php if ($currentSub['status'] === 'trialing' && $trialDays > 0): ?>
<p style="color: var(--warning); margin: 0.5rem 0 0 0;">
Trial endet in <?php echo $trialDays; ?> Tag<?php echo $trialDays !== 1 ? 'en' : ''; ?>
</p>
<?php elseif ($currentSub['current_period_end']): ?>
<p style="color: var(--gray-500); margin: 0.5rem 0 0 0;">
Nächste Abrechnung: <?php echo date('d.m.Y', strtotime($currentSub['current_period_end'])); ?>
</p>
<?php endif; ?>
</div>
<?php if ($stripe->isConfigured() && !empty($currentSub['stripe_customer_id'])): ?>
<a href="?portal=1" class="btn btn-secondary">
Abo verwalten
</a>
<?php endif; ?>
</div>
<?php if (!empty($currentSub['plan_features'])): ?>
<div style="margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--gray-200);">
<h4 style="font-size: 0.875rem; color: var(--gray-500); margin-bottom: 0.75rem;">Enthaltene Features:</h4>
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
<?php foreach ($currentSub['plan_features'] as $feature => $value): ?>
<?php if ($value): ?>
<span class="badge badge-info">
<?php
$labels = [
'max_viewers' => 'Max. Zuschauer: ' . ($value === -1 ? '∞' : $value),
'storage_gb' => 'Speicher: ' . $value . ' GB',
'custom_domain' => 'Custom Domain',
'weather_widget' => 'Wetter-Widget',
'timelapse' => 'Timelapse',
'analytics' => 'Analytics',
'branding' => 'Custom Branding',
'priority_support' => 'Priority Support',
];
echo $labels[$feature] ?? ucfirst(str_replace('_', ' ', $feature));
?>
</span>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php else: ?>
<p style="color: var(--gray-500);">Kein aktives Abo</p>
<?php endif; ?>
</div>
</div>
<!-- Verfügbare Pläne -->
<?php if (!empty($plans)): ?>
<div class="card">
<div class="card-header">
<h3 class="card-title">Verfügbare Pläne</h3>
</div>
<div class="card-body">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem;">
<?php foreach ($plans as $plan): ?>
<?php $isCurrent = $currentSub && $currentSub['plan_id'] == $plan['id']; ?>
<div style="border: 2px solid <?php echo $isCurrent ? 'var(--primary)' : 'var(--gray-200)'; ?>; border-radius: 0.75rem; padding: 1.5rem; <?php echo $isCurrent ? 'background: rgba(102,126,234,0.05);' : ''; ?>">
<h4 style="margin: 0 0 0.5rem 0;"><?php echo htmlspecialchars($plan['name']); ?></h4>
<div style="font-size: 2rem; font-weight: 700; color: var(--gray-900);">
<?php if ($plan['price_monthly'] > 0): ?>
CHF <?php echo number_format($plan['price_monthly'], 0); ?>
<span style="font-size: 1rem; font-weight: 400; color: var(--gray-500);">/Monat</span>
<?php else: ?>
Kostenlos
<?php endif; ?>
</div>
<?php if (!empty($plan['features'])): ?>
<ul style="list-style: none; padding: 0; margin: 1rem 0; font-size: 0.875rem;">
<?php foreach ($plan['features'] as $feature => $value): ?>
<?php if ($value): ?>
<li style="padding: 0.25rem 0; color: var(--gray-600);">
<?php
$labels = [
'max_viewers' => 'Bis ' . ($value === -1 ? 'unbegrenzt' : $value) . ' Zuschauer',
'storage_gb' => $value . ' GB Speicher',
'custom_domain' => 'Eigene Domain',
'weather_widget' => 'Wetter-Widget',
'timelapse' => 'Timelapse',
'analytics' => 'Analytics',
'branding' => 'Custom Branding',
'priority_support' => 'Priority Support',
];
echo $labels[$feature] ?? ucfirst(str_replace('_', ' ', $feature));
?>
</li>
<?php endif; ?>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($isCurrent): ?>
<button class="btn btn-secondary" style="width: 100%;" disabled>Aktueller Plan</button>
<?php elseif ($plan['price_monthly'] > 0 && $stripe->isConfigured()): ?>
<form method="POST" action="">
<input type="hidden" name="plan_id" value="<?php echo $plan['id']; ?>">
<button type="submit" class="btn btn-primary" style="width: 100%;">
Upgrade
</button>
</form>
<?php elseif ($plan['price_monthly'] == 0): ?>
<button class="btn btn-secondary" style="width: 100%;" disabled>Free Plan</button>
<?php else: ?>
<button class="btn btn-secondary" style="width: 100%;" disabled>Stripe nicht konfiguriert</button>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
<!-- Rechnungen -->
<?php if (!empty($invoices)): ?>
<div class="card">
<div class="card-header">
<h3 class="card-title">Rechnungen</h3>
</div>
<div class="card-body">
<table class="table">
<thead>
<tr>
<th>Datum</th>
<th>Betrag</th>
<th>Status</th>
<th>PDF</th>
</tr>
</thead>
<tbody>
<?php foreach ($invoices as $invoice): ?>
<tr>
<td><?php echo date('d.m.Y', strtotime($invoice['created_at'])); ?></td>
<td><?php echo $invoice['currency']; ?> <?php echo number_format($invoice['amount'], 2); ?></td>
<td>
<span class="badge badge-<?php echo $invoice['status'] === 'paid' ? 'success' : 'warning'; ?>">
<?php echo ucfirst($invoice['status']); ?>
</span>
</td>
<td>
<?php if ($invoice['invoice_pdf_url']): ?>
<a href="<?php echo htmlspecialchars($invoice['invoice_pdf_url']); ?>" target="_blank" class="btn btn-sm btn-secondary">
Download
</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<?php if (!$stripe->isConfigured()): ?>
<div class="alert alert-warning">
<strong>Hinweis:</strong> Stripe ist noch nicht konfiguriert. Bitte fügen Sie Ihre Stripe API-Keys in config.php hinzu.
</div>
<?php endif; ?>
<?php
$content = ob_get_clean();
include __DIR__ . '/templates/layout.php';
+230
View File
@@ -0,0 +1,230 @@
<?php
/**
* Dashboard - Branding Einstellungen
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
if (file_exists(dirname(__DIR__) . '/src/bootstrap.php')) {
require_once dirname(__DIR__) . '/src/bootstrap.php';
}
use AuroraLivecam\Auth\AuthManager;
use AuroraLivecam\Core\Database;
use AuroraLivecam\Tenant\TenantManager;
$settingsManager = new SettingsManager();
$auth = new AuthManager();
$auth->requireLogin();
$user = $auth->getUser();
$tenantId = $user['tenant_id'] ?? 0;
$flashMessage = null;
$flashType = 'info';
// Branding-Daten laden
$branding = [
'site_name' => '',
'site_name_full' => '',
'tagline' => '',
'primary_color' => '#667eea',
'secondary_color' => '#764ba2',
'accent_color' => '#f093fb',
'welcome_text_de' => '',
'welcome_text_en' => '',
'footer_text' => '',
'custom_css' => '',
];
try {
$db = Database::getInstance();
if ($tenantId > 0) {
$tenantManager = new TenantManager($db);
$dbBranding = $tenantManager->getBranding($tenantId);
if ($dbBranding) {
$branding = array_merge($branding, $dbBranding);
}
}
} catch (\Exception $e) {
// DB nicht verfügbar
}
// Formular verarbeiten
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$newBranding = [
'site_name' => trim($_POST['site_name'] ?? ''),
'site_name_full' => trim($_POST['site_name_full'] ?? ''),
'tagline' => trim($_POST['tagline'] ?? ''),
'primary_color' => $_POST['primary_color'] ?? '#667eea',
'secondary_color' => $_POST['secondary_color'] ?? '#764ba2',
'accent_color' => $_POST['accent_color'] ?? '#f093fb',
'welcome_text_de' => trim($_POST['welcome_text_de'] ?? ''),
'welcome_text_en' => trim($_POST['welcome_text_en'] ?? ''),
'footer_text' => trim($_POST['footer_text'] ?? ''),
'custom_css' => trim($_POST['custom_css'] ?? ''),
];
try {
$db = Database::getInstance();
if ($tenantId > 0) {
$tenantManager = new TenantManager($db);
$tenantManager->updateBranding($tenantId, $newBranding);
$flashMessage = 'Branding gespeichert!';
$flashType = 'success';
$branding = array_merge($branding, $newBranding);
} else {
$flashMessage = 'Branding kann im Legacy-Modus nicht gespeichert werden.';
$flashType = 'warning';
}
} catch (\Exception $e) {
$flashMessage = 'Fehler beim Speichern: ' . $e->getMessage();
$flashType = 'error';
}
}
$pageTitle = 'Branding';
$currentPage = 'branding';
ob_start();
?>
<form method="POST" action="">
<div class="grid grid-2">
<!-- Grundeinstellungen -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Grundeinstellungen</h3>
</div>
<div class="card-body">
<div class="form-group">
<label class="form-label" for="site_name">Site Name (kurz)</label>
<input type="text" id="site_name" name="site_name" class="form-input"
value="<?php echo htmlspecialchars($branding['site_name']); ?>"
placeholder="MeineCam">
</div>
<div class="form-group">
<label class="form-label" for="site_name_full">Site Name (vollständig)</label>
<input type="text" id="site_name_full" name="site_name_full" class="form-input"
value="<?php echo htmlspecialchars($branding['site_name_full']); ?>"
placeholder="Meine Wetter Livecam">
</div>
<div class="form-group">
<label class="form-label" for="tagline">Tagline / Slogan</label>
<input type="text" id="tagline" name="tagline" class="form-input"
value="<?php echo htmlspecialchars($branding['tagline']); ?>"
placeholder="Ihre Live-Webcam 24/7">
</div>
</div>
</div>
<!-- Farben -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Farben</h3>
</div>
<div class="card-body">
<div class="form-group">
<label class="form-label">Primärfarbe</label>
<div class="color-picker-wrapper">
<input type="color" name="primary_color" class="color-picker"
value="<?php echo htmlspecialchars($branding['primary_color']); ?>">
<span class="color-value"><?php echo htmlspecialchars($branding['primary_color']); ?></span>
</div>
</div>
<div class="form-group">
<label class="form-label">Sekundärfarbe</label>
<div class="color-picker-wrapper">
<input type="color" name="secondary_color" class="color-picker"
value="<?php echo htmlspecialchars($branding['secondary_color']); ?>">
<span class="color-value"><?php echo htmlspecialchars($branding['secondary_color']); ?></span>
</div>
</div>
<div class="form-group">
<label class="form-label">Akzentfarbe</label>
<div class="color-picker-wrapper">
<input type="color" name="accent_color" class="color-picker"
value="<?php echo htmlspecialchars($branding['accent_color']); ?>">
<span class="color-value"><?php echo htmlspecialchars($branding['accent_color']); ?></span>
</div>
</div>
<!-- Vorschau -->
<div style="margin-top: 1rem; padding: 1rem; border-radius: 0.5rem;
background: linear-gradient(135deg, <?php echo htmlspecialchars($branding['primary_color']); ?> 0%, <?php echo htmlspecialchars($branding['secondary_color']); ?> 100%);">
<span style="color: white; font-weight: bold;">Farbvorschau</span>
</div>
</div>
</div>
</div>
<!-- Texte -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Willkommenstexte</h3>
</div>
<div class="card-body">
<div class="grid grid-2">
<div class="form-group">
<label class="form-label" for="welcome_text_de">Willkommenstext (Deutsch)</label>
<textarea id="welcome_text_de" name="welcome_text_de" class="form-textarea"
placeholder="Willkommen bei unserer Livecam..."><?php echo htmlspecialchars($branding['welcome_text_de']); ?></textarea>
</div>
<div class="form-group">
<label class="form-label" for="welcome_text_en">Welcome Text (English)</label>
<textarea id="welcome_text_en" name="welcome_text_en" class="form-textarea"
placeholder="Welcome to our livecam..."><?php echo htmlspecialchars($branding['welcome_text_en']); ?></textarea>
</div>
</div>
<div class="form-group">
<label class="form-label" for="footer_text">Footer Text</label>
<input type="text" id="footer_text" name="footer_text" class="form-input"
value="<?php echo htmlspecialchars($branding['footer_text']); ?>"
placeholder="© 2024 Ihre Livecam">
</div>
</div>
</div>
<!-- Custom CSS -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Eigenes CSS</h3>
</div>
<div class="card-body">
<div class="form-group">
<label class="form-label" for="custom_css">Custom CSS (optional)</label>
<textarea id="custom_css" name="custom_css" class="form-textarea"
style="font-family: monospace; min-height: 150px;"
placeholder="/* Eigene CSS-Regeln hier */"><?php echo htmlspecialchars($branding['custom_css']); ?></textarea>
<p class="form-help">Fortgeschrittene Benutzer können hier eigene CSS-Regeln hinzufügen.</p>
</div>
</div>
</div>
<div style="margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary">
Branding speichern
</button>
</div>
</form>
<script>
// Color picker update
document.querySelectorAll('.color-picker').forEach(picker => {
picker.addEventListener('input', (e) => {
e.target.parentNode.querySelector('.color-value').textContent = e.target.value;
});
});
</script>
<?php
$content = ob_get_clean();
include __DIR__ . '/templates/layout.php';
+147
View File
@@ -0,0 +1,147 @@
<?php
/**
* Dashboard - Übersicht
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
if (file_exists(dirname(__DIR__) . '/src/bootstrap.php')) {
require_once dirname(__DIR__) . '/src/bootstrap.php';
}
use AuroraLivecam\Auth\AuthManager;
use AuroraLivecam\Core\Database;
use AuroraLivecam\Core\TenantResolver;
$settingsManager = new SettingsManager();
$auth = new AuthManager();
// Login erforderlich
$auth->requireLogin();
$user = $auth->getUser();
$tenantId = $user['tenant_id'] ?? 0;
// Stats laden
$stats = [
'viewers_current' => 0,
'viewers_today' => 0,
'viewers_peak' => 0,
'stream_status' => 'unknown',
];
// Versuche Stats aus DB zu laden
try {
$db = Database::getInstance();
if ($tenantId > 0) {
// Aktuelle Zuschauer (vereinfacht)
$viewerFile = dirname(__DIR__) . '/active_viewers.json';
if (file_exists($viewerFile)) {
$viewers = json_decode(file_get_contents($viewerFile), true);
$stats['viewers_current'] = count($viewers ?? []);
}
// Heute Stats
$todayStats = $db->fetchOne(
"SELECT SUM(viewer_count) as total, MAX(viewer_count) as peak
FROM viewer_stats
WHERE tenant_id = ? AND DATE(recorded_at) = CURDATE()",
[$tenantId]
);
if ($todayStats) {
$stats['viewers_today'] = $todayStats['total'] ?? 0;
$stats['viewers_peak'] = $todayStats['peak'] ?? 0;
}
// Stream Status
$stream = $db->fetchOne(
"SELECT last_status FROM tenant_streams WHERE tenant_id = ? AND is_primary = 1",
[$tenantId]
);
$stats['stream_status'] = $stream['last_status'] ?? 'unknown';
}
} catch (\Exception $e) {
// DB nicht verfügbar - Legacy-Modus
$viewerFile = dirname(__DIR__) . '/active_viewers.json';
if (file_exists($viewerFile)) {
$viewers = json_decode(file_get_contents($viewerFile), true);
$stats['viewers_current'] = count($viewers ?? []);
}
}
// Page Setup
$pageTitle = 'Übersicht';
$currentPage = 'overview';
ob_start();
?>
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">👥</div>
<div class="stat-value"><?php echo $stats['viewers_current']; ?></div>
<div class="stat-label">Aktuelle Zuschauer</div>
</div>
<div class="stat-card">
<div class="stat-icon">📊</div>
<div class="stat-value"><?php echo $stats['viewers_today']; ?></div>
<div class="stat-label">Zuschauer heute</div>
</div>
<div class="stat-card">
<div class="stat-icon">🏆</div>
<div class="stat-value"><?php echo $stats['viewers_peak']; ?></div>
<div class="stat-label">Peak heute</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<?php echo $stats['stream_status'] === 'online' ? '🟢' : ($stats['stream_status'] === 'offline' ? '🔴' : '⚪'); ?>
</div>
<div class="stat-value" style="font-size: 1.25rem; text-transform: capitalize;">
<?php echo $stats['stream_status'] === 'online' ? 'Online' : ($stats['stream_status'] === 'offline' ? 'Offline' : 'Unbekannt'); ?>
</div>
<div class="stat-label">Stream Status</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Schnellzugriff</h3>
</div>
<div class="card-body">
<div class="grid grid-3">
<a href="/dashboard/stream.php" class="btn btn-secondary">
📹 Stream bearbeiten
</a>
<a href="/dashboard/branding.php" class="btn btn-secondary">
🎨 Branding anpassen
</a>
<a href="/dashboard/settings.php" class="btn btn-secondary">
⚙️ Einstellungen
</a>
</div>
</div>
</div>
<!-- Recent Activity (Platzhalter) -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Letzte Aktivitäten</h3>
</div>
<div class="card-body">
<p style="color: var(--gray-500); text-align: center; padding: 2rem;">
Aktivitäten werden hier angezeigt, sobald Analytics aktiviert ist.
</p>
</div>
</div>
<?php
$content = ob_get_clean();
include __DIR__ . '/templates/layout.php';
+102
View File
@@ -0,0 +1,102 @@
<?php
/**
* Dashboard Login
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
if (file_exists(dirname(__DIR__) . '/src/bootstrap.php')) {
require_once dirname(__DIR__) . '/src/bootstrap.php';
}
use AuroraLivecam\Auth\AuthManager;
$settingsManager = new SettingsManager();
// Prüfe ob Dashboard aktiviert ist
if (!$settingsManager->isTenantDashboardEnabled() && !$settingsManager->isMultiTenantEnabled()) {
// Fallback auf Legacy-Admin
header('Location: /?admin=1');
exit;
}
$auth = new AuthManager();
// Bereits eingeloggt?
if ($auth->isLoggedIn()) {
header('Location: /dashboard/');
exit;
}
$error = '';
// Login verarbeiten
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';
$remember = isset($_POST['remember']);
if ($auth->login($email, $password, $remember)) {
header('Location: /dashboard/');
exit;
} else {
$error = 'Ungültige Anmeldedaten';
}
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Dashboard</title>
<link rel="stylesheet" href="/dashboard/assets/dashboard.css">
</head>
<body>
<div class="login-container">
<div class="login-box">
<div class="login-title">
<h1>Dashboard Login</h1>
<p>Melden Sie sich an, um fortzufahren</p>
</div>
<?php if ($error): ?>
<div class="alert alert-error"><?php echo htmlspecialchars($error); ?></div>
<?php endif; ?>
<form method="POST" action="">
<div class="form-group">
<label class="form-label" for="email">E-Mail / Benutzername</label>
<input type="text" id="email" name="email" class="form-input"
value="<?php echo htmlspecialchars($_POST['email'] ?? ''); ?>"
required autofocus>
</div>
<div class="form-group">
<label class="form-label" for="password">Passwort</label>
<input type="password" id="password" name="password" class="form-input" required>
</div>
<div class="form-group">
<label class="toggle-wrapper">
<span class="toggle">
<input type="checkbox" name="remember">
<span class="toggle-slider"></span>
</span>
<span>Angemeldet bleiben</span>
</label>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%;">
Anmelden
</button>
</form>
<p style="text-align: center; margin-top: 1.5rem; color: var(--gray-500);">
<a href="/" style="color: var(--primary);">Zurück zur Livecam</a>
</p>
</div>
</div>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
<?php
/**
* Dashboard Logout
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
if (file_exists(dirname(__DIR__) . '/src/bootstrap.php')) {
require_once dirname(__DIR__) . '/src/bootstrap.php';
}
use AuroraLivecam\Auth\AuthManager;
$auth = new AuthManager();
$auth->logout();
header('Location: /dashboard/login.php');
exit;
+271
View File
@@ -0,0 +1,271 @@
<?php
/**
* Dashboard - Einstellungen
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
if (file_exists(dirname(__DIR__) . '/src/bootstrap.php')) {
require_once dirname(__DIR__) . '/src/bootstrap.php';
}
use AuroraLivecam\Auth\AuthManager;
use AuroraLivecam\Tenant\TenantSettingsManager;
$settingsManager = new SettingsManager();
$auth = new AuthManager();
$auth->requireLogin();
$user = $auth->getUser();
$tenantId = $user['tenant_id'] ?? 0;
$flashMessage = null;
$flashType = 'info';
// Tenant-Settings laden
try {
$tenantSettings = new TenantSettingsManager($tenantId);
} catch (\Exception $e) {
$tenantSettings = null;
}
// Einstellungen für das Template
$settings = [
'viewer_display_enabled' => $settingsManager->get('viewer_display.enabled') ?? true,
'viewer_min' => $settingsManager->get('viewer_display.min_viewers') ?? 1,
'weather_enabled' => $settingsManager->get('weather.enabled') ?? true,
'weather_location' => $settingsManager->get('weather.location') ?? 'Zürich,CH',
'weather_lat' => $settingsManager->get('weather.lat') ?? '47.3769',
'weather_lon' => $settingsManager->get('weather.lon') ?? '8.5417',
'guestbook_enabled' => $settingsManager->get('content.guestbook_enabled') ?? true,
'gallery_enabled' => $settingsManager->get('content.gallery_enabled') ?? true,
'ai_events_enabled' => $settingsManager->get('content.ai_events_enabled') ?? true,
'show_qr_code' => $settingsManager->get('ui_display.show_qr_code') ?? true,
'show_social_media' => $settingsManager->get('ui_display.show_social_media') ?? true,
'timelapse_reverse' => $settingsManager->get('zoom_timelapse.timelapse_reverse_enabled') ?? true,
'max_zoom' => $settingsManager->get('zoom_timelapse.max_zoom_level') ?? 4.0,
];
// Formular verarbeiten
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$updates = [
'viewer_display.enabled' => isset($_POST['viewer_display_enabled']),
'viewer_display.min_viewers' => (int)($_POST['viewer_min'] ?? 1),
'weather.enabled' => isset($_POST['weather_enabled']),
'weather.location' => trim($_POST['weather_location'] ?? ''),
'weather.lat' => trim($_POST['weather_lat'] ?? ''),
'weather.lon' => trim($_POST['weather_lon'] ?? ''),
'content.guestbook_enabled' => isset($_POST['guestbook_enabled']),
'content.gallery_enabled' => isset($_POST['gallery_enabled']),
'content.ai_events_enabled' => isset($_POST['ai_events_enabled']),
'ui_display.show_qr_code' => isset($_POST['show_qr_code']),
'ui_display.show_social_media' => isset($_POST['show_social_media']),
'zoom_timelapse.timelapse_reverse_enabled' => isset($_POST['timelapse_reverse']),
'zoom_timelapse.max_zoom_level' => (float)($_POST['max_zoom'] ?? 4.0),
];
$success = true;
foreach ($updates as $key => $value) {
if (!$settingsManager->set($key, $value)) {
$success = false;
}
}
if ($success) {
$flashMessage = 'Einstellungen gespeichert!';
$flashType = 'success';
// Reload settings
$settings = [
'viewer_display_enabled' => $updates['viewer_display.enabled'],
'viewer_min' => $updates['viewer_display.min_viewers'],
'weather_enabled' => $updates['weather.enabled'],
'weather_location' => $updates['weather.location'],
'weather_lat' => $updates['weather.lat'],
'weather_lon' => $updates['weather.lon'],
'guestbook_enabled' => $updates['content.guestbook_enabled'],
'gallery_enabled' => $updates['content.gallery_enabled'],
'ai_events_enabled' => $updates['content.ai_events_enabled'],
'show_qr_code' => $updates['ui_display.show_qr_code'],
'show_social_media' => $updates['ui_display.show_social_media'],
'timelapse_reverse' => $updates['zoom_timelapse.timelapse_reverse_enabled'],
'max_zoom' => $updates['zoom_timelapse.max_zoom_level'],
];
} else {
$flashMessage = 'Fehler beim Speichern einiger Einstellungen.';
$flashType = 'error';
}
}
$pageTitle = 'Einstellungen';
$currentPage = 'settings';
ob_start();
?>
<form method="POST" action="">
<div class="grid grid-2">
<!-- Viewer-Anzeige -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Zuschauer-Anzeige</h3>
</div>
<div class="card-body">
<div class="form-group">
<label class="toggle-wrapper">
<span class="toggle">
<input type="checkbox" name="viewer_display_enabled"
<?php echo $settings['viewer_display_enabled'] ? 'checked' : ''; ?>>
<span class="toggle-slider"></span>
</span>
<span>Zuschauer-Anzahl anzeigen</span>
</label>
</div>
<div class="form-group">
<label class="form-label" for="viewer_min">Mindestanzahl für Anzeige</label>
<input type="number" id="viewer_min" name="viewer_min" class="form-input"
value="<?php echo (int)$settings['viewer_min']; ?>" min="0" max="100">
<p class="form-help">Zuschauer werden erst ab dieser Anzahl angezeigt</p>
</div>
</div>
</div>
<!-- Wetter-Widget -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Wetter-Widget</h3>
</div>
<div class="card-body">
<div class="form-group">
<label class="toggle-wrapper">
<span class="toggle">
<input type="checkbox" name="weather_enabled"
<?php echo $settings['weather_enabled'] ? 'checked' : ''; ?>>
<span class="toggle-slider"></span>
</span>
<span>Wetter-Widget aktivieren</span>
</label>
</div>
<div class="form-group">
<label class="form-label" for="weather_location">Standort-Name</label>
<input type="text" id="weather_location" name="weather_location" class="form-input"
value="<?php echo htmlspecialchars($settings['weather_location']); ?>">
</div>
<div class="grid grid-2">
<div class="form-group">
<label class="form-label" for="weather_lat">Breitengrad</label>
<input type="text" id="weather_lat" name="weather_lat" class="form-input"
value="<?php echo htmlspecialchars($settings['weather_lat']); ?>">
</div>
<div class="form-group">
<label class="form-label" for="weather_lon">Längengrad</label>
<input type="text" id="weather_lon" name="weather_lon" class="form-input"
value="<?php echo htmlspecialchars($settings['weather_lon']); ?>">
</div>
</div>
</div>
</div>
<!-- Content -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Inhalte</h3>
</div>
<div class="card-body">
<div class="form-group">
<label class="toggle-wrapper">
<span class="toggle">
<input type="checkbox" name="guestbook_enabled"
<?php echo $settings['guestbook_enabled'] ? 'checked' : ''; ?>>
<span class="toggle-slider"></span>
</span>
<span>Gästebuch aktivieren</span>
</label>
</div>
<div class="form-group">
<label class="toggle-wrapper">
<span class="toggle">
<input type="checkbox" name="gallery_enabled"
<?php echo $settings['gallery_enabled'] ? 'checked' : ''; ?>>
<span class="toggle-slider"></span>
</span>
<span>Galerie aktivieren</span>
</label>
</div>
<div class="form-group">
<label class="toggle-wrapper">
<span class="toggle">
<input type="checkbox" name="ai_events_enabled"
<?php echo $settings['ai_events_enabled'] ? 'checked' : ''; ?>>
<span class="toggle-slider"></span>
</span>
<span>AI-Events aktivieren</span>
</label>
</div>
</div>
</div>
<!-- UI -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Oberfläche</h3>
</div>
<div class="card-body">
<div class="form-group">
<label class="toggle-wrapper">
<span class="toggle">
<input type="checkbox" name="show_qr_code"
<?php echo $settings['show_qr_code'] ? 'checked' : ''; ?>>
<span class="toggle-slider"></span>
</span>
<span>QR-Code anzeigen</span>
</label>
</div>
<div class="form-group">
<label class="toggle-wrapper">
<span class="toggle">
<input type="checkbox" name="show_social_media"
<?php echo $settings['show_social_media'] ? 'checked' : ''; ?>>
<span class="toggle-slider"></span>
</span>
<span>Social Media Links anzeigen</span>
</label>
</div>
<div class="form-group">
<label class="toggle-wrapper">
<span class="toggle">
<input type="checkbox" name="timelapse_reverse"
<?php echo $settings['timelapse_reverse'] ? 'checked' : ''; ?>>
<span class="toggle-slider"></span>
</span>
<span>Timelapse Rückwärts erlauben</span>
</label>
</div>
<div class="form-group">
<label class="form-label" for="max_zoom">Maximaler Zoom</label>
<input type="number" id="max_zoom" name="max_zoom" class="form-input"
value="<?php echo (float)$settings['max_zoom']; ?>" min="1" max="10" step="0.5">
</div>
</div>
</div>
</div>
<div style="margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary">
Einstellungen speichern
</button>
</div>
</form>
<?php
$content = ob_get_clean();
include __DIR__ . '/templates/layout.php';
+183
View File
@@ -0,0 +1,183 @@
<?php
/**
* Dashboard - Stream Einstellungen
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
if (file_exists(dirname(__DIR__) . '/src/bootstrap.php')) {
require_once dirname(__DIR__) . '/src/bootstrap.php';
}
use AuroraLivecam\Auth\AuthManager;
use AuroraLivecam\Core\Database;
$settingsManager = new SettingsManager();
$auth = new AuthManager();
$auth->requireLogin();
$user = $auth->getUser();
$tenantId = $user['tenant_id'] ?? 0;
$flashMessage = null;
$flashType = 'info';
// Stream-Daten laden
$stream = [
'stream_url' => '',
'stream_type' => 'hls',
'is_active' => true,
'last_status' => 'unknown',
];
try {
$db = Database::getInstance();
if ($tenantId > 0) {
$dbStream = $db->fetchOne(
"SELECT * FROM tenant_streams WHERE tenant_id = ? AND is_primary = 1",
[$tenantId]
);
if ($dbStream) {
$stream = $dbStream;
}
}
} catch (\Exception $e) {
// DB nicht verfügbar
}
// Formular verarbeiten
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$streamUrl = trim($_POST['stream_url'] ?? '');
$streamType = $_POST['stream_type'] ?? 'hls';
if (empty($streamUrl)) {
$flashMessage = 'Bitte geben Sie eine Stream-URL ein.';
$flashType = 'error';
} else {
try {
$db = Database::getInstance();
if ($tenantId > 0) {
// Prüfe ob Stream existiert
$existing = $db->fetchOne(
"SELECT id FROM tenant_streams WHERE tenant_id = ? AND is_primary = 1",
[$tenantId]
);
if ($existing) {
$db->update('tenant_streams', [
'stream_url' => $streamUrl,
'stream_type' => $streamType,
], 'id = ?', [$existing['id']]);
} else {
$db->insert('tenant_streams', [
'tenant_id' => $tenantId,
'stream_url' => $streamUrl,
'stream_type' => $streamType,
'is_primary' => 1,
]);
}
$flashMessage = 'Stream-Einstellungen gespeichert!';
$flashType = 'success';
// Reload stream data
$stream['stream_url'] = $streamUrl;
$stream['stream_type'] = $streamType;
} else {
$flashMessage = 'Stream-Einstellungen können im Legacy-Modus nicht gespeichert werden.';
$flashType = 'warning';
}
} catch (\Exception $e) {
$flashMessage = 'Fehler beim Speichern: ' . $e->getMessage();
$flashType = 'error';
}
}
}
$pageTitle = 'Stream Einstellungen';
$currentPage = 'stream';
ob_start();
?>
<div class="card">
<div class="card-header">
<h3 class="card-title">Stream Konfiguration</h3>
<span class="badge badge-<?php echo $stream['last_status'] === 'online' ? 'success' : ($stream['last_status'] === 'offline' ? 'danger' : 'info'); ?>">
<?php echo ucfirst($stream['last_status'] ?? 'Unbekannt'); ?>
</span>
</div>
<div class="card-body">
<form method="POST" action="">
<div class="form-group">
<label class="form-label" for="stream_url">Stream URL</label>
<input type="url" id="stream_url" name="stream_url" class="form-input"
value="<?php echo htmlspecialchars($stream['stream_url']); ?>"
placeholder="https://example.com/stream.m3u8">
<p class="form-help">Die URL zu Ihrem HLS-Stream (.m3u8) oder RTMP-Stream</p>
</div>
<div class="form-group">
<label class="form-label" for="stream_type">Stream Typ</label>
<select id="stream_type" name="stream_type" class="form-select">
<option value="hls" <?php echo ($stream['stream_type'] ?? 'hls') === 'hls' ? 'selected' : ''; ?>>
HLS (.m3u8)
</option>
<option value="rtmp" <?php echo ($stream['stream_type'] ?? '') === 'rtmp' ? 'selected' : ''; ?>>
RTMP
</option>
<option value="webrtc" <?php echo ($stream['stream_type'] ?? '') === 'webrtc' ? 'selected' : ''; ?>>
WebRTC
</option>
<option value="iframe" <?php echo ($stream['stream_type'] ?? '') === 'iframe' ? 'selected' : ''; ?>>
iFrame Embed
</option>
</select>
</div>
<button type="submit" class="btn btn-primary">
Speichern
</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">Stream Vorschau</h3>
</div>
<div class="card-body">
<?php if (!empty($stream['stream_url'])): ?>
<div style="aspect-ratio: 16/9; background: #000; border-radius: 0.5rem; overflow: hidden;">
<video id="preview-player" controls style="width: 100%; height: 100%;">
<source src="<?php echo htmlspecialchars($stream['stream_url']); ?>" type="application/x-mpegURL">
</video>
</div>
<p class="form-help" style="margin-top: 1rem;">
Hinweis: Die Vorschau funktioniert nur mit HLS-Streams und wenn Ihr Browser HLS unterstützt.
</p>
<?php else: ?>
<div class="preview-box">
<p>Keine Stream-URL konfiguriert</p>
</div>
<?php endif; ?>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">Stream Monitoring</h3>
</div>
<div class="card-body">
<p style="color: var(--gray-500);">
Stream-Monitoring zeigt automatische Verfügbarkeitsprüfungen an.
Diese Funktion wird demnächst verfügbar sein.
</p>
</div>
</div>
<?php
$content = ob_get_clean();
include __DIR__ . '/templates/layout.php';
@@ -0,0 +1,126 @@
<?php
/**
* Dashboard Layout Template
*
* Variablen:
* - $pageTitle: Seitentitel
* - $currentPage: Aktuelle Seite (für Navigation)
* - $content: Hauptinhalt
*/
$user = $auth->getUser();
$tenantName = $user['tenant_name'] ?? 'Dashboard';
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo htmlspecialchars($pageTitle ?? 'Dashboard'); ?> - <?php echo htmlspecialchars($tenantName); ?></title>
<link rel="stylesheet" href="/dashboard/assets/dashboard.css">
</head>
<body>
<div class="dashboard-container">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<h2><?php echo htmlspecialchars($tenantName); ?></h2>
<span class="role-badge"><?php echo htmlspecialchars($user['role'] ?? 'user'); ?></span>
</div>
<nav class="sidebar-nav">
<a href="/dashboard/" class="nav-item <?php echo ($currentPage ?? '') === 'overview' ? 'active' : ''; ?>">
<span class="nav-icon">📊</span>
<span>Übersicht</span>
</a>
<a href="/dashboard/stream.php" class="nav-item <?php echo ($currentPage ?? '') === 'stream' ? 'active' : ''; ?>">
<span class="nav-icon">📹</span>
<span>Stream</span>
</a>
<a href="/dashboard/branding.php" class="nav-item <?php echo ($currentPage ?? '') === 'branding' ? 'active' : ''; ?>">
<span class="nav-icon">🎨</span>
<span>Branding</span>
</a>
<a href="/dashboard/settings.php" class="nav-item <?php echo ($currentPage ?? '') === 'settings' ? 'active' : ''; ?>">
<span class="nav-icon">⚙️</span>
<span>Einstellungen</span>
</a>
<?php if ($settingsManager->isAnalyticsEnabled()): ?>
<a href="/dashboard/analytics.php" class="nav-item <?php echo ($currentPage ?? '') === 'analytics' ? 'active' : ''; ?>">
<span class="nav-icon">📈</span>
<span>Analytics</span>
</a>
<?php endif; ?>
<?php if ($settingsManager->isCustomDomainEnabled()): ?>
<a href="/dashboard/domains.php" class="nav-item <?php echo ($currentPage ?? '') === 'domains' ? 'active' : ''; ?>">
<span class="nav-icon">🌐</span>
<span>Domains</span>
</a>
<?php endif; ?>
<?php if ($settingsManager->isBillingEnabled()): ?>
<a href="/dashboard/billing.php" class="nav-item <?php echo ($currentPage ?? '') === 'billing' ? 'active' : ''; ?>">
<span class="nav-icon">💳</span>
<span>Abrechnung</span>
</a>
<?php endif; ?>
<?php if ($auth->isSuperAdmin()): ?>
<div class="nav-divider"></div>
<span class="nav-label">Admin</span>
<a href="/dashboard/admin/tenants.php" class="nav-item <?php echo ($currentPage ?? '') === 'admin-tenants' ? 'active' : ''; ?>">
<span class="nav-icon">👥</span>
<span>Kunden</span>
</a>
<a href="/dashboard/admin/plans.php" class="nav-item <?php echo ($currentPage ?? '') === 'admin-plans' ? 'active' : ''; ?>">
<span class="nav-icon">📋</span>
<span>Pläne</span>
</a>
<?php endif; ?>
</nav>
<div class="sidebar-footer">
<a href="/" class="nav-item" target="_blank">
<span class="nav-icon">🔗</span>
<span>Zur Livecam</span>
</a>
<a href="/dashboard/logout.php" class="nav-item logout">
<span class="nav-icon">🚪</span>
<span>Abmelden</span>
</a>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<header class="main-header">
<h1><?php echo htmlspecialchars($pageTitle ?? 'Dashboard'); ?></h1>
<div class="header-actions">
<span class="user-info">
<?php echo htmlspecialchars($user['email'] ?? ''); ?>
</span>
</div>
</header>
<div class="content-wrapper">
<?php if (isset($flashMessage)): ?>
<div class="alert alert-<?php echo $flashType ?? 'info'; ?>">
<?php echo htmlspecialchars($flashMessage); ?>
</div>
<?php endif; ?>
<?php echo $content ?? ''; ?>
</div>
</main>
</div>
<script src="/dashboard/assets/dashboard.js"></script>
</body>
</html>
+205
View File
@@ -0,0 +1,205 @@
-- Aurora Livecam - Multi-Tenant SaaS Schema
-- Version: 1.0.0
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- --------------------------------------------------------
-- Subscription Plans
-- --------------------------------------------------------
CREATE TABLE IF NOT EXISTS `plans` (
`id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL,
`slug` VARCHAR(50) UNIQUE NOT NULL,
`stripe_price_id` VARCHAR(100) NULL,
`price_monthly` DECIMAL(10,2) DEFAULT 0.00,
`price_yearly` DECIMAL(10,2) DEFAULT 0.00,
`features` JSON NULL COMMENT '{"max_viewers": 100, "storage_gb": 5, "custom_domain": true}',
`is_active` TINYINT(1) DEFAULT 1,
`sort_order` INT DEFAULT 0,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Default Plans
INSERT INTO `plans` (`name`, `slug`, `price_monthly`, `price_yearly`, `features`, `sort_order`) VALUES
('Free', 'free', 0.00, 0.00, '{"max_viewers": 10, "storage_gb": 0.5, "custom_domain": false, "weather_widget": true, "timelapse": false, "analytics": false, "branding": false}', 1),
('Basic', 'basic', 19.00, 190.00, '{"max_viewers": 50, "storage_gb": 5, "custom_domain": false, "weather_widget": true, "timelapse": true, "analytics": true, "branding": false}', 2),
('Professional', 'professional', 49.00, 490.00, '{"max_viewers": 200, "storage_gb": 20, "custom_domain": true, "weather_widget": true, "timelapse": true, "analytics": true, "branding": true}', 3),
('Enterprise', 'enterprise', 149.00, 1490.00, '{"max_viewers": -1, "storage_gb": 100, "custom_domain": true, "weather_widget": true, "timelapse": true, "analytics": true, "branding": true, "priority_support": true}', 4);
-- --------------------------------------------------------
-- Tenants (Customers)
-- --------------------------------------------------------
CREATE TABLE IF NOT EXISTS `tenants` (
`id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`uuid` VARCHAR(36) UNIQUE NOT NULL,
`name` VARCHAR(255) NOT NULL,
`slug` VARCHAR(100) UNIQUE NOT NULL COMMENT 'URL-safe identifier, e.g. aurora, seecam',
`email` VARCHAR(255) NOT NULL,
`status` ENUM('trial', 'active', 'suspended', 'cancelled') DEFAULT 'trial',
`plan_id` INT UNSIGNED NULL,
`trial_ends_at` TIMESTAMP NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`plan_id`) REFERENCES `plans`(`id`) ON DELETE SET NULL,
INDEX `idx_status` (`status`),
INDEX `idx_slug` (`slug`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Tenant Domains
-- --------------------------------------------------------
CREATE TABLE IF NOT EXISTS `tenant_domains` (
`id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`tenant_id` INT UNSIGNED NOT NULL,
`domain` VARCHAR(255) UNIQUE NOT NULL,
`is_primary` TINYINT(1) DEFAULT 0,
`ssl_status` ENUM('pending', 'active', 'failed') DEFAULT 'pending',
`verified_at` TIMESTAMP NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE,
INDEX `idx_domain` (`domain`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Tenant Settings (replaces settings.json per tenant)
-- --------------------------------------------------------
CREATE TABLE IF NOT EXISTS `tenant_settings` (
`id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`tenant_id` INT UNSIGNED NOT NULL,
`setting_key` VARCHAR(255) NOT NULL,
`setting_value` TEXT NULL,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_tenant_key` (`tenant_id`, `setting_key`),
FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Tenant Branding
-- --------------------------------------------------------
CREATE TABLE IF NOT EXISTS `tenant_branding` (
`tenant_id` INT UNSIGNED PRIMARY KEY,
`site_name` VARCHAR(255) NULL,
`site_name_full` VARCHAR(255) NULL,
`tagline` VARCHAR(255) NULL,
`logo_path` VARCHAR(500) NULL,
`favicon_path` VARCHAR(500) NULL,
`primary_color` VARCHAR(7) DEFAULT '#667eea',
`secondary_color` VARCHAR(7) DEFAULT '#764ba2',
`accent_color` VARCHAR(7) DEFAULT '#f093fb',
`welcome_text_de` TEXT NULL,
`welcome_text_en` TEXT NULL,
`footer_text` TEXT NULL,
`custom_css` TEXT NULL,
`custom_js` TEXT NULL,
`social_facebook` VARCHAR(255) NULL,
`social_instagram` VARCHAR(255) NULL,
`social_youtube` VARCHAR(255) NULL,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Tenant Streams
-- --------------------------------------------------------
CREATE TABLE IF NOT EXISTS `tenant_streams` (
`id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`tenant_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(255) DEFAULT 'Main Stream',
`stream_url` VARCHAR(500) NOT NULL,
`stream_type` ENUM('hls', 'rtmp', 'webrtc', 'iframe') DEFAULT 'hls',
`is_active` TINYINT(1) DEFAULT 1,
`is_primary` TINYINT(1) DEFAULT 1,
`last_check_at` TIMESTAMP NULL,
`last_status` ENUM('online', 'offline', 'error') NULL,
`error_message` VARCHAR(500) NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Users
-- --------------------------------------------------------
CREATE TABLE IF NOT EXISTS `users` (
`id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`tenant_id` INT UNSIGNED NULL COMMENT 'NULL = Super Admin',
`email` VARCHAR(255) UNIQUE NOT NULL,
`password_hash` VARCHAR(255) NOT NULL,
`name` VARCHAR(255) NULL,
`role` ENUM('super_admin', 'tenant_admin', 'tenant_user') NOT NULL DEFAULT 'tenant_user',
`email_verified_at` TIMESTAMP NULL,
`last_login_at` TIMESTAMP NULL,
`remember_token` VARCHAR(100) NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE,
INDEX `idx_email` (`email`),
INDEX `idx_tenant` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Subscriptions
-- --------------------------------------------------------
CREATE TABLE IF NOT EXISTS `subscriptions` (
`id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`tenant_id` INT UNSIGNED NOT NULL,
`plan_id` INT UNSIGNED NOT NULL,
`stripe_subscription_id` VARCHAR(100) NULL,
`stripe_customer_id` VARCHAR(100) NULL,
`status` ENUM('trialing', 'active', 'past_due', 'canceled', 'unpaid', 'incomplete') DEFAULT 'trialing',
`current_period_start` TIMESTAMP NULL,
`current_period_end` TIMESTAMP NULL,
`canceled_at` TIMESTAMP NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`plan_id`) REFERENCES `plans`(`id`),
INDEX `idx_tenant` (`tenant_id`),
INDEX `idx_stripe_sub` (`stripe_subscription_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Invoices (Stripe cache)
-- --------------------------------------------------------
CREATE TABLE IF NOT EXISTS `invoices` (
`id` INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`tenant_id` INT UNSIGNED NOT NULL,
`stripe_invoice_id` VARCHAR(100) UNIQUE NULL,
`amount` DECIMAL(10,2) NOT NULL,
`currency` VARCHAR(3) DEFAULT 'CHF',
`status` VARCHAR(50) NULL,
`paid_at` TIMESTAMP NULL,
`invoice_pdf_url` VARCHAR(500) NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Viewer Statistics
-- --------------------------------------------------------
CREATE TABLE IF NOT EXISTS `viewer_stats` (
`id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`tenant_id` INT UNSIGNED NOT NULL,
`recorded_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`viewer_count` INT DEFAULT 0,
`unique_sessions` INT DEFAULT 0,
FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE,
INDEX `idx_tenant_time` (`tenant_id`, `recorded_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
-- Onboarding Progress
-- --------------------------------------------------------
CREATE TABLE IF NOT EXISTS `tenant_onboarding` (
`tenant_id` INT UNSIGNED PRIMARY KEY,
`current_step` INT DEFAULT 1,
`stream_verified` TINYINT(1) DEFAULT 0,
`branding_configured` TINYINT(1) DEFAULT 0,
`payment_configured` TINYINT(1) DEFAULT 0,
`completed_at` TIMESTAMP NULL,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET FOREIGN_KEY_CHECKS = 1;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+721
View File
@@ -0,0 +1,721 @@
<?php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
require __DIR__ . '/vendor/autoload.php';
require_once 'SettingsManager.php';
// SettingsManager initialisieren
$settingsManager = new SettingsManager();
// AJAX-Handler für Settings (MUSS ganz am Anfang sein!)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['settings_action'])) {
header('Content-Type: application/json');
switch ($_POST['settings_action']) {
case 'get':
echo json_encode(['success' => true, 'settings' => $settingsManager->get()]);
exit;
case 'update':
$key = $_POST['key'] ?? null;
$value = $_POST['value'] ?? null;
if ($value === 'true') $value = true;
if ($value === 'false') $value = false;
if (is_numeric($value)) $value = intval($value);
if ($key && $settingsManager->set($key, $value)) {
echo json_encode(['success' => true, 'message' => 'Gespeichert']);
} else {
echo json_encode(['success' => false, 'message' => 'Fehler']);
}
exit;
}
}
if (isset($_GET['download_video'])) {
$videoDir = './videos/';
$latestVideo = null;
$latestTime = 0;
foreach (glob($videoDir . '*.mp4') as $video) {
$mtime = filemtime($video);
if ($mtime > $latestTime) { $latestTime = $mtime; $latestVideo = $video; }
}
if ($latestVideo) {
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="'.basename($latestVideo).'"');
header('Content-Length: ' . filesize($latestVideo));
readfile($latestVideo);
exit;
}
echo "Kein Video gefunden.";
exit;
}
$oldDomains = ['www.aurora-wetter-lifecam.ch', 'www.aurora-wetter-livecam.ch'];
$newDomain = 'www.aurora-weather-livecam.com';
if (in_array($_SERVER['HTTP_HOST'] ?? '', $oldDomains)) {
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
header("HTTP/1.1 301 Moved Permanently");
header("Location: " . $protocol . '://' . $newDomain . $_SERVER['REQUEST_URI']);
exit;
}
session_start();
error_reporting(E_ALL);
ini_set('display_errors', 0);
$imageDir = "./image";
$imageFiles = glob("$imageDir/screenshot_*.jpg");
if ($imageFiles) rsort($imageFiles);
$imageFilesJson = json_encode($imageFiles ?: []);
class ViewerCounter {
private $file = 'active_viewers.json';
private $timeout = 30;
public function handleHeartbeat() {
$ip = md5($_SERVER['REMOTE_ADDR'] . ($_SERVER['HTTP_USER_AGENT'] ?? ''));
$now = time();
$viewers = file_exists($this->file) ? json_decode(file_get_contents($this->file), true) ?? [] : [];
$viewers[$ip] = $now;
$active = [];
foreach ($viewers as $u => $t) { if ($now - $t < $this->timeout) $active[$u] = $t; }
file_put_contents($this->file, json_encode($active));
header('Content-Type: application/json');
echo json_encode(['count' => count($active)]);
exit;
}
public function getInitialCount() {
if (file_exists($this->file)) {
return max(1, count(json_decode(file_get_contents($this->file), true) ?? []));
}
return 1;
}
}
$viewerCounter = new ViewerCounter();
class WebcamManager {
private $videoSrc = 'test_video.m3u8';
public function displayWebcam() {
return '<video id="webcam-player" autoplay muted playsinline></video>';
}
public function displayStreamStats() {
return '<div class="info-badge tech-stat" id="bitrate-display" style="display:none;">
<i class="fas fa-tachometer-alt"></i> <span id="bitrate-value">0.00</span> MBit/s
</div>';
}
public function getImageFiles() {
$f = glob("image/screenshot_*.jpg");
if ($f) rsort($f);
return json_encode($f ?: []);
}
public function getJavaScript() {
return "
document.addEventListener('DOMContentLoaded', function () {
var video = document.getElementById('webcam-player');
var videoSrc = '{$this->videoSrc}';
if(video && typeof Hls !== 'undefined' && Hls.isSupported()) {
var hls = new Hls();
hls.loadSource(videoSrc);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function () { video.play().catch(()=>{}); });
} else if (video) {
video.src = videoSrc;
video.play().catch(()=>{});
}
});";
}
}
class VisualCalendarManager {
private $videoDir, $settingsManager;
private $months = [1=>'Jan',2=>'Feb',3=>'Mär',4=>'Apr',5=>'Mai',6=>'Jun',7=>'Jul',8=>'Aug',9=>'Sep',10=>'Okt',11=>'Nov',12=>'Dez'];
public function __construct($videoDir = './videos/', $sm = null) {
$this->videoDir = $videoDir;
$this->settingsManager = $sm;
}
public function hasVideosForDate($y, $m, $d) {
return count(glob($this->videoDir . sprintf("daily_video_%04d%02d%02d_*.mp4", $y, $m, $d))) > 0;
}
public function getVideosForDate($y, $m, $d) {
$vids = [];
foreach (glob($this->videoDir . sprintf("daily_video_%04d%02d%02d_*.mp4", $y, $m, $d)) as $v) {
$vids[] = ['path' => $v, 'name' => basename($v), 'size' => filesize($v), 'time' => date('H:i', filemtime($v))];
}
return $vids;
}
public function displayVisualCalendar() {
$cy = isset($_GET['cal_year']) ? intval($_GET['cal_year']) : date('Y');
$cm = isset($_GET['cal_month']) ? intval($_GET['cal_month']) : date('n');
$sd = isset($_GET['cal_day']) ? intval($_GET['cal_day']) : null;
$pip = $this->settingsManager ? $this->settingsManager->get('video_mode.play_in_player') : true;
$dl = $this->settingsManager ? $this->settingsManager->get('video_mode.allow_download') : true;
$o = '<div class="calendar-box">';
$o .= '<div class="cal-nav"><button onclick="chgM('.$cy.','.($cm-1).')">&laquo;</button><span>'.$this->months[$cm].' '.$cy.'</span><button onclick="chgM('.$cy.','.($cm+1).')">&raquo;</button></div>';
$o .= '<div class="cal-grid">';
foreach(['Mo','Di','Mi','Do','Fr','Sa','So'] as $wd) $o .= '<div class="cal-hd">'.$wd.'</div>';
$fd = mktime(0,0,0,$cm,1,$cy);
$dim = date('t', $fd);
$dow = date('N', $fd) - 1;
for ($i=0; $i<$dow; $i++) $o .= '<div class="cal-day empty"></div>';
for ($d=1; $d<=$dim; $d++) {
$hv = $this->hasVideosForDate($cy,$cm,$d);
$sel = $sd==$d;
$td = ($cy==date('Y') && $cm==date('n') && $d==date('j'));
$cls = 'cal-day' . ($hv?' has-vid':'') . ($sel?' sel':'') . ($td?' today':'');
$o .= '<div class="'.$cls.'" onclick="selD('.$cy.','.$cm.','.$d.')"><span>'.$d.'</span>'.($hv?'<small>📹</small>':'').'</div>';
}
$o .= '</div>';
if ($sd) {
$vids = $this->getVideosForDate($cy,$cm,$sd);
$o .= '<div class="day-vids"><h4>📅 '.sprintf('%02d.%02d.%04d',$sd,$cm,$cy).'</h4>';
if ($vids) {
$o .= '<ul>';
foreach ($vids as $v) {
$sz = round($v['size']/1024/1024,1);
$tk = hash_hmac('sha256', $v['path'], session_id());
$o .= '<li><span>🕐 '.$v['time'].'</span><span>'.$sz.' MB</span><span class="vid-btns">';
if ($pip) $o .= '<a href="#" onclick="playVid(\''.htmlspecialchars($v['path']).'\');return false;" class="btn-play">▶️</a>';
if ($dl) $o .= '<a href="?download_specific_video='.urlencode($v['path']).'&token='.$tk.'" class="btn-dl">⬇️</a>';
$o .= '</span></li>';
}
$o .= '</ul>';
} else {
$o .= '<p>Keine Videos.</p>';
}
$o .= '</div>';
}
$o .= '</div>';
return $o;
}
}
class GuestbookManager {
private $entries = [], $file = 'guestbook.json';
public function __construct() { if (file_exists($this->file)) $this->entries = json_decode(file_get_contents($this->file), true) ?? []; }
public function handleFormSubmission() {
if (isset($_POST['guestbook'],$_POST['guest-name'],$_POST['guest-message'])) {
$this->entries[] = ['name'=>htmlspecialchars($_POST['guest-name']),'message'=>htmlspecialchars($_POST['guest-message']),'date'=>date('Y-m-d H:i:s')];
file_put_contents($this->file, json_encode($this->entries));
}
}
public function deleteEntry($i) { if (isset($this->entries[$i])) { unset($this->entries[$i]); $this->entries = array_values($this->entries); file_put_contents($this->file, json_encode($this->entries)); return true; } return false; }
public function displayForm() { return '<form method="post"><input type="hidden" name="guestbook" value="1"><label>Name:</label><input name="guest-name" required><label>Nachricht:</label><textarea name="guest-message" required></textarea><button type="submit">Senden</button></form>'; }
public function displayEntries($admin=false) {
$o = '<div class="gb-entries">';
foreach ($this->entries as $i=>$e) {
$o .= '<div class="gb-entry"><h4>'.$e['name'].'</h4><p>'.$e['message'].'</p><small>'.$e['date'].'</small>';
if ($admin) $o .= '<form method="post" style="display:inline"><input type="hidden" name="action" value="delete_guestbook"><input type="hidden" name="delete_entry" value="'.$i.'"><button class="del-btn">X</button></form>';
$o .= '</div>';
}
return $o.'</div>';
}
}
class ContactManager {
private $file = 'feedbacks.json';
public function displayForm() { return '<form method="post" id="contact-form"><input type="hidden" name="contact" value="1"><label>Name:</label><input name="name" required><label>E-Mail:</label><input type="email" name="email" required><label>Nachricht:</label><textarea name="message" required></textarea><button type="submit">Senden</button></form><div id="contact-fb"></div>'; }
public function handleSubmission($n,$e,$m) {
if (!$n||!$e||!$m) return ['success'=>false,'message'=>'Alle Felder ausfüllen'];
$fb = ['name'=>htmlspecialchars($n),'email'=>filter_var($e,FILTER_SANITIZE_EMAIL),'message'=>htmlspecialchars($m),'date'=>date('Y-m-d H:i:s'),'ip'=>$_SERVER['REMOTE_ADDR']??''];
$all = file_exists($this->file) ? json_decode(file_get_contents($this->file),true) : [];
$all[] = $fb;
file_put_contents($this->file, json_encode($all, JSON_PRETTY_PRINT));
return ['success'=>true,'message'=>'Nachricht gesendet!'];
}
public function deleteFeedback($i) { $all = json_decode(file_get_contents($this->file),true); if (isset($all[$i])) { unset($all[$i]); file_put_contents($this->file, json_encode(array_values($all),JSON_PRETTY_PRINT)); return true; } return false; }
}
class AdminManager {
public function isAdmin() { return isset($_SESSION['admin']) && $_SESSION['admin'] === true; }
public function handleLogin($u,$p) { if ($u==='admin' && $p==='sonne4000$$$$Q') { $_SESSION['admin']=true; return true; } return false; }
public function displayLoginForm() { return '<form method="post"><input type="hidden" name="admin-login" value="1"><label>User:</label><input name="username" required><label>Pass:</label><input type="password" name="password" required><button type="submit">Login</button></form>'; }
public function displayAdminContent() {
global $settingsManager;
$o = '<div class="admin-panel">';
$o .= '<h3>⚙️ Einstellungen</h3>';
$o .= '<div class="setting"><label>Zuschauer anzeigen</label><input type="checkbox" id="s-viewer" '.($settingsManager->get('viewer_display.enabled')?'checked':'').'></div>';
$o .= '<div class="setting"><label>Mindestanzahl</label><input type="number" id="s-min" value="'.$settingsManager->get('viewer_display.min_viewers').'" min="1" max="100"></div>';
$o .= '<div class="setting"><label>Im Player abspielen</label><input type="checkbox" id="s-play" '.($settingsManager->get('video_mode.play_in_player')?'checked':'').'></div>';
$o .= '<div class="setting"><label>Download erlauben</label><input type="checkbox" id="s-dl" '.($settingsManager->get('video_mode.allow_download')?'checked':'').'></div>';
$o .= '</div>';
$o .= '<div class="admin-panel"><h3>📩 Nachrichten</h3>';
$msgs = file_exists('feedbacks.json') ? json_decode(file_get_contents('feedbacks.json'),true) : [];
foreach ($msgs as $i=>$m) {
$o .= '<div class="msg"><strong>'.$m['name'].'</strong> ('.$m['email'].')<p>'.$m['message'].'</p><small>'.$m['date'].'</small>';
$o .= '<form method="post" style="display:inline"><input type="hidden" name="action" value="delete_feedback"><input type="hidden" name="delete_index" value="'.$i.'"><button class="del-btn">X</button></form></div>';
}
if (!$msgs) $o .= '<p>Keine Nachrichten.</p>';
$o .= '</div>';
return $o;
}
public function displayGalleryImages() {
$o = '<div class="gallery">';
foreach (glob("uploads/*.{jpg,jpeg,png,gif}",GLOB_BRACE) as $f) $o .= '<img src="'.$f.'" onclick="openImg(this.src)">';
return $o.'</div>';
}
}
class VideoArchiveManager {
private $dir;
public function __construct($d='./videos/') { $this->dir = $d; }
public function handleSpecificVideoDownload() {
if (isset($_GET['download_specific_video'],$_GET['token'])) {
$p = $_GET['download_specific_video'];
if (!hash_equals(hash_hmac('sha256',$p,session_id()), $_GET['token'])) { echo "Invalid"; exit; }
$rp = realpath($p);
$rd = realpath($this->dir);
if ($rp && strpos($rp,$rd)===0 && file_exists($rp)) {
header('Content-Type: video/mp4');
header('Content-Disposition: attachment; filename="'.basename($rp).'"');
header('Content-Length: '.filesize($rp));
readfile($rp);
exit;
}
echo "Not found"; exit;
}
}
}
$webcamManager = new WebcamManager();
$guestbookManager = new GuestbookManager();
$contactManager = new ContactManager();
$adminManager = new AdminManager();
$videoArchiveManager = new VideoArchiveManager('./videos/');
$videoArchiveManager->handleSpecificVideoDownload();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['action']) && $_POST['action'] === 'viewer_heartbeat') $viewerCounter->handleHeartbeat();
if (isset($_POST['guestbook'])) { $guestbookManager->handleFormSubmission(); header("Location: ".$_SERVER['PHP_SELF']."#guestbook"); exit; }
if (isset($_POST['contact'])) {
$r = $contactManager->handleSubmission($_POST['name'],$_POST['email'],$_POST['message']);
if (isset($_SERVER['HTTP_X_REQUESTED_WITH'])) { header('Content-Type: application/json'); echo json_encode($r); exit; }
header('Location: '.$_SERVER['PHP_SELF'].'#kontakt'); exit;
}
if (isset($_POST['admin-login'])) { $adminManager->handleLogin($_POST['username'],$_POST['password']); header('Location: '.$_SERVER['PHP_SELF'].'#admin'); exit; }
if ($adminManager->isAdmin()) {
if (isset($_POST['action']) && $_POST['action']==='delete_guestbook') { $guestbookManager->deleteEntry(intval($_POST['delete_entry'])); header("Location: ".$_SERVER['PHP_SELF']."#guestbook"); exit; }
if (isset($_POST['action']) && $_POST['action']==='delete_feedback') { $contactManager->deleteFeedback(intval($_POST['delete_index'])); header("Location: ".$_SERVER['PHP_SELF']."#admin"); exit; }
}
}
$vc = $viewerCounter->getInitialCount();
$sv = $settingsManager->get('viewer_display.enabled') && $vc >= $settingsManager->get('viewer_display.min_viewers');
$mv = $settingsManager->get('viewer_display.min_viewers');
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=5,user-scalable=yes">
<title>Aurora Livecam</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Arial,sans-serif;background:#f0f0f0;color:#333;line-height:1.6}
.container{max-width:1100px;margin:0 auto;padding:0 15px}
.section{padding:50px 0;background:#fff;margin-bottom:15px}
.section h2{text-align:center;margin-bottom:25px;font-size:28px}
header{background:#fff;padding:12px 0;position:sticky;top:0;z-index:100;box-shadow:0 2px 8px rgba(0,0,0,0.1)}
.header-inner{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px}
.logo img{height:45px}
nav ul{list-style:none;display:flex;flex-wrap:wrap;gap:5px}
nav a{text-decoration:none;color:#333;padding:8px 14px;border-radius:5px;font-weight:bold;transition:.3s}
nav a:hover{background:#4CAF50;color:#fff}
.hero{text-align:center;padding:40px 15px;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff}
.hero h1{font-size:2em;margin-bottom:10px}
.video-box{max-width:900px;margin:0 auto 20px}
.video-wrap{position:relative;padding-bottom:56.25%;background:#000;border-radius:10px;overflow:hidden}
.video-wrap video,.video-wrap img,.video-wrap #dvp{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:contain}
#tlv,#dvp{display:none;background:#000}
#dvp video{width:100%;height:100%}
.zoom-btns{position:absolute;bottom:15px;right:15px;display:flex;gap:8px;z-index:100}
.zoom-btns button{width:44px;height:44px;border:none;border-radius:50%;background:rgba(255,255,255,.95);font-size:20px;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.3);transition:.2s}
.zoom-btns button:hover{transform:scale(1.1);background:#fff}
.info-bar{display:flex;justify-content:center;gap:15px;margin:15px 0;flex-wrap:wrap}
.badge{background:#fff;padding:8px 18px;border-radius:25px;font-weight:bold;display:flex;align-items:center;gap:8px;box-shadow:0 2px 8px rgba(0,0,0,.1)}
.badge.live{background:#fff5f5;color:#d32f2f}
.dot{width:8px;height:8px;background:#f44;border-radius:50%;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(244,67,54,.6)}50%{box-shadow:0 0 0 8px transparent}}
.btns{display:flex;justify-content:center;gap:10px;flex-wrap:wrap;margin:15px 0}
.btn{padding:10px 20px;background:linear-gradient(135deg,#4CAF50,#45a049);color:#fff;border:none;border-radius:6px;font-weight:bold;cursor:pointer;text-decoration:none;transition:.3s}
.btn:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(76,175,80,.4)}
.btn.purple{background:linear-gradient(135deg,#667eea,#764ba2)}
#tl-ctrl{display:none;background:#fff;padding:12px 20px;border-radius:30px;margin:15px auto;max-width:700px;box-shadow:0 3px 10px rgba(0,0,0,.1)}
.tl-bar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:center}
.tl-btn{width:40px;height:40px;border:none;border-radius:50%;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;cursor:pointer;font-size:14px}
.tl-btn.on{background:linear-gradient(135deg,#4CAF50,#45a049)}
.tl-btn.wide{width:auto;padding:0 15px;border-radius:20px}
#tl-slider{flex:1;min-width:120px;max-width:250px}
#tl-time{font-family:monospace;background:#f5f5f5;padding:6px 12px;border-radius:15px}
#back-live{display:none}
.calendar-box{max-width:700px;margin:0 auto;background:#fff;border-radius:10px;padding:20px;box-shadow:0 3px 15px rgba(0,0,0,.1)}
.cal-nav{display:flex;justify-content:space-between;align-items:center;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;padding:12px 15px;border-radius:8px;margin-bottom:15px}
.cal-nav button{background:rgba(255,255,255,.2);border:none;color:#fff;padding:8px 15px;border-radius:5px;font-size:18px;cursor:pointer}
.cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:5px}
.cal-hd{text-align:center;font-weight:bold;padding:8px;background:#f5f5f5;border-radius:4px;font-size:12px}
.cal-day{aspect-ratio:1;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#fff;border:2px solid #e0e0e0;border-radius:8px;cursor:pointer;transition:.2s;position:relative;font-size:14px}
.cal-day:hover:not(.empty){transform:scale(1.05);border-color:#667eea}
.cal-day.empty{background:transparent;border:none;cursor:default}
.cal-day.has-vid{background:linear-gradient(135deg,#e3f2fd,#bbdefb);border-color:#2196F3}
.cal-day.sel{background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;transform:scale(1.08)}
.cal-day.today{border:2px solid #4CAF50}
.cal-day small{position:absolute;bottom:2px;right:2px;font-size:10px}
.day-vids{background:#f9f9f9;border-radius:8px;padding:15px;margin-top:15px}
.day-vids h4{margin-bottom:10px;border-bottom:2px solid #667eea;padding-bottom:8px}
.day-vids ul{list-style:none}
.day-vids li{display:flex;justify-content:space-between;align-items:center;padding:10px;background:#fff;margin-bottom:8px;border-radius:6px;flex-wrap:wrap;gap:8px}
.vid-btns{display:flex;gap:8px}
.btn-play,.btn-dl{padding:6px 12px;border-radius:15px;text-decoration:none;color:#fff;font-size:13px}
.btn-play{background:linear-gradient(135deg,#667eea,#764ba2)}
.btn-dl{background:linear-gradient(135deg,#4CAF50,#45a049)}
form{display:grid;gap:12px;background:#f9f9f9;padding:20px;border-radius:8px;max-width:500px;margin:0 auto}
input,textarea{width:100%;padding:10px;border:2px solid #ddd;border-radius:6px;font-size:15px}
input:focus,textarea:focus{border-color:#667eea;outline:none}
button[type=submit]{padding:10px 20px;background:linear-gradient(135deg,#4CAF50,#45a049);color:#fff;border:none;border-radius:6px;font-weight:bold;cursor:pointer}
.gb-entries{max-width:600px;margin:20px auto 0}
.gb-entry{background:#fff;border-left:4px solid #4CAF50;padding:15px;margin-bottom:10px;border-radius:6px;box-shadow:0 2px 6px rgba(0,0,0,.08)}
.gb-entry h4{margin-bottom:5px}
.gb-entry small{color:#888}
.gallery{display:flex;gap:10px;overflow-x:auto;padding:10px 0}
.gallery img{width:200px;height:140px;object-fit:cover;border-radius:8px;cursor:pointer;flex-shrink:0}
.admin-panel{background:#fff;padding:20px;border-radius:10px;margin-bottom:20px}
.admin-panel h3{margin-bottom:15px;border-bottom:2px solid #667eea;padding-bottom:8px}
.setting{display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid #eee}
.setting:last-child{border-bottom:none}
.setting input[type=checkbox]{width:20px;height:20px}
.setting input[type=number]{width:60px;padding:5px;text-align:center}
.msg{background:#f9f9f9;padding:12px;border-left:3px solid #667eea;margin-bottom:8px;border-radius:4px}
.del-btn{background:#f44;color:#fff;border:none;padding:4px 10px;border-radius:4px;cursor:pointer}
footer{background:#333;color:#fff;padding:30px 0;text-align:center}
footer a{color:#fff;margin:0 10px}
.modal{display:none;position:fixed;z-index:1000;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,.9);align-items:center;justify-content:center}
.modal img{max-width:95%;max-height:90%}
.modal .close{position:absolute;top:15px;right:25px;color:#fff;font-size:35px;cursor:pointer}
@media(max-width:600px){
.header-inner{flex-direction:column}
nav ul{justify-content:center}
.hero h1{font-size:1.5em}
.btns{flex-direction:column}
.btn{width:100%}
.tl-bar{flex-direction:column}
#tl-slider{width:100%;max-width:none}
}
</style>
</head>
<body>
<header>
<div class="container header-inner">
<div class="logo"><img src="logo.png" alt="Logo"></div>
<nav><ul>
<li><a href="#cam">Webcam</a></li>
<li><a href="#archive">Archiv</a></li>
<li><a href="#guestbook">Gästebuch</a></li>
<li><a href="#kontakt">Kontakt</a></li>
<?php if($adminManager->isAdmin()): ?><li><a href="#admin">Admin</a></li><?php endif; ?>
</ul></nav>
</div>
</header>
<section class="hero">
<h1>Aurora Wetter Livecam</h1>
<p>Faszinierende Ausblicke aus dem Zürcher Oberland</p>
</section>
<section id="cam" class="section">
<div class="container">
<div class="video-box">
<div class="video-wrap" id="vw">
<?php echo $webcamManager->displayWebcam(); ?>
<div id="tlv"><img id="tl-img"><div id="tl-overlay" style="position:absolute;top:10px;left:10px;background:rgba(0,0,0,.7);color:#fff;padding:6px 12px;border-radius:4px;font-family:monospace"></div></div>
<div id="dvp"><video id="dv" controls playsinline></video></div>
<div class="zoom-btns">
<button onclick="zoom(-1)"></button>
<button onclick="zoom(0)"></button>
<button onclick="zoom(1)">+</button>
</div>
</div>
</div>
<div id="tl-ctrl">
<div class="tl-bar">
<button class="tl-btn" id="tl-play"><i class="fas fa-play"></i></button>
<button class="tl-btn" id="tl-rev"><i class="fas fa-backward"></i></button>
<input type="range" id="tl-slider" min="0" value="0">
<span id="tl-time">--:--:--</span>
<button class="tl-btn wide" id="tl-spd">1x</button>
<button class="tl-btn wide on" id="tl-back"><i class="fas fa-video"></i> Live</button>
</div>
</div>
<button class="btn purple" id="back-live" onclick="toLive()"><i class="fas fa-video"></i> Zurück zu Live</button>
<div class="info-bar">
<?php echo $webcamManager->displayStreamStats(); ?>
<?php if($sv): ?><div class="badge live"><span class="dot"></span><strong id="vc"><?php echo $vc; ?></strong> Zuschauer</div><?php endif; ?>
</div>
<div class="btns">
<a href="?action=snapshot" class="btn">📷 Snapshot</a>
<button class="btn" id="tl-btn">🎬 Zeitraffer</button>
<a href="?download_video=1" class="btn">⬇️ Tagesvideo</a>
</div>
</div>
</section>
<section id="archive" class="section">
<div class="container">
<h2>📅 Videoarchiv</h2>
<?php $cal = new VisualCalendarManager('./videos/', $settingsManager); echo $cal->displayVisualCalendar(); ?>
</div>
</section>
<section id="guestbook" class="section">
<div class="container">
<h2>Gästebuch</h2>
<?php echo $guestbookManager->displayForm(); echo $guestbookManager->displayEntries($adminManager->isAdmin()); ?>
</div>
</section>
<section id="kontakt" class="section">
<div class="container">
<h2>Kontakt</h2>
<?php echo $contactManager->displayForm(); ?>
</div>
</section>
<section id="gallery" class="section">
<div class="container">
<h2>Galerie</h2>
<?php echo $adminManager->displayGalleryImages(); ?>
</div>
</section>
<?php if($adminManager->isAdmin()): ?>
<section id="admin" class="section">
<div class="container">
<h2>⚙️ Admin</h2>
<?php echo $adminManager->displayAdminContent(); ?>
</div>
</section>
<?php else: ?>
<section id="admin" class="section">
<div class="container">
<h2>Admin Login</h2>
<?php echo $adminManager->displayLoginForm(); ?>
</div>
</section>
<?php endif; ?>
<footer>
<a href="#cam">Webcam</a>
<a href="#archive">Archiv</a>
<a href="#kontakt">Kontakt</a>
<p style="margin-top:15px">&copy; 2024 Aurora Livecam</p>
</footer>
<div class="modal" id="modal" onclick="this.style.display='none'">
<span class="close">&times;</span>
<img id="modal-img">
</div>
<script>
<?php echo $webcamManager->getJavaScript(); ?>
let zoomLvl=1;
function zoom(d){
if(d===0) zoomLvl=1;
else zoomLvl=Math.max(1,Math.min(4,zoomLvl+d*0.5));
// Alle Video-Elemente in allen Modi
const targets=['#webcam-player','#tl-img','#dv'];
targets.forEach(sel=>{
const el=document.querySelector(sel);
if(el){
el.style.transform='scale('+zoomLvl+')';
el.style.transformOrigin='center center';
el.style.transition='transform 0.2s ease';
}
});
// Zoom-Level Anzeige
showZoomLevel();
}
function showZoomLevel(){
let ind=document.getElementById('zoom-ind');
if(!ind){
ind=document.createElement('div');
ind.id='zoom-ind';
ind.style.cssText='position:absolute;top:15px;left:15px;background:rgba(0,0,0,0.7);color:#fff;padding:8px 14px;border-radius:20px;font-weight:bold;z-index:100;transition:opacity 0.3s';
document.getElementById('vw').appendChild(ind);
}
ind.textContent='🔍 '+Math.round(zoomLvl*100)+'%';
ind.style.opacity='1';
clearTimeout(ind.hideTimer);
ind.hideTimer=setTimeout(()=>{ind.style.opacity='0';},1500);
}
const TL={
imgs:<?php echo $imageFilesJson; ?>,
idx:0,playing:false,rev:false,spd:1,spds:[1,10,100],iv:null,
init(){
document.getElementById('tl-play').onclick=()=>this.toggle();
document.getElementById('tl-rev').onclick=()=>this.toggleRev();
document.getElementById('tl-spd').onclick=()=>this.cycleSpd();
document.getElementById('tl-back').onclick=()=>toLive();
document.getElementById('tl-slider').max=this.imgs.length-1;
document.getElementById('tl-slider').oninput=e=>this.seek(+e.target.value);
},
show(){
document.getElementById('webcam-player').style.display='none';
document.getElementById('dvp').style.display='none';
document.getElementById('tlv').style.display='block';
document.getElementById('tl-ctrl').style.display='block';
document.getElementById('back-live').style.display='none';
this.idx=0;this.frame();
},
toggle(){
this.playing=!this.playing;
document.getElementById('tl-play').innerHTML=this.playing?'<i class="fas fa-pause"></i>':'<i class="fas fa-play"></i>';
if(this.playing)this.play();else this.stop();
},
toggleRev(){this.rev=!this.rev;document.getElementById('tl-rev').classList.toggle('on',this.rev);},
cycleSpd(){const i=this.spds.indexOf(this.spd);this.spd=this.spds[(i+1)%this.spds.length];document.getElementById('tl-spd').textContent=this.spd+'x';if(this.playing){this.stop();this.play();}},
play(){this.iv=setInterval(()=>this.next(),200/this.spd);},
stop(){clearInterval(this.iv);},
next(){this.idx+=this.rev?-1:1;if(this.idx<0)this.idx=this.imgs.length-1;if(this.idx>=this.imgs.length)this.idx=0;this.frame();},
seek(i){this.idx=i;this.frame();},
frame(){
const img=this.imgs[this.idx];if(!img)return;
document.getElementById('tl-img').src=img;
document.getElementById('tl-slider').value=this.idx;
const m=img.match(/(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/);
if(m){const t=m[3]+'.'+m[2]+'.'+m[1]+' '+m[4]+':'+m[5]+':'+m[6];document.getElementById('tl-time').textContent=t;document.getElementById('tl-overlay').textContent=t;}
}
};
function playVid(p){
document.getElementById('webcam-player').style.display='none';
document.getElementById('tlv').style.display='none';
document.getElementById('tl-ctrl').style.display='none';
document.getElementById('dvp').style.display='block';
document.getElementById('back-live').style.display='block';
const v=document.getElementById('dv');v.src=p;v.play();
document.getElementById('cam').scrollIntoView({behavior:'smooth'});
}
function toLive(){
TL.stop();TL.playing=false;
document.getElementById('tl-play').innerHTML='<i class="fas fa-play"></i>';
document.getElementById('tlv').style.display='none';
document.getElementById('tl-ctrl').style.display='none';
document.getElementById('dvp').style.display='none';
document.getElementById('back-live').style.display='none';
document.getElementById('webcam-player').style.display='block';
document.getElementById('tl-btn').textContent='🎬 Zeitraffer';
document.getElementById('dv').pause();document.getElementById('dv').src='';
zoomLvl=1;zoom(0);
}
function chgM(y,m){if(m<1){m=12;y--;}if(m>12){m=1;y++;}location.href='?cal_year='+y+'&cal_month='+m+'#archive';}
function selD(y,m,d){location.href='?cal_year='+y+'&cal_month='+m+'&cal_day='+d+'#archive';}
function openImg(s){document.getElementById('modal-img').src=s;document.getElementById('modal').style.display='flex';}
function updV(){
fetch(location.href,{method:'POST',body:new URLSearchParams({action:'viewer_heartbeat'})})
.then(r=>r.json()).then(d=>{const e=document.getElementById('vc');if(e&&d.count)e.textContent=d.count;});
}
<?php if($adminManager->isAdmin()): ?>
function saveSetting(key, value) {
const formData = new FormData();
formData.append('settings_action', 'update');
formData.append('key', key);
formData.append('value', value);
fetch(window.location.pathname, {
method: 'POST',
body: formData
})
.then(r => r.json())
.then(data => {
const toast = document.createElement('div');
toast.innerHTML = data.success ? '✓ Gespeichert' : '✗ Fehler: ' + (data.message || '');
toast.style.cssText = 'position:fixed;top:20px;right:20px;padding:15px 25px;border-radius:8px;background:' +
(data.success ? '#4CAF50' : '#f44336') + ';color:#fff;font-weight:bold;z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,0.3);';
document.body.appendChild(toast);
setTimeout(() => { toast.style.opacity = '0'; toast.style.transition = 'opacity 0.3s'; }, 1500);
setTimeout(() => toast.remove(), 2000);
})
.catch(err => {
console.error('Settings save error:', err);
alert('Fehler beim Speichern: ' + err.message);
});
}
// Settings Event-Handler nach DOM-Load binden
document.addEventListener('DOMContentLoaded', function() {
const sViewer = document.getElementById('s-viewer');
const sMin = document.getElementById('s-min');
const sPlay = document.getElementById('s-play');
const sDl = document.getElementById('s-dl');
if (sViewer) sViewer.addEventListener('change', function() {
saveSetting('viewer_display.enabled', this.checked ? 'true' : 'false');
});
if (sMin) sMin.addEventListener('change', function() {
saveSetting('viewer_display.min_viewers', this.value);
});
if (sPlay) sPlay.addEventListener('change', function() {
saveSetting('video_mode.play_in_player', this.checked ? 'true' : 'false');
});
if (sDl) sDl.addEventListener('change', function() {
saveSetting('video_mode.allow_download', this.checked ? 'true' : 'false');
});
});
<?php endif; ?>
document.addEventListener('DOMContentLoaded',()=>{
TL.init();
document.getElementById('tl-btn').onclick=()=>{
if(document.getElementById('tlv').style.display==='block'){toLive();}
else{TL.show();document.getElementById('tl-btn').textContent='↩️ Zurück zu Live';}
};
setTimeout(updV,2000);setInterval(updV,10000);
});
</script>
</body>
</html>
+140
View File
@@ -0,0 +1,140 @@
/**
* Admin Settings Manager - AJAX ohne Reload
*/
const AdminSettings = {
settings: {},
init: function() {
this.loadSettings();
this.setupEventListeners();
},
loadSettings: function() {
fetch(window.location.href, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'settings_action=get'
})
.then(r => r.json())
.then(data => {
if (data.success) {
this.settings = data.settings;
this.updateUI();
}
})
.catch(err => console.error('Settings load error:', err));
},
updateSetting: function(key, value) {
fetch(window.location.href, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `settings_action=update&key=${encodeURIComponent(key)}&value=${encodeURIComponent(value)}`
})
.then(r => r.json())
.then(data => {
if (data.success) {
this.showNotification('✓ Einstellung gespeichert', 'success');
// Sofort UI aktualisieren
this.applySettingImmediately(key, value);
} else {
this.showNotification('✗ Fehler beim Speichern', 'error');
}
})
.catch(err => {
console.error('Settings update error:', err);
this.showNotification('✗ Netzwerkfehler', 'error');
});
},
applySettingImmediately: function(key, value) {
// Sofortige Anwendung ohne Reload
switch(key) {
case 'viewer_display.enabled':
const viewerEl = document.querySelector('.viewer-stat');
if (viewerEl) {
viewerEl.style.display = value === true || value === 'true' ? 'inline-flex' : 'none';
}
break;
case 'viewer_display.min_viewers':
// Wird beim nächsten Heartbeat angewendet
window.minViewersToShow = parseInt(value);
break;
}
},
updateUI: function() {
// Checkbox für Zuschauer-Anzeige
const viewerEnabled = document.getElementById('setting-viewer-enabled');
if (viewerEnabled) {
viewerEnabled.checked = this.settings.viewer_display?.enabled ?? true;
}
// Mindestanzahl
const minViewers = document.getElementById('setting-min-viewers');
if (minViewers) {
minViewers.value = this.settings.viewer_display?.min_viewers ?? 1;
}
// Video-Modus
const playInPlayer = document.getElementById('setting-play-in-player');
if (playInPlayer) {
playInPlayer.checked = this.settings.video_mode?.play_in_player ?? true;
}
const allowDownload = document.getElementById('setting-allow-download');
if (allowDownload) {
allowDownload.checked = this.settings.video_mode?.allow_download ?? true;
}
},
setupEventListeners: function() {
// Zuschauer-Anzeige Toggle
document.getElementById('setting-viewer-enabled')?.addEventListener('change', (e) => {
this.updateSetting('viewer_display.enabled', e.target.checked);
});
// Mindestanzahl Zuschauer
document.getElementById('setting-min-viewers')?.addEventListener('change', (e) => {
this.updateSetting('viewer_display.min_viewers', e.target.value);
});
// Video im Player abspielen
document.getElementById('setting-play-in-player')?.addEventListener('change', (e) => {
this.updateSetting('video_mode.play_in_player', e.target.checked);
});
// Download erlauben
document.getElementById('setting-allow-download')?.addEventListener('change', (e) => {
this.updateSetting('video_mode.allow_download', e.target.checked);
});
},
showNotification: function(message, type) {
const notification = document.createElement('div');
notification.className = `admin-notification ${type}`;
notification.textContent = message;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 15px 25px;
border-radius: 8px;
background: ${type === 'success' ? '#4CAF50' : '#f44336'};
color: white;
font-weight: bold;
z-index: 10000;
animation: slideIn 0.3s ease;
`;
document.body.appendChild(notification);
setTimeout(() => notification.remove(), 3000);
}
};
// Initialisierung nur im Admin-Bereich
document.addEventListener('DOMContentLoaded', function() {
if (document.getElementById('admin-settings-panel')) {
AdminSettings.init();
}
});
+167
View File
@@ -0,0 +1,167 @@
/**
* Timelapse Controller mit Slider, Geschwindigkeit und Rückwärts
*/
const TimelapseController = {
imageFiles: [],
currentIndex: 0,
isPlaying: false,
isReverse: false,
speed: 1,
availableSpeeds: [1, 10, 100],
intervalId: null,
baseInterval: 200, // ms bei 1x
init: function(imageFilesArray) {
this.imageFiles = imageFilesArray;
this.setupControls();
this.updateSlider();
},
setupControls: function() {
const container = document.getElementById('timelapse-controls');
if (!container) return;
container.innerHTML = `
<div class="timelapse-control-bar">
<button id="tl-play-pause" class="tl-btn" title="Play/Pause">
<i class="fas fa-play"></i>
</button>
<button id="tl-reverse" class="tl-btn" title="Rückwärts">
<i class="fas fa-backward"></i>
</button>
<div class="tl-slider-container">
<input type="range" id="tl-slider" min="0" max="100" value="0">
<span id="tl-time-display">00:00:00</span>
</div>
<div class="tl-speed-container">
<button id="tl-speed" class="tl-btn tl-speed-btn">1x</button>
</div>
<button id="tl-back-live" class="tl-btn tl-back-btn" title="Zurück zu Live">
<i class="fas fa-video"></i> Live
</button>
</div>
`;
// Event Listeners
document.getElementById('tl-play-pause').onclick = () => this.togglePlay();
document.getElementById('tl-reverse').onclick = () => this.toggleReverse();
document.getElementById('tl-speed').onclick = () => this.cycleSpeed();
document.getElementById('tl-back-live').onclick = () => this.backToLive();
const slider = document.getElementById('tl-slider');
slider.max = this.imageFiles.length - 1;
slider.oninput = (e) => this.seekTo(parseInt(e.target.value));
},
togglePlay: function() {
this.isPlaying = !this.isPlaying;
const btn = document.getElementById('tl-play-pause');
btn.innerHTML = this.isPlaying ? '<i class="fas fa-pause"></i>' : '<i class="fas fa-play"></i>';
if (this.isPlaying) {
this.startPlayback();
} else {
this.stopPlayback();
}
},
toggleReverse: function() {
this.isReverse = !this.isReverse;
const btn = document.getElementById('tl-reverse');
btn.classList.toggle('active', this.isReverse);
btn.innerHTML = this.isReverse ?
'<i class="fas fa-forward"></i>' :
'<i class="fas fa-backward"></i>';
},
cycleSpeed: function() {
const idx = this.availableSpeeds.indexOf(this.speed);
this.speed = this.availableSpeeds[(idx + 1) % this.availableSpeeds.length];
document.getElementById('tl-speed').textContent = this.speed + 'x';
if (this.isPlaying) {
this.stopPlayback();
this.startPlayback();
}
},
startPlayback: function() {
const interval = this.baseInterval / this.speed;
this.intervalId = setInterval(() => this.nextFrame(), interval);
},
stopPlayback: function() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
},
nextFrame: function() {
if (this.isReverse) {
this.currentIndex--;
if (this.currentIndex < 0) this.currentIndex = this.imageFiles.length - 1;
} else {
this.currentIndex++;
if (this.currentIndex >= this.imageFiles.length) this.currentIndex = 0;
}
this.showFrame(this.currentIndex);
},
seekTo: function(index) {
this.currentIndex = index;
this.showFrame(index);
},
showFrame: function(index) {
const img = document.getElementById('timelapse-image');
if (img && this.imageFiles[index]) {
img.src = this.imageFiles[index];
}
this.updateSlider();
this.updateTimeDisplay();
},
updateSlider: function() {
const slider = document.getElementById('tl-slider');
if (slider) slider.value = this.currentIndex;
},
updateTimeDisplay: function() {
const display = document.getElementById('tl-time-display');
if (!display || !this.imageFiles[this.currentIndex]) return;
const filename = this.imageFiles[this.currentIndex];
const match = filename.match(/(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/);
if (match) {
const [_, y, m, d, h, min, s] = match;
display.textContent = `${d}.${m}.${y} ${h}:${min}:${s}`;
}
},
backToLive: function() {
this.stopPlayback();
this.isPlaying = false;
// Live-Video wieder anzeigen
document.getElementById('timelapse-viewer').style.display = 'none';
document.getElementById('webcam-player').style.display = 'block';
document.getElementById('timelapse-button').textContent = 'Wochenzeitraffer';
// Controls verstecken
const controls = document.getElementById('timelapse-controls');
if (controls) controls.style.display = 'none';
},
show: function() {
document.getElementById('timelapse-viewer').style.display = 'block';
document.getElementById('webcam-player').style.display = 'none';
document.getElementById('daily-video-player').style.display = 'none';
const controls = document.getElementById('timelapse-controls');
if (controls) controls.style.display = 'block';
this.currentIndex = 0;
this.showFrame(0);
}
};
+108
View File
@@ -0,0 +1,108 @@
/**
* Daily Video Player - Spielt Tagesvideos im Hauptfenster ab
*/
const DailyVideoPlayer = {
currentVideo: null,
videoElement: null,
init: function() {
this.createPlayerElement();
this.setupEventListeners();
},
createPlayerElement: function() {
// Player-Container erstellen falls nicht vorhanden
if (document.getElementById('daily-video-player')) return;
const container = document.createElement('div');
container.id = 'daily-video-player';
container.style.display = 'none';
container.innerHTML = `
<video id="daily-video" controls playsinline>
<source src="" type="video/mp4">
</video>
<div class="video-player-controls">
<button id="dvp-back-live" class="tl-btn tl-back-btn">
<i class="fas fa-video"></i> Zurück zu Live
</button>
<a id="dvp-download" class="button" style="display:none;">
<i class="fas fa-download"></i> Download
</a>
</div>
`;
// Nach dem Webcam-Player einfügen
const videoContainer = document.querySelector('.video-container');
if (videoContainer) {
videoContainer.appendChild(container);
}
this.videoElement = document.getElementById('daily-video');
},
setupEventListeners: function() {
document.getElementById('dvp-back-live')?.addEventListener('click', () => this.backToLive());
// Video-Ende Event
this.videoElement?.addEventListener('ended', () => {
// Optional: Automatisch zurück zu Live
});
},
playVideo: function(videoPath, allowDownload = true) {
this.currentVideo = videoPath;
// Andere Player verstecken
document.getElementById('webcam-player').style.display = 'none';
document.getElementById('timelapse-viewer').style.display = 'none';
document.getElementById('timelapse-controls')?.style.display = 'none';
// Diesen Player anzeigen
const player = document.getElementById('daily-video-player');
player.style.display = 'block';
// Video laden
this.videoElement.src = videoPath;
this.videoElement.load();
this.videoElement.play();
// Download-Button
const downloadBtn = document.getElementById('dvp-download');
if (allowDownload && downloadBtn) {
downloadBtn.style.display = 'inline-block';
downloadBtn.href = videoPath;
downloadBtn.download = videoPath.split('/').pop();
} else if (downloadBtn) {
downloadBtn.style.display = 'none';
}
},
backToLive: function() {
// Video stoppen
if (this.videoElement) {
this.videoElement.pause();
this.videoElement.src = '';
}
// Player verstecken
document.getElementById('daily-video-player').style.display = 'none';
// Live-Stream anzeigen
document.getElementById('webcam-player').style.display = 'block';
},
// Wird vom Kalender aufgerufen
handleCalendarClick: function(videoPath, playInPlayer, allowDownload) {
if (playInPlayer) {
this.playVideo(videoPath, allowDownload);
} else {
// Nur Download
window.location.href = videoPath;
}
}
};
// Initialisierung
document.addEventListener('DOMContentLoaded', function() {
DailyVideoPlayer.init();
});
+215
View File
@@ -0,0 +1,215 @@
/**
* Video Zoom & Pan Controller
* Zoomt auf Wrapper-Layer statt direkt auf Video-Elemente
*/
(() => {
const config = window.zoomConfig || {};
if (!config.enabled) return;
let currentZoom = 1;
let panX = 0;
let panY = 0;
let isDragging = false;
let lastX = 0;
let lastY = 0;
const minZoom = Number(config.minZoom || 1);
const maxZoom = Number(config.maxZoom || 4);
const slider = document.getElementById('zoom-range');
const valueEl = document.getElementById('zoom-value');
// Wrapper-IDs für jeden Modus
const wrapperIds = ['live-video-wrapper', 'timelapse-wrapper', 'daily-video-wrapper'];
// Finde den aktuell sichtbaren Wrapper
function getActiveWrapper() {
// Prüfe daily-video-player
const dailyPlayer = document.getElementById('daily-video-player');
if (dailyPlayer && dailyPlayer.style.display !== 'none') {
return document.getElementById('daily-video-wrapper');
}
// Prüfe timelapse-viewer
const timelapseViewer = document.getElementById('timelapse-viewer');
if (timelapseViewer && timelapseViewer.style.display !== 'none') {
return document.getElementById('timelapse-wrapper');
}
// Fallback: Live-Video
return document.getElementById('live-video-wrapper');
}
// Wende Transform auf ALLE Wrapper an (damit beim Wechsel der Zoom erhalten bleibt)
function applyTransform() {
// Bei Zoom 1x: Kein Pan
if (currentZoom <= 1) {
panX = 0;
panY = 0;
}
// Pan begrenzen basierend auf Zoom
const maxPan = (currentZoom - 1) * 50;
panX = Math.max(-maxPan, Math.min(maxPan, panX));
panY = Math.max(-maxPan, Math.min(maxPan, panY));
// Transform auf alle Wrapper anwenden
wrapperIds.forEach(id => {
const wrapper = document.getElementById(id);
if (wrapper) {
wrapper.style.transform = `scale(${currentZoom}) translate(${panX}%, ${panY}%)`;
wrapper.style.transition = isDragging ? 'none' : 'transform 0.15s ease-out';
}
});
// UI Update
if (valueEl) valueEl.textContent = `${currentZoom.toFixed(1)}x`;
if (slider) slider.value = currentZoom;
// Cursor Update
updateCursor();
}
function updateCursor() {
const container = document.querySelector('.video-container');
if (container) {
if (currentZoom > 1) {
container.classList.add('zoomed');
} else {
container.classList.remove('zoomed');
}
}
}
// Zoom setzen
function setZoom(value) {
currentZoom = Math.max(minZoom, Math.min(maxZoom, value));
applyTransform();
}
// Zoom anpassen
function adjustZoom(delta) {
setZoom(currentZoom + delta);
}
// Zoom zurücksetzen
function resetZoom() {
currentZoom = 1;
panX = 0;
panY = 0;
applyTransform();
}
// Mouse Events für Pan
function setupPanEvents() {
const container = document.querySelector('.video-container');
if (!container) return;
// Mousedown - Start dragging
container.addEventListener('mousedown', (e) => {
if (currentZoom <= 1) return;
// Ignoriere Klicks auf Controls
if (e.target.closest('.zoom-controls, button, a')) return;
isDragging = true;
lastX = e.clientX;
lastY = e.clientY;
e.preventDefault();
});
// Mousemove - Dragging
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const deltaX = e.clientX - lastX;
const deltaY = e.clientY - lastY;
// Sensitivität basierend auf Zoom
const sensitivity = 0.15 / currentZoom;
panX += deltaX * sensitivity;
panY += deltaY * sensitivity;
lastX = e.clientX;
lastY = e.clientY;
applyTransform();
});
// Mouseup - Stop dragging
document.addEventListener('mouseup', () => {
isDragging = false;
});
// Mouse leave
document.addEventListener('mouseleave', () => {
isDragging = false;
});
// Touch Events für Mobile
container.addEventListener('touchstart', (e) => {
if (currentZoom <= 1 || e.touches.length !== 1) return;
if (e.target.closest('.zoom-controls, button, a')) return;
isDragging = true;
lastX = e.touches[0].clientX;
lastY = e.touches[0].clientY;
}, { passive: true });
container.addEventListener('touchmove', (e) => {
if (!isDragging || e.touches.length !== 1) return;
const deltaX = e.touches[0].clientX - lastX;
const deltaY = e.touches[0].clientY - lastY;
const sensitivity = 0.15 / currentZoom;
panX += deltaX * sensitivity;
panY += deltaY * sensitivity;
lastX = e.touches[0].clientX;
lastY = e.touches[0].clientY;
applyTransform();
}, { passive: true });
container.addEventListener('touchend', () => {
isDragging = false;
});
// Doppelklick zum Zurücksetzen
container.addEventListener('dblclick', (e) => {
if (e.target.closest('.zoom-controls, button, a')) return;
resetZoom();
});
}
// Slider Setup
function setupSlider() {
if (!slider) return;
slider.min = minZoom;
slider.max = maxZoom;
slider.step = 0.5;
slider.value = 1;
slider.addEventListener('input', (e) => {
setZoom(Number(e.target.value));
});
}
// Globale Funktionen
window.adjustZoom = adjustZoom;
window.resetZoom = resetZoom;
window.setZoom = setZoom;
// Initialisierung
document.addEventListener('DOMContentLoaded', () => {
setupSlider();
setupPanEvents();
// Initial State
currentZoom = 1;
applyTransform();
console.log('Video Zoom & Pan initialized');
});
})();
+422
View File
@@ -0,0 +1,422 @@
<?php
/**
* Landing Page - Marketing Seite
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
$settingsManager = new SettingsManager();
// Prüfe ob Landing Page aktiviert
if (!$settingsManager->isLandingPageEnabled()) {
header('Location: /');
exit;
}
$trialDays = $settingsManager->getTrialDays();
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aurora Livecam - Ihre Webcam als Service</title>
<meta name="description" content="Erstellen Sie Ihre eigene Live-Webcam in wenigen Minuten. Wetter-Widget, Timelapse, Analytics und mehr. Jetzt kostenlos testen!">
<link rel="stylesheet" href="/dashboard/assets/dashboard.css">
<style>
:root {
--gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #1a202c;
}
/* Header */
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
background: rgba(255,255,255,0.95);
backdrop-filter: blur(10px);
z-index: 100;
border-bottom: 1px solid #e2e8f0;
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
background: var(--gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-decoration: none;
}
.nav-links {
display: flex;
gap: 2rem;
align-items: center;
}
.nav-links a {
color: #4a5568;
text-decoration: none;
font-weight: 500;
transition: color 0.2s;
}
.nav-links a:hover {
color: #667eea;
}
/* Hero */
.hero {
padding: 8rem 2rem 6rem;
background: var(--gradient);
color: white;
text-align: center;
}
.hero h1 {
font-size: 3rem;
font-weight: 800;
margin-bottom: 1.5rem;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.hero p {
font-size: 1.25rem;
opacity: 0.9;
max-width: 600px;
margin: 0 auto 2rem;
}
.hero-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.btn-hero {
padding: 1rem 2rem;
border-radius: 0.5rem;
font-size: 1.1rem;
font-weight: 600;
text-decoration: none;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn-hero-primary {
background: white;
color: #667eea;
}
.btn-hero-secondary {
background: rgba(255,255,255,0.2);
color: white;
border: 2px solid rgba(255,255,255,0.5);
}
.btn-hero:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0,0,0,0.2);
}
.trial-badge {
display: inline-block;
background: rgba(255,255,255,0.2);
padding: 0.5rem 1rem;
border-radius: 2rem;
margin-top: 2rem;
font-size: 0.9rem;
}
/* Features */
.features {
padding: 6rem 2rem;
background: #f7fafc;
}
.section-title {
text-align: center;
margin-bottom: 4rem;
}
.section-title h2 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.section-title p {
color: #718096;
font-size: 1.1rem;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.feature-card {
background: white;
padding: 2rem;
border-radius: 1rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
transition: transform 0.2s, box-shadow 0.2s;
}
.feature-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}
.feature-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.feature-card h3 {
font-size: 1.25rem;
margin-bottom: 0.75rem;
}
.feature-card p {
color: #718096;
}
/* How it works */
.how-it-works {
padding: 6rem 2rem;
max-width: 1000px;
margin: 0 auto;
}
.steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 2rem;
margin-top: 3rem;
}
.step {
text-align: center;
}
.step-number {
width: 60px;
height: 60px;
background: var(--gradient);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: 700;
margin: 0 auto 1rem;
}
.step h4 {
margin-bottom: 0.5rem;
}
.step p {
color: #718096;
font-size: 0.9rem;
}
/* CTA */
.cta {
padding: 6rem 2rem;
background: var(--gradient);
color: white;
text-align: center;
}
.cta h2 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.cta p {
font-size: 1.1rem;
opacity: 0.9;
margin-bottom: 2rem;
}
/* Footer */
.footer {
background: #1a202c;
color: #a0aec0;
padding: 3rem 2rem;
}
.footer-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 2rem;
}
.footer-links a {
color: #a0aec0;
text-decoration: none;
margin-right: 1.5rem;
}
.footer-links a:hover {
color: white;
}
/* Responsive */
@media (max-width: 768px) {
.hero h1 { font-size: 2rem; }
.nav-links { display: none; }
.features-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<!-- Header -->
<header class="header">
<div class="header-inner">
<a href="/" class="logo">Aurora Livecam</a>
<nav class="nav-links">
<a href="#features">Features</a>
<a href="/landing/pricing.php">Preise</a>
<a href="/dashboard/login.php">Login</a>
<a href="/onboarding/register.php" class="btn btn-primary btn-sm">Kostenlos starten</a>
</nav>
</div>
</header>
<!-- Hero -->
<section class="hero">
<h1>Ihre Webcam als Service - in 5 Minuten online</h1>
<p>Erstellen Sie Ihre eigene Live-Webcam-Website mit Wetter-Widget, Timelapse, Analytics und mehr. Keine Programmierkenntnisse erforderlich.</p>
<div class="hero-buttons">
<a href="/onboarding/register.php" class="btn-hero btn-hero-primary">
Jetzt starten
</a>
<a href="#features" class="btn-hero btn-hero-secondary">
Features ansehen
</a>
</div>
<div class="trial-badge">
<?php echo $trialDays; ?> Tage kostenlos testen - Keine Kreditkarte erforderlich
</div>
</section>
<!-- Features -->
<section class="features" id="features">
<div class="section-title">
<h2>Alles was Sie brauchen</h2>
<p>Professionelle Features für Ihre Live-Webcam</p>
</div>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">📹</div>
<h3>Live-Streaming</h3>
<p>HLS, RTMP oder WebRTC - verbinden Sie jeden Stream in Sekunden. Automatische Qualitätsanpassung inklusive.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🌤️</div>
<h3>Wetter-Widget</h3>
<p>Zeigen Sie Temperatur, Wind, Luftdruck und mehr an. Kostenlose Open-Meteo Integration ohne API-Key.</p>
</div>
<div class="feature-card">
<div class="feature-icon">⏱️</div>
<h3>Timelapse</h3>
<p>Automatische Zeitraffer-Erstellung. Scrubben Sie durch den ganzen Tag mit variabler Geschwindigkeit.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔍</div>
<h3>Zoom & Pan</h3>
<p>Lassen Sie Besucher in Ihren Stream hineinzoomen. Unterstützt Touch-Gesten und Maus-Steuerung.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📊</div>
<h3>Analytics</h3>
<p>Sehen Sie wer Ihre Webcam besucht. Echtzeit-Zuschauerzähler und detaillierte Statistiken.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🎨</div>
<h3>Custom Branding</h3>
<p>Ihr Logo, Ihre Farben, Ihre Domain. Machen Sie die Webcam zu Ihrer eigenen.</p>
</div>
</div>
</section>
<!-- How it works -->
<section class="how-it-works">
<div class="section-title">
<h2>So einfach geht's</h2>
<p>In 3 Schritten zur eigenen Livecam</p>
</div>
<div class="steps">
<div class="step">
<div class="step-number">1</div>
<h4>Registrieren</h4>
<p>Erstellen Sie in 30 Sekunden Ihr kostenloses Konto.</p>
</div>
<div class="step">
<div class="step-number">2</div>
<h4>Stream verbinden</h4>
<p>Fügen Sie Ihre Stream-URL ein. Wir unterstützen alle gängigen Formate.</p>
</div>
<div class="step">
<div class="step-number">3</div>
<h4>Anpassen & Teilen</h4>
<p>Personalisieren Sie Ihre Seite und teilen Sie den Link.</p>
</div>
</div>
</section>
<!-- CTA -->
<section class="cta">
<h2>Bereit loszulegen?</h2>
<p><?php echo $trialDays; ?> Tage kostenlos testen - keine Kreditkarte erforderlich</p>
<a href="/onboarding/register.php" class="btn-hero btn-hero-primary">
Jetzt kostenlos starten
</a>
</section>
<!-- Footer -->
<footer class="footer">
<div class="footer-inner">
<div>
© <?php echo date('Y'); ?> Aurora Livecam. Alle Rechte vorbehalten.
</div>
<div class="footer-links">
<a href="/terms">AGB</a>
<a href="/privacy">Datenschutz</a>
<a href="/imprint">Impressum</a>
<a href="mailto:support@aurora-livecam.com">Kontakt</a>
</div>
</div>
</footer>
</body>
</html>
+497
View File
@@ -0,0 +1,497 @@
<?php
/**
* Landing Page - Preise
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
if (file_exists(dirname(__DIR__) . '/src/bootstrap.php')) {
require_once dirname(__DIR__) . '/src/bootstrap.php';
}
use AuroraLivecam\Billing\SubscriptionManager;
$settingsManager = new SettingsManager();
// Pläne laden
$plans = [];
try {
$subscriptions = new SubscriptionManager();
$plans = $subscriptions->getPlans();
} catch (\Exception $e) {
// Fallback-Pläne
$plans = [
['name' => 'Free', 'slug' => 'free', 'price_monthly' => 0, 'features' => ['max_viewers' => 10, 'weather_widget' => true]],
['name' => 'Basic', 'slug' => 'basic', 'price_monthly' => 19, 'features' => ['max_viewers' => 50, 'weather_widget' => true, 'timelapse' => true, 'analytics' => true]],
['name' => 'Professional', 'slug' => 'professional', 'price_monthly' => 49, 'features' => ['max_viewers' => 200, 'custom_domain' => true, 'weather_widget' => true, 'timelapse' => true, 'analytics' => true, 'branding' => true]],
['name' => 'Enterprise', 'slug' => 'enterprise', 'price_monthly' => 149, 'features' => ['max_viewers' => -1, 'custom_domain' => true, 'weather_widget' => true, 'timelapse' => true, 'analytics' => true, 'branding' => true, 'priority_support' => true]],
];
}
$trialDays = $settingsManager->getTrialDays();
// Feature-Labels
$featureLabels = [
'max_viewers' => 'Gleichzeitige Zuschauer',
'storage_gb' => 'Speicherplatz',
'custom_domain' => 'Eigene Domain',
'weather_widget' => 'Wetter-Widget',
'timelapse' => 'Timelapse',
'analytics' => 'Analytics & Statistiken',
'branding' => 'Custom Branding',
'priority_support' => 'Priority Support',
];
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Preise - Aurora Livecam</title>
<link rel="stylesheet" href="/dashboard/assets/dashboard.css">
<style>
:root {
--gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #1a202c;
background: #f7fafc;
}
.header {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 1rem 2rem;
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
background: var(--gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-decoration: none;
}
.nav-links a {
color: #4a5568;
text-decoration: none;
margin-left: 1.5rem;
}
.page-header {
text-align: center;
padding: 4rem 2rem;
background: var(--gradient);
color: white;
}
.page-header h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.page-header p {
font-size: 1.1rem;
opacity: 0.9;
}
.pricing-toggle {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 2rem;
align-items: center;
}
.pricing-toggle span {
font-size: 0.9rem;
}
.pricing-toggle .active {
font-weight: 600;
}
.toggle-switch {
width: 60px;
height: 30px;
background: rgba(255,255,255,0.3);
border-radius: 15px;
position: relative;
cursor: pointer;
}
.toggle-switch::after {
content: '';
position: absolute;
width: 26px;
height: 26px;
background: white;
border-radius: 50%;
top: 2px;
left: 2px;
transition: 0.3s;
}
.toggle-switch.yearly::after {
left: 32px;
}
.save-badge {
background: #48bb78;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
}
.pricing-container {
max-width: 1200px;
margin: -3rem auto 4rem;
padding: 0 2rem;
}
.pricing-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.pricing-card {
background: white;
border-radius: 1rem;
padding: 2rem;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
position: relative;
display: flex;
flex-direction: column;
}
.pricing-card.featured {
border: 2px solid #667eea;
transform: scale(1.05);
}
.pricing-card.featured::before {
content: 'Beliebt';
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
background: var(--gradient);
color: white;
padding: 0.25rem 1rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 600;
}
.pricing-card h3 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.pricing-card .price {
font-size: 3rem;
font-weight: 800;
margin: 1rem 0;
}
.pricing-card .price span {
font-size: 1rem;
font-weight: 400;
color: #718096;
}
.pricing-card .price-yearly {
display: none;
}
.yearly-mode .price-monthly { display: none; }
.yearly-mode .price-yearly { display: block; }
.pricing-card ul {
list-style: none;
flex: 1;
margin: 1.5rem 0;
}
.pricing-card li {
padding: 0.5rem 0;
color: #4a5568;
display: flex;
align-items: center;
gap: 0.5rem;
}
.pricing-card li.included::before {
content: '✓';
color: #48bb78;
font-weight: bold;
}
.pricing-card li.not-included {
color: #a0aec0;
text-decoration: line-through;
}
.pricing-card li.not-included::before {
content: '✗';
color: #e53e3e;
}
.pricing-card .btn {
width: 100%;
padding: 1rem;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
text-align: center;
transition: all 0.2s;
}
.pricing-card .btn-primary {
background: var(--gradient);
color: white;
}
.pricing-card .btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.pricing-card .btn-secondary {
background: #e2e8f0;
color: #4a5568;
}
.faq {
max-width: 800px;
margin: 0 auto 4rem;
padding: 0 2rem;
}
.faq h2 {
text-align: center;
margin-bottom: 2rem;
}
.faq-item {
background: white;
border-radius: 0.5rem;
margin-bottom: 1rem;
overflow: hidden;
}
.faq-question {
padding: 1.25rem;
font-weight: 600;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.faq-answer {
padding: 0 1.25rem 1.25rem;
color: #718096;
display: none;
}
.faq-item.open .faq-answer {
display: block;
}
.footer {
background: #1a202c;
color: #a0aec0;
padding: 2rem;
text-align: center;
}
@media (max-width: 768px) {
.pricing-card.featured {
transform: none;
}
.pricing-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<header class="header">
<div class="header-inner">
<a href="/landing/" class="logo">Aurora Livecam</a>
<nav class="nav-links">
<a href="/landing/">Home</a>
<a href="/dashboard/login.php">Login</a>
<a href="/onboarding/register.php" class="btn btn-primary btn-sm">Kostenlos starten</a>
</nav>
</div>
</header>
<section class="page-header">
<h1>Einfache, transparente Preise</h1>
<p><?php echo $trialDays; ?> Tage kostenlos testen - jederzeit kündbar</p>
<div class="pricing-toggle">
<span class="monthly-label active">Monatlich</span>
<div class="toggle-switch" id="billing-toggle"></div>
<span class="yearly-label">Jährlich</span>
<span class="save-badge">2 Monate gratis</span>
</div>
</section>
<div class="pricing-container" id="pricing-container">
<div class="pricing-grid">
<?php foreach ($plans as $index => $plan): ?>
<?php $isFeatured = $plan['slug'] === 'professional'; ?>
<div class="pricing-card <?php echo $isFeatured ? 'featured' : ''; ?>">
<h3><?php echo htmlspecialchars($plan['name']); ?></h3>
<div class="price price-monthly">
<?php if ($plan['price_monthly'] > 0): ?>
CHF <?php echo number_format($plan['price_monthly'], 0); ?><span>/Monat</span>
<?php else: ?>
Kostenlos
<?php endif; ?>
</div>
<div class="price price-yearly">
<?php if (isset($plan['price_yearly']) && $plan['price_yearly'] > 0): ?>
CHF <?php echo number_format($plan['price_yearly'] / 12, 0); ?><span>/Monat</span>
<div style="font-size: 0.875rem; color: #718096;">
CHF <?php echo number_format($plan['price_yearly'], 0); ?> jährlich
</div>
<?php elseif ($plan['price_monthly'] > 0): ?>
CHF <?php echo number_format($plan['price_monthly'] * 10 / 12, 0); ?><span>/Monat</span>
<div style="font-size: 0.875rem; color: #718096;">
CHF <?php echo number_format($plan['price_monthly'] * 10, 0); ?> jährlich
</div>
<?php else: ?>
Kostenlos
<?php endif; ?>
</div>
<ul>
<?php
$features = is_array($plan['features']) ? $plan['features'] : json_decode($plan['features'], true) ?? [];
$allFeatures = ['max_viewers', 'weather_widget', 'timelapse', 'analytics', 'custom_domain', 'branding', 'priority_support'];
foreach ($allFeatures as $feature):
$hasFeature = !empty($features[$feature]);
$value = $features[$feature] ?? null;
?>
<li class="<?php echo $hasFeature ? 'included' : 'not-included'; ?>">
<?php
if ($feature === 'max_viewers' && $value) {
echo $value === -1 ? 'Unbegrenzte Zuschauer' : "Bis $value Zuschauer";
} elseif ($feature === 'storage_gb' && $value) {
echo "$value GB Speicher";
} else {
echo $featureLabels[$feature] ?? ucfirst(str_replace('_', ' ', $feature));
}
?>
</li>
<?php endforeach; ?>
</ul>
<a href="/onboarding/register.php?plan=<?php echo $plan['slug']; ?>"
class="btn <?php echo $isFeatured || $plan['price_monthly'] > 0 ? 'btn-primary' : 'btn-secondary'; ?>">
<?php echo $plan['price_monthly'] > 0 ? 'Jetzt starten' : 'Kostenlos starten'; ?>
</a>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- FAQ -->
<section class="faq">
<h2>Häufige Fragen</h2>
<div class="faq-item">
<div class="faq-question">
Kann ich jederzeit wechseln oder kündigen?
<span>+</span>
</div>
<div class="faq-answer">
Ja! Sie können Ihren Plan jederzeit upgraden oder downgraden. Bei einer Kündigung bleibt Ihr Zugang bis zum Ende der Abrechnungsperiode aktiv.
</div>
</div>
<div class="faq-item">
<div class="faq-question">
Was passiert nach dem Trial?
<span>+</span>
</div>
<div class="faq-answer">
Nach Ablauf der <?php echo $trialDays; ?> Tage werden Sie automatisch auf den kostenlosen Plan umgestellt, sofern Sie kein Abo abschliessen. Keine Sorge, Ihre Daten bleiben erhalten.
</div>
</div>
<div class="faq-item">
<div class="faq-question">
Welche Zahlungsmethoden werden akzeptiert?
<span>+</span>
</div>
<div class="faq-answer">
Wir akzeptieren alle gängigen Kreditkarten (Visa, Mastercard, American Express) sowie TWINT und Banküberweisung bei Jahresabos.
</div>
</div>
<div class="faq-item">
<div class="faq-question">
Brauche ich technisches Wissen?
<span>+</span>
</div>
<div class="faq-answer">
Nein! Unser Onboarding-Wizard führt Sie Schritt für Schritt durch die Einrichtung. Sie benötigen lediglich eine Stream-URL (HLS/m3u8) von Ihrem Kamera-Anbieter.
</div>
</div>
</section>
<footer class="footer">
© <?php echo date('Y'); ?> Aurora Livecam. Alle Rechte vorbehalten.
</footer>
<script>
// Billing toggle
const toggle = document.getElementById('billing-toggle');
const container = document.getElementById('pricing-container');
toggle.addEventListener('click', () => {
toggle.classList.toggle('yearly');
container.classList.toggle('yearly-mode');
document.querySelector('.monthly-label').classList.toggle('active');
document.querySelector('.yearly-label').classList.toggle('active');
});
// FAQ accordion
document.querySelectorAll('.faq-question').forEach(q => {
q.addEventListener('click', () => {
q.parentElement.classList.toggle('open');
q.querySelector('span').textContent = q.parentElement.classList.contains('open') ? '' : '+';
});
});
</script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More