Compare commits

...

35 Commits

Author SHA1 Message Date
Claude 69e09c2708 Fix API paths and add missing settings
- Fix PHP syntax error in auto-screenshot.php (cron comment)
- Change fetch paths from absolute /api/ to relative api/
- Add missing settings to settings.json (weekly_timelapse_enabled, auto_screenshot, sharing)
2026-01-30 20:55:52 +00: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
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
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
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
48 changed files with 12163 additions and 157 deletions
+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/
+271 -1
View File
@@ -26,7 +26,8 @@ class SettingsManager {
return [ return [
'viewer_display' => [ 'viewer_display' => [
'enabled' => true, 'enabled' => true,
'min_viewers' => 1 'min_viewers' => 1,
'update_interval' => 5 // Sekunden
], ],
'video_mode' => [ 'video_mode' => [
'play_in_player' => true, 'play_in_player' => true,
@@ -36,6 +37,97 @@ class SettingsManager {
'default_speed' => 1, 'default_speed' => 1,
'available_speeds' => [1, 10, 100] '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,
'api_key' => '',
'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, 'last_updated' => null,
'updated_by' => null 'updated_by' => null
]; ];
@@ -123,4 +215,182 @@ class SettingsManager {
public function shouldAllowDownload() { public function shouldAllowDownload() {
return $this->get('video_mode.allow_download') === true; 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') !== false;
}
// 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 getWeatherApiKey() {
return $this->get('weather.api_key') ?? '';
}
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;
}
} }
+225
View File
@@ -0,0 +1,225 @@
<?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;
}
// Hole frische Daten von API (Open-Meteo)
$coords = $this->settingsManager->getWeatherCoords();
// 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'
]);
$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 ['error' => 'API Fehler'];
}
$data = json_decode($response, true);
if (!$data || !isset($data['current'])) {
return ['error' => 'Ungültige API Antwort'];
}
$current = $data['current'];
// Formatiere Daten
$weather = [
'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()
];
// Cache speichern
$this->saveCache($weather);
return $weather;
}
/**
* 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:
* Beispiel: 0,10,20,30,40,50 * * * * 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,
],
];
+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;
+1463 -85
View File
File diff suppressed because it is too large Load Diff
+84 -69
View File
@@ -734,8 +734,9 @@ class GuestbookManager {
<label for="guest-name" <label for="guest-name"
data-en="Name:" data-en="Name:"
data-de="Name:" data-de="Name:"
data-it="Nome:" data-zh="姓名: data-it="Nome:"
data-fr="Nom:"> data-fr="Nom:"
data-zh="姓名:">
Name: Name:
</label> </label>
<input type="text" id="guest-name" name="guest-name" required> <input type="text" id="guest-name" name="guest-name" required>
@@ -743,7 +744,8 @@ class GuestbookManager {
data-en="Message:" data-en="Message:"
data-de="Nachricht:" data-de="Nachricht:"
data-it="Messaggio:" data-it="Messaggio:"
data-fr="Message:"> data-fr="Message :"
data-zh="留言:">
Nachricht: Nachricht:
</label> </label>
<textarea id="guest-message" name="guest-message" required></textarea> <textarea id="guest-message" name="guest-message" required></textarea>
@@ -751,7 +753,8 @@ class GuestbookManager {
data-en="Add Entry" data-en="Add Entry"
data-de="Eintrag hinzufügen" data-de="Eintrag hinzufügen"
data-it="Aggiungi Voce" data-it="Aggiungi Voce"
data-fr="Ajouter une entrée"> data-fr="Ajouter une entrée"
data-zh="添加留言">
Eintrag hinzufügen Eintrag hinzufügen
</button> </button>
@@ -2133,21 +2136,21 @@ body.theme-neo footer {
</div> </div>
<nav> <nav>
<ul> <ul>
<li><a href="#webcams" data-en="Webcam" data-de="Webcam">Webcam</a></li> <li><a href="#webcams" data-en="Webcam" data-de="Webcam" data-it="Webcam" data-fr="Webcam" data-zh="摄像头">Webcam</a></li>
<li><a href="#guestbook" data-en="Guestbook" data-de="Gästebuch">Gästebuch</a></li> <li><a href="#guestbook" data-en="Guestbook" data-de="Gästebuch" data-it="Libro degli ospiti" data-fr="Livre d'or" data-zh="留言簿">Gästebuch</a></li>
<li><a href="#kontakt" data-en="Contact" data-de="Kontakt">Kontakt</a></li> <li><a href="#kontakt" data-en="Contact" data-de="Kontakt" data-it="Contatto" data-fr="Contact" data-zh="联系">Kontakt</a></li>
<li><a href="#gallery" data-en="Gallery" data-de="Galerie">Galerie</a></li> <li><a href="#gallery" data-en="Gallery" data-de="Galerie" data-it="Galleria" data-fr="Galerie" data-zh="图库">Galerie</a></li>
<li><a href="#archive" data-en="Video Archive" data-de="Videoarchiv">Videoarchiv</a></li> <li><a href="#archive" data-en="Video Archive" data-de="Videoarchiv" data-it="Archivio video" data-fr="Archive vidéo" data-zh="视频档案">Videoarchiv</a></li>
<?php if ($adminManager->isAdmin()): ?> <?php if ($adminManager->isAdmin()): ?>
<li><a href="#admin">Admin</a></li> <li><a href="#admin" data-en="Admin" data-de="Admin" data-it="Admin" data-fr="Admin" data-zh="管理员">Admin</a></li>
<?php endif; ?> <?php endif; ?>
</ul> </ul>
</nav> </nav>
<div class="theme-switcher" aria-label="Design wechseln"> <div class="theme-switcher" aria-label="Design wechseln">
<span>Design</span> <span data-en="Design" data-de="Design" data-it="Design" data-fr="Design" data-zh="设计">Design</span>
<button class="theme-button active" data-theme="theme-legacy" type="button">Klassisch</button> <button class="theme-button active" data-theme="theme-legacy" type="button" data-en="Classic" data-de="Klassisch" data-it="Classico" data-fr="Classique" data-zh="经典">Klassisch</button>
<button class="theme-button" data-theme="theme-alpine" type="button">Alpin</button> <button class="theme-button" data-theme="theme-alpine" type="button" data-en="Alpine" data-de="Alpin" data-it="Alpino" data-fr="Alpin" data-zh="高山">Alpin</button>
<button class="theme-button" data-theme="theme-neo" type="button">Modern</button> <button class="theme-button" data-theme="theme-neo" type="button" data-en="Modern" data-de="Modern" data-it="Moderno" data-fr="Moderne" data-zh="现代">Modern</button>
</div> </div>
</div> </div>
</header> </header>
@@ -2157,13 +2160,16 @@ body.theme-neo footer {
<div class="container"> <div class="container">
<div class="flag-title-container"> <div class="flag-title-container">
<img src="images/swiss.jpg" alt="Schweizer Flagge" class="flag-image"> <img src="images/swiss.jpg" alt="Schweizer Flagge" class="flag-image">
<h1 data-en="<?php echo $siteConfig['welcomeEn']; ?>" data-de="<?php echo $siteConfig['welcomeDe']; ?>"> <h1 data-en="<?php echo $siteConfig['welcomeEn']; ?>" data-de="<?php echo $siteConfig['welcomeDe']; ?>" data-it="Benvenuti su <?php echo $siteConfig['siteNameFullEn']; ?>" data-fr="Bienvenue sur <?php echo $siteConfig['siteNameFullEn']; ?>" data-zh="欢迎来到<?php echo $siteConfig['siteNameFullEn']; ?>">
<?php echo $siteConfig['welcomeDe']; ?> <?php echo $siteConfig['welcomeDe']; ?>
</h1> </h1>
<img src="local-flag.jpg" alt="Ortsflagge" class="flag-image"> <img src="local-flag.jpg" alt="Ortsflagge" class="flag-image">
</div> </div>
<p data-en="Experience fascinating views of the Zurich region - in real time!" <p data-en="Experience fascinating views of the Zurich region - in real time!"
data-de="Erleben Sie faszinierende Ausblicke der Züricher Region - in Echtzeit!"> data-de="Erleben Sie faszinierende Ausblicke der Züricher Region - in Echtzeit!"
data-it="Vivi affascinanti panorami della regione di Zurigo in tempo reale!"
data-fr="Découvrez des panoramas fascinants de la région de Zurich en temps réel !"
data-zh="实时欣赏苏黎世地区的迷人景色!">
Erleben Sie faszinierende Ausblicke der Züricher Region - in Echtzeit! Erleben Sie faszinierende Ausblicke der Züricher Region - in Echtzeit!
</p> </p>
</div> </div>
@@ -2240,7 +2246,7 @@ body.theme-neo footer {
<div class="info-badge viewer-stat" id="viewer-stat-container"> <div class="info-badge viewer-stat" id="viewer-stat-container">
<span class="live-dot"></span> <span class="live-dot"></span>
<strong id="viewer-count-display"><?php echo $viewerCount; ?></strong> <strong id="viewer-count-display"><?php echo $viewerCount; ?></strong>
<span data-en="Watching" data-de="Zuschauer">Zuschauer</span> <span data-en="Watching" data-de="Zuschauer" data-it="Spettatori" data-fr="Spectateurs" data-zh="观看人数">Zuschauer</span>
</div> </div>
<?php endif; ?> <?php endif; ?>
@@ -2249,16 +2255,16 @@ body.theme-neo footer {
<!-- STEUERUNG BUTTONS --> <!-- STEUERUNG BUTTONS -->
<div class="webcam-controls" style="text-align: center;"> <div class="webcam-controls" style="text-align: center;">
<a href="?action=snapshot" class="button" data-en="Save Snapshot" data-de="Snapshot speichern"> <a href="?action=snapshot" class="button" data-en="Save Snapshot" data-de="Snapshot speichern" data-it="Salva istantanea" data-fr="Enregistrer l'instantané" data-zh="保存截图">
Snapshot speichern Snapshot speichern
</a> </a>
<a href="#" class="button" id="timelapse-button" data-en="Week Timelapse" data-de="Wochenzeitraffer"> <a href="#" class="button" id="timelapse-button" data-en="Week Timelapse" data-de="Wochenzeitraffer" data-it="Timelapse settimanale" data-fr="Timelapse hebdomadaire" data-zh="一周延时">
Wochenzeitraffer Wochenzeitraffer
</a> </a>
<a href="?action=sequence" class="button" data-en="Save Video Clip" data-de="Videoclip speichern"> <a href="?action=sequence" class="button" data-en="Save Video Clip" data-de="Videoclip speichern" data-it="Salva clip video" data-fr="Enregistrer le clip vidéo" data-zh="保存视频片段">
Videoclip speichern Videoclip speichern
</a> </a>
<a href="?download_video=1" class="button" data-en="Download Latest Video" data-de="Tagesvideo downloaden"> <a href="?download_video=1" class="button" data-en="Download Latest Video" data-de="Tagesvideo downloaden" data-it="Scarica l'ultimo video" data-fr="Télécharger la dernière vidéo" data-zh="下载最新视频">
Tagesvideo downloaden Tagesvideo downloaden
</a> </a>
</div> </div>
@@ -2268,7 +2274,7 @@ body.theme-neo footer {
<!-- ARCHIVE SECTION --> <!-- ARCHIVE SECTION -->
<section id="archive" class="section"> <section id="archive" class="section">
<div class="container"> <div class="container">
<h2 data-en="Video Archive" data-de="Videoarchiv Tagesvideos">Videoarchiv Tagesvideos</h2> <h2 data-en="Video Archive" data-de="Videoarchiv Tagesvideos" data-it="Archivio video giornalieri" data-fr="Archive des vidéos quotidiennes" data-zh="每日视频档案">Videoarchiv Tagesvideos</h2>
<?php <?php
$visualCalendar = new VisualCalendarManager('./videos/', './ai/', $settingsManager); $visualCalendar = new VisualCalendarManager('./videos/', './ai/', $settingsManager);
echo $visualCalendar->displayVisualCalendar(); echo $visualCalendar->displayVisualCalendar();
@@ -2279,7 +2285,7 @@ body.theme-neo footer {
<!-- STANDORT --> <!-- STANDORT -->
<section id="standort" class="section" style="padding: 40px 0;"> <section id="standort" class="section" style="padding: 40px 0;">
<div class="container" style="text-align: center;"> <div class="container" style="text-align: center;">
<h2 data-en="Camera Direction" data-de="Kamera-Blickrichtung">Kamera-Blickrichtung</h2> <h2 data-en="Camera Direction" data-de="Kamera-Blickrichtung" data-it="Direzione della camera" data-fr="Direction de la caméra" data-zh="摄像头方向">Kamera-Blickrichtung</h2>
<div style="display: flex; justify-content: center; align-items: center; flex-wrap: wrap; gap: 30px; margin-top: 30px;"> <div style="display: flex; justify-content: center; align-items: center; flex-wrap: wrap; gap: 30px; margin-top: 30px;">
<div style="max-width: 350px;"> <div style="max-width: 350px;">
<img src="kompass1.png" alt="Kompass zeigt Blickrichtung der Webcam Richtung Zürichsee und Schweizer Alpen" style="width: 100%; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.3);"> <img src="kompass1.png" alt="Kompass zeigt Blickrichtung der Webcam Richtung Zürichsee und Schweizer Alpen" style="width: 100%; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.3);">
@@ -2298,12 +2304,12 @@ body.theme-neo footer {
<section id="qr-code" class="section"> <section id="qr-code" class="section">
<div class="container" style="text-align: center;"> <div class="container" style="text-align: center;">
<h1> <h1>
<p data-en="Follow us and share with friends" data-de="Folge uns und teile mit Freunden"> <p data-en="Follow us and share with friends" data-de="Folge uns und teile mit Freunden" data-it="Seguici e condividi con gli amici" data-fr="Suivez-nous et partagez avec vos amis" data-zh="关注我们并分享给朋友">
Folge uns und kopiere den Code und sende es deinen Freunden Folge uns und kopiere den Code und sende es deinen Freunden
</p> </p>
</h1> </h1>
<div id="qrcode" data-url="<?php echo $siteConfig['domainUrl']; ?>/"></div> <div id="qrcode" data-url="<?php echo $siteConfig['domainUrl']; ?>/"></div>
<p data-en="Click QR code to copy URL" data-de="Klicke auf den QR-Code um die URL zu kopieren"> <p data-en="Click QR code to copy URL" data-de="Klicke auf den QR-Code um die URL zu kopieren" data-it="Fai clic sul codice QR per copiare l'URL" data-fr="Cliquez sur le code QR pour copier l'URL" data-zh="点击二维码复制网址">
Klicke auf den QR-Code, um die URL zu kopieren Klicke auf den QR-Code, um die URL zu kopieren
</p> </p>
</div> </div>
@@ -2312,7 +2318,7 @@ body.theme-neo footer {
<!-- GUESTBOOK --> <!-- GUESTBOOK -->
<section id="guestbook" class="section"> <section id="guestbook" class="section">
<div class="container"> <div class="container">
<h2 data-en="Guestbook" data-de="Gästebuch">Gästebuch</h2> <h2 data-en="Guestbook" data-de="Gästebuch" data-it="Libro degli ospiti" data-fr="Livre d'or" data-zh="留言簿">Gästebuch</h2>
<?php <?php
echo $guestbookManager->displayForm(); echo $guestbookManager->displayForm();
echo $guestbookManager->displayEntries($adminManager->isAdmin()); echo $guestbookManager->displayEntries($adminManager->isAdmin());
@@ -2323,9 +2329,12 @@ body.theme-neo footer {
<!-- CONTACT --> <!-- CONTACT -->
<section id="kontakt" class="section"> <section id="kontakt" class="section">
<div class="container"> <div class="container">
<h2 data-en="Contact" data-de="Kontakt">Kontakt</h2> <h2 data-en="Contact" data-de="Kontakt" data-it="Contatto" data-fr="Contact" data-zh="联系">Kontakt</h2>
<p data-en="Questions or suggestions? We look forward to hearing from you!" <p data-en="Questions or suggestions? We look forward to hearing from you!"
data-de="Haben Sie Fragen, Anregungen oder möchten uns unterstützen? Wir freuen uns auf Ihre Nachricht!"> data-de="Haben Sie Fragen, Anregungen oder möchten uns unterstützen? Wir freuen uns auf Ihre Nachricht!"
data-it="Domande o suggerimenti? Saremo felici di sentirti!"
data-fr="Des questions ou des suggestions ? Nous serions ravis d'avoir de vos nouvelles !"
data-zh="有问题或建议吗?期待您的来信!">
Haben Sie Fragen, Anregungen oder möchten uns unterstützen? Wir freuen uns auf Ihre Nachricht! Haben Sie Fragen, Anregungen oder möchten uns unterstützen? Wir freuen uns auf Ihre Nachricht!
</p> </p>
<?php echo $contactManager->displayForm(); ?> <?php echo $contactManager->displayForm(); ?>
@@ -2335,7 +2344,7 @@ body.theme-neo footer {
<!-- GALLERY --> <!-- GALLERY -->
<section id="gallery" class="section"> <section id="gallery" class="section">
<div class="container"> <div class="container">
<h2 data-en="Image Gallery" data-de="Bildergalerie">Bildergalerie</h2> <h2 data-en="Image Gallery" data-de="Bildergalerie" data-it="Galleria immagini" data-fr="Galerie d'images" data-zh="图片库">Bildergalerie</h2>
<div class="gallery-wrapper"> <div class="gallery-wrapper">
<button class="gallery-nav-btn left" onclick="scrollGallery('left')"><i class="fas fa-chevron-left"></i></button> <button class="gallery-nav-btn left" onclick="scrollGallery('left')"><i class="fas fa-chevron-left"></i></button>
<?php echo $adminManager->displayGalleryImages(); ?> <?php echo $adminManager->displayGalleryImages(); ?>
@@ -2347,15 +2356,21 @@ body.theme-neo footer {
<!-- ABOUT --> <!-- ABOUT -->
<section id="ueber-uns" class="section"> <section id="ueber-uns" class="section">
<div class="container"> <div class="container">
<h2 data-en="About Our Project" data-de="Über unser Projekt">Über unser Projekt</h2> <h2 data-en="About Our Project" data-de="Über unser Projekt" data-it="Il nostro progetto" data-fr="À propos de notre projet" data-zh="关于我们的项目">Über unser Projekt</h2>
<div class="about-grid"> <div class="about-grid">
<div class="about-item"> <div class="about-item">
<p data-en="<?php echo $siteConfig['aboutEn']; ?>" <p data-en="<?php echo $siteConfig['aboutEn']; ?>"
data-de="<?php echo $siteConfig['aboutDe']; ?>"> data-de="<?php echo $siteConfig['aboutDe']; ?>"
data-it="Aurora Weather Livecam è un progetto del cuore di appassionati di meteorologia. Vogliamo avvicinarvi alla bellezza della natura e al fascino del tempo."
data-fr="Aurora Weather Livecam est un projet de passionnés de météo. Nous souhaitons vous faire découvrir la beauté de la nature et la fascination du temps."
data-zh="Aurora Weather Livecam 是天气爱好者的热情项目。我们希望让您更贴近自然之美与天气的魅力。">
<?php echo $siteConfig['aboutDe']; ?> <?php echo $siteConfig['aboutDe']; ?>
</p> </p>
<p data-en="We have been operating high-resolution webcams around the clock since 2010. We are particularly proud of unique insights, such as the Patrouille Suisse training flights every Monday morning." <p data-en="We have been operating high-resolution webcams around the clock since 2010. We are particularly proud of unique insights, such as the Patrouille Suisse training flights every Monday morning."
data-de="Dazu betreiben wir seit 2010 rund um die Uhr hochauflösende Webcams. Besonders stolz sind wir auf einzigartige Einblicke, wie z.B. die Trainingsflüge der Patrouille Suisse jeden Montagmorgen."> data-de="Dazu betreiben wir seit 2010 rund um die Uhr hochauflösende Webcams. Besonders stolz sind wir auf einzigartige Einblicke, wie z.B. die Trainingsflüge der Patrouille Suisse jeden Montagmorgen."
data-it="Dal 2010 gestiamo webcam ad alta risoluzione 24 ore su 24. Siamo particolarmente orgogliosi di scorci unici, come i voli di addestramento della Patrouille Suisse ogni lunedì mattina."
data-fr="Depuis 2010, nous exploitons des webcams haute résolution 24h/24. Nous sommes particulièrement fiers d'aperçus uniques, comme les vols d'entraînement de la Patrouille Suisse chaque lundi matin."
data-zh="自2010年以来,我们全天候运行高分辨率摄像头。我们尤其自豪于独特的视角,例如每周一早上的瑞士巡逻兵训练飞行。">
Dazu betreiben wir seit 2010 rund um die Uhr hochauflösende Webcams. Besonders stolz sind wir auf einzigartige Einblicke, wie z.B. die Trainingsflüge der Patrouille Suisse jeden Montagmorgen. Dazu betreiben wir seit 2010 rund um die Uhr hochauflösende Webcams. Besonders stolz sind wir auf einzigartige Einblicke, wie z.B. die Trainingsflüge der Patrouille Suisse jeden Montagmorgen.
</p> </p>
</div> </div>
@@ -2367,14 +2382,14 @@ body.theme-neo footer {
<?php if ($adminManager->isAdmin()): ?> <?php if ($adminManager->isAdmin()): ?>
<section id="admin" class="section"> <section id="admin" class="section">
<div class="container"> <div class="container">
<h2 data-en="Admin Area" data-de="Admin-Bereich">Admin-Bereich</h2> <h2 data-en="Admin Area" data-de="Admin-Bereich" data-it="Area admin" data-fr="Espace admin" data-zh="管理员区域">Admin-Bereich</h2>
<?php echo $adminManager->displayAdminContent(); ?> <?php echo $adminManager->displayAdminContent(); ?>
</div> </div>
</section> </section>
<?php else: ?> <?php else: ?>
<section id="admin-login" class="section"> <section id="admin-login" class="section">
<div class="container"> <div class="container">
<h2 data-en="Admin Login" data-de="Admin Login">Admin Login</h2> <h2 data-en="Admin Login" data-de="Admin Login" data-it="Accesso admin" data-fr="Connexion admin" data-zh="管理员登录">Admin Login</h2>
<?php echo $adminManager->displayLoginForm(); ?> <?php echo $adminManager->displayLoginForm(); ?>
</div> </div>
</section> </section>
@@ -2383,42 +2398,42 @@ body.theme-neo footer {
<!-- PATROUILLE SUISSE SEKTION --> <!-- PATROUILLE SUISSE SEKTION -->
<section id="patrouille-suisse" class="section" style="background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);"> <section id="patrouille-suisse" class="section" style="background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);">
<div class="container"> <div class="container">
<h2 style="color: #fff; text-align: center;" data-en="Patrouille Suisse Live - Watch Training Flights" data-de="Patrouille Suisse Live - Trainingsflüge Beobachten">Patrouille Suisse Live - Trainingsflüge Beobachten</h2> <h2 style="color: #fff; text-align: center;" data-en="Patrouille Suisse Live - Watch Training Flights" data-de="Patrouille Suisse Live - Trainingsflüge Beobachten" data-it="Patrouille Suisse Live - Guarda i voli di addestramento" data-fr="Patrouille Suisse en direct - Regardez les vols d'entraînement" data-zh="瑞士巡逻兵直播 - 观看训练飞行">Patrouille Suisse Live - Trainingsflüge Beobachten</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 30px; margin-top: 30px;"> <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 30px; margin-top: 30px;">
<div style="background: rgba(255,255,255,0.1); padding: 25px; border-radius: 15px; backdrop-filter: blur(10px);"> <div style="background: rgba(255,255,255,0.1); padding: 25px; border-radius: 15px; backdrop-filter: blur(10px);">
<h3 style="color: #ff6b6b; margin-bottom: 15px;" data-en="Every Monday Live!" data-de="Jeden Montag Live!">Jeden Montag Live!</h3> <h3 style="color: #ff6b6b; margin-bottom: 15px;" data-en="Every Monday Live!" data-de="Jeden Montag Live!" data-it="Ogni lunedì in diretta!" data-fr="Tous les lundis en direct !" data-zh="每周一直播!">Jeden Montag Live!</h3>
<p style="color: #ddd; line-height: 1.8;" data-en="The Patrouille Suisse, the official aerobatic team of the Swiss Air Force, trains every Monday morning in the Zurich Oberland region. Our webcam offers a unique view of the spectacular flight maneuvers of the six F-5E Tiger II jets." data-de="Die Patrouille Suisse, das offizielle Kunstflugteam der Schweizer Luftwaffe, trainiert jeden Montagmorgen in der Region Zürich Oberland. Unsere Webcam bietet einen einzigartigen Blick auf die spektakulären Flugmanöver der sechs F-5E Tiger II Jets."> <p style="color: #ddd; line-height: 1.8;" data-en="The Patrouille Suisse, the official aerobatic team of the Swiss Air Force, trains every Monday morning in the Zurich Oberland region. Our webcam offers a unique view of the spectacular flight maneuvers of the six F-5E Tiger II jets." data-de="Die Patrouille Suisse, das offizielle Kunstflugteam der Schweizer Luftwaffe, trainiert jeden Montagmorgen in der Region Zürich Oberland. Unsere Webcam bietet einen einzigartigen Blick auf die spektakulären Flugmanöver der sechs F-5E Tiger II Jets." data-it="La Patrouille Suisse, il team acrobatico ufficiale dell'Aeronautica militare svizzera, si addestra ogni lunedì mattina nella regione dell'Oberland di Zurigo. La nostra webcam offre una vista unica delle spettacolari manovre di volo dei sei F-5E Tiger II." data-fr="La Patrouille Suisse, l'équipe officielle de voltige des Forces aériennes suisses, s'entraîne chaque lundi matin dans la région de l'Oberland zurichois. Notre webcam offre une vue unique des spectaculaires manœuvres de vol des six F-5E Tiger II." data-zh="瑞士巡逻兵是瑞士空军的官方特技飞行队,每周一早上在苏黎世高地地区训练。我们的摄像头提供了观赏六架 F-5E Tiger II 喷气机精彩机动的独特视角。">
Die Patrouille Suisse, das offizielle Kunstflugteam der Schweizer Luftwaffe, trainiert jeden <strong style="color: #fff;">Montagmorgen</strong> in der Region Zürich Oberland. Unsere Webcam bietet einen einzigartigen Blick auf die spektakulären Flugmanöver der sechs F-5E Tiger II Jets. Die Patrouille Suisse, das offizielle Kunstflugteam der Schweizer Luftwaffe, trainiert jeden <strong style="color: #fff;">Montagmorgen</strong> in der Region Zürich Oberland. Unsere Webcam bietet einen einzigartigen Blick auf die spektakulären Flugmanöver der sechs F-5E Tiger II Jets.
</p> </p>
<ul style="color: #ccc; margin-top: 15px; padding-left: 20px;"> <ul style="color: #ccc; margin-top: 15px; padding-left: 20px;">
<li data-en="Training time: approx. 09:00 - 11:00" data-de="Trainingszeit: ca. 09:00 - 11:00 Uhr">Trainingszeit: ca. 09:00 - 11:00 Uhr</li> <li data-en="Training time: approx. 09:00 - 11:00" data-de="Trainingszeit: ca. 09:00 - 11:00 Uhr" data-it="Orario di addestramento: circa 09:00 - 11:00" data-fr="Heure d'entraînement : env. 09:00 - 11:00" data-zh="训练时间:约 09:00 - 11:00">Trainingszeit: ca. 09:00 - 11:00 Uhr</li>
<li data-en="Visible in good weather" data-de="Bei gutem Wetter sichtbar">Bei gutem Wetter sichtbar</li> <li data-en="Visible in good weather" data-de="Bei gutem Wetter sichtbar" data-it="Visibile con bel tempo" data-fr="Visible par beau temps" data-zh="天气良好时可见">Bei gutem Wetter sichtbar</li>
<li data-en="Unique perspective from Zurich Oberland" data-de="Einzigartige Perspektive aus dem Zürcher Oberland">Einzigartige Perspektive aus dem Zürcher Oberland</li> <li data-en="Unique perspective from Zurich Oberland" data-de="Einzigartige Perspektive aus dem Zürcher Oberland" data-it="Prospettiva unica dall'Oberland di Zurigo" data-fr="Perspective unique depuis l'Oberland zurichois" data-zh="来自苏黎世高地的独特视角">Einzigartige Perspektive aus dem Zürcher Oberland</li>
</ul> </ul>
</div> </div>
<div style="background: rgba(255,255,255,0.1); padding: 25px; border-radius: 15px; backdrop-filter: blur(10px);"> <div style="background: rgba(255,255,255,0.1); padding: 25px; border-radius: 15px; backdrop-filter: blur(10px);">
<h3 style="color: #4ecdc4; margin-bottom: 15px;" data-en="History of Patrouille Suisse" data-de="Geschichte der Patrouille Suisse">Geschichte der Patrouille Suisse</h3> <h3 style="color: #4ecdc4; margin-bottom: 15px;" data-en="History of Patrouille Suisse" data-de="Geschichte der Patrouille Suisse" data-it="Storia della Patrouille Suisse" data-fr="Histoire de la Patrouille Suisse" data-zh="瑞士巡逻兵历史">Geschichte der Patrouille Suisse</h3>
<p style="color: #ddd; line-height: 1.8;" data-en="Founded in 1964, the Patrouille Suisse is one of Europe's most renowned aerobatic teams. The team has been flying the Northrop F-5E Tiger II since 1995 and delights audiences at shows throughout Switzerland and internationally." data-de="Gegründet 1964, ist die Patrouille Suisse eines der renommiertesten Kunstflugteams Europas. Das Team fliegt seit 1995 die Northrop F-5E Tiger II und begeistert bei Shows in der ganzen Schweiz und international."> <p style="color: #ddd; line-height: 1.8;" data-en="Founded in 1964, the Patrouille Suisse is one of Europe's most renowned aerobatic teams. The team has been flying the Northrop F-5E Tiger II since 1995 and delights audiences at shows throughout Switzerland and internationally." data-de="Gegründet 1964, ist die Patrouille Suisse eines der renommiertesten Kunstflugteams Europas. Das Team fliegt seit 1995 die Northrop F-5E Tiger II und begeistert bei Shows in der ganzen Schweiz und international." data-it="Fondata nel 1964, la Patrouille Suisse è uno dei team acrobatici più rinomati d'Europa. Dal 1995 il team vola con i Northrop F-5E Tiger II e entusiasma il pubblico in Svizzera e all'estero." data-fr="Fondée en 1964, la Patrouille Suisse est l'une des équipes de voltige les plus renommées d'Europe. L'équipe vole sur Northrop F-5E Tiger II depuis 1995 et séduit le public en Suisse et à l'international." data-zh="瑞士巡逻兵成立于1964年,是欧洲最著名的特技飞行队之一。该队自1995年以来驾驶 Northrop F-5E Tiger II,在瑞士及国际航展上深受观众喜爱。">
Gegründet 1964, ist die Patrouille Suisse eines der renommiertesten Kunstflugteams Europas. Das Team fliegt seit 1995 die Northrop F-5E Tiger II und begeistert bei Shows in der ganzen Schweiz und international. Gegründet 1964, ist die Patrouille Suisse eines der renommiertesten Kunstflugteams Europas. Das Team fliegt seit 1995 die Northrop F-5E Tiger II und begeistert bei Shows in der ganzen Schweiz und international.
</p> </p>
<p style="color: #ddd; margin-top: 15px;" data-en="Home base: Payerne (VD) | Aircraft: 6x F-5E Tiger II | Team size: 6 pilots + crew" data-de="Heimatbasis: Payerne (VD) | Flugzeuge: 6x F-5E Tiger II | Teamgrösse: 6 Piloten + Crew"> <p style="color: #ddd; margin-top: 15px;" data-en="Home base: Payerne (VD) | Aircraft: 6x F-5E Tiger II | Team size: 6 pilots + crew" data-de="Heimatbasis: Payerne (VD) | Flugzeuge: 6x F-5E Tiger II | Teamgrösse: 6 Piloten + Crew" data-it="Base: Payerne (VD) | Aeromobili: 6x F-5E Tiger II | Team: 6 piloti + personale" data-fr="Base : Payerne (VD) | Avions : 6x F-5E Tiger II | Équipe : 6 pilotes + équipe" data-zh="基地:Payerne (VD) | 飞机:6 架 F-5E Tiger II | 团队规模:6 名飞行员 + 机组">
<strong style="color: #fff;" data-en="Home base:" data-de="Heimatbasis:">Heimatbasis:</strong> Payerne (VD)<br> <strong style="color: #fff;" data-en="Home base:" data-de="Heimatbasis:" data-it="Base:" data-fr="Base :" data-zh="基地:">Heimatbasis:</strong> Payerne (VD)<br>
<strong style="color: #fff;" data-en="Aircraft:" data-de="Flugzeuge:">Flugzeuge:</strong> 6x F-5E Tiger II<br> <strong style="color: #fff;" data-en="Aircraft:" data-de="Flugzeuge:" data-it="Aeromobili:" data-fr="Avions :" data-zh="飞机:">Flugzeuge:</strong> 6x F-5E Tiger II<br>
<strong style="color: #fff;" data-en="Team size:" data-de="Teamgrösse:">Teamgrösse:</strong> 6 <span data-en="pilots + crew" data-de="Piloten + Crew">Piloten + Crew</span> <strong style="color: #fff;" data-en="Team size:" data-de="Teamgrösse:" data-it="Team:" data-fr="Équipe :" data-zh="团队规模:">Teamgrösse:</strong> 6 <span data-en="pilots + crew" data-de="Piloten + Crew" data-it="piloti + personale" data-fr="pilotes + équipe" data-zh="飞行员 + 机组">Piloten + Crew</span>
</p> </p>
</div> </div>
<div style="background: rgba(255,255,255,0.1); padding: 25px; border-radius: 15px; backdrop-filter: blur(10px);"> <div style="background: rgba(255,255,255,0.1); padding: 25px; border-radius: 15px; backdrop-filter: blur(10px);">
<h3 style="color: #ffd93d; margin-bottom: 15px;" data-en="Best Viewing Tips" data-de="Beste Beobachtungstipps">Beste Beobachtungstipps</h3> <h3 style="color: #ffd93d; margin-bottom: 15px;" data-en="Best Viewing Tips" data-de="Beste Beobachtungstipps" data-it="Consigli per la migliore visione" data-fr="Conseils pour une meilleure observation" data-zh="最佳观看提示">Beste Beobachtungstipps</h3>
<p style="color: #ddd; line-height: 1.8;" data-en="For the best view of the training flights, we recommend:" data-de="Für die beste Sicht auf die Trainingsflüge empfehlen wir:"> <p style="color: #ddd; line-height: 1.8;" data-en="For the best view of the training flights, we recommend:" data-de="Für die beste Sicht auf die Trainingsflüge empfehlen wir:" data-it="Per la migliore visione dei voli di addestramento, consigliamo:" data-fr="Pour la meilleure vue des vols d'entraînement, nous recommandons :" data-zh="为获得最佳的训练飞行观赏效果,我们建议:">
Für die beste Sicht auf die Trainingsflüge empfehlen wir: Für die beste Sicht auf die Trainingsflüge empfehlen wir:
</p> </p>
<ul style="color: #ccc; margin-top: 15px; padding-left: 20px;"> <ul style="color: #ccc; margin-top: 15px; padding-left: 20px;">
<li data-en="Use the zoom function of our webcam" data-de="Nutzen Sie die Zoom-Funktion unserer Webcam">Nutzen Sie die Zoom-Funktion unserer Webcam</li> <li data-en="Use the zoom function of our webcam" data-de="Nutzen Sie die Zoom-Funktion unserer Webcam" data-it="Usa la funzione zoom della nostra webcam" data-fr="Utilisez la fonction zoom de notre webcam" data-zh="使用我们摄像头的缩放功能">Nutzen Sie die Zoom-Funktion unserer Webcam</li>
<li data-en="Timelapse mode for accelerated view" data-de="Timelapse-Modus für beschleunigte Ansicht">Timelapse-Modus für beschleunigte Ansicht</li> <li data-en="Timelapse mode for accelerated view" data-de="Timelapse-Modus für beschleunigte Ansicht" data-it="Modalità timelapse per una vista accelerata" data-fr="Mode timelapse pour une vue accélérée" data-zh="使用延时模式加速观看">Timelapse-Modus für beschleunigte Ansicht</li>
<li data-en="Daily videos to watch later" data-de="Tagesvideos zum Nachschauen">Tagesvideos zum Nachschauen</li> <li data-en="Daily videos to watch later" data-de="Tagesvideos zum Nachschauen" data-it="Video giornalieri da rivedere" data-fr="Vidéos quotidiennes à revoir" data-zh="每日视频可供回看">Tagesvideos zum Nachschauen</li>
<li data-en="AI detection marks aircraft sightings" data-de="AI-Erkennung markiert Flugzeug-Sichtungen">AI-Erkennung markiert Flugzeug-Sichtungen</li> <li data-en="AI detection marks aircraft sightings" data-de="AI-Erkennung markiert Flugzeug-Sichtungen" data-it="Il rilevamento AI segnala gli avvistamenti di aerei" data-fr="La détection IA signale les observations d'avions" data-zh="AI 检测会标记飞机出现">AI-Erkennung markiert Flugzeug-Sichtungen</li>
</ul> </ul>
<p style="color: #aaa; margin-top: 15px; font-size: 14px;" data-en="Note: Trainings may be cancelled in bad weather." data-de="Hinweis: Bei schlechtem Wetter können Trainings abgesagt werden."> <p style="color: #aaa; margin-top: 15px; font-size: 14px;" data-en="Note: Trainings may be cancelled in bad weather." data-de="Hinweis: Bei schlechtem Wetter können Trainings abgesagt werden." data-it="Nota: gli addestramenti possono essere annullati in caso di maltempo." data-fr="Remarque : les entraînements peuvent être annulés en cas de mauvais temps." data-zh="注意:恶劣天气时训练可能会取消。">
<em>Hinweis: Bei schlechtem Wetter können Trainings abgesagt werden.</em> <em>Hinweis: Bei schlechtem Wetter können Trainings abgesagt werden.</em>
</p> </p>
</div> </div>
@@ -2430,7 +2445,7 @@ body.theme-neo footer {
<section id="blog" class="section" style="background: #f8f9fa;"> <section id="blog" class="section" style="background: #f8f9fa;">
<div class="container"> <div class="container">
<h2 style="text-align: center; margin-bottom: 10px;"><?php echo $siteConfig['blogTitle']; ?></h2> <h2 style="text-align: center; margin-bottom: 10px;"><?php echo $siteConfig['blogTitle']; ?></h2>
<p style="text-align: center; color: #666; margin-bottom: 40px;" data-en="Latest weather news, webcam updates and nature observations from Zurich Oberland" data-de="Aktuelle Wetter-News, Webcam-Updates und Naturbeobachtungen aus dem Zürcher Oberland">Aktuelle Wetter-News, Webcam-Updates und Naturbeobachtungen aus dem Zürcher Oberland</p> <p style="text-align: center; color: #666; margin-bottom: 40px;" data-en="Latest weather news, webcam updates and nature observations from Zurich Oberland" data-de="Aktuelle Wetter-News, Webcam-Updates und Naturbeobachtungen aus dem Zürcher Oberland" data-it="Ultime notizie meteo, aggiornamenti della webcam e osservazioni naturalistiche dall'Oberland di Zurigo" data-fr="Dernières actualités météo, mises à jour de la webcam et observations de la nature depuis l'Oberland zurichois" data-zh="来自苏黎世高地的最新天气资讯、摄像头更新和自然观察">Aktuelle Wetter-News, Webcam-Updates und Naturbeobachtungen aus dem Zürcher Oberland</p>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 30px;"> <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 30px;">
<!-- Blog Artikel 1 --> <!-- Blog Artikel 1 -->
@@ -2439,12 +2454,12 @@ body.theme-neo footer {
<span style="font-size: 60px;">🌅</span> <span style="font-size: 60px;">🌅</span>
</div> </div>
<div style="padding: 25px;"> <div style="padding: 25px;">
<h3 style="margin-bottom: 10px; color: #333;" data-en="Sunrises over Lake Zurich" data-de="Sonnenaufgänge über dem Zürichsee">Sonnenaufgänge über dem Zürichsee</h3> <h3 style="margin-bottom: 10px; color: #333;" data-en="Sunrises over Lake Zurich" data-de="Sonnenaufgänge über dem Zürichsee" data-it="Albe sul Lago di Zurigo" data-fr="Levers de soleil sur le lac de Zurich" data-zh="苏黎世湖日出">Sonnenaufgänge über dem Zürichsee</h3>
<p style="color: #666; font-size: 14px; margin-bottom: 15px;" data-en="January 2024" data-de="Januar 2024">Januar 2024</p> <p style="color: #666; font-size: 14px; margin-bottom: 15px;" data-en="January 2024" data-de="Januar 2024" data-it="Gennaio 2024" data-fr="Janvier 2024" data-zh="2024年1月">Januar 2024</p>
<p style="color: #555; line-height: 1.7;" data-en="The winter months offer spectacular sunrises over Lake Zurich. Our AI detection automatically identifies the most beautiful moments and saves them in the gallery." data-de="Die Wintermonate bieten spektakuläre Sonnenaufgänge über dem Zürichsee. Unsere AI-Erkennung identifiziert automatisch die schönsten Momente und speichert sie in der Galerie."> <p style="color: #555; line-height: 1.7;" data-en="The winter months offer spectacular sunrises over Lake Zurich. Our AI detection automatically identifies the most beautiful moments and saves them in the gallery." data-de="Die Wintermonate bieten spektakuläre Sonnenaufgänge über dem Zürichsee. Unsere AI-Erkennung identifiziert automatisch die schönsten Momente und speichert sie in der Galerie." data-it="I mesi invernali offrono spettacolari albe sul Lago di Zurigo. Il nostro rilevamento AI identifica automaticamente i momenti più belli e li salva nella galleria." data-fr="Les mois d'hiver offrent des levers de soleil spectaculaires sur le lac de Zurich. Notre détection IA identifie automatiquement les plus beaux moments et les enregistre dans la galerie." data-zh="冬季在苏黎世湖上空可见壮观的日出。我们的 AI 检测会自动识别最美瞬间并保存到图库。">
Die Wintermonate bieten spektakuläre Sonnenaufgänge über dem Zürichsee. Unsere AI-Erkennung identifiziert automatisch die schönsten Momente und speichert sie in der Galerie. Die Wintermonate bieten spektakuläre Sonnenaufgänge über dem Zürichsee. Unsere AI-Erkennung identifiziert automatisch die schönsten Momente und speichert sie in der Galerie.
</p> </p>
<p style="color: #555; line-height: 1.7; margin-top: 10px;" data-en="Especially with high fog, impressive lighting moods are created when the sun breaks through the cloud cover." data-de="Besonders bei Hochnebel entstehen eindrucksvolle Lichtstimmungen, wenn die Sonne durch die Wolkendecke bricht."> <p style="color: #555; line-height: 1.7; margin-top: 10px;" data-en="Especially with high fog, impressive lighting moods are created when the sun breaks through the cloud cover." data-de="Besonders bei Hochnebel entstehen eindrucksvolle Lichtstimmungen, wenn die Sonne durch die Wolkendecke bricht." data-it="Soprattutto con la nebbia alta si creano suggestive atmosfere di luce quando il sole rompe la coltre di nubi." data-fr="Surtout en cas de brouillard élevé, des ambiances lumineuses impressionnantes se créent lorsque le soleil perce la couverture nuageuse." data-zh="尤其在高雾天气,当太阳穿透云层时会形成迷人的光影氛围。">
Besonders bei Hochnebel entstehen eindrucksvolle Lichtstimmungen, wenn die Sonne durch die Wolkendecke bricht. Besonders bei Hochnebel entstehen eindrucksvolle Lichtstimmungen, wenn die Sonne durch die Wolkendecke bricht.
</p> </p>
</div> </div>
@@ -2456,12 +2471,12 @@ body.theme-neo footer {
<span style="font-size: 60px;">🏔️</span> <span style="font-size: 60px;">🏔️</span>
</div> </div>
<div style="padding: 25px;"> <div style="padding: 25px;">
<h3 style="margin-bottom: 10px; color: #333;" data-en="Alpine Panorama in Winter" data-de="Alpenpanorama im Winter">Alpenpanorama im Winter</h3> <h3 style="margin-bottom: 10px; color: #333;" data-en="Alpine Panorama in Winter" data-de="Alpenpanorama im Winter" data-it="Panorama alpino in inverno" data-fr="Panorama alpin en hiver" data-zh="冬季阿尔卑斯全景">Alpenpanorama im Winter</h3>
<p style="color: #666; font-size: 14px; margin-bottom: 15px;" data-en="December 2023" data-de="Dezember 2023">Dezember 2023</p> <p style="color: #666; font-size: 14px; margin-bottom: 15px;" data-en="December 2023" data-de="Dezember 2023" data-it="Dicembre 2023" data-fr="Décembre 2023" data-zh="2023年12月">Dezember 2023</p>
<p style="color: #555; line-height: 1.7;" data-en="On clear winter days, the view from our webcam at 616m altitude extends to the snow-covered peaks of the Glarus Alps. Säntis, Glärnisch and other mountain peaks are visible." data-de="An klaren Wintertagen reicht die Sicht von unserer Webcam auf 616m Höhe bis zu den schneebedeckten Gipfeln der Glarner Alpen. Säntis, Glärnisch und weitere Bergspitzen sind sichtbar."> <p style="color: #555; line-height: 1.7;" data-en="On clear winter days, the view from our webcam at 616m altitude extends to the snow-covered peaks of the Glarus Alps. Säntis, Glärnisch and other mountain peaks are visible." data-de="An klaren Wintertagen reicht die Sicht von unserer Webcam auf 616m Höhe bis zu den schneebedeckten Gipfeln der Glarner Alpen. Säntis, Glärnisch und weitere Bergspitzen sind sichtbar." data-it="Nelle limpide giornate invernali, la vista dalla nostra webcam a 616 m di altitudine si estende alle vette innevate delle Alpi di Glarona. Si vedono Säntis, Glärnisch e altre cime." data-fr="Par temps clair en hiver, la vue depuis notre webcam à 616 m d'altitude s'étend jusqu'aux sommets enneigés des Alpes glaronnaises. Le Säntis, le Glärnisch et d'autres sommets sont visibles." data-zh="在晴朗的冬日,从我们海拔616米的摄像头可远眺格拉鲁斯阿尔卑斯的雪峰,可见 Säntis、Glärnisch 等山峰。">
An klaren Wintertagen reicht die Sicht von unserer Webcam auf 616m Höhe bis zu den schneebedeckten Gipfeln der Glarner Alpen. Säntis, Glärnisch und weitere Bergspitzen sind sichtbar. An klaren Wintertagen reicht die Sicht von unserer Webcam auf 616m Höhe bis zu den schneebedeckten Gipfeln der Glarner Alpen. Säntis, Glärnisch und weitere Bergspitzen sind sichtbar.
</p> </p>
<p style="color: #555; line-height: 1.7; margin-top: 10px;" data-en="Use the zoom function for detailed views of the mountain landscape." data-de="Nutzen Sie die Zoom-Funktion für detaillierte Ansichten der Berglandschaft."> <p style="color: #555; line-height: 1.7; margin-top: 10px;" data-en="Use the zoom function for detailed views of the mountain landscape." data-de="Nutzen Sie die Zoom-Funktion für detaillierte Ansichten der Berglandschaft." data-it="Usa la funzione zoom per viste dettagliate del paesaggio montano." data-fr="Utilisez la fonction zoom pour des vues détaillées du paysage montagneux." data-zh="使用缩放功能可查看更细致的山景。">
Nutzen Sie die Zoom-Funktion für detaillierte Ansichten der Berglandschaft. Nutzen Sie die Zoom-Funktion für detaillierte Ansichten der Berglandschaft.
</p> </p>
</div> </div>
@@ -2473,12 +2488,12 @@ body.theme-neo footer {
<span style="font-size: 60px;">✈️</span> <span style="font-size: 60px;">✈️</span>
</div> </div>
<div style="padding: 25px;"> <div style="padding: 25px;">
<h3 style="margin-bottom: 10px; color: #333;" data-en="Patrouille Suisse Season 2024" data-de="Patrouille Suisse Saison 2024">Patrouille Suisse Saison 2024</h3> <h3 style="margin-bottom: 10px; color: #333;" data-en="Patrouille Suisse Season 2024" data-de="Patrouille Suisse Saison 2024" data-it="Stagione 2024 della Patrouille Suisse" data-fr="Saison 2024 de la Patrouille Suisse" data-zh="2024年瑞士巡逻兵季">Patrouille Suisse Saison 2024</h3>
<p style="color: #666; font-size: 14px; margin-bottom: 15px;" data-en="March 2024" data-de="März 2024">März 2024</p> <p style="color: #666; font-size: 14px; margin-bottom: 15px;" data-en="March 2024" data-de="März 2024" data-it="Marzo 2024" data-fr="Mars 2024" data-zh="2024年3月">März 2024</p>
<p style="color: #555; line-height: 1.7;" data-en="The new flight season of Patrouille Suisse has begun! Every Monday the aerobatic team trains over Zurich Oberland - our webcam captures the flight maneuvers live." data-de="Die neue Flugsaison der Patrouille Suisse hat begonnen! Jeden Montag trainiert das Kunstflugteam über dem Zürcher Oberland - unsere Webcam fängt die Flugmanöver live ein."> <p style="color: #555; line-height: 1.7;" data-en="The new flight season of Patrouille Suisse has begun! Every Monday the aerobatic team trains over Zurich Oberland - our webcam captures the flight maneuvers live." data-de="Die neue Flugsaison der Patrouille Suisse hat begonnen! Jeden Montag trainiert das Kunstflugteam über dem Zürcher Oberland - unsere Webcam fängt die Flugmanöver live ein." data-it="È iniziata la nuova stagione di volo della Patrouille Suisse! Ogni lunedì il team acrobatico si addestra sopra l'Oberland di Zurigo: la nostra webcam cattura le manovre in diretta." data-fr="La nouvelle saison de vol de la Patrouille Suisse a commencé ! Chaque lundi, l'équipe de voltige s'entraîne au-dessus de l'Oberland zurichois : notre webcam capture les manœuvres en direct." data-zh="瑞士巡逻兵新一季飞行已开始!每周一特技飞行队在苏黎世高地训练,我们的摄像头会实时捕捉飞行动作。">
Die neue Flugsaison der Patrouille Suisse hat begonnen! Jeden Montag trainiert das Kunstflugteam über dem Zürcher Oberland - unsere Webcam fängt die Flugmanöver live ein. Die neue Flugsaison der Patrouille Suisse hat begonnen! Jeden Montag trainiert das Kunstflugteam über dem Zürcher Oberland - unsere Webcam fängt die Flugmanöver live ein.
</p> </p>
<p style="color: #555; line-height: 1.7; margin-top: 10px;" data-en="AI detection automatically marks aircraft sightings in our gallery." data-de="Die AI-Erkennung markiert Flugzeug-Sichtungen automatisch in unserer Galerie."> <p style="color: #555; line-height: 1.7; margin-top: 10px;" data-en="AI detection automatically marks aircraft sightings in our gallery." data-de="Die AI-Erkennung markiert Flugzeug-Sichtungen automatisch in unserer Galerie." data-it="Il rilevamento AI contrassegna automaticamente gli avvistamenti di aerei nella nostra galleria." data-fr="La détection IA marque automatiquement les observations d'avions dans notre galerie." data-zh="AI 检测会在我们的图库中自动标记飞机出现。">
Die AI-Erkennung markiert Flugzeug-Sichtungen automatisch in unserer Galerie. Die AI-Erkennung markiert Flugzeug-Sichtungen automatisch in unserer Galerie.
</p> </p>
</div> </div>
@@ -2486,7 +2501,7 @@ body.theme-neo footer {
</div> </div>
<div style="text-align: center; margin-top: 40px;"> <div style="text-align: center; margin-top: 40px;">
<p style="color: #888; font-size: 14px;" data-en="More weather updates and observations can be found on our social media channels." data-de="Weitere Wetter-Updates und Beobachtungen finden Sie auf unseren Social Media Kanälen."> <p style="color: #888; font-size: 14px;" data-en="More weather updates and observations can be found on our social media channels." data-de="Weitere Wetter-Updates und Beobachtungen finden Sie auf unseren Social Media Kanälen." data-it="Altri aggiornamenti meteo e osservazioni sono disponibili sui nostri canali social." data-fr="D'autres mises à jour météo et observations sont disponibles sur nos réseaux sociaux." data-zh="更多天气更新和观测内容请关注我们的社交媒体渠道。">
Weitere Wetter-Updates und Beobachtungen finden Sie auf unseren Social Media Kanälen. Weitere Wetter-Updates und Beobachtungen finden Sie auf unseren Social Media Kanälen.
</p> </p>
</div> </div>
@@ -2496,11 +2511,11 @@ body.theme-neo footer {
<!-- IMPRESSUM --> <!-- IMPRESSUM -->
<section id="impressum" class="section"> <section id="impressum" class="section">
<div class="container"> <div class="container">
<h2 data-en="Imprint" data-de="Impressum">Impressum</h2> <h2 data-en="Imprint" data-de="Impressum" data-it="Note legali" data-fr="Mentions légales" data-zh="法律声明">Impressum</h2>
<p><?php echo $siteConfig['footerName']; ?></p> <p><?php echo $siteConfig['footerName']; ?></p>
<p>M. Kessler</p> <p>M. Kessler</p>
<p>Dürnten, Schweiz</p> <p>Dürnten, Schweiz</p>
<p data-en="Inquiries via contact form" data-de="Anfragen per Kontaktformular">Anfragen per Kontaktformular</p> <p data-en="Inquiries via contact form" data-de="Anfragen per Kontaktformular" data-it="Richieste tramite modulo di contatto" data-fr="Demandes via le formulaire de contact" data-zh="通过联系表单咨询">Anfragen per Kontaktformular</p>
</div> </div>
</section> </section>
+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>
+253
View File
@@ -0,0 +1,253 @@
<?php
/**
* Onboarding - Branding (Schritt 4)
*/
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\Onboarding\OnboardingManager;
$settingsManager = new SettingsManager();
$auth = new AuthManager();
if (!$auth->isLoggedIn()) {
header('Location: /onboarding/register.php');
exit;
}
$user = $auth->getUser();
$tenantId = $user['tenant_id'] ?? 0;
$error = '';
$branding = [
'site_name' => $user['tenant_name'] ?? '',
'tagline' => '',
'primary_color' => '#667eea',
'secondary_color' => '#764ba2',
];
// Formular verarbeiten
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$branding = [
'site_name' => trim($_POST['site_name'] ?? ''),
'site_name_full' => trim($_POST['site_name'] ?? ''),
'tagline' => trim($_POST['tagline'] ?? ''),
'primary_color' => $_POST['primary_color'] ?? '#667eea',
'secondary_color' => $_POST['secondary_color'] ?? '#764ba2',
];
try {
$onboarding = new OnboardingManager();
$result = $onboarding->saveBranding($tenantId, $branding);
if ($result['success']) {
header('Location: /onboarding/complete.php');
exit;
} else {
$error = $result['error'] ?? 'Fehler beim Speichern';
}
} catch (\Exception $e) {
$error = 'Fehler: ' . $e->getMessage();
}
}
// Skip
if (isset($_GET['skip'])) {
header('Location: /onboarding/complete.php');
exit;
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Branding - Aurora Livecam</title>
<link rel="stylesheet" href="/dashboard/assets/dashboard.css">
<style>
.onboarding-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
padding: 2rem;
}
.onboarding-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: 600px;
}
.progress-steps {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.step {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--gray-300);
}
.step.active { background: var(--primary); }
.step.completed { background: var(--success); }
.onboarding-header {
text-align: center;
margin-bottom: 2rem;
}
.onboarding-header h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.color-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.preview-card {
margin-top: 1.5rem;
border-radius: 0.75rem;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.preview-header {
padding: 1.5rem;
color: white;
text-align: center;
}
.preview-header h3 {
margin: 0;
font-size: 1.25rem;
}
.preview-header p {
margin: 0.5rem 0 0 0;
opacity: 0.9;
font-size: 0.875rem;
}
.preview-body {
padding: 1rem;
background: var(--gray-100);
text-align: center;
font-size: 0.875rem;
color: var(--gray-500);
}
.skip-link {
display: block;
text-align: center;
margin-top: 1.5rem;
color: var(--gray-500);
font-size: 0.875rem;
}
</style>
</head>
<body>
<div class="onboarding-container">
<div class="onboarding-box">
<div class="progress-steps">
<div class="step completed"></div>
<div class="step completed"></div>
<div class="step completed"></div>
<div class="step active"></div>
</div>
<div class="onboarding-header">
<h1>🎨 Branding</h1>
<p style="color: var(--gray-500);">Personalisieren Sie Ihre Livecam</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="site_name">Name Ihrer Livecam</label>
<input type="text" id="site_name" name="site_name" class="form-input"
value="<?php echo htmlspecialchars($branding['site_name']); ?>"
placeholder="z.B. Berghütte Webcam">
</div>
<div class="form-group">
<label class="form-label" for="tagline">Slogan / Beschreibung</label>
<input type="text" id="tagline" name="tagline" class="form-input"
value="<?php echo htmlspecialchars($branding['tagline']); ?>"
placeholder="z.B. Live aus den Schweizer Alpen">
</div>
<div class="color-row">
<div class="form-group">
<label class="form-label">Primärfarbe</label>
<div class="color-picker-wrapper">
<input type="color" name="primary_color" id="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" id="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>
<!-- Live Preview -->
<div class="preview-card">
<div class="preview-header" id="preview-header" style="background: linear-gradient(135deg, <?php echo htmlspecialchars($branding['primary_color']); ?> 0%, <?php echo htmlspecialchars($branding['secondary_color']); ?> 100%);">
<h3 id="preview-name"><?php echo htmlspecialchars($branding['site_name'] ?: 'Ihre Livecam'); ?></h3>
<p id="preview-tagline"><?php echo htmlspecialchars($branding['tagline'] ?: 'Ihr Slogan hier'); ?></p>
</div>
<div class="preview-body">
Live-Vorschau
</div>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 1.5rem;">
Speichern & abschliessen
</button>
</form>
<a href="?skip=1" class="skip-link">
Später anpassen →
</a>
</div>
</div>
<script>
// Live preview updates
document.getElementById('site_name').addEventListener('input', (e) => {
document.getElementById('preview-name').textContent = e.target.value || 'Ihre Livecam';
});
document.getElementById('tagline').addEventListener('input', (e) => {
document.getElementById('preview-tagline').textContent = e.target.value || 'Ihr Slogan hier';
});
document.getElementById('primary_color').addEventListener('input', updateColors);
document.getElementById('secondary_color').addEventListener('input', updateColors);
function updateColors() {
const primary = document.getElementById('primary_color').value;
const secondary = document.getElementById('secondary_color').value;
document.getElementById('preview-header').style.background =
`linear-gradient(135deg, ${primary} 0%, ${secondary} 100%)`;
document.querySelectorAll('.color-value')[0].textContent = primary;
document.querySelectorAll('.color-value')[1].textContent = secondary;
}
</script>
</body>
</html>
+237
View File
@@ -0,0 +1,237 @@
<?php
/**
* Onboarding - Abgeschlossen
*/
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\Onboarding\OnboardingManager;
use AuroraLivecam\Core\Database;
$settingsManager = new SettingsManager();
$auth = new AuthManager();
if (!$auth->isLoggedIn()) {
header('Location: /onboarding/register.php');
exit;
}
$user = $auth->getUser();
$tenantId = $user['tenant_id'] ?? 0;
// Onboarding abschliessen
try {
$onboarding = new OnboardingManager();
$onboarding->complete($tenantId);
} catch (\Exception $e) {
// Ignorieren wenn DB nicht verfügbar
}
// Tenant-Info laden
$tenantSlug = 'demo';
$subdomain = '';
try {
$db = Database::getInstance();
$tenant = $db->fetchOne("SELECT slug FROM tenants WHERE id = ?", [$tenantId]);
if ($tenant) {
$tenantSlug = $tenant['slug'];
$subdomain = $tenantSlug . '.aurora-livecam.com';
}
} catch (\Exception $e) {
// Fallback
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fertig! - Aurora Livecam</title>
<link rel="stylesheet" href="/dashboard/assets/dashboard.css">
<style>
.complete-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
padding: 2rem;
}
.complete-box {
background: var(--white);
padding: 3rem;
border-radius: 1rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
width: 100%;
max-width: 600px;
text-align: center;
}
.complete-icon {
font-size: 5rem;
margin-bottom: 1.5rem;
animation: bounce 0.5s ease;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.complete-box h1 {
font-size: 2rem;
margin-bottom: 1rem;
color: var(--success);
}
.complete-box p {
color: var(--gray-600);
margin-bottom: 2rem;
font-size: 1.1rem;
}
.url-box {
background: var(--gray-100);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 2rem;
}
.url-box label {
display: block;
font-size: 0.875rem;
color: var(--gray-500);
margin-bottom: 0.5rem;
}
.url-box .url {
font-family: monospace;
font-size: 1rem;
color: var(--primary);
word-break: break-all;
}
.action-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.next-steps {
margin-top: 2.5rem;
text-align: left;
background: var(--gray-50);
border-radius: 0.5rem;
padding: 1.5rem;
}
.next-steps h3 {
font-size: 1rem;
margin-bottom: 1rem;
color: var(--gray-700);
}
.next-steps ul {
list-style: none;
padding: 0;
margin: 0;
}
.next-steps li {
padding: 0.5rem 0;
padding-left: 1.5rem;
position: relative;
color: var(--gray-600);
}
.next-steps li::before {
content: '→';
position: absolute;
left: 0;
color: var(--primary);
}
.confetti {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
z-index: 1000;
}
.confetti-piece {
position: absolute;
width: 10px;
height: 10px;
background: var(--primary);
animation: confetti-fall 3s ease-out forwards;
}
@keyframes confetti-fall {
0% { transform: translateY(-100px) rotate(0deg); opacity: 1; }
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
}
</style>
</head>
<body>
<div class="confetti" id="confetti"></div>
<div class="complete-container">
<div class="complete-box">
<div class="complete-icon">🎉</div>
<h1>Herzlichen Glückwunsch!</h1>
<p>Ihre Livecam ist jetzt eingerichtet und bereit.</p>
<?php if ($subdomain): ?>
<div class="url-box">
<label>Ihre Livecam-Adresse:</label>
<div class="url">https://<?php echo htmlspecialchars($subdomain); ?></div>
</div>
<?php endif; ?>
<div class="action-buttons">
<a href="/dashboard/" class="btn btn-primary">
Zum Dashboard
</a>
<a href="/" class="btn btn-secondary" target="_blank">
Livecam ansehen
</a>
</div>
<div class="next-steps">
<h3>Nächste Schritte</h3>
<ul>
<li>Stream-URL im Dashboard anpassen (falls noch nicht geschehen)</li>
<li>Logo und Farben im Branding-Bereich hochladen</li>
<li>Wetter-Widget konfigurieren</li>
<li>Eigene Domain verbinden (optional)</li>
<?php if ($settingsManager->isBillingEnabled()): ?>
<li>Abo auswählen für mehr Funktionen</li>
<?php endif; ?>
</ul>
</div>
</div>
</div>
<script>
// Confetti Animation
function createConfetti() {
const container = document.getElementById('confetti');
const colors = ['#667eea', '#764ba2', '#f093fb', '#48bb78', '#ed8936'];
for (let i = 0; i < 50; i++) {
const piece = document.createElement('div');
piece.className = 'confetti-piece';
piece.style.left = Math.random() * 100 + '%';
piece.style.background = colors[Math.floor(Math.random() * colors.length)];
piece.style.animationDelay = Math.random() * 2 + 's';
piece.style.width = (Math.random() * 10 + 5) + 'px';
piece.style.height = piece.style.width;
container.appendChild(piece);
}
// Cleanup after animation
setTimeout(() => {
container.innerHTML = '';
}, 5000);
}
createConfetti();
</script>
</body>
</html>
+265
View File
@@ -0,0 +1,265 @@
<?php
/**
* Onboarding - Registrierung
*/
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\Onboarding\OnboardingManager;
use AuroraLivecam\Auth\AuthManager;
$settingsManager = new SettingsManager();
// Prüfe ob Self-Registration aktiviert ist
if (!$settingsManager->isSelfRegistrationEnabled()) {
header('Location: /');
exit;
}
$auth = new AuthManager();
// Bereits eingeloggt?
if ($auth->isLoggedIn()) {
header('Location: /dashboard/');
exit;
}
$errors = [];
$formData = [];
$success = false;
// Formular verarbeiten
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$formData = [
'name' => trim($_POST['name'] ?? ''),
'company_name' => trim($_POST['company_name'] ?? ''),
'email' => trim($_POST['email'] ?? ''),
'password' => $_POST['password'] ?? '',
'password_confirm' => $_POST['password_confirm'] ?? '',
'stream_url' => trim($_POST['stream_url'] ?? ''),
'accept_terms' => isset($_POST['accept_terms']),
];
try {
$onboarding = new OnboardingManager();
$result = $onboarding->register($formData);
if ($result['success']) {
// Session starten und User einloggen
$auth->login($formData['email'], $formData['password']);
// Zur nächsten Seite weiterleiten
if ($onboarding->requiresEmailVerification()) {
// Token für Demo-Zwecke in Session speichern
$_SESSION['verification_token'] = $result['verification_token'];
header('Location: /onboarding/verify.php');
} else {
header('Location: /onboarding/stream.php');
}
exit;
} else {
$errors = $result['errors'];
}
} catch (\Exception $e) {
$errors['general'] = 'Registrierung fehlgeschlagen: ' . $e->getMessage();
}
}
$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>Registrierung - Aurora Livecam</title>
<link rel="stylesheet" href="/dashboard/assets/dashboard.css">
<style>
.register-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
padding: 2rem;
}
.register-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: 500px;
}
.register-header {
text-align: center;
margin-bottom: 2rem;
}
.register-header h1 {
font-size: 1.75rem;
margin-bottom: 0.5rem;
}
.register-header p {
color: var(--gray-500);
}
.trial-badge {
display: inline-block;
background: linear-gradient(135deg, var(--success) 0%, #38a169 100%);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
margin-top: 0.5rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.error-text {
color: var(--danger);
font-size: 0.875rem;
margin-top: 0.25rem;
}
.input-error {
border-color: var(--danger) !important;
}
.terms-text {
font-size: 0.875rem;
color: var(--gray-600);
}
.terms-text a {
color: var(--primary);
}
.divider {
display: flex;
align-items: center;
margin: 1.5rem 0;
color: var(--gray-400);
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--gray-300);
}
.divider span {
padding: 0 1rem;
font-size: 0.875rem;
}
@media (max-width: 500px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="register-container">
<div class="register-box">
<div class="register-header">
<h1>Jetzt starten</h1>
<p>Erstellen Sie Ihre eigene Live-Webcam</p>
<span class="trial-badge"><?php echo $trialDays; ?> Tage kostenlos testen</span>
</div>
<?php if (!empty($errors['general'])): ?>
<div class="alert alert-error"><?php echo htmlspecialchars($errors['general']); ?></div>
<?php endif; ?>
<form method="POST" action="" novalidate>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="name">Ihr Name *</label>
<input type="text" id="name" name="name" class="form-input <?php echo isset($errors['name']) ? 'input-error' : ''; ?>"
value="<?php echo htmlspecialchars($formData['name'] ?? ''); ?>" required>
<?php if (isset($errors['name'])): ?>
<p class="error-text"><?php echo htmlspecialchars($errors['name']); ?></p>
<?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="company_name">Webcam / Firma *</label>
<input type="text" id="company_name" name="company_name" class="form-input <?php echo isset($errors['company_name']) ? 'input-error' : ''; ?>"
value="<?php echo htmlspecialchars($formData['company_name'] ?? ''); ?>"
placeholder="z.B. Berghütte Webcam" required>
<?php if (isset($errors['company_name'])): ?>
<p class="error-text"><?php echo htmlspecialchars($errors['company_name']); ?></p>
<?php endif; ?>
</div>
</div>
<div class="form-group">
<label class="form-label" for="email">E-Mail-Adresse *</label>
<input type="email" id="email" name="email" class="form-input <?php echo isset($errors['email']) ? 'input-error' : ''; ?>"
value="<?php echo htmlspecialchars($formData['email'] ?? ''); ?>" required>
<?php if (isset($errors['email'])): ?>
<p class="error-text"><?php echo htmlspecialchars($errors['email']); ?></p>
<?php endif; ?>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="password">Passwort *</label>
<input type="password" id="password" name="password" class="form-input <?php echo isset($errors['password']) ? 'input-error' : ''; ?>"
minlength="8" required>
<?php if (isset($errors['password'])): ?>
<p class="error-text"><?php echo htmlspecialchars($errors['password']); ?></p>
<?php endif; ?>
</div>
<div class="form-group">
<label class="form-label" for="password_confirm">Passwort bestätigen *</label>
<input type="password" id="password_confirm" name="password_confirm" class="form-input <?php echo isset($errors['password_confirm']) ? 'input-error' : ''; ?>"
required>
<?php if (isset($errors['password_confirm'])): ?>
<p class="error-text"><?php echo htmlspecialchars($errors['password_confirm']); ?></p>
<?php endif; ?>
</div>
</div>
<div class="divider"><span>Optional</span></div>
<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 <?php echo isset($errors['stream_url']) ? 'input-error' : ''; ?>"
value="<?php echo htmlspecialchars($formData['stream_url'] ?? ''); ?>"
placeholder="https://example.com/stream.m3u8">
<p class="form-help">Sie können die Stream-URL auch später im Dashboard hinzufügen</p>
<?php if (isset($errors['stream_url'])): ?>
<p class="error-text"><?php echo htmlspecialchars($errors['stream_url']); ?></p>
<?php endif; ?>
</div>
<div class="form-group">
<label class="toggle-wrapper">
<input type="checkbox" name="accept_terms" <?php echo !empty($formData['accept_terms']) ? 'checked' : ''; ?> required>
<span class="terms-text">
Ich akzeptiere die <a href="/terms" target="_blank">AGB</a> und
<a href="/privacy" target="_blank">Datenschutzerklärung</a> *
</span>
</label>
<?php if (isset($errors['accept_terms'])): ?>
<p class="error-text"><?php echo htmlspecialchars($errors['accept_terms']); ?></p>
<?php endif; ?>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 1rem;">
Kostenlos registrieren
</button>
</form>
<p style="text-align: center; margin-top: 1.5rem; color: var(--gray-500);">
Bereits registriert?
<a href="/dashboard/login.php" style="color: var(--primary);">Anmelden</a>
</p>
</div>
</div>
</body>
</html>
+265
View File
@@ -0,0 +1,265 @@
<?php
/**
* Onboarding - Stream Konfiguration (Schritt 3)
*/
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\Onboarding\OnboardingManager;
use AuroraLivecam\Onboarding\StreamValidator;
$settingsManager = new SettingsManager();
$auth = new AuthManager();
// Login prüfen
if (!$auth->isLoggedIn()) {
header('Location: /onboarding/register.php');
exit;
}
$user = $auth->getUser();
$tenantId = $user['tenant_id'] ?? 0;
$error = '';
$streamUrl = '';
$streamType = 'hls';
$validationResult = null;
// Formular verarbeiten
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$streamUrl = trim($_POST['stream_url'] ?? '');
$streamType = $_POST['stream_type'] ?? 'hls';
if (empty($streamUrl)) {
$error = 'Bitte geben Sie eine Stream-URL ein';
} else {
try {
// Stream validieren
$validator = new StreamValidator();
$validationResult = $validator->validate($streamUrl);
if ($validationResult['valid']) {
// Speichern
$onboarding = new OnboardingManager();
$result = $onboarding->saveStream($tenantId, $streamUrl, $streamType);
if ($result['success']) {
header('Location: /onboarding/branding.php');
exit;
} else {
$error = $result['error'];
}
} else {
$error = $validationResult['error'] ?? 'Stream-URL konnte nicht validiert werden';
}
} catch (\Exception $e) {
$error = 'Fehler: ' . $e->getMessage();
}
}
}
// Skip erlauben
if (isset($_GET['skip'])) {
header('Location: /onboarding/branding.php');
exit;
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stream einrichten - Aurora Livecam</title>
<link rel="stylesheet" href="/dashboard/assets/dashboard.css">
<style>
.onboarding-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
padding: 2rem;
}
.onboarding-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: 600px;
}
.onboarding-header {
text-align: center;
margin-bottom: 2rem;
}
.onboarding-header h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.progress-steps {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.step {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--gray-300);
}
.step.active { background: var(--primary); }
.step.completed { background: var(--success); }
.validation-result {
margin-top: 1rem;
padding: 1rem;
border-radius: 0.5rem;
}
.validation-success {
background: #c6f6d5;
border: 1px solid #9ae6b4;
}
.validation-error {
background: #fed7d7;
border: 1px solid #feb2b2;
}
.validation-details {
font-size: 0.875rem;
margin-top: 0.5rem;
color: var(--gray-600);
}
.stream-types {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.stream-type-card {
border: 2px solid var(--gray-200);
border-radius: 0.5rem;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.stream-type-card:hover {
border-color: var(--primary);
}
.stream-type-card.selected {
border-color: var(--primary);
background: rgba(102, 126, 234, 0.05);
}
.stream-type-card input {
display: none;
}
.stream-type-card h4 {
margin: 0 0 0.25rem 0;
font-size: 1rem;
}
.stream-type-card p {
margin: 0;
font-size: 0.75rem;
color: var(--gray-500);
}
.skip-link {
display: block;
text-align: center;
margin-top: 1.5rem;
color: var(--gray-500);
font-size: 0.875rem;
}
</style>
</head>
<body>
<div class="onboarding-container">
<div class="onboarding-box">
<div class="progress-steps">
<div class="step completed"></div>
<div class="step completed"></div>
<div class="step active"></div>
<div class="step"></div>
</div>
<div class="onboarding-header">
<h1>📹 Stream einrichten</h1>
<p style="color: var(--gray-500);">Verbinden Sie Ihre Webcam oder Ihren Stream</p>
</div>
<?php if ($error): ?>
<div class="alert alert-error"><?php echo htmlspecialchars($error); ?></div>
<?php endif; ?>
<form method="POST" action="" id="stream-form">
<div class="form-group">
<label class="form-label">Stream-Typ wählen</label>
<div class="stream-types">
<label class="stream-type-card <?php echo $streamType === 'hls' ? 'selected' : ''; ?>">
<input type="radio" name="stream_type" value="hls" <?php echo $streamType === 'hls' ? 'checked' : ''; ?>>
<h4>🎬 HLS Stream</h4>
<p>.m3u8 Playlist (empfohlen)</p>
</label>
<label class="stream-type-card <?php echo $streamType === 'rtmp' ? 'selected' : ''; ?>">
<input type="radio" name="stream_type" value="rtmp" <?php echo $streamType === 'rtmp' ? 'checked' : ''; ?>>
<h4>📡 RTMP</h4>
<p>Real-Time Messaging Protocol</p>
</label>
<label class="stream-type-card <?php echo $streamType === 'iframe' ? 'selected' : ''; ?>">
<input type="radio" name="stream_type" value="iframe" <?php echo $streamType === 'iframe' ? 'checked' : ''; ?>>
<h4>🖼️ Embed</h4>
<p>YouTube, Vimeo, Twitch</p>
</label>
<label class="stream-type-card <?php echo $streamType === 'webrtc' ? 'selected' : ''; ?>">
<input type="radio" name="stream_type" value="webrtc" <?php echo $streamType === 'webrtc' ? 'checked' : ''; ?>>
<h4>⚡ WebRTC</h4>
<p>Ultra-niedrige Latenz</p>
</label>
</div>
</div>
<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($streamUrl); ?>"
placeholder="https://example.com/stream.m3u8" required>
<p class="form-help">Die vollständige URL zu Ihrem Stream</p>
</div>
<?php if ($validationResult): ?>
<div class="validation-result <?php echo $validationResult['valid'] ? 'validation-success' : 'validation-error'; ?>">
<strong><?php echo $validationResult['valid'] ? '✓ Stream erreichbar' : '✗ Stream nicht erreichbar'; ?></strong>
<?php if (!empty($validationResult['details'])): ?>
<div class="validation-details">
<?php if (isset($validationResult['details']['detected_type'])): ?>
Erkannter Typ: <?php echo htmlspecialchars($validationResult['details']['detected_type']); ?>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 1.5rem;">
Stream testen & weiter
</button>
</form>
<a href="?skip=1" class="skip-link">
Später einrichten →
</a>
</div>
</div>
<script>
document.querySelectorAll('.stream-type-card').forEach(card => {
card.addEventListener('click', () => {
document.querySelectorAll('.stream-type-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
});
});
</script>
</body>
</html>
+214
View File
@@ -0,0 +1,214 @@
<?php
/**
* Onboarding - E-Mail Verifizierung
*/
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\Onboarding\OnboardingManager;
$settingsManager = new SettingsManager();
$auth = new AuthManager();
// Login prüfen
if (!$auth->isLoggedIn()) {
header('Location: /onboarding/register.php');
exit;
}
$user = $auth->getUser();
$message = '';
$error = '';
$verified = false;
// Token aus URL verarbeiten
if (isset($_GET['token'])) {
try {
$onboarding = new OnboardingManager();
$result = $onboarding->verifyEmail($_GET['token']);
if ($result['success']) {
$verified = true;
$message = 'E-Mail erfolgreich verifiziert!';
} else {
$error = $result['error'];
}
} catch (\Exception $e) {
$error = 'Verifikation fehlgeschlagen';
}
}
// E-Mail erneut senden
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['resend'])) {
try {
$onboarding = new OnboardingManager();
$result = $onboarding->resendVerification($user['id']);
if ($result['success']) {
$_SESSION['verification_token'] = $result['token'];
$message = 'Verifikations-E-Mail wurde erneut gesendet!';
} else {
$error = $result['error'];
}
} catch (\Exception $e) {
$error = 'Fehler beim Senden';
}
}
// Demo: Token anzeigen (in Produktion würde eine E-Mail gesendet)
$demoToken = $_SESSION['verification_token'] ?? null;
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>E-Mail verifizieren - Aurora Livecam</title>
<link rel="stylesheet" href="/dashboard/assets/dashboard.css">
<style>
.verify-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
padding: 2rem;
}
.verify-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: 500px;
text-align: center;
}
.verify-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.verify-box h1 {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.verify-box p {
color: var(--gray-600);
margin-bottom: 1.5rem;
}
.email-highlight {
font-weight: 600;
color: var(--gray-800);
}
.demo-box {
background: var(--gray-100);
border: 1px dashed var(--gray-300);
border-radius: 0.5rem;
padding: 1rem;
margin: 1.5rem 0;
text-align: left;
}
.demo-box h4 {
font-size: 0.875rem;
color: var(--warning);
margin-bottom: 0.5rem;
}
.demo-link {
word-break: break-all;
font-family: monospace;
font-size: 0.75rem;
background: white;
padding: 0.5rem;
border-radius: 0.25rem;
display: block;
margin-top: 0.5rem;
}
.progress-steps {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-bottom: 2rem;
}
.step {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--gray-300);
}
.step.active {
background: var(--primary);
}
.step.completed {
background: var(--success);
}
</style>
</head>
<body>
<div class="verify-container">
<div class="verify-box">
<div class="progress-steps">
<div class="step completed"></div>
<div class="step active"></div>
<div class="step"></div>
<div class="step"></div>
</div>
<?php if ($verified): ?>
<div class="verify-icon">✅</div>
<h1>E-Mail verifiziert!</h1>
<p>Ihre E-Mail-Adresse wurde erfolgreich bestätigt.</p>
<a href="/onboarding/stream.php" class="btn btn-primary" style="width: 100%;">
Weiter zur Stream-Konfiguration
</a>
<?php else: ?>
<div class="verify-icon">📧</div>
<h1>E-Mail bestätigen</h1>
<p>
Wir haben eine Bestätigungs-E-Mail an<br>
<span class="email-highlight"><?php echo htmlspecialchars($user['email'] ?? ''); ?></span><br>
gesendet.
</p>
<?php if ($message): ?>
<div class="alert alert-success"><?php echo htmlspecialchars($message); ?></div>
<?php endif; ?>
<?php if ($error): ?>
<div class="alert alert-error"><?php echo htmlspecialchars($error); ?></div>
<?php endif; ?>
<?php if ($demoToken): ?>
<div class="demo-box">
<h4>⚠️ Demo-Modus</h4>
<p style="font-size: 0.875rem; margin: 0;">In der Produktion würde eine E-Mail gesendet. Für Demo-Zwecke:</p>
<a href="/onboarding/verify.php?token=<?php echo urlencode($demoToken); ?>" class="demo-link">
Klicken Sie hier um zu verifizieren
</a>
</div>
<?php endif; ?>
<p style="margin-top: 1.5rem; color: var(--gray-500); font-size: 0.875rem;">
Keine E-Mail erhalten?
</p>
<form method="POST" action="" style="display: inline;">
<button type="submit" name="resend" class="btn btn-secondary">
Erneut senden
</button>
</form>
<?php endif; ?>
<p style="margin-top: 2rem;">
<a href="/dashboard/logout.php" style="color: var(--gray-500); font-size: 0.875rem;">
Abmelden
</a>
</p>
</div>
</div>
</body>
</html>
+57 -2
View File
@@ -1,7 +1,8 @@
{ {
"viewer_display": { "viewer_display": {
"enabled": true, "enabled": true,
"min_viewers": 1 "min_viewers": 1,
"update_interval": 5
}, },
"video_mode": { "video_mode": {
"play_in_player": true, "play_in_player": true,
@@ -9,7 +10,61 @@
}, },
"timelapse": { "timelapse": {
"default_speed": 1, "default_speed": 1,
"available_speeds": [1, 10, 100] "available_speeds": [
1,
10,
100
]
},
"ui_display": {
"show_recommendation_banner": true,
"show_qr_code": true,
"show_social_media": true,
"show_patrouille_suisse": true
},
"zoom_timelapse": {
"show_zoom_controls": true,
"max_zoom_level": 4.0,
"timelapse_reverse_enabled": true,
"weekly_timelapse_enabled": true
},
"auto_screenshot": {
"enabled": false,
"interval_minutes": 10,
"max_images": 144,
"save_to_gallery": true
},
"sharing": {
"email_enabled": false,
"share_link_expiry_hours": 24
},
"content": {
"guestbook_enabled": true,
"gallery_enabled": true,
"ai_events_enabled": true,
"max_guestbook_entries": 50
},
"technical": {
"viewer_update_interval": 5,
"session_timeout": 30
},
"theme": {
"default_theme": "theme-legacy",
"show_theme_switcher": false
},
"seo": {
"custom_title": "",
"meta_description": "",
"meta_keywords": ""
},
"weather": {
"enabled": true,
"api_key": "",
"location": "Oberdürnten,CH",
"lat": "47.2833",
"lon": "8.7167",
"update_interval": 5,
"units": "metric"
}, },
"last_updated": null, "last_updated": null,
"updated_by": null "updated_by": null
+355
View File
@@ -0,0 +1,355 @@
<?php
/**
* AuthManager - Sichere Authentifizierung für Dashboard
*/
namespace AuroraLivecam\Auth;
use AuroraLivecam\Core\Database;
class AuthManager
{
private Database $db;
private bool $dbAvailable = false;
public function __construct(?Database $db = null)
{
$this->db = $db ?? Database::getInstance();
$this->checkDbAvailability();
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
private function checkDbAvailability(): void
{
try {
$this->db->fetchOne("SELECT 1 FROM users LIMIT 1");
$this->dbAvailable = true;
} catch (\Exception $e) {
$this->dbAvailable = false;
}
}
/**
* Registriert einen neuen Benutzer
*/
public function register(array $data): int
{
if (!$this->dbAvailable) {
throw new \Exception('Database not available');
}
// Validierung
if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
throw new \Exception('Invalid email address');
}
if (empty($data['password']) || strlen($data['password']) < 8) {
throw new \Exception('Password must be at least 8 characters');
}
// Prüfe ob Email bereits existiert
$existing = $this->db->fetchOne("SELECT id FROM users WHERE email = ?", [$data['email']]);
if ($existing) {
throw new \Exception('Email already registered');
}
// Benutzer erstellen
return $this->db->insert('users', [
'tenant_id' => $data['tenant_id'] ?? null,
'email' => strtolower($data['email']),
'password_hash' => password_hash($data['password'], PASSWORD_ARGON2ID),
'name' => $data['name'] ?? null,
'role' => $data['role'] ?? 'tenant_user',
]);
}
/**
* Login mit Email und Passwort
*/
public function login(string $email, string $password, bool $remember = false): bool
{
// Legacy-Modus (hardcoded admin)
if (!$this->dbAvailable) {
return $this->legacyLogin($email, $password);
}
$user = $this->db->fetchOne(
"SELECT u.*, t.name as tenant_name, t.slug as tenant_slug
FROM users u
LEFT JOIN tenants t ON u.tenant_id = t.id
WHERE u.email = ?",
[strtolower($email)]
);
if (!$user || !password_verify($password, $user['password_hash'])) {
return false;
}
// Session setzen
$this->setSession($user);
// Last login aktualisieren
$this->db->update('users', ['last_login_at' => date('Y-m-d H:i:s')], 'id = ?', [$user['id']]);
// Remember-Me Cookie
if ($remember) {
$this->setRememberToken($user['id']);
}
return true;
}
/**
* Legacy Login (kompatibel mit altem AdminManager)
*/
private function legacyLogin(string $email, string $password): bool
{
// Alte hardcoded Credentials als Fallback
if ($email === 'admin' && $password === 'sonne4000$$$$Q') {
$_SESSION['admin'] = true;
$_SESSION['user'] = [
'id' => 0,
'email' => 'admin',
'name' => 'Administrator',
'role' => 'super_admin',
'tenant_id' => null,
];
return true;
}
return false;
}
/**
* Setzt die Session-Daten
*/
private function setSession(array $user): void
{
$_SESSION['admin'] = true; // Kompatibilität mit Legacy
$_SESSION['user'] = [
'id' => $user['id'],
'email' => $user['email'],
'name' => $user['name'],
'role' => $user['role'],
'tenant_id' => $user['tenant_id'],
'tenant_name' => $user['tenant_name'] ?? null,
'tenant_slug' => $user['tenant_slug'] ?? null,
];
}
/**
* Setzt Remember-Me Token
*/
private function setRememberToken(int $userId): void
{
$token = bin2hex(random_bytes(32));
$hashedToken = hash('sha256', $token);
$this->db->update('users', ['remember_token' => $hashedToken], 'id = ?', [$userId]);
setcookie('remember_token', $token, [
'expires' => time() + (86400 * 30), // 30 Tage
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax'
]);
}
/**
* Prüft Remember-Me Cookie
*/
public function checkRememberToken(): bool
{
if (!isset($_COOKIE['remember_token']) || !$this->dbAvailable) {
return false;
}
$hashedToken = hash('sha256', $_COOKIE['remember_token']);
$user = $this->db->fetchOne(
"SELECT u.*, t.name as tenant_name, t.slug as tenant_slug
FROM users u
LEFT JOIN tenants t ON u.tenant_id = t.id
WHERE u.remember_token = ?",
[$hashedToken]
);
if ($user) {
$this->setSession($user);
return true;
}
return false;
}
/**
* Logout
*/
public function logout(): void
{
// Remember-Token löschen
if ($this->isLoggedIn() && $this->dbAvailable) {
$userId = $_SESSION['user']['id'] ?? 0;
if ($userId > 0) {
$this->db->update('users', ['remember_token' => null], 'id = ?', [$userId]);
}
}
// Cookie löschen
setcookie('remember_token', '', [
'expires' => time() - 3600,
'path' => '/',
'secure' => true,
'httponly' => true,
]);
// Session zerstören
$_SESSION = [];
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$params["path"], $params["domain"],
$params["secure"], $params["httponly"]
);
}
session_destroy();
}
/**
* Prüft ob User eingeloggt ist
*/
public function isLoggedIn(): bool
{
return isset($_SESSION['admin']) && $_SESSION['admin'] === true;
}
/**
* Gibt den aktuellen User zurück
*/
public function getUser(): ?array
{
return $_SESSION['user'] ?? null;
}
/**
* Prüft ob User eine bestimmte Rolle hat
*/
public function hasRole(string $role): bool
{
$user = $this->getUser();
return $user && $user['role'] === $role;
}
/**
* Prüft ob User Super-Admin ist
*/
public function isSuperAdmin(): bool
{
return $this->hasRole('super_admin');
}
/**
* Prüft ob User Tenant-Admin ist
*/
public function isTenantAdmin(): bool
{
return $this->hasRole('tenant_admin') || $this->hasRole('super_admin');
}
/**
* Gibt die Tenant-ID des aktuellen Users zurück
*/
public function getTenantId(): ?int
{
$user = $this->getUser();
return $user ? ($user['tenant_id'] ?? null) : null;
}
/**
* Prüft ob User Zugriff auf einen bestimmten Tenant hat
*/
public function canAccessTenant(int $tenantId): bool
{
if ($this->isSuperAdmin()) {
return true;
}
return $this->getTenantId() === $tenantId;
}
/**
* Ändert das Passwort
*/
public function changePassword(int $userId, string $currentPassword, string $newPassword): bool
{
if (!$this->dbAvailable) {
return false;
}
$user = $this->db->fetchOne("SELECT password_hash FROM users WHERE id = ?", [$userId]);
if (!$user || !password_verify($currentPassword, $user['password_hash'])) {
return false;
}
if (strlen($newPassword) < 8) {
throw new \Exception('Password must be at least 8 characters');
}
return $this->db->update('users', [
'password_hash' => password_hash($newPassword, PASSWORD_ARGON2ID)
], 'id = ?', [$userId]) > 0;
}
/**
* Generiert ein Passwort-Reset-Token
*/
public function generateResetToken(string $email): ?string
{
if (!$this->dbAvailable) {
return null;
}
$user = $this->db->fetchOne("SELECT id FROM users WHERE email = ?", [strtolower($email)]);
if (!$user) {
return null; // Keine Info leaken ob Email existiert
}
$token = bin2hex(random_bytes(32));
// Token würde normalerweise in separater Tabelle mit Ablaufzeit gespeichert
// Für jetzt: vereinfachte Version
return $token;
}
/**
* Middleware: Erfordert Login
*/
public function requireLogin(): void
{
if (!$this->isLoggedIn()) {
if (!$this->checkRememberToken()) {
header('Location: /dashboard/login.php');
exit;
}
}
}
/**
* Middleware: Erfordert bestimmte Rolle
*/
public function requireRole(string $role): void
{
$this->requireLogin();
if (!$this->hasRole($role) && !$this->isSuperAdmin()) {
http_response_code(403);
echo "Access denied";
exit;
}
}
}
@@ -0,0 +1,290 @@
<?php
/**
* StripeService - Stripe API Wrapper
*/
namespace AuroraLivecam\Billing;
use AuroraLivecam\Core\Database;
class StripeService
{
private ?string $secretKey;
private ?string $publicKey;
private ?string $webhookSecret;
private string $currency;
private Database $db;
public function __construct(?Database $db = null)
{
$this->db = $db ?? Database::getInstance();
$this->loadConfig();
}
/**
* Lädt Stripe-Konfiguration
*/
private function loadConfig(): void
{
$configFile = dirname(__DIR__, 2) . '/config.php';
if (file_exists($configFile)) {
$config = require $configFile;
$this->secretKey = $config['stripe']['secret_key'] ?? '';
$this->publicKey = $config['stripe']['public_key'] ?? '';
$this->webhookSecret = $config['stripe']['webhook_secret'] ?? '';
$this->currency = $config['stripe']['currency'] ?? 'chf';
} else {
$this->secretKey = getenv('STRIPE_SECRET_KEY') ?: '';
$this->publicKey = getenv('STRIPE_PUBLIC_KEY') ?: '';
$this->webhookSecret = getenv('STRIPE_WEBHOOK_SECRET') ?: '';
$this->currency = 'chf';
}
}
/**
* Prüft ob Stripe konfiguriert ist
*/
public function isConfigured(): bool
{
return !empty($this->secretKey) && !empty($this->publicKey);
}
/**
* Gibt den Public Key zurück
*/
public function getPublicKey(): string
{
return $this->publicKey ?? '';
}
/**
* Erstellt einen Stripe Customer
*/
public function createCustomer(int $tenantId, string $email, string $name): ?string
{
$response = $this->request('POST', '/v1/customers', [
'email' => $email,
'name' => $name,
'metadata' => [
'tenant_id' => $tenantId,
],
]);
if ($response && isset($response['id'])) {
// In DB speichern
$this->db->execute(
"UPDATE subscriptions SET stripe_customer_id = ? WHERE tenant_id = ?",
[$response['id'], $tenantId]
);
return $response['id'];
}
return null;
}
/**
* Erstellt eine Checkout Session
*/
public function createCheckoutSession(int $tenantId, string $priceId, string $successUrl, string $cancelUrl): ?array
{
// Customer ID holen oder erstellen
$customerId = $this->getOrCreateCustomer($tenantId);
$params = [
'customer' => $customerId,
'payment_method_types' => ['card'],
'line_items' => [[
'price' => $priceId,
'quantity' => 1,
]],
'mode' => 'subscription',
'success_url' => $successUrl,
'cancel_url' => $cancelUrl,
'metadata' => [
'tenant_id' => $tenantId,
],
];
return $this->request('POST', '/v1/checkout/sessions', $params);
}
/**
* Erstellt ein Billing Portal Session
*/
public function createPortalSession(int $tenantId, string $returnUrl): ?array
{
$customerId = $this->getCustomerId($tenantId);
if (!$customerId) {
return null;
}
return $this->request('POST', '/v1/billing_portal/sessions', [
'customer' => $customerId,
'return_url' => $returnUrl,
]);
}
/**
* Holt oder erstellt Customer
*/
private function getOrCreateCustomer(int $tenantId): ?string
{
$customerId = $this->getCustomerId($tenantId);
if ($customerId) {
return $customerId;
}
// Tenant-Daten laden
$tenant = $this->db->fetchOne(
"SELECT t.*, u.email, u.name FROM tenants t
LEFT JOIN users u ON u.tenant_id = t.id AND u.role = 'tenant_admin'
WHERE t.id = ? LIMIT 1",
[$tenantId]
);
if (!$tenant) {
return null;
}
return $this->createCustomer($tenantId, $tenant['email'], $tenant['name'] ?? $tenant['name']);
}
/**
* Holt Customer ID aus DB
*/
private function getCustomerId(int $tenantId): ?string
{
$sub = $this->db->fetchOne(
"SELECT stripe_customer_id FROM subscriptions WHERE tenant_id = ?",
[$tenantId]
);
return $sub['stripe_customer_id'] ?? null;
}
/**
* Holt Subscription von Stripe
*/
public function getSubscription(string $subscriptionId): ?array
{
return $this->request('GET', '/v1/subscriptions/' . $subscriptionId);
}
/**
* Kündigt Subscription
*/
public function cancelSubscription(string $subscriptionId, bool $immediately = false): ?array
{
if ($immediately) {
return $this->request('DELETE', '/v1/subscriptions/' . $subscriptionId);
}
return $this->request('POST', '/v1/subscriptions/' . $subscriptionId, [
'cancel_at_period_end' => true,
]);
}
/**
* Holt Rechnungen
*/
public function getInvoices(string $customerId, int $limit = 10): array
{
$response = $this->request('GET', '/v1/invoices', [
'customer' => $customerId,
'limit' => $limit,
]);
return $response['data'] ?? [];
}
/**
* Verifiziert Webhook-Signatur
*/
public function verifyWebhook(string $payload, string $signature): ?array
{
if (empty($this->webhookSecret)) {
return json_decode($payload, true);
}
$elements = explode(',', $signature);
$timestamp = null;
$signatures = [];
foreach ($elements as $element) {
$parts = explode('=', $element, 2);
if ($parts[0] === 't') {
$timestamp = $parts[1];
} elseif ($parts[0] === 'v1') {
$signatures[] = $parts[1];
}
}
if (!$timestamp || empty($signatures)) {
return null;
}
// Toleranz: 5 Minuten
if (abs(time() - $timestamp) > 300) {
return null;
}
$signedPayload = $timestamp . '.' . $payload;
$expectedSignature = hash_hmac('sha256', $signedPayload, $this->webhookSecret);
foreach ($signatures as $sig) {
if (hash_equals($expectedSignature, $sig)) {
return json_decode($payload, true);
}
}
return null;
}
/**
* Stripe API Request
*/
private function request(string $method, string $endpoint, array $data = []): ?array
{
if (!$this->isConfigured()) {
return null;
}
$url = 'https://api.stripe.com' . $endpoint;
$ch = curl_init();
$headers = [
'Authorization: Bearer ' . $this->secretKey,
'Content-Type: application/x-www-form-urlencoded',
];
curl_setopt_array($ch, [
CURLOPT_URL => $url . ($method === 'GET' && $data ? '?' . http_build_query($data) : ''),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
} elseif ($method === 'DELETE') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 200 && $httpCode < 300) {
return json_decode($response, true);
}
// Log error
error_log("Stripe API Error ($httpCode): $response");
return null;
}
}
@@ -0,0 +1,285 @@
<?php
/**
* SubscriptionManager - Verwaltet Subscriptions
*/
namespace AuroraLivecam\Billing;
use AuroraLivecam\Core\Database;
class SubscriptionManager
{
private Database $db;
private StripeService $stripe;
public function __construct(?Database $db = null)
{
$this->db = $db ?? Database::getInstance();
$this->stripe = new StripeService($this->db);
}
/**
* Gibt alle Pläne zurück
*/
public function getPlans(bool $activeOnly = true): array
{
$sql = "SELECT * FROM plans";
if ($activeOnly) {
$sql .= " WHERE is_active = 1";
}
$sql .= " ORDER BY sort_order ASC";
$plans = $this->db->fetchAll($sql);
// Features JSON decodieren
foreach ($plans as &$plan) {
if (isset($plan['features'])) {
$plan['features'] = json_decode($plan['features'], true) ?? [];
}
}
return $plans;
}
/**
* Gibt einen Plan zurück
*/
public function getPlan(int $planId): ?array
{
$plan = $this->db->fetchOne("SELECT * FROM plans WHERE id = ?", [$planId]);
if ($plan && isset($plan['features'])) {
$plan['features'] = json_decode($plan['features'], true) ?? [];
}
return $plan;
}
/**
* Gibt Plan by Slug zurück
*/
public function getPlanBySlug(string $slug): ?array
{
$plan = $this->db->fetchOne("SELECT * FROM plans WHERE slug = ?", [$slug]);
if ($plan && isset($plan['features'])) {
$plan['features'] = json_decode($plan['features'], true) ?? [];
}
return $plan;
}
/**
* Gibt die aktuelle Subscription eines Tenants zurück
*/
public function getSubscription(int $tenantId): ?array
{
$sub = $this->db->fetchOne(
"SELECT s.*, p.name as plan_name, p.slug as plan_slug, p.features as plan_features
FROM subscriptions s
JOIN plans p ON s.plan_id = p.id
WHERE s.tenant_id = ?
ORDER BY s.created_at DESC LIMIT 1",
[$tenantId]
);
if ($sub && isset($sub['plan_features'])) {
$sub['plan_features'] = json_decode($sub['plan_features'], true) ?? [];
}
return $sub;
}
/**
* Erstellt oder aktualisiert eine Subscription
*/
public function createOrUpdate(int $tenantId, array $data): int
{
$existing = $this->db->fetchOne(
"SELECT id FROM subscriptions WHERE tenant_id = ?",
[$tenantId]
);
if ($existing) {
$this->db->update('subscriptions', $data, 'id = ?', [$existing['id']]);
return $existing['id'];
}
$data['tenant_id'] = $tenantId;
return $this->db->insert('subscriptions', $data);
}
/**
* Startet Trial für einen Tenant
*/
public function startTrial(int $tenantId, int $planId = null, int $days = 14): void
{
if (!$planId) {
$freePlan = $this->getPlanBySlug('basic');
$planId = $freePlan['id'] ?? 1;
}
$this->createOrUpdate($tenantId, [
'plan_id' => $planId,
'status' => 'trialing',
'current_period_start' => date('Y-m-d H:i:s'),
'current_period_end' => date('Y-m-d H:i:s', strtotime("+$days days")),
]);
// Tenant Status
$this->db->update('tenants', [
'status' => 'trial',
'trial_ends_at' => date('Y-m-d H:i:s', strtotime("+$days days")),
], 'id = ?', [$tenantId]);
}
/**
* Aktiviert Subscription nach Zahlung
*/
public function activate(int $tenantId, string $stripeSubscriptionId, int $planId): void
{
$this->createOrUpdate($tenantId, [
'plan_id' => $planId,
'stripe_subscription_id' => $stripeSubscriptionId,
'status' => 'active',
'current_period_start' => date('Y-m-d H:i:s'),
]);
$this->db->update('tenants', ['status' => 'active', 'plan_id' => $planId], 'id = ?', [$tenantId]);
}
/**
* Kündigt Subscription
*/
public function cancel(int $tenantId, bool $immediately = false): bool
{
$sub = $this->getSubscription($tenantId);
if (!$sub) {
return false;
}
// Bei Stripe kündigen
if (!empty($sub['stripe_subscription_id'])) {
$this->stripe->cancelSubscription($sub['stripe_subscription_id'], $immediately);
}
$status = $immediately ? 'canceled' : 'active'; // Bleibt aktiv bis Periodenende
$this->db->update('subscriptions', [
'status' => $status,
'canceled_at' => date('Y-m-d H:i:s'),
], 'tenant_id = ?', [$tenantId]);
if ($immediately) {
$this->db->update('tenants', ['status' => 'cancelled'], 'id = ?', [$tenantId]);
}
return true;
}
/**
* Prüft ob Tenant aktiv ist (Trial oder bezahlt)
*/
public function isActive(int $tenantId): bool
{
$sub = $this->getSubscription($tenantId);
if (!$sub) {
return false;
}
if ($sub['status'] === 'active') {
return true;
}
if ($sub['status'] === 'trialing') {
$endDate = strtotime($sub['current_period_end']);
return $endDate > time();
}
return false;
}
/**
* Gibt verbleibende Trial-Tage zurück
*/
public function getTrialDaysRemaining(int $tenantId): int
{
$tenant = $this->db->fetchOne(
"SELECT trial_ends_at FROM tenants WHERE id = ?",
[$tenantId]
);
if (!$tenant || !$tenant['trial_ends_at']) {
return 0;
}
$remaining = strtotime($tenant['trial_ends_at']) - time();
return max(0, (int)ceil($remaining / 86400));
}
/**
* Prüft Feature-Zugriff
*/
public function hasFeature(int $tenantId, string $feature): bool
{
$sub = $this->getSubscription($tenantId);
if (!$sub || !isset($sub['plan_features'])) {
return false;
}
return !empty($sub['plan_features'][$feature]);
}
/**
* Gibt Feature-Limit zurück
*/
public function getFeatureLimit(int $tenantId, string $feature): int
{
$sub = $this->getSubscription($tenantId);
if (!$sub || !isset($sub['plan_features'][$feature])) {
return 0;
}
$value = $sub['plan_features'][$feature];
// -1 = unlimited
if ($value === -1 || $value === true) {
return PHP_INT_MAX;
}
return (int)$value;
}
/**
* Speichert Rechnung
*/
public function saveInvoice(int $tenantId, array $invoiceData): void
{
$this->db->insert('invoices', [
'tenant_id' => $tenantId,
'stripe_invoice_id' => $invoiceData['id'] ?? null,
'amount' => ($invoiceData['amount_paid'] ?? 0) / 100,
'currency' => strtoupper($invoiceData['currency'] ?? 'CHF'),
'status' => $invoiceData['status'] ?? 'unknown',
'paid_at' => isset($invoiceData['status_transitions']['paid_at'])
? date('Y-m-d H:i:s', $invoiceData['status_transitions']['paid_at'])
: null,
'invoice_pdf_url' => $invoiceData['invoice_pdf'] ?? null,
]);
}
/**
* Gibt Rechnungen eines Tenants zurück
*/
public function getInvoices(int $tenantId, int $limit = 10): array
{
return $this->db->fetchAll(
"SELECT * FROM invoices WHERE tenant_id = ? ORDER BY created_at DESC LIMIT ?",
[$tenantId, $limit]
);
}
}
@@ -0,0 +1,250 @@
<?php
/**
* WebhookHandler - Verarbeitet Stripe Webhooks
*/
namespace AuroraLivecam\Billing;
use AuroraLivecam\Core\Database;
class WebhookHandler
{
private Database $db;
private StripeService $stripe;
private SubscriptionManager $subscriptions;
public function __construct(?Database $db = null)
{
$this->db = $db ?? Database::getInstance();
$this->stripe = new StripeService($this->db);
$this->subscriptions = new SubscriptionManager($this->db);
}
/**
* Verarbeitet einen Webhook
*/
public function handle(string $payload, string $signature): array
{
// Signatur verifizieren
$event = $this->stripe->verifyWebhook($payload, $signature);
if (!$event) {
return ['success' => false, 'error' => 'Invalid signature'];
}
$type = $event['type'] ?? '';
$data = $event['data']['object'] ?? [];
try {
switch ($type) {
case 'checkout.session.completed':
return $this->handleCheckoutComplete($data);
case 'customer.subscription.created':
case 'customer.subscription.updated':
return $this->handleSubscriptionUpdate($data);
case 'customer.subscription.deleted':
return $this->handleSubscriptionDeleted($data);
case 'invoice.paid':
return $this->handleInvoicePaid($data);
case 'invoice.payment_failed':
return $this->handlePaymentFailed($data);
default:
return ['success' => true, 'message' => 'Event ignored: ' . $type];
}
} catch (\Exception $e) {
error_log("Webhook error: " . $e->getMessage());
return ['success' => false, 'error' => $e->getMessage()];
}
}
/**
* Checkout abgeschlossen
*/
private function handleCheckoutComplete(array $session): array
{
$tenantId = $session['metadata']['tenant_id'] ?? null;
$subscriptionId = $session['subscription'] ?? null;
if (!$tenantId || !$subscriptionId) {
return ['success' => false, 'error' => 'Missing tenant_id or subscription'];
}
// Subscription-Details von Stripe holen
$subscription = $this->stripe->getSubscription($subscriptionId);
if (!$subscription) {
return ['success' => false, 'error' => 'Could not fetch subscription'];
}
// Plan aus Stripe Price ID ermitteln
$priceId = $subscription['items']['data'][0]['price']['id'] ?? null;
$plan = $this->db->fetchOne(
"SELECT id FROM plans WHERE stripe_price_id = ?",
[$priceId]
);
$planId = $plan['id'] ?? 1;
// Subscription aktivieren
$this->subscriptions->activate($tenantId, $subscriptionId, $planId);
// Customer ID speichern
$this->db->update('subscriptions', [
'stripe_customer_id' => $session['customer'],
], 'tenant_id = ?', [$tenantId]);
return ['success' => true, 'message' => 'Subscription activated'];
}
/**
* Subscription erstellt/aktualisiert
*/
private function handleSubscriptionUpdate(array $subscription): array
{
$customerId = $subscription['customer'] ?? null;
if (!$customerId) {
return ['success' => false, 'error' => 'No customer ID'];
}
// Tenant über Customer ID finden
$sub = $this->db->fetchOne(
"SELECT tenant_id FROM subscriptions WHERE stripe_customer_id = ?",
[$customerId]
);
if (!$sub) {
return ['success' => true, 'message' => 'Customer not found in DB'];
}
$tenantId = $sub['tenant_id'];
$status = $this->mapStripeStatus($subscription['status']);
$this->db->update('subscriptions', [
'stripe_subscription_id' => $subscription['id'],
'status' => $status,
'current_period_start' => date('Y-m-d H:i:s', $subscription['current_period_start']),
'current_period_end' => date('Y-m-d H:i:s', $subscription['current_period_end']),
], 'tenant_id = ?', [$tenantId]);
// Tenant-Status aktualisieren
$tenantStatus = in_array($status, ['active', 'trialing']) ? 'active' : 'suspended';
$this->db->update('tenants', ['status' => $tenantStatus], 'id = ?', [$tenantId]);
return ['success' => true, 'message' => 'Subscription updated'];
}
/**
* Subscription gelöscht/gekündigt
*/
private function handleSubscriptionDeleted(array $subscription): array
{
$customerId = $subscription['customer'] ?? null;
if (!$customerId) {
return ['success' => false, 'error' => 'No customer ID'];
}
$sub = $this->db->fetchOne(
"SELECT tenant_id FROM subscriptions WHERE stripe_customer_id = ?",
[$customerId]
);
if (!$sub) {
return ['success' => true, 'message' => 'Customer not found'];
}
$this->db->update('subscriptions', [
'status' => 'canceled',
'canceled_at' => date('Y-m-d H:i:s'),
], 'tenant_id = ?', [$sub['tenant_id']]);
// Downgrade zu Free-Plan
$freePlan = $this->db->fetchOne("SELECT id FROM plans WHERE slug = 'free'");
if ($freePlan) {
$this->db->update('tenants', [
'status' => 'active',
'plan_id' => $freePlan['id'],
], 'id = ?', [$sub['tenant_id']]);
}
return ['success' => true, 'message' => 'Subscription canceled'];
}
/**
* Rechnung bezahlt
*/
private function handleInvoicePaid(array $invoice): array
{
$customerId = $invoice['customer'] ?? null;
if (!$customerId) {
return ['success' => false, 'error' => 'No customer ID'];
}
$sub = $this->db->fetchOne(
"SELECT tenant_id FROM subscriptions WHERE stripe_customer_id = ?",
[$customerId]
);
if (!$sub) {
return ['success' => true, 'message' => 'Customer not found'];
}
// Rechnung speichern
$this->subscriptions->saveInvoice($sub['tenant_id'], $invoice);
return ['success' => true, 'message' => 'Invoice saved'];
}
/**
* Zahlung fehlgeschlagen
*/
private function handlePaymentFailed(array $invoice): array
{
$customerId = $invoice['customer'] ?? null;
if (!$customerId) {
return ['success' => false, 'error' => 'No customer ID'];
}
$sub = $this->db->fetchOne(
"SELECT tenant_id FROM subscriptions WHERE stripe_customer_id = ?",
[$customerId]
);
if (!$sub) {
return ['success' => true, 'message' => 'Customer not found'];
}
// Status auf past_due setzen
$this->db->update('subscriptions', ['status' => 'past_due'], 'tenant_id = ?', [$sub['tenant_id']]);
// TODO: E-Mail an Tenant senden
return ['success' => true, 'message' => 'Payment failure recorded'];
}
/**
* Mappt Stripe-Status auf DB-Status
*/
private function mapStripeStatus(string $stripeStatus): string
{
$map = [
'active' => 'active',
'trialing' => 'trialing',
'past_due' => 'past_due',
'canceled' => 'canceled',
'unpaid' => 'unpaid',
'incomplete' => 'incomplete',
'incomplete_expired' => 'canceled',
];
return $map[$stripeStatus] ?? 'unknown';
}
}
+215
View File
@@ -0,0 +1,215 @@
<?php
/**
* Database - PDO Wrapper mit Singleton Pattern
*
* Verwendung:
* $db = Database::getInstance();
* $users = $db->fetchAll("SELECT * FROM users WHERE tenant_id = ?", [$tenantId]);
*/
namespace AuroraLivecam\Core;
use PDO;
use PDOException;
use Exception;
class Database
{
private static ?Database $instance = null;
private ?PDO $pdo = null;
private array $config;
private function __construct()
{
$this->config = $this->loadConfig();
}
/**
* Singleton: Gibt die einzige Instanz zurück
*/
public static function getInstance(): Database
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Lädt die Datenbank-Konfiguration
*/
private function loadConfig(): array
{
// Versuche .env oder config.php zu laden
$configFile = dirname(__DIR__, 2) . '/config.php';
if (file_exists($configFile)) {
$config = require $configFile;
return $config['database'] ?? [];
}
// Fallback auf Umgebungsvariablen
return [
'host' => getenv('DB_HOST') ?: 'localhost',
'port' => getenv('DB_PORT') ?: 3306,
'database' => getenv('DB_DATABASE') ?: 'aurora_livecam',
'username' => getenv('DB_USERNAME') ?: 'root',
'password' => getenv('DB_PASSWORD') ?: '',
'charset' => 'utf8mb4',
];
}
/**
* Stellt die Datenbankverbindung her (Lazy Loading)
*/
public function connect(): PDO
{
if ($this->pdo !== null) {
return $this->pdo;
}
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$this->config['host'],
$this->config['port'],
$this->config['database'],
$this->config['charset']
);
try {
$this->pdo = new PDO($dsn, $this->config['username'], $this->config['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci"
]);
} catch (PDOException $e) {
throw new Exception('Database connection failed: ' . $e->getMessage());
}
return $this->pdo;
}
/**
* Führt eine Query aus und gibt alle Ergebnisse zurück
*/
public function fetchAll(string $sql, array $params = []): array
{
$stmt = $this->connect()->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
/**
* Führt eine Query aus und gibt eine Zeile zurück
*/
public function fetchOne(string $sql, array $params = []): ?array
{
$stmt = $this->connect()->prepare($sql);
$stmt->execute($params);
$result = $stmt->fetch();
return $result ?: null;
}
/**
* Führt eine Query aus und gibt einen einzelnen Wert zurück
*/
public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed
{
$stmt = $this->connect()->prepare($sql);
$stmt->execute($params);
return $stmt->fetchColumn($column);
}
/**
* Führt INSERT/UPDATE/DELETE aus und gibt die Anzahl betroffener Zeilen zurück
*/
public function execute(string $sql, array $params = []): int
{
$stmt = $this->connect()->prepare($sql);
$stmt->execute($params);
return $stmt->rowCount();
}
/**
* INSERT und gibt die neue ID zurück
*/
public function insert(string $table, array $data): int
{
$columns = implode(', ', array_map(fn($col) => "`$col`", array_keys($data)));
$placeholders = implode(', ', array_fill(0, count($data), '?'));
$sql = "INSERT INTO `$table` ($columns) VALUES ($placeholders)";
$this->execute($sql, array_values($data));
return (int) $this->connect()->lastInsertId();
}
/**
* UPDATE mit WHERE-Bedingung
*/
public function update(string $table, array $data, string $where, array $whereParams = []): int
{
$set = implode(', ', array_map(fn($col) => "`$col` = ?", array_keys($data)));
$sql = "UPDATE `$table` SET $set WHERE $where";
return $this->execute($sql, [...array_values($data), ...$whereParams]);
}
/**
* DELETE mit WHERE-Bedingung
*/
public function delete(string $table, string $where, array $params = []): int
{
return $this->execute("DELETE FROM `$table` WHERE $where", $params);
}
/**
* Startet eine Transaktion
*/
public function beginTransaction(): bool
{
return $this->connect()->beginTransaction();
}
/**
* Bestätigt eine Transaktion
*/
public function commit(): bool
{
return $this->connect()->commit();
}
/**
* Macht eine Transaktion rückgängig
*/
public function rollback(): bool
{
return $this->connect()->rollBack();
}
/**
* Prüft ob eine Datenbankverbindung besteht
*/
public function isConnected(): bool
{
return $this->pdo !== null;
}
/**
* Gibt die PDO-Instanz direkt zurück (für komplexe Queries)
*/
public function getPdo(): PDO
{
return $this->connect();
}
// Prevent cloning
private function __clone() {}
// Prevent unserialization
public function __wakeup()
{
throw new Exception("Cannot unserialize singleton");
}
}
+316
View File
@@ -0,0 +1,316 @@
<?php
/**
* TenantResolver - Ermittelt den aktuellen Tenant basierend auf Domain
*
* Ersetzt den hardcoded Domain-Switch in index.php
*/
namespace AuroraLivecam\Core;
class TenantResolver
{
private Database $db;
private ?array $currentTenant = null;
private ?array $currentBranding = null;
private static ?TenantResolver $instance = null;
// Cache für Domain-Lookups (vermeidet DB-Anfragen bei jedem Request)
private static array $domainCache = [];
public function __construct(?Database $db = null)
{
$this->db = $db ?? Database::getInstance();
}
/**
* Singleton für globalen Zugriff
*/
public static function getInstance(): TenantResolver
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Löst die aktuelle Domain auf und gibt den Tenant zurück
*/
public function resolve(?string $domain = null): ?array
{
$domain = $domain ?? $this->getCurrentDomain();
if ($this->currentTenant !== null && ($this->currentTenant['domain'] ?? '') === $domain) {
return $this->currentTenant;
}
// Cache prüfen
if (isset(self::$domainCache[$domain])) {
$this->currentTenant = self::$domainCache[$domain];
return $this->currentTenant;
}
// Aus DB laden
$this->currentTenant = $this->loadTenantByDomain($domain);
// In Cache speichern
self::$domainCache[$domain] = $this->currentTenant;
return $this->currentTenant;
}
/**
* Lädt einen Tenant anhand der Domain aus der Datenbank
*/
private function loadTenantByDomain(string $domain): ?array
{
// Normalisiere Domain (ohne www.)
$normalizedDomain = $this->normalizeDomain($domain);
try {
$sql = "
SELECT
t.*,
td.domain,
td.is_primary,
p.name as plan_name,
p.slug as plan_slug,
p.features as plan_features
FROM tenant_domains td
JOIN tenants t ON td.tenant_id = t.id
LEFT JOIN plans p ON t.plan_id = p.id
WHERE td.domain = ? OR td.domain = ?
LIMIT 1
";
$tenant = $this->db->fetchOne($sql, [$domain, $normalizedDomain]);
if ($tenant && isset($tenant['plan_features'])) {
$tenant['plan_features'] = json_decode($tenant['plan_features'], true);
}
return $tenant;
} catch (\Exception $e) {
// Fallback: Keine DB-Verbindung oder Tabelle existiert nicht
return $this->getFallbackTenant($domain);
}
}
/**
* Fallback für Legacy-Modus (ohne Datenbank)
* Unterstützt die alten hardcoded Domains
*/
private function getFallbackTenant(string $domain): ?array
{
$normalizedDomain = $this->normalizeDomain($domain);
// Alte seecam.ch Konfiguration
if (str_contains($normalizedDomain, 'seecam.ch')) {
return [
'id' => 0,
'uuid' => 'legacy-seecam',
'name' => 'Seecam',
'slug' => 'seecam',
'status' => 'active',
'domain' => $domain,
'is_legacy' => true,
'branding' => [
'site_name' => 'Seecam',
'site_name_full' => 'Seecam.ch - Live Webcam',
'tagline' => 'Ihre Live-Webcam',
'primary_color' => '#667eea',
'secondary_color' => '#764ba2',
],
];
}
// Default: Aurora
if (str_contains($normalizedDomain, 'aurora') ||
str_contains($normalizedDomain, 'localhost') ||
$normalizedDomain === '127.0.0.1') {
return [
'id' => 0,
'uuid' => 'legacy-aurora',
'name' => 'Aurora Weather Livecam',
'slug' => 'aurora',
'status' => 'active',
'domain' => $domain,
'is_legacy' => true,
'branding' => [
'site_name' => 'Aurora',
'site_name_full' => 'Aurora Weather Livecam - Zürich Oberland',
'tagline' => 'Wetter Webcam Schweiz',
'primary_color' => '#667eea',
'secondary_color' => '#764ba2',
],
];
}
// Unbekannte Domain - Default Tenant
return [
'id' => 0,
'uuid' => 'default',
'name' => 'Livecam',
'slug' => 'default',
'status' => 'active',
'domain' => $domain,
'is_legacy' => true,
'branding' => [
'site_name' => 'Livecam',
'site_name_full' => 'Livecam',
'primary_color' => '#667eea',
'secondary_color' => '#764ba2',
],
];
}
/**
* Gibt das Branding des aktuellen Tenants zurück
*/
public function getBranding(): array
{
if ($this->currentBranding !== null) {
return $this->currentBranding;
}
$tenant = $this->resolve();
if (!$tenant) {
return $this->getDefaultBranding();
}
// Legacy-Tenant hat Branding inline
if (isset($tenant['is_legacy']) && $tenant['is_legacy']) {
$this->currentBranding = $tenant['branding'] ?? $this->getDefaultBranding();
return $this->currentBranding;
}
// Aus DB laden
try {
$branding = $this->db->fetchOne(
"SELECT * FROM tenant_branding WHERE tenant_id = ?",
[$tenant['id']]
);
$this->currentBranding = $branding ?: $this->getDefaultBranding();
} catch (\Exception $e) {
$this->currentBranding = $this->getDefaultBranding();
}
return $this->currentBranding;
}
/**
* Default Branding
*/
private function getDefaultBranding(): array
{
return [
'site_name' => 'Livecam',
'site_name_full' => 'Live Webcam',
'tagline' => '',
'logo_path' => null,
'favicon_path' => null,
'primary_color' => '#667eea',
'secondary_color' => '#764ba2',
'accent_color' => '#f093fb',
'welcome_text_de' => '',
'welcome_text_en' => '',
'footer_text' => '',
'custom_css' => '',
];
}
/**
* Gibt die aktuelle Domain zurück
*/
public function getCurrentDomain(): string
{
return $_SERVER['HTTP_HOST'] ?? 'localhost';
}
/**
* Normalisiert eine Domain (entfernt www.)
*/
private function normalizeDomain(string $domain): string
{
return preg_replace('/^www\./i', '', strtolower($domain));
}
/**
* Prüft ob der aktuelle Tenant aktiv ist
*/
public function isActive(): bool
{
$tenant = $this->resolve();
return $tenant && in_array($tenant['status'], ['active', 'trial']);
}
/**
* Prüft ob der Tenant im Trial ist
*/
public function isTrial(): bool
{
$tenant = $this->resolve();
return $tenant && $tenant['status'] === 'trial';
}
/**
* Gibt die Tenant-ID zurück (oder 0 für Legacy)
*/
public function getTenantId(): int
{
$tenant = $this->resolve();
return $tenant['id'] ?? 0;
}
/**
* Gibt den Tenant-Slug zurück
*/
public function getTenantSlug(): string
{
$tenant = $this->resolve();
return $tenant['slug'] ?? 'default';
}
/**
* Prüft ob Multi-Tenant-Modus aktiv ist (DB vorhanden)
*/
public function isMultiTenantEnabled(): bool
{
$tenant = $this->resolve();
return $tenant && !isset($tenant['is_legacy']);
}
/**
* Gibt alle Domains eines Tenants zurück
*/
public function getTenantDomains(int $tenantId): array
{
try {
return $this->db->fetchAll(
"SELECT * FROM tenant_domains WHERE tenant_id = ? ORDER BY is_primary DESC",
[$tenantId]
);
} catch (\Exception $e) {
return [];
}
}
/**
* Setzt den aktuellen Tenant manuell (für Tests oder CLI)
*/
public function setTenant(array $tenant): void
{
$this->currentTenant = $tenant;
$this->currentBranding = null;
}
/**
* Leert den Cache
*/
public static function clearCache(): void
{
self::$domainCache = [];
}
}
@@ -0,0 +1,366 @@
<?php
/**
* OnboardingManager - Verwaltet den Onboarding-Prozess
*/
namespace AuroraLivecam\Onboarding;
use AuroraLivecam\Core\Database;
use AuroraLivecam\Tenant\TenantManager;
use AuroraLivecam\Auth\AuthManager;
class OnboardingManager
{
private Database $db;
private TenantManager $tenantManager;
private StreamValidator $streamValidator;
public const STEP_REGISTER = 1;
public const STEP_VERIFY_EMAIL = 2;
public const STEP_STREAM = 3;
public const STEP_BRANDING = 4;
public const STEP_COMPLETE = 5;
public function __construct(?Database $db = null)
{
$this->db = $db ?? Database::getInstance();
$this->tenantManager = new TenantManager($this->db);
$this->streamValidator = new StreamValidator();
}
/**
* Startet den Onboarding-Prozess (Registrierung)
*/
public function register(array $data): array
{
$errors = $this->validateRegistration($data);
if (!empty($errors)) {
return ['success' => false, 'errors' => $errors];
}
try {
$this->db->beginTransaction();
// Tenant erstellen
$tenantId = $this->tenantManager->create([
'name' => $data['company_name'] ?? $data['name'],
'email' => $data['email'],
'subdomain' => $this->generateSubdomain($data['company_name'] ?? $data['name']),
'stream_url' => $data['stream_url'] ?? '',
'stream_type' => $data['stream_type'] ?? 'hls',
]);
// Admin-User für den Tenant erstellen
$auth = new AuthManager($this->db);
$userId = $auth->register([
'tenant_id' => $tenantId,
'email' => $data['email'],
'password' => $data['password'],
'name' => $data['name'],
'role' => 'tenant_admin',
]);
// Verification-Token generieren
$verificationToken = $this->generateVerificationToken($userId);
$this->db->commit();
return [
'success' => true,
'tenant_id' => $tenantId,
'user_id' => $userId,
'verification_token' => $verificationToken,
'next_step' => self::STEP_VERIFY_EMAIL,
];
} catch (\Exception $e) {
$this->db->rollback();
return ['success' => false, 'errors' => ['general' => $e->getMessage()]];
}
}
/**
* Validiert Registrierungsdaten
*/
private function validateRegistration(array $data): array
{
$errors = [];
// Name
if (empty($data['name'])) {
$errors['name'] = 'Name ist erforderlich';
}
// Company/Site Name
if (empty($data['company_name'])) {
$errors['company_name'] = 'Firmen-/Site-Name ist erforderlich';
}
// Email
if (empty($data['email'])) {
$errors['email'] = 'E-Mail ist erforderlich';
} elseif (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'Ungültige E-Mail-Adresse';
} else {
// Prüfe ob Email bereits existiert
$existing = $this->db->fetchOne("SELECT id FROM users WHERE email = ?", [strtolower($data['email'])]);
if ($existing) {
$errors['email'] = 'Diese E-Mail-Adresse ist bereits registriert';
}
}
// Password
if (empty($data['password'])) {
$errors['password'] = 'Passwort ist erforderlich';
} elseif (strlen($data['password']) < 8) {
$errors['password'] = 'Passwort muss mindestens 8 Zeichen lang sein';
}
// Password Confirmation
if (($data['password'] ?? '') !== ($data['password_confirm'] ?? '')) {
$errors['password_confirm'] = 'Passwörter stimmen nicht überein';
}
// Stream URL (optional, aber wenn angegeben, validieren)
if (!empty($data['stream_url'])) {
$validation = $this->streamValidator->validate($data['stream_url']);
if (!$validation['valid']) {
$errors['stream_url'] = $validation['error'] ?? 'Stream-URL ungültig';
}
}
// Terms
if (empty($data['accept_terms'])) {
$errors['accept_terms'] = 'Sie müssen die AGB akzeptieren';
}
return $errors;
}
/**
* Generiert eine Subdomain aus dem Firmennamen
*/
private function generateSubdomain(string $name): string
{
// Umlaute ersetzen
$replacements = ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss'];
$slug = str_replace(array_keys($replacements), array_values($replacements), strtolower($name));
// Nur alphanumerische Zeichen und Bindestriche
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
$slug = trim($slug, '-');
// Max 30 Zeichen
$slug = substr($slug, 0, 30);
// Eindeutigkeit prüfen
$baseSlug = $slug;
$counter = 1;
while (!$this->tenantManager->isDomainAvailable($slug . '.aurora-livecam.com')) {
$slug = $baseSlug . '-' . $counter;
$counter++;
}
return $slug;
}
/**
* Generiert einen E-Mail-Verification-Token
*/
private function generateVerificationToken(int $userId): string
{
$token = bin2hex(random_bytes(32));
// Token in einer separaten Tabelle speichern (oder im User)
// Vereinfacht: Wir nutzen remember_token temporär
$this->db->update('users', ['remember_token' => hash('sha256', $token)], 'id = ?', [$userId]);
return $token;
}
/**
* Verifiziert E-Mail-Adresse
*/
public function verifyEmail(string $token): array
{
$hashedToken = hash('sha256', $token);
$user = $this->db->fetchOne(
"SELECT id, tenant_id FROM users WHERE remember_token = ? AND email_verified_at IS NULL",
[$hashedToken]
);
if (!$user) {
return ['success' => false, 'error' => 'Ungültiger oder abgelaufener Token'];
}
$this->db->update('users', [
'email_verified_at' => date('Y-m-d H:i:s'),
'remember_token' => null,
], 'id = ?', [$user['id']]);
// Onboarding-Status aktualisieren
$this->updateOnboardingStep($user['tenant_id'], self::STEP_STREAM);
return [
'success' => true,
'user_id' => $user['id'],
'tenant_id' => $user['tenant_id'],
'next_step' => self::STEP_STREAM,
];
}
/**
* Speichert Stream-Konfiguration
*/
public function saveStream(int $tenantId, string $url, string $type = 'hls'): array
{
// Validieren
$validation = $this->streamValidator->validate($url);
if (!$validation['valid']) {
return ['success' => false, 'error' => $validation['error']];
}
// Speichern
$existing = $this->db->fetchOne(
"SELECT id FROM tenant_streams WHERE tenant_id = ? AND is_primary = 1",
[$tenantId]
);
if ($existing) {
$this->db->update('tenant_streams', [
'stream_url' => $url,
'stream_type' => $validation['type'] ?? $type,
'last_status' => 'online',
'last_check_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$existing['id']]);
} else {
$this->db->insert('tenant_streams', [
'tenant_id' => $tenantId,
'stream_url' => $url,
'stream_type' => $validation['type'] ?? $type,
'is_primary' => 1,
'last_status' => 'online',
'last_check_at' => date('Y-m-d H:i:s'),
]);
}
// Onboarding-Schritt aktualisieren
$this->updateOnboardingStep($tenantId, self::STEP_BRANDING, ['stream_verified' => 1]);
return [
'success' => true,
'stream_type' => $validation['type'],
'next_step' => self::STEP_BRANDING,
];
}
/**
* Speichert Basis-Branding
*/
public function saveBranding(int $tenantId, array $branding): array
{
$this->tenantManager->updateBranding($tenantId, $branding);
// Onboarding-Schritt aktualisieren
$this->updateOnboardingStep($tenantId, self::STEP_COMPLETE, ['branding_configured' => 1]);
return [
'success' => true,
'next_step' => self::STEP_COMPLETE,
];
}
/**
* Schliesst das Onboarding ab
*/
public function complete(int $tenantId): array
{
$this->db->update('tenant_onboarding', [
'current_step' => self::STEP_COMPLETE,
'completed_at' => date('Y-m-d H:i:s'),
], 'tenant_id = ?', [$tenantId]);
// Tenant aktivieren
$this->tenantManager->activate($tenantId);
return ['success' => true, 'completed' => true];
}
/**
* Aktualisiert den Onboarding-Schritt
*/
private function updateOnboardingStep(int $tenantId, int $step, array $extra = []): void
{
$data = array_merge(['current_step' => $step], $extra);
$this->db->update('tenant_onboarding', $data, 'tenant_id = ?', [$tenantId]);
}
/**
* Gibt den aktuellen Onboarding-Status zurück
*/
public function getStatus(int $tenantId): array
{
$onboarding = $this->db->fetchOne(
"SELECT * FROM tenant_onboarding WHERE tenant_id = ?",
[$tenantId]
);
if (!$onboarding) {
return [
'current_step' => self::STEP_REGISTER,
'completed' => false,
];
}
return [
'current_step' => (int)$onboarding['current_step'],
'stream_verified' => (bool)$onboarding['stream_verified'],
'branding_configured' => (bool)$onboarding['branding_configured'],
'payment_configured' => (bool)$onboarding['payment_configured'],
'completed' => $onboarding['completed_at'] !== null,
'completed_at' => $onboarding['completed_at'],
];
}
/**
* Prüft ob E-Mail-Verification erforderlich ist
*/
public function requiresEmailVerification(): bool
{
// Aus Settings laden
$settingsFile = dirname(__DIR__, 2) . '/SettingsManager.php';
if (file_exists($settingsFile)) {
require_once $settingsFile;
$settings = new \SettingsManager();
return $settings->get('saas_features.email_verification_required') ?? true;
}
return true;
}
/**
* Sendet Verification-E-Mail erneut
*/
public function resendVerification(int $userId): array
{
$user = $this->db->fetchOne("SELECT email, email_verified_at FROM users WHERE id = ?", [$userId]);
if (!$user) {
return ['success' => false, 'error' => 'Benutzer nicht gefunden'];
}
if ($user['email_verified_at']) {
return ['success' => false, 'error' => 'E-Mail bereits verifiziert'];
}
$token = $this->generateVerificationToken($userId);
return [
'success' => true,
'token' => $token,
'email' => $user['email'],
];
}
}
@@ -0,0 +1,263 @@
<?php
/**
* StreamValidator - Validiert Stream-URLs
*/
namespace AuroraLivecam\Onboarding;
class StreamValidator
{
private array $supportedTypes = ['hls', 'rtmp', 'webrtc', 'iframe'];
private int $timeout = 10;
/**
* Validiert eine Stream-URL
*/
public function validate(string $url): array
{
$result = [
'valid' => false,
'type' => null,
'error' => null,
'details' => [],
];
// URL-Format prüfen
if (!filter_var($url, FILTER_VALIDATE_URL)) {
$result['error'] = 'Ungültiges URL-Format';
return $result;
}
// Stream-Typ erkennen
$type = $this->detectStreamType($url);
$result['type'] = $type;
$result['details']['detected_type'] = $type;
// Je nach Typ validieren
switch ($type) {
case 'hls':
return $this->validateHls($url, $result);
case 'rtmp':
return $this->validateRtmp($url, $result);
case 'iframe':
return $this->validateIframe($url, $result);
default:
// Generische HTTP-Prüfung
return $this->validateHttp($url, $result);
}
}
/**
* Erkennt den Stream-Typ anhand der URL
*/
public function detectStreamType(string $url): string
{
$url = strtolower($url);
if (str_contains($url, '.m3u8')) {
return 'hls';
}
if (str_starts_with($url, 'rtmp://') || str_starts_with($url, 'rtmps://')) {
return 'rtmp';
}
if (str_contains($url, 'youtube.com') || str_contains($url, 'youtu.be') ||
str_contains($url, 'vimeo.com') || str_contains($url, 'twitch.tv')) {
return 'iframe';
}
if (str_contains($url, '.mp4') || str_contains($url, '.webm')) {
return 'video';
}
return 'unknown';
}
/**
* Validiert HLS-Stream
*/
private function validateHls(string $url, array $result): array
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_HTTPHEADER => [
'User-Agent: Mozilla/5.0 (compatible; StreamValidator/1.0)'
],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$error = curl_error($ch);
curl_close($ch);
$result['details']['http_code'] = $httpCode;
$result['details']['content_type'] = $contentType;
if ($error) {
$result['error'] = 'Verbindungsfehler: ' . $error;
return $result;
}
if ($httpCode !== 200) {
$result['error'] = "HTTP-Fehler: $httpCode";
return $result;
}
// Prüfe ob es ein gültiges M3U8 ist
if (!str_contains($response, '#EXTM3U')) {
$result['error'] = 'Keine gültige HLS-Playlist gefunden';
return $result;
}
$result['valid'] = true;
$result['details']['is_master'] = str_contains($response, '#EXT-X-STREAM-INF');
$result['details']['segments'] = substr_count($response, '#EXTINF');
return $result;
}
/**
* Validiert RTMP-Stream (nur Format-Check)
*/
private function validateRtmp(string $url, array $result): array
{
// RTMP kann nicht einfach per HTTP geprüft werden
// Wir prüfen nur das Format
$parsed = parse_url($url);
if (!isset($parsed['host']) || empty($parsed['host'])) {
$result['error'] = 'RTMP-URL enthält keinen gültigen Host';
return $result;
}
// DNS-Check
$ip = gethostbyname($parsed['host']);
if ($ip === $parsed['host']) {
$result['error'] = 'RTMP-Host nicht erreichbar (DNS-Fehler)';
return $result;
}
$result['valid'] = true;
$result['details']['host'] = $parsed['host'];
$result['details']['note'] = 'RTMP-Streams können erst zur Laufzeit vollständig validiert werden';
return $result;
}
/**
* Validiert iFrame-Embed URL
*/
private function validateIframe(string $url, array $result): array
{
// Bekannte Embed-Plattformen
$embedPatterns = [
'youtube' => '/(?:youtube\.com\/(?:embed|watch)|youtu\.be)/i',
'vimeo' => '/vimeo\.com/i',
'twitch' => '/(?:twitch\.tv|player\.twitch\.tv)/i',
'dailymotion' => '/dailymotion\.com/i',
];
$platform = 'unknown';
foreach ($embedPatterns as $name => $pattern) {
if (preg_match($pattern, $url)) {
$platform = $name;
break;
}
}
$result['details']['platform'] = $platform;
// HTTP-Check
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_NOBODY => true, // HEAD request
CURLOPT_SSL_VERIFYPEER => false,
]);
curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$result['details']['http_code'] = $httpCode;
if ($httpCode >= 200 && $httpCode < 400) {
$result['valid'] = true;
} else {
$result['error'] = "URL nicht erreichbar (HTTP $httpCode)";
}
return $result;
}
/**
* Generische HTTP-Validierung
*/
private function validateHttp(string $url, array $result): array
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_NOBODY => true,
CURLOPT_SSL_VERIFYPEER => false,
]);
curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$error = curl_error($ch);
curl_close($ch);
$result['details']['http_code'] = $httpCode;
$result['details']['content_type'] = $contentType;
if ($error) {
$result['error'] = 'Verbindungsfehler: ' . $error;
return $result;
}
if ($httpCode >= 200 && $httpCode < 400) {
$result['valid'] = true;
} else {
$result['error'] = "URL nicht erreichbar (HTTP $httpCode)";
}
return $result;
}
/**
* Schnelle Erreichbarkeitsprüfung
*/
public function isReachable(string $url): bool
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_NOBODY => true,
CURLOPT_SSL_VERIFYPEER => false,
]);
curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $httpCode >= 200 && $httpCode < 400;
}
}
+404
View File
@@ -0,0 +1,404 @@
<?php
/**
* TenantManager - CRUD-Operationen für Tenants
*/
namespace AuroraLivecam\Tenant;
use AuroraLivecam\Core\Database;
use Exception;
class TenantManager
{
private Database $db;
public function __construct(?Database $db = null)
{
$this->db = $db ?? Database::getInstance();
}
/**
* Erstellt einen neuen Tenant
*/
public function create(array $data): int
{
$this->db->beginTransaction();
try {
// UUID generieren
$uuid = $this->generateUuid();
// Slug generieren falls nicht vorhanden
$slug = $data['slug'] ?? $this->generateSlug($data['name']);
// Tenant erstellen
$tenantId = $this->db->insert('tenants', [
'uuid' => $uuid,
'name' => $data['name'],
'slug' => $slug,
'email' => $data['email'],
'status' => $data['status'] ?? 'trial',
'plan_id' => $data['plan_id'] ?? $this->getDefaultPlanId(),
'trial_ends_at' => $data['trial_ends_at'] ?? $this->calculateTrialEnd(),
]);
// Domain hinzufügen
if (!empty($data['domain'])) {
$this->addDomain($tenantId, $data['domain'], true);
}
// Default-Subdomain erstellen
if (!empty($data['subdomain'])) {
$subdomain = $data['subdomain'] . '.aurora-livecam.com';
$this->addDomain($tenantId, $subdomain, empty($data['domain']));
}
// Branding mit Defaults initialisieren
$this->db->insert('tenant_branding', [
'tenant_id' => $tenantId,
'site_name' => $data['name'],
'site_name_full' => $data['name'],
]);
// Onboarding initialisieren
$this->db->insert('tenant_onboarding', [
'tenant_id' => $tenantId,
'current_step' => 1,
]);
// Stream hinzufügen falls vorhanden
if (!empty($data['stream_url'])) {
$this->db->insert('tenant_streams', [
'tenant_id' => $tenantId,
'name' => 'Main Stream',
'stream_url' => $data['stream_url'],
'stream_type' => $data['stream_type'] ?? 'hls',
'is_primary' => 1,
]);
}
$this->db->commit();
return $tenantId;
} catch (Exception $e) {
$this->db->rollback();
throw $e;
}
}
/**
* Aktualisiert einen Tenant
*/
public function update(int $tenantId, array $data): bool
{
$allowedFields = ['name', 'email', 'status', 'plan_id'];
$updateData = array_intersect_key($data, array_flip($allowedFields));
if (empty($updateData)) {
return false;
}
return $this->db->update('tenants', $updateData, 'id = ?', [$tenantId]) > 0;
}
/**
* Löscht einen Tenant (Soft-Delete durch Status-Änderung)
*/
public function delete(int $tenantId): bool
{
return $this->db->update('tenants', ['status' => 'cancelled'], 'id = ?', [$tenantId]) > 0;
}
/**
* Hard-Delete (wirklich löschen - Vorsicht!)
*/
public function hardDelete(int $tenantId): bool
{
return $this->db->delete('tenants', 'id = ?', [$tenantId]) > 0;
}
/**
* Gibt einen Tenant anhand der ID zurück
*/
public function getById(int $id): ?array
{
return $this->db->fetchOne(
"SELECT t.*, p.name as plan_name, p.features as plan_features
FROM tenants t
LEFT JOIN plans p ON t.plan_id = p.id
WHERE t.id = ?",
[$id]
);
}
/**
* Gibt einen Tenant anhand des Slugs zurück
*/
public function getBySlug(string $slug): ?array
{
return $this->db->fetchOne(
"SELECT t.*, p.name as plan_name, p.features as plan_features
FROM tenants t
LEFT JOIN plans p ON t.plan_id = p.id
WHERE t.slug = ?",
[$slug]
);
}
/**
* Gibt einen Tenant anhand der UUID zurück
*/
public function getByUuid(string $uuid): ?array
{
return $this->db->fetchOne(
"SELECT t.*, p.name as plan_name, p.features as plan_features
FROM tenants t
LEFT JOIN plans p ON t.plan_id = p.id
WHERE t.uuid = ?",
[$uuid]
);
}
/**
* Listet alle Tenants auf
*/
public function getAll(array $filters = []): array
{
$sql = "SELECT t.*, p.name as plan_name, p.features as plan_features
FROM tenants t
LEFT JOIN plans p ON t.plan_id = p.id
WHERE 1=1";
$params = [];
if (!empty($filters['status'])) {
$sql .= " AND t.status = ?";
$params[] = $filters['status'];
}
if (!empty($filters['search'])) {
$sql .= " AND (t.name LIKE ? OR t.email LIKE ?)";
$params[] = '%' . $filters['search'] . '%';
$params[] = '%' . $filters['search'] . '%';
}
$sql .= " ORDER BY t.created_at DESC";
if (!empty($filters['limit'])) {
$sql .= " LIMIT " . (int)$filters['limit'];
if (!empty($filters['offset'])) {
$sql .= " OFFSET " . (int)$filters['offset'];
}
}
return $this->db->fetchAll($sql, $params);
}
/**
* Zählt Tenants
*/
public function count(array $filters = []): int
{
$sql = "SELECT COUNT(*) FROM tenants WHERE 1=1";
$params = [];
if (!empty($filters['status'])) {
$sql .= " AND status = ?";
$params[] = $filters['status'];
}
return (int) $this->db->fetchColumn($sql, $params);
}
/**
* Fügt eine Domain zu einem Tenant hinzu
*/
public function addDomain(int $tenantId, string $domain, bool $isPrimary = false): int
{
// Normalisiere Domain
$domain = strtolower(trim($domain));
// Prüfe ob Domain bereits existiert
$existing = $this->db->fetchOne(
"SELECT id FROM tenant_domains WHERE domain = ?",
[$domain]
);
if ($existing) {
throw new Exception("Domain '$domain' is already in use");
}
// Wenn primary, setze alle anderen auf non-primary
if ($isPrimary) {
$this->db->execute(
"UPDATE tenant_domains SET is_primary = 0 WHERE tenant_id = ?",
[$tenantId]
);
}
return $this->db->insert('tenant_domains', [
'tenant_id' => $tenantId,
'domain' => $domain,
'is_primary' => $isPrimary ? 1 : 0,
]);
}
/**
* Entfernt eine Domain von einem Tenant
*/
public function removeDomain(int $tenantId, string $domain): bool
{
return $this->db->delete('tenant_domains', 'tenant_id = ? AND domain = ?', [$tenantId, $domain]) > 0;
}
/**
* Gibt alle Domains eines Tenants zurück
*/
public function getDomains(int $tenantId): array
{
return $this->db->fetchAll(
"SELECT * FROM tenant_domains WHERE tenant_id = ? ORDER BY is_primary DESC",
[$tenantId]
);
}
/**
* Aktualisiert das Branding eines Tenants
*/
public function updateBranding(int $tenantId, array $data): bool
{
$allowedFields = [
'site_name', 'site_name_full', 'tagline', 'logo_path', 'favicon_path',
'primary_color', 'secondary_color', 'accent_color',
'welcome_text_de', 'welcome_text_en', 'footer_text',
'custom_css', 'custom_js',
'social_facebook', 'social_instagram', 'social_youtube'
];
$updateData = array_intersect_key($data, array_flip($allowedFields));
if (empty($updateData)) {
return false;
}
// Prüfe ob Branding existiert
$exists = $this->db->fetchColumn(
"SELECT tenant_id FROM tenant_branding WHERE tenant_id = ?",
[$tenantId]
);
if ($exists) {
return $this->db->update('tenant_branding', $updateData, 'tenant_id = ?', [$tenantId]) > 0;
} else {
$updateData['tenant_id'] = $tenantId;
return $this->db->insert('tenant_branding', $updateData) > 0;
}
}
/**
* Gibt das Branding eines Tenants zurück
*/
public function getBranding(int $tenantId): ?array
{
return $this->db->fetchOne(
"SELECT * FROM tenant_branding WHERE tenant_id = ?",
[$tenantId]
);
}
/**
* Prüft ob ein Slug verfügbar ist
*/
public function isSlugAvailable(string $slug, ?int $excludeTenantId = null): bool
{
$sql = "SELECT id FROM tenants WHERE slug = ?";
$params = [$slug];
if ($excludeTenantId) {
$sql .= " AND id != ?";
$params[] = $excludeTenantId;
}
return $this->db->fetchOne($sql, $params) === null;
}
/**
* Prüft ob eine Domain verfügbar ist
*/
public function isDomainAvailable(string $domain, ?int $excludeTenantId = null): bool
{
$sql = "SELECT td.id FROM tenant_domains td WHERE td.domain = ?";
$params = [$domain];
if ($excludeTenantId) {
$sql .= " AND td.tenant_id != ?";
$params[] = $excludeTenantId;
}
return $this->db->fetchOne($sql, $params) === null;
}
/**
* Generiert einen URL-sicheren Slug aus einem Namen
*/
private function generateSlug(string $name): string
{
$slug = strtolower($name);
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
$slug = trim($slug, '-');
// Sicherstellen dass Slug einzigartig ist
$baseSlug = $slug;
$counter = 1;
while (!$this->isSlugAvailable($slug)) {
$slug = $baseSlug . '-' . $counter;
$counter++;
}
return $slug;
}
/**
* Generiert eine UUID v4
*/
private function generateUuid(): string
{
$data = random_bytes(16);
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
/**
* Berechnet das Trial-Ende (14 Tage)
*/
private function calculateTrialEnd(): string
{
return date('Y-m-d H:i:s', strtotime('+14 days'));
}
/**
* Gibt die ID des Default-Plans (Free) zurück
*/
private function getDefaultPlanId(): int
{
$plan = $this->db->fetchOne("SELECT id FROM plans WHERE slug = 'free' LIMIT 1");
return $plan ? (int)$plan['id'] : 1;
}
/**
* Aktiviert einen Tenant (z.B. nach Zahlung)
*/
public function activate(int $tenantId): bool
{
return $this->db->update('tenants', ['status' => 'active'], 'id = ?', [$tenantId]) > 0;
}
/**
* Suspendiert einen Tenant (z.B. bei Zahlungsausfall)
*/
public function suspend(int $tenantId): bool
{
return $this->db->update('tenants', ['status' => 'suspended'], 'id = ?', [$tenantId]) > 0;
}
}
@@ -0,0 +1,427 @@
<?php
/**
* TenantSettingsManager - DB-basierte Settings pro Tenant
*
* Erweitert/ersetzt SettingsManager für Multi-Tenant Betrieb
* Fällt auf den alten SettingsManager zurück wenn DB nicht verfügbar
*/
namespace AuroraLivecam\Tenant;
use AuroraLivecam\Core\Database;
use AuroraLivecam\Core\TenantResolver;
class TenantSettingsManager
{
private Database $db;
private TenantResolver $resolver;
private int $tenantId;
private array $settings = [];
private bool $loaded = false;
private bool $dbAvailable = false;
// Fallback auf Legacy-SettingsManager
private ?\SettingsManager $legacyManager = null;
public function __construct(?int $tenantId = null, ?Database $db = null, ?TenantResolver $resolver = null)
{
$this->db = $db ?? Database::getInstance();
$this->resolver = $resolver ?? TenantResolver::getInstance();
$this->tenantId = $tenantId ?? $this->resolver->getTenantId();
$this->checkDbAvailability();
}
/**
* Prüft ob die DB verfügbar ist
*/
private function checkDbAvailability(): void
{
try {
$this->db->fetchOne("SELECT 1 FROM tenant_settings LIMIT 1");
$this->dbAvailable = true;
} catch (\Exception $e) {
$this->dbAvailable = false;
}
}
/**
* Lädt alle Settings für den Tenant
*/
private function load(): void
{
if ($this->loaded) {
return;
}
// Wenn keine DB, nutze Legacy
if (!$this->dbAvailable || $this->tenantId === 0) {
$this->loadFromLegacy();
return;
}
$rows = $this->db->fetchAll(
"SELECT setting_key, setting_value FROM tenant_settings WHERE tenant_id = ?",
[$this->tenantId]
);
foreach ($rows as $row) {
$value = $row['setting_value'];
// JSON-Werte parsen
if ($value !== null && ($value[0] === '{' || $value[0] === '[')) {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE) {
$value = $decoded;
}
}
// Booleans und Zahlen konvertieren
elseif ($value === 'true') $value = true;
elseif ($value === 'false') $value = false;
elseif (is_numeric($value)) $value = strpos($value, '.') !== false ? (float)$value : (int)$value;
$this->settings[$row['setting_key']] = $value;
}
// Defaults für fehlende Keys
$this->settings = array_merge($this->getDefaults(), $this->settings);
$this->loaded = true;
}
/**
* Fallback auf Legacy SettingsManager
*/
private function loadFromLegacy(): void
{
if ($this->legacyManager === null) {
// Legacy-Manager einbinden
$legacyFile = dirname(__DIR__, 2) . '/SettingsManager.php';
if (file_exists($legacyFile) && !class_exists('\SettingsManager')) {
require_once $legacyFile;
}
if (class_exists('\SettingsManager')) {
$this->legacyManager = new \SettingsManager();
}
}
if ($this->legacyManager) {
// Konvertiere Legacy-Settings in unser Format
$this->settings = $this->convertLegacySettings($this->legacyManager);
} else {
$this->settings = $this->getDefaults();
}
$this->loaded = true;
}
/**
* Konvertiert Legacy-Settings
*/
private function convertLegacySettings(\SettingsManager $legacy): array
{
$settings = $this->getDefaults();
// Mappe Legacy-Werte
$mappings = [
'viewer_display.enabled' => 'viewer_display.enabled',
'viewer_display.min_viewers' => 'viewer_display.min_viewers',
'video_mode.play_in_player' => 'video_mode.play_in_player',
'video_mode.allow_download' => 'video_mode.allow_download',
'timelapse.default_speed' => 'timelapse.default_speed',
'ui_display.show_recommendation_banner' => 'ui_display.show_recommendation_banner',
'ui_display.show_qr_code' => 'ui_display.show_qr_code',
'ui_display.show_social_media' => 'ui_display.show_social_media',
'content.guestbook_enabled' => 'content.guestbook_enabled',
'content.gallery_enabled' => 'content.gallery_enabled',
'weather.enabled' => 'weather.enabled',
'weather.location' => 'weather.location',
'weather.lat' => 'weather.lat',
'weather.lon' => 'weather.lon',
'seo.custom_title' => 'seo.custom_title',
'seo.meta_description' => 'seo.meta_description',
];
foreach ($mappings as $legacyKey => $newKey) {
$value = $legacy->get($legacyKey);
if ($value !== null) {
$settings[$newKey] = $value;
}
}
return $settings;
}
/**
* Gibt einen Setting-Wert zurück (mit Dot-Notation)
*/
public function get(string $key, mixed $default = null): mixed
{
$this->load();
// Direkte Keys
if (isset($this->settings[$key])) {
return $this->settings[$key];
}
// Dot-Notation auflösen
$keys = explode('.', $key);
$value = $this->settings;
foreach ($keys as $k) {
if (!is_array($value) || !isset($value[$k])) {
return $default;
}
$value = $value[$k];
}
return $value;
}
/**
* Setzt einen Setting-Wert
*/
public function set(string $key, mixed $value): bool
{
$this->load();
// Wenn keine DB, nutze Legacy
if (!$this->dbAvailable || $this->tenantId === 0) {
return $this->setLegacy($key, $value);
}
// Wert für DB vorbereiten
$dbValue = $this->prepareValueForDb($value);
// UPSERT
$sql = "INSERT INTO tenant_settings (tenant_id, setting_key, setting_value)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)";
$result = $this->db->execute($sql, [$this->tenantId, $key, $dbValue]) > 0;
if ($result) {
$this->settings[$key] = $value;
}
return $result;
}
/**
* Setzt Legacy-Setting
*/
private function setLegacy(string $key, mixed $value): bool
{
if ($this->legacyManager) {
return $this->legacyManager->set($key, $value);
}
return false;
}
/**
* Bereitet einen Wert für die DB vor
*/
private function prepareValueForDb(mixed $value): string
{
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_array($value) || is_object($value)) {
return json_encode($value);
}
return (string)$value;
}
/**
* Löscht ein Setting
*/
public function delete(string $key): bool
{
if (!$this->dbAvailable || $this->tenantId === 0) {
return false;
}
$result = $this->db->delete('tenant_settings', 'tenant_id = ? AND setting_key = ?', [$this->tenantId, $key]) > 0;
if ($result) {
unset($this->settings[$key]);
}
return $result;
}
/**
* Gibt alle Settings zurück
*/
public function all(): array
{
$this->load();
return $this->settings;
}
/**
* Setzt mehrere Settings auf einmal
*/
public function setMany(array $settings): bool
{
foreach ($settings as $key => $value) {
$this->set($key, $value);
}
return true;
}
/**
* Default-Settings
*/
private function getDefaults(): array
{
return [
// Viewer Display
'viewer_display.enabled' => true,
'viewer_display.min_viewers' => 1,
'viewer_display.update_interval' => 5,
// Video Mode
'video_mode.play_in_player' => true,
'video_mode.allow_download' => true,
// Timelapse
'timelapse.default_speed' => 1,
'timelapse.available_speeds' => [1, 10, 100],
'timelapse.reverse_enabled' => true,
// UI Display
'ui_display.show_recommendation_banner' => true,
'ui_display.show_qr_code' => true,
'ui_display.show_social_media' => true,
// Zoom
'zoom.show_controls' => true,
'zoom.max_level' => 4.0,
// Content
'content.guestbook_enabled' => true,
'content.gallery_enabled' => true,
'content.ai_events_enabled' => true,
// Weather
'weather.enabled' => true,
'weather.location' => 'Zürich,CH',
'weather.lat' => '47.3769',
'weather.lon' => '8.5417',
'weather.update_interval' => 5,
'weather.units' => 'metric',
// SEO
'seo.custom_title' => '',
'seo.meta_description' => '',
'seo.meta_keywords' => '',
// Theme
'theme.default' => 'theme-legacy',
'theme.show_switcher' => false,
];
}
// === Helper-Methoden (kompatibel mit altem SettingsManager) ===
public function isWeatherEnabled(): bool
{
return $this->get('weather.enabled', true) === true;
}
public function getWeatherLocation(): string
{
return $this->get('weather.location', 'Zürich,CH');
}
public function getWeatherCoords(): array
{
return [
'lat' => $this->get('weather.lat', '47.3769'),
'lon' => $this->get('weather.lon', '8.5417'),
];
}
public function getWeatherUpdateInterval(): int
{
return (int)$this->get('weather.update_interval', 5);
}
public function shouldShowViewers(): bool
{
return $this->get('viewer_display.enabled', true) === true;
}
public function getMinViewers(): int
{
return (int)$this->get('viewer_display.min_viewers', 1);
}
public function isGuestbookEnabled(): bool
{
return $this->get('content.guestbook_enabled', true) === true;
}
public function isGalleryEnabled(): bool
{
return $this->get('content.gallery_enabled', true) === true;
}
/**
* AJAX-Handler (kompatibel mit altem SettingsManager)
*/
public function handleAjax(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
if (!isset($_POST['settings_action'])) return;
header('Content-Type: application/json');
// Auth prüfen
if (!$this->isAdmin()) {
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
$action = $_POST['settings_action'];
if ($action === 'update' && isset($_POST['key'], $_POST['value'])) {
$key = $_POST['key'];
$value = $_POST['value'];
// Booleans konvertieren
if ($value === 'true') $value = true;
elseif ($value === 'false') $value = false;
$success = $this->set($key, $value);
echo json_encode(['success' => $success]);
exit;
}
if ($action === 'get') {
echo json_encode(['success' => true, 'data' => $this->all()]);
exit;
}
echo json_encode(['success' => false, 'error' => 'Unknown action']);
exit;
}
/**
* Prüft ob der User Admin ist
*/
private function isAdmin(): bool
{
return isset($_SESSION['admin']) && $_SESSION['admin'] === true;
}
/**
* Lädt Settings neu aus der DB
*/
public function reload(): void
{
$this->loaded = false;
$this->settings = [];
$this->load();
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
/**
* Bootstrap - Initialisiert die Multi-Tenant Umgebung
*
* Einbinden am Anfang von index.php:
* require_once __DIR__ . '/src/bootstrap.php';
*/
// Autoloader für src/ Klassen
spl_autoload_register(function ($class) {
// Namespace-Präfix
$prefix = 'AuroraLivecam\\';
$baseDir = __DIR__ . '/';
// Prüfe ob die Klasse unseren Namespace verwendet
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
return;
}
// Relativer Klassenname
$relativeClass = substr($class, $len);
// Pfad zur Datei
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
if (file_exists($file)) {
require $file;
}
});
use AuroraLivecam\Core\TenantResolver;
use AuroraLivecam\Core\Database;
/**
* Gibt die Site-Konfiguration basierend auf dem aktuellen Tenant zurück
* Ersetzt den hardcoded Domain-Switch in index.php
*/
function getSiteConfig(): array
{
// Legacy SettingsManager laden
$settingsFile = dirname(__DIR__) . '/SettingsManager.php';
if (!class_exists('SettingsManager') && file_exists($settingsFile)) {
require_once $settingsFile;
}
$settingsManager = new \SettingsManager();
// Wenn Multi-Tenant nicht aktiviert, nutze Legacy-Modus
if (!$settingsManager->isMultiTenantEnabled()) {
return getLegacySiteConfig();
}
// Multi-Tenant Modus
try {
$resolver = TenantResolver::getInstance();
$tenant = $resolver->resolve();
$branding = $resolver->getBranding();
if (!$tenant) {
return getLegacySiteConfig();
}
return [
'tenant_id' => $tenant['id'],
'tenant_slug' => $tenant['slug'],
'is_multi_tenant' => true,
'site_name' => $branding['site_name'] ?? $tenant['name'],
'site_name_full' => $branding['site_name_full'] ?? $tenant['name'],
'tagline' => $branding['tagline'] ?? '',
'logo_path' => $branding['logo_path'] ?? null,
'favicon_path' => $branding['favicon_path'] ?? null,
'primary_color' => $branding['primary_color'] ?? '#667eea',
'secondary_color' => $branding['secondary_color'] ?? '#764ba2',
'accent_color' => $branding['accent_color'] ?? '#f093fb',
'welcome_de' => $branding['welcome_text_de'] ?? '',
'welcome_en' => $branding['welcome_text_en'] ?? '',
'footer_text' => $branding['footer_text'] ?? '',
'custom_css' => $branding['custom_css'] ?? '',
'social' => [
'facebook' => $branding['social_facebook'] ?? '',
'instagram' => $branding['social_instagram'] ?? '',
'youtube' => $branding['social_youtube'] ?? '',
],
];
} catch (\Exception $e) {
// Fallback auf Legacy bei Fehlern
return getLegacySiteConfig();
}
}
/**
* Legacy Site-Konfiguration (hardcoded Domains)
* Kompatibilität mit bestehendem Code
*/
function getLegacySiteConfig(): array
{
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$isSeecam = (stripos($host, 'seecam.ch') !== false);
if ($isSeecam) {
return [
'tenant_id' => 0,
'tenant_slug' => 'seecam',
'is_multi_tenant' => false,
'site_name' => 'Seecam',
'site_name_full' => 'Seecam.ch - Live Webcam am See',
'tagline' => 'Ihre Live-Webcam am See',
'logo_path' => null,
'favicon_path' => null,
'primary_color' => '#667eea',
'secondary_color' => '#764ba2',
'accent_color' => '#f093fb',
'welcome_de' => 'Willkommen bei Seecam - Ihrer Live-Webcam am See!',
'welcome_en' => 'Welcome to Seecam - Your Live Webcam at the Lake!',
'footer_text' => '',
'custom_css' => '',
'social' => [
'facebook' => '',
'instagram' => '',
'youtube' => '',
],
];
}
// Default: Aurora
return [
'tenant_id' => 0,
'tenant_slug' => 'aurora',
'is_multi_tenant' => false,
'site_name' => 'Aurora',
'site_name_full' => 'Aurora Weather Livecam - Zürich Oberland',
'tagline' => 'Wetter Webcam Schweiz - Zürich Oberland',
'logo_path' => null,
'favicon_path' => null,
'primary_color' => '#667eea',
'secondary_color' => '#764ba2',
'accent_color' => '#f093fb',
'welcome_de' => 'Willkommen bei Aurora Weather Livecam - Ihre Wetter-Webcam im Zürcher Oberland mit AI-Erkennung für Aurora, Starlink und mehr!',
'welcome_en' => 'Welcome to Aurora Weather Livecam - Your weather webcam in the Zurich Oberland with AI detection for Aurora, Starlink and more!',
'footer_text' => '',
'custom_css' => '',
'social' => [
'facebook' => '',
'instagram' => '',
'youtube' => '',
],
];
}
/**
* Redirect Handler für alte Domains
*/
function handleDomainRedirects(): void
{
$host = $_SERVER['HTTP_HOST'] ?? '';
// Alte Aurora-Domains auf neue Domain umleiten
$oldDomains = [
'www.aurora-wetter-lifecam.ch',
'aurora-wetter-lifecam.ch',
'www.aurora-wetter-livecam.ch',
'aurora-wetter-livecam.ch'
];
$newDomain = 'www.aurora-weather-livecam.com';
if (in_array($host, $oldDomains)) {
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
header("HTTP/1.1 301 Moved Permanently");
header("Location: {$protocol}://{$newDomain}{$requestUri}");
exit;
}
}
// Domain-Redirects automatisch ausführen
handleDomainRedirects();
+31
View File
@@ -0,0 +1,31 @@
<?php
// Fehler anzeigen
error_reporting(E_ALL);
ini_set('display_errors', 1);
echo "Test 1: Settings Manager laden...<br>";
require_once 'SettingsManager.php';
echo "✓ SettingsManager.php geladen<br>";
echo "Test 2: Weather Manager laden...<br>";
require_once 'WeatherManager.php';
echo "✓ WeatherManager.php geladen<br>";
echo "Test 3: SettingsManager initialisieren...<br>";
$settingsManager = new SettingsManager();
echo "✓ SettingsManager initialisiert<br>";
echo "Test 4: WeatherManager initialisieren...<br>";
$weatherManager = new WeatherManager($settingsManager);
echo "✓ WeatherManager initialisiert<br>";
echo "Test 5: Wetter abrufen...<br>";
$weather = $weatherManager->getCurrentWeather();
echo "✓ Wetter abgerufen<br>";
echo "<pre>";
print_r($weather);
echo "</pre>";
echo "<br><br>✅ ALLE TESTS ERFOLGREICH!";
?>
+225
View File
@@ -0,0 +1,225 @@
Erstelle ein Word-Dokument: "Power BI Schulungshandbuch für HR" mit Schritt-für-Schritt-Anleitungen.
ZIELGRUPPE:
- 3-4 HR-Mitarbeiterinnen, Schweiz
- Excel-Kenntnisse: Basis + SVERWEIS
- Technikaffinität: 5-6/10
- Keine Power BI Vorkenntnisse
DATENQUELLEN DER TEILNEHMER:
- SAP HCM/HRM (alle Infotypen, besonders PA0001, PA0002, PA0008, PA2001)
- Rexx HR-System (Stellenplan, Pulsumfrage, MA-Zufriedenheit)
- Excel/CSV (Kununu-Score, Refline/Time-to-hire)
KPIs DIE ABGEBILDET WERDEN SOLLEN:
- Headcount/FTE (monatlich)
- Fluktuation (monatlich)
- Krankenquote gesamt + ohne Langzeitkrankheiten >30 Tage (Quartal)
- Überstunden (Quartal)
- Produktivstunden (wöchentlich)
- Ferientage/GLZ-Saldi (jährlich)
- Stellenplan Soll vs Ist (monatlich, aus Rexx)
- Lohnkosten (monatlich)
- Time to hire (Quartal)
- Kununu Score (monatlich)
- Pulsumfrage (Quartal, aus Rexx)
- MA-Zufriedenheitsumfrage (jährlich, aus Rexx)
ZIELGRUPPEN DER REPORTS:
- Geschäftsleitung
- Verwaltungsrat
- Finanzbuchhaltung
- Abteilungsleiter
STRUKTUR DES DOKUMENTS:
1. MODUL 1: GRUNDLAGEN & DATENIMPORT
1.1 Power BI Desktop installieren und starten
- Wo herunterladen, Installation, erster Start
1.2 Oberfläche kennenlernen
- Berichtsansicht, Datenansicht, Modellansicht erklären
- Wo findet man was (Menüband, Felder-Bereich, Visualisierungen)
1.3 Excel-Datei importieren
- Schritt-für-Schritt: Daten abrufen → Excel → Datei wählen → Navigator → Laden
- Häufige Probleme und Lösungen
1.4 CSV importieren
- Unterschiede zu Excel, Encoding-Probleme Schweiz (Umlaute)
1.5 SAP-Export importieren
- Typische SAP-Exportformate verarbeiten
- Spaltenüberschriften aus erster Zeile
2. MODUL 2: POWER QUERY EDITOR
2.1 Power Query öffnen
- Daten transformieren → Button finden
2.2 Erste Zeile als Header verwenden
- Schritt-für-Schritt mit Menüpfad
2.3 Datentypen ändern
- Datum, Zahl, Text erkennen und korrigieren
- Schweizer Datumsformat beachten
2.4 Spalten entfernen/behalten
- Nur relevante Spalten behalten
2.5 Zeilen filtern
- Beispiel: Nur aktive Mitarbeiter, nur bestimmter Zeitraum
2.6 Werte ersetzen
- null durch 0 ersetzen, Codes durch Klartext
2.7 Spalten teilen/zusammenführen
2.8 Berechnete Spalte hinzufügen
2.9 Schliessen und Laden
- Unterschied: Laden vs. Laden in
3. MODUL 3: DATENMODELL
3.1 Zur Modellansicht wechseln
3.2 Beziehungen verstehen
- 1:n, 1:1 erklären
- Warum Beziehungen wichtig sind
3.3 Beziehung erstellen
- Drag & Drop zwischen Tabellen
- Beziehung bearbeiten (Kardinalität, Kreuzfilterrichtung)
3.4 Datumstabelle erstellen
- Warum eigene Datumstabelle nötig
- DAX-Formel zum Erstellen:
Datum = ADDCOLUMNS(CALENDAR(DATE(2020,1,1), TODAY()), "Jahr", YEAR([Date]), "Monat", MONTH([Date]), "MonatName", FORMAT([Date],"MMMM"), "Quartal", "Q" & QUARTER([Date]), "KW", WEEKNUM([Date]))
- Als Datumstabelle markieren (Menüpfad)
3.5 PERNR als Schlüssel
- Personalnummer verbindet alle SAP-Tabellen
4. MODUL 4: DAX MEASURES
4.1 Was ist ein Measure vs. berechnete Spalte
4.2 Neues Measure erstellen
- Menüpfad: Modellierung → Neues Measure
4.3 Basis-Measures für HR:
Headcount:
Headcount = COUNTROWS(Mitarbeiter)
FTE:
FTE = SUMX(Mitarbeiter, Mitarbeiter[Beschäftigungsgrad]/100)
Krankheitstage:
Krankheitstage = SUM(Abwesenheiten[Kalendertage])
Sollarbeitstage:
Sollarbeitstage = [Headcount] * 21
Krankenquote:
Krankenquote = DIVIDE([Krankheitstage], [Sollarbeitstage], 0)
Krankenquote ohne Langzeit (>30 Tage):
Krankenquote_ohne_LZ =
VAR KrankheitstageKurz = CALCULATE([Krankheitstage], FILTER(Abwesenheiten, Abwesenheiten[Kalendertage] <= 30))
RETURN DIVIDE(KrankheitstageKurz, [Sollarbeitstage], 0)
Austritte:
Austritte = CALCULATE(COUNTROWS(Mitarbeiter), Mitarbeiter[Austritt] <> BLANK())
Durchschnittlicher Headcount:
Avg_Headcount = AVERAGEX(VALUES(Datum[Monat]), [Headcount])
Fluktuation:
Fluktuation = DIVIDE([Austritte], [Avg_Headcount], 0) * 100
4.4 Zeitintelligenz-Measures:
Vorjahreswert:
Headcount_VJ = CALCULATE([Headcount], SAMEPERIODLASTYEAR(Datum[Date]))
Vormonat:
Headcount_VM = CALCULATE([Headcount], PREVIOUSMONTH(Datum[Date]))
Year-to-Date:
Headcount_YTD = TOTALYTD([Headcount], Datum[Date])
Delta zum Vorjahr:
Delta_VJ = [Headcount] - [Headcount_VJ]
Delta Prozent:
Delta_VJ_Proz = DIVIDE([Delta_VJ], [Headcount_VJ], 0)
4.5 Measures formatieren
- Prozent, Dezimalstellen, Währung einstellen
5. MODUL 5: VISUALISIERUNGEN
5.1 Visualisierungstypen und wann verwenden:
- Karte/Card: Einzelne KPI-Zahl (Headcount, Krankenquote)
- Balkendiagramm: Vergleiche (Abteilungen, Monate)
- Liniendiagramm: Zeitverläufe (Headcount über 12 Monate)
- Ringdiagramm: Anteile (Absenzen nach Typ)
- Tachometer: Ziel vs Ist (Stellenplan-Erfüllung)
- Tabelle/Matrix: Details mit Drill-down
5.2 Erste Visualisierung erstellen
- Schritt-für-Schritt: Visualisierung wählen → Felder reinziehen
5.3 Visualisierung formatieren
- Titel, Farben, Schriftgrössen
5.4 Filter hinzufügen
- Visualfilter, Seitenfilter, Berichtsfilter
5.5 Slicer erstellen
- Zeitraum-Auswahl, Abteilungs-Auswahl
5.6 Bedingte Formatierung
- Rot/Grün je nach Wert (Ampel-Logik)
6. MODUL 6: DASHBOARD BAUEN
6.1 Dashboard-Layout planen
- F-Muster: Wichtigstes oben links
- Max 6-8 Visualisierungen pro Seite
6.2 Seite 1: Management-Übersicht erstellen
- KPI-Karten oben: Headcount, Krankenquote, Fluktuation, Stellenplan
- Trendlinie Headcount
- Absenzquote nach Typ
6.3 Seite 2: Detailanalyse erstellen
- Matrix mit Drill-down nach Abteilung
- Filter für Zeitraum und Kostenstelle
6.4 Interaktionen zwischen Visualisierungen
- Klick auf Balken filtert andere Visuals
- Interaktionen bearbeiten (Menüpfad)
6.5 Design-Tipps
- Konsistente Farben (Firmen-CI)
- Genügend Weissraum
- Beschriftungen lesbar
7. MODUL 7: VERÖFFENTLICHEN & TEILEN
7.1 Power BI Service (app.powerbi.com)
- Konto erstellen/anmelden
- Unterschied Desktop vs Service
7.2 Bericht veröffentlichen
- Menüpfad: Datei → Veröffentlichen → Arbeitsbereich wählen
7.3 Arbeitsbereich einrichten
7.4 Dashboard erstellen (aus Bericht)
- Visualisierung anheften
7.5 Bericht teilen
- Link teilen, Zugriff verwalten
7.6 Automatische Aktualisierung einrichten
- Geplante Aktualisierung (täglich, wöchentlich)
- Gateway für lokale Daten (IT einbeziehen)
7.7 Row-Level Security (RLS)
- Abteilungsleiter sehen nur eigene Daten
- Rolle erstellen, DAX-Filter: [Abteilung] = USERPRINCIPALNAME()
8. TROUBLESHOOTING
8.1 Häufige Fehler beim Import
- Encoding-Probleme (UTF-8)
- Falsches Dezimaltrennzeichen (Punkt vs Komma)
- Datum wird als Text erkannt
8.2 Häufige DAX-Fehler
- Zirkelbezug
- Division durch Null (DIVIDE verwenden)
- Falscher Filterkontext
8.3 Beziehungsprobleme
- Mehrdeutige Beziehungen
- Fehlende Beziehung
8.4 Performance-Probleme
- Zu viele Spalten importiert
- Berechnete Spalten vs Measures
9. ANHANG
9.1 DAX Cheat Sheet (alle HR-Formeln auf einer Seite)
9.2 Checkliste: Neuen Report erstellen
9.3 Glossar (Power Query, DAX, Measure, etc.)
FORMAT-ANWEISUNGEN:
- Jeder Schritt nummeriert
- Menüpfade in Format: Reiter → Gruppe → Button
- DAX-Formeln in Codeblock/Monospace
- Tipps und Warnungen hervorheben
- Screenshots beschreiben wo sinnvoll: [Screenshot: Beschreibung was zu sehen sein sollte]
- Sprache: Deutsch (Schweiz), Du-Form
@@ -0,0 +1,556 @@
<!DOCTYPE html>
<html lang="de-CH">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Power BI Schulungshandbuch für HR</title>
<style>
:root {
color-scheme: light;
--accent: #1f6feb;
--accent-soft: #e0f2fe;
--text: #0f172a;
--muted: #475569;
--bg: #f8fafc;
--card: #ffffff;
--border: #e2e8f0;
--warning: #f97316;
--success: #16a34a;
--code: #0b1020;
}
body {
margin: 0;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
color: var(--text);
background: var(--bg);
line-height: 1.7;
}
header {
background: linear-gradient(120deg, #e0f2fe 0%, #eef2ff 100%);
padding: 40px 24px 24px;
border-bottom: 1px solid var(--border);
}
header h1 {
margin: 0 0 8px 0;
font-size: 2.2rem;
}
header p {
margin: 6px 0;
color: var(--muted);
}
main {
max-width: 1050px;
margin: 0 auto;
padding: 24px;
}
section {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 26px;
margin-bottom: 22px;
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.05);
}
h2 {
margin-top: 0;
color: #111827;
border-bottom: 2px solid var(--border);
padding-bottom: 6px;
}
h3 {
margin-bottom: 6px;
color: #1e293b;
}
h4 {
margin: 14px 0 6px;
color: #1f2937;
}
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 0.85rem;
background: var(--accent-soft);
color: #0369a1;
margin-left: 8px;
}
ul, ol {
margin: 8px 0 16px 24px;
}
.callout {
border-left: 4px solid var(--accent);
background: #eef2ff;
padding: 12px 16px;
border-radius: 8px;
margin: 12px 0;
color: #1e293b;
}
.warning {
border-left-color: var(--warning);
background: #fff7ed;
}
.success {
border-left-color: var(--success);
background: #ecfdf3;
}
pre {
background: var(--code);
color: #e2e8f0;
padding: 16px;
border-radius: 10px;
overflow-x: auto;
}
code {
font-family: "Consolas", "Courier New", monospace;
}
figure {
margin: 0;
padding: 0;
}
figcaption {
color: var(--muted);
font-size: 0.9rem;
margin-top: 8px;
}
.grid-two {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.grid-three {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 16px;
}
.kpi-list li {
margin-bottom: 4px;
}
.checklist li {
margin-bottom: 6px;
}
.small {
font-size: 0.92rem;
color: var(--muted);
}
.flow-box {
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
background: #f1f5f9;
}
</style>
</head>
<body>
<header>
<h1>Power BI Schulungshandbuch für HR</h1>
<p>Für 34 HR-Mitarbeiterinnen (Schweiz) mit wenig IT-Kenntnissen und Excel-Basis.</p>
<p class="small">Hinweis: Eine Word-Version ist nicht im Repo enthalten (Binary-Dateien werden beim PR-Erstellen nicht unterstützt).</p>
</header>
<main>
<section>
<h2>Überblick</h2>
<div class="grid-two">
<div>
<h3>Zielgruppe</h3>
<ul>
<li>34 HR-Mitarbeiterinnen (Schweiz)</li>
<li>Excel: Basis + SVERWEIS</li>
<li>Technikaffinität: 56/10</li>
<li>Keine Power BI Vorkenntnisse</li>
</ul>
</div>
<div>
<h3>Zielgruppen der Reports</h3>
<ul>
<li>Geschäftsleitung</li>
<li>Verwaltungsrat</li>
<li>Finanzbuchhaltung</li>
<li>Abteilungsleiter</li>
</ul>
</div>
</div>
<h3>Datenquellen</h3>
<ul>
<li>SAP HCM/HRM (Infotypen PA0001, PA0002, PA0008, PA2001)</li>
<li>Rexx HR-System (Stellenplan, Pulsumfrage, MA-Zufriedenheit)</li>
<li>Excel/CSV (Kununu-Score, Refline/Time-to-hire)</li>
</ul>
<h3>KPIs (mit Periodizität)</h3>
<ul class="kpi-list">
<li>Headcount/FTE (monatlich)</li>
<li>Fluktuation (monatlich)</li>
<li>Krankenquote gesamt + ohne Langzeitkrankheiten &gt;30 Tage (Quartal)</li>
<li>Überstunden (Quartal)</li>
<li>Produktivstunden (wöchentlich)</li>
<li>Ferientage/GLZ-Saldi (jährlich)</li>
<li>Stellenplan Soll vs Ist (monatlich, Rexx)</li>
<li>Lohnkosten (monatlich)</li>
<li>Time to hire (Quartal)</li>
<li>Kununu Score (monatlich)</li>
<li>Pulsumfrage (Quartal, Rexx)</li>
<li>MA-Zufriedenheitsumfrage (jährlich, Rexx)</li>
</ul>
<figure>
<svg width="100%" height="260" viewBox="0 0 980 260" role="img" aria-label="Datenfluss von Quellen zu Power BI und Reports">
<defs>
<linearGradient id="box" x1="0" x2="1">
<stop offset="0%" stop-color="#e0f2fe"/>
<stop offset="100%" stop-color="#eef2ff"/>
</linearGradient>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L9,3 L0,6 Z" fill="#64748b" />
</marker>
</defs>
<rect x="20" y="30" width="240" height="60" rx="12" fill="url(#box)" stroke="#94a3b8" />
<text x="140" y="65" text-anchor="middle" font-size="14" fill="#0f172a">SAP HCM/HRM</text>
<rect x="20" y="120" width="240" height="60" rx="12" fill="url(#box)" stroke="#94a3b8" />
<text x="140" y="155" text-anchor="middle" font-size="14" fill="#0f172a">Rexx HR-System</text>
<rect x="20" y="210" width="240" height="60" rx="12" fill="url(#box)" stroke="#94a3b8" />
<text x="140" y="245" text-anchor="middle" font-size="14" fill="#0f172a">Excel/CSV</text>
<rect x="350" y="100" width="260" height="80" rx="14" fill="#1f6feb" opacity="0.12" stroke="#1f6feb" />
<text x="480" y="145" text-anchor="middle" font-size="16" fill="#1f6feb">Power BI Desktop</text>
<rect x="700" y="70" width="260" height="120" rx="14" fill="#ecfeff" stroke="#0ea5e9" />
<text x="830" y="120" text-anchor="middle" font-size="14" fill="#0f172a">Berichte &amp; Dashboards</text>
<text x="830" y="145" text-anchor="middle" font-size="12" fill="#475569">GL · VR · Finanzen · Abteilungen</text>
<line x1="260" y1="60" x2="350" y2="120" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)" />
<line x1="260" y1="150" x2="350" y2="140" stroke="#64748b" stroke-width="2" />
<line x1="260" y1="240" x2="350" y2="160" stroke="#64748b" stroke-width="2" />
<line x1="610" y1="140" x2="700" y2="130" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)" />
</svg>
<figcaption>Grafik: Datenfluss von HR-Quellen in Power BI bis zu den Zielgruppen-Reports.</figcaption>
</figure>
</section>
<section>
<h2>Vorbereitung: Arbeitsordner &amp; Dateien <span class="badge">Start</span></h2>
<ol>
<li>Lege einen Ordner <strong>HR-Power-BI</strong> an.</li>
<li>Erstelle Unterordner: <code>01_Rohdaten</code>, <code>02_Transformiert</code>, <code>03_Berichte</code>.</li>
<li>Speichere Exporte aus SAP/Rexx/Excel immer in <code>01_Rohdaten</code>.</li>
<li>Benutze klare Dateinamen mit Datum, z. B. <code>SAP_PA0001_2025-01.csv</code>.</li>
</ol>
<div class="callout success">Ziel: Alle Teammitglieder finden Dateien sofort wieder und arbeiten mit den gleichen Daten.</div>
</section>
<section>
<h2>1. Grundlagen &amp; Datenimport <span class="badge">Modul 1</span></h2>
<h3>1.1 Installation &amp; erster Start</h3>
<ol>
<li>Gehe auf <strong>https://powerbi.microsoft.com/de-de/desktop/</strong> und lade Power BI Desktop herunter.</li>
<li>Installiere mit Standardoptionen (Weiter → Installieren → Fertigstellen).</li>
<li>Starte Power BI Desktop und wähle <strong>Leerer Bericht</strong>.</li>
<li>Speichere die Datei als <code>HR-Reporting.pbix</code> in <code>03_Berichte</code>.</li>
</ol>
<div class="callout">Tipp: Speichere früh und oft Power BI Desktop hat keine Auto-Speicherung.</div>
<h3>1.2 Oberfläche kennenlernen</h3>
<ol>
<li>Links: Berichtsansicht (Diagramme), Datenansicht (Tabellen), Modellansicht (Beziehungen).</li>
<li>Rechts: Visualisierungen (Diagramm-Typ), Felder (Spalten), Filter.</li>
<li>Oben: Menüband mit allen Funktionen.</li>
</ol>
<div class="callout">Merksatz: <strong>Felder</strong> sind die Daten, <strong>Visualisierungen</strong> sind die Diagramme.</div>
<h3>1.3 Excel importieren (Kununu, Refline)</h3>
<ol>
<li><strong>Start → Daten abrufen → Excel</strong>.</li>
<li>Datei auswählen → <strong>Öffnen</strong>.</li>
<li>Im Navigator das richtige Blatt wählen (z. B. <code>Kununu_Score</code>).</li>
<li>Klicke <strong>Laden</strong>.</li>
</ol>
<div class="callout warning">Warnung: Excel-Tabellen ohne Überschrift führen zu „Spalte1/Spalte2“. Nutze in Power Query „Erste Zeile als Überschrift“.</div>
<h3>1.4 CSV importieren (Time-to-hire)</h3>
<ol>
<li><strong>Start → Daten abrufen → Text/CSV</strong>.</li>
<li>Datei auswählen → <strong>Öffnen</strong>.</li>
<li>Prüfe <strong>Trennzeichen</strong> (meist Semikolon).</li>
<li>Setze <strong>Dateiursprung</strong> auf UTF-8.</li>
</ol>
<div class="callout">Tipp: Umlaute (ä, ö, ü) sind das beste Zeichen, ob die Kodierung stimmt.</div>
<h3>1.5 SAP-Exporte importieren</h3>
<ol>
<li>SAP-Export lokal speichern (z. B. PA0001, PA0002, PA0008, PA2001).</li>
<li>Jeden Infotyp als eigene Tabelle laden.</li>
<li>Tabellen sofort umbenennen: <code>Mitarbeiter_Org</code>, <code>Mitarbeiter_Personal</code>, <code>Mitarbeiter_Lohn</code>, <code>Absenzen</code>.</li>
</ol>
<div class="callout warning">Warnung: SAP-Daten enthalten oft führende Nullen bei Personalnummern (PERNR). Nicht löschen!</div>
</section>
<section>
<h2>2. Power Query Editor <span class="badge">Modul 2</span></h2>
<h3>2.1 Power Query öffnen</h3>
<ol>
<li><strong>Start → Daten transformieren</strong>.</li>
<li>Du siehst eine Vorschau-Tabelle pro Datenquelle.</li>
</ol>
<h3>2.2 Erste Zeile als Überschrift</h3>
<ol>
<li><strong>Transformieren → Erste Zeile als Überschriften</strong>.</li>
<li>Kontrolliere, ob Spaltennamen sinnvoll sind.</li>
</ol>
<h3>2.3 Datentypen richtig setzen</h3>
<ol>
<li>Datumsspalten: <strong>Datum</strong> auswählen.</li>
<li>Zahlen: <strong>Ganze Zahl</strong> oder <strong>Dezimalzahl</strong>.</li>
<li>Text: <strong>Text</strong>.</li>
</ol>
<div class="callout warning">Warnung: Schweizer Datumsformat (TT.MM.JJJJ) braucht oft „Datentyp mit Gebietsschema (Deutsch Schweiz)“.</div>
<h3>2.4 Spalten entfernen / behalten</h3>
<ol>
<li>Unnötige Spalten markieren → <strong>Spalten entfernen</strong>.</li>
<li>Wenn nur 68 Spalten relevant sind: <strong>Andere Spalten entfernen</strong>.</li>
</ol>
<h3>2.5 Zeilen filtern</h3>
<ol>
<li>Filterpfeil in der Spalte <strong>Status</strong>.</li>
<li>Nur aktive Mitarbeitende wählen.</li>
<li>Zeitraum (z. B. letztes Jahr) filtern.</li>
</ol>
<h3>2.6 Werte ersetzen</h3>
<ol>
<li><strong>Transformieren → Werte ersetzen</strong>.</li>
<li><code>null</code> durch <code>0</code> ersetzen.</li>
<li>Codes wie <code>A</code> in Klartext (<code>Aktiv</code>) umwandeln.</li>
</ol>
<h3>2.7 Spalten teilen / zusammenführen</h3>
<ol>
<li>Spalte auswählen → <strong>Spalte teilen</strong> (z. B. Vorname/Nachname).</li>
<li>Mehrere Spalten zusammenführen (z. B. Vorname + Nachname).</li>
</ol>
<h3>2.8 Berechnete Spalte</h3>
<ol>
<li><strong>Spalte hinzufügen → Benutzerdefinierte Spalte</strong>.</li>
<li>Beispiel: FTE = Beschäftigungsgrad / 100.</li>
</ol>
<h3>2.9 Schliessen &amp; Laden</h3>
<ol>
<li><strong>Start → Schliessen &amp; Laden</strong>.</li>
<li>„Laden in“ nutzen, wenn du nur eine Verbindung brauchst.</li>
</ol>
</section>
<section>
<h2>3. Datenmodell <span class="badge">Modul 3</span></h2>
<h3>3.1 Beziehungen verstehen</h3>
<div class="grid-three">
<div class="flow-box">
<strong>1:n Beziehung</strong>
<p class="small">Eine Personalnummer in der Mitarbeitertabelle kann viele Abwesenheitszeilen haben.</p>
</div>
<div class="flow-box">
<strong>1:1 Beziehung</strong>
<p class="small">Eine Personalnummer hat genau eine Detailzeile (z. B. Stammdaten).</p>
</div>
<div class="flow-box">
<strong>Filterfluss</strong>
<p class="small">Filter sollen meistens nur in eine Richtung laufen (Einweg).</p>
</div>
</div>
<h3>3.2 Beziehung erstellen</h3>
<ol>
<li>Modellansicht öffnen (Beziehungs-Icon links).</li>
<li>Spalte <strong>PERNR</strong> von Tabelle A auf Tabelle B ziehen.</li>
<li>Kardinalität prüfen (1:n) und Kreuzfilterrichtung auf Einweg setzen.</li>
</ol>
<h3>3.3 Datumstabelle erstellen</h3>
<ol>
<li><strong>Modellierung → Neue Tabelle</strong>.</li>
<li>DAX-Formel eingeben:</li>
</ol>
<pre><code>Datum = ADDCOLUMNS(
CALENDAR(DATE(2020,1,1), TODAY()),
"Jahr", YEAR([Date]),
"Monat", MONTH([Date]),
"MonatName", FORMAT([Date],"MMMM"),
"Quartal", "Q" &amp; QUARTER([Date]),
"KW", WEEKNUM([Date])
)</code></pre>
<ol start="3">
<li><strong>Tabellen-Tools → Als Datumstabelle markieren → Datum[Date]</strong>.</li>
</ol>
<h3>3.4 PERNR als Schlüssel</h3>
<ol>
<li>PERNR in allen SAP-Tabellen verwenden.</li>
<li>In Rexx/Excel dieselbe Spalte sicherstellen.</li>
<li>Bei führenden Nullen: Datentyp Text setzen (nicht Zahl).</li>
</ol>
</section>
<section>
<h2>4. DAX Measures <span class="badge">Modul 4</span></h2>
<h3>4.1 Measure vs. berechnete Spalte</h3>
<ul>
<li><strong>Measure:</strong> wird im Bericht berechnet, schneller und flexibler.</li>
<li><strong>Berechnete Spalte:</strong> wird in jeder Zeile gespeichert (macht Modell grösser).</li>
</ul>
<h3>4.2 Neues Measure erstellen</h3>
<ol>
<li><strong>Modellierung → Neues Measure</strong>.</li>
<li>Formel eingeben und Enter drücken.</li>
<li>Measure klar benennen (z. B. <code>Headcount</code>, <code>Fluktuation</code>).</li>
</ol>
<h3>4.3 Basis-Measures für HR</h3>
<pre><code>Headcount = COUNTROWS(Mitarbeiter)
FTE = SUMX(Mitarbeiter, Mitarbeiter[Beschäftigungsgrad]/100)
Krankheitstage = SUM(Abwesenheiten[Kalendertage])
Sollarbeitstage = [Headcount] * 21
Krankenquote = DIVIDE([Krankheitstage], [Sollarbeitstage], 0)
Krankenquote_ohne_LZ =
VAR KrankheitstageKurz = CALCULATE([Krankheitstage], FILTER(Abwesenheiten, Abwesenheiten[Kalendertage] <= 30))
RETURN DIVIDE(KrankheitstageKurz, [Sollarbeitstage], 0)
Austritte = CALCULATE(COUNTROWS(Mitarbeiter), Mitarbeiter[Austritt] <> BLANK())
Avg_Headcount = AVERAGEX(VALUES(Datum[Monat]), [Headcount])
Fluktuation = DIVIDE([Austritte], [Avg_Headcount], 0) * 100</code></pre>
<h3>4.4 Zeitintelligenz</h3>
<pre><code>Headcount_VJ = CALCULATE([Headcount], SAMEPERIODLASTYEAR(Datum[Date]))
Headcount_VM = CALCULATE([Headcount], PREVIOUSMONTH(Datum[Date]))
Headcount_YTD = TOTALYTD([Headcount], Datum[Date])
Delta_VJ = [Headcount] - [Headcount_VJ]
Delta_VJ_Proz = DIVIDE([Delta_VJ], [Headcount_VJ], 0)</code></pre>
<h3>4.5 Measures formatieren</h3>
<ol>
<li>Measure auswählen.</li>
<li><strong>Measure-Tools → Format</strong> (Prozent, Währung, Dezimalstellen).</li>
</ol>
<div class="callout">Tipp: Für Krankenquote Prozentformat mit 1 Dezimalstelle verwenden.</div>
</section>
<section>
<h2>5. Visualisierungen <span class="badge">Modul 5</span></h2>
<h3>5.1 Welche Visualisierung wofür?</h3>
<ul>
<li><strong>Karte/Card:</strong> Einzelne KPI-Zahl (Headcount, Fluktuation).</li>
<li><strong>Balken:</strong> Vergleich von Abteilungen/Monaten.</li>
<li><strong>Linie:</strong> Trendverlauf (Headcount über 12 Monate).</li>
<li><strong>Ring:</strong> Anteil Absenzen nach Typ.</li>
<li><strong>Tachometer:</strong> Ziel vs Ist (Stellenplan).</li>
<li><strong>Matrix:</strong> Detailansicht mit Drill-down.</li>
</ul>
<h3>5.2 Erste Visualisierung erstellen</h3>
<ol>
<li>Visualisierung auswählen (z. B. Karte).</li>
<li>Feld <code>Headcount</code> in Werte ziehen.</li>
<li>Visual rechts auf der Seite platzieren.</li>
</ol>
<h3>5.3 Visualisierung formatieren</h3>
<ol>
<li>Visual auswählen → <strong>Format</strong> (Pinsel).</li>
<li>Titel hinzufügen: „Headcount aktuell“.</li>
<li>Farben gemäss Firmen-CI setzen.</li>
</ol>
<h3>5.4 Filter &amp; Slicer</h3>
<ol>
<li>Filterbereich öffnen.</li>
<li>Feld <code>Abteilung</code> als Seitenfilter setzen.</li>
<li>Slicer für Zeitraum hinzufügen.</li>
</ol>
<div class="callout warning">Warnung: Zu viele Filter verwirren. Maximal 23 Slicer pro Seite.</div>
</section>
<section>
<h2>6. Dashboard bauen <span class="badge">Modul 6</span></h2>
<h3>6.1 Layout planen</h3>
<ol>
<li>Wichtigste KPIs oben links platzieren (F-Muster).</li>
<li>Maximal 68 Visuals pro Seite.</li>
<li>Genug Weissraum für bessere Lesbarkeit.</li>
</ol>
<h3>6.2 Management-Übersicht (Seite 1)</h3>
<ol>
<li>KPI-Karten: Headcount, Krankenquote, Fluktuation, Stellenplan.</li>
<li>Trendlinie Headcount (12 Monate).</li>
<li>Absenzquote nach Typ als Ringdiagramm.</li>
</ol>
<h3>6.3 Detailanalyse (Seite 2)</h3>
<ol>
<li>Matrix mit Drill-down nach Abteilung.</li>
<li>Slicer: Zeitraum und Kostenstelle.</li>
</ol>
<h3>6.4 Interaktionen</h3>
<ol>
<li><strong>Format → Interaktionen bearbeiten</strong>.</li>
<li>Prüfen, ob Klick auf Balken andere Visuals filtert.</li>
</ol>
</section>
<section>
<h2>7. Veröffentlichen &amp; Teilen <span class="badge">Modul 7</span></h2>
<ol>
<li><strong>Datei → Veröffentlichen → Arbeitsbereich wählen</strong>.</li>
<li>Im Service Visuals anheften → Dashboard erstellen.</li>
<li>Teilen-Link an Geschäftsleitung/Finanzen senden.</li>
<li>Geplante Aktualisierung einrichten (Gateway für lokale Daten).</li>
</ol>
<div class="callout">Tipp: Teste RLS im Service immer mit „Als Rolle anzeigen“.</div>
</section>
<section>
<h2>8. Troubleshooting <span class="badge">Modul 8</span></h2>
<h3>8.1 Häufige Import-Fehler</h3>
<ul>
<li>Umlaute falsch → Encoding auf UTF-8 stellen.</li>
<li>Datum als Text → Datentyp mit Gebietsschema Schweiz.</li>
<li>Dezimaltrennzeichen falsch → Gebietsschema prüfen.</li>
</ul>
<h3>8.2 DAX-Fehler</h3>
<ul>
<li>Zirkelbezug → berechnete Spalten vermeiden.</li>
<li>Division durch Null → <code>DIVIDE()</code> verwenden.</li>
<li>Filterkontext falsch → <code>CALCULATE()</code> prüfen.</li>
</ul>
</section>
<section>
<h2>9. Anhang: Cheat Sheet &amp; Checkliste <span class="badge">Modul 9</span></h2>
<h3>9.1 DAX Cheat Sheet</h3>
<pre><code>Headcount = COUNTROWS(Mitarbeiter)
FTE = SUMX(Mitarbeiter, Mitarbeiter[Beschäftigungsgrad]/100)
Krankenquote = DIVIDE([Krankheitstage], [Sollarbeitstage], 0)
Fluktuation = DIVIDE([Austritte], [Avg_Headcount], 0) * 100</code></pre>
<h3>9.2 Checkliste: Neuer Report</h3>
<ul class="checklist">
<li>Datenquellen klären (SAP, Rexx, Excel/CSV).</li>
<li>Daten importieren und bereinigen (Power Query).</li>
<li>Beziehungen und Datumstabelle erstellen.</li>
<li>Measures bauen und formatieren.</li>
<li>Dashboard layouten, testen, veröffentlichen.</li>
</ul>
</section>
</main>
</body>
</html>
@@ -0,0 +1,320 @@
# Power BI Schulungshandbuch für HR
Word-Version: Nicht im Repo enthalten (Binary-Dateien werden beim PR-Erstellen nicht unterstützt).
Zielgruppe: 34 HR-Mitarbeiterinnen (Schweiz), Excel-Basis + SVERWEIS, Technikaffinität 56/10, keine Power BI Vorkenntnisse.
Datenquellen: SAP HCM/HRM (PA0001, PA0002, PA0008, PA2001), Rexx HR-System (Stellenplan, Pulsumfrage, MA-Zufriedenheit), Excel/CSV (Kununu-Score, Refline/Time-to-hire).
KPIs: Headcount/FTE (monatlich), Fluktuation (monatlich), Krankenquote gesamt & ohne Langzeit >30 Tage (Quartal), Überstunden (Quartal), Produktivstunden (wöchentlich), Ferientage/GLZ-Saldi (jährlich), Stellenplan Soll vs Ist (monatlich), Lohnkosten (monatlich), Time to hire (Quartal), Kununu Score (monatlich), Pulsumfrage (Quartal), MA-Zufriedenheitsumfrage (jährlich).
Zielgruppen der Reports: Geschäftsleitung, Verwaltungsrat, Finanzbuchhaltung, Abteilungsleiter.
## 1. MODUL 1: GRUNDLAGEN & DATENIMPORT
### 1.1 Power BI Desktop installieren und starten
1. Schritt: Gehe auf https://powerbi.microsoft.com/de-de/desktop/ und lade Power BI Desktop herunter.
2. Schritt: Installiere die Anwendung mit den Standardoptionen (Weiter → Installieren → Fertigstellen).
3. Schritt: Starte Power BI Desktop über das Startmenü.
[Screenshot: Startfenster von Power BI Desktop mit leeren Berichtsvorlagen].
Tipp: Wenn der Download blockiert ist, wende Dich an die IT (Admin-Rechte erforderlich).
### 1.2 Oberfläche kennenlernen
1. Schritt: Wechsle links zwischen Berichtsansicht, Datenansicht und Modellansicht.
2. Schritt: Erkenne die Bereiche: Menüband oben, Visualisierungen rechts, Felder-Bereich rechts, Seiten-Navigation links.
3. Schritt: Klicke auf eine leere Seite, damit Visualisierungen verfügbar werden.
[Screenshot: Power BI Desktop mit markierter Berichtsansicht, Visualisierungen und Felder-Bereich].
### 1.3 Excel-Datei importieren
1. Schritt: Reiter → Start → Daten abrufen → Excel.
2. Schritt: Datei auswählen → Öffnen.
3. Schritt: Im Navigator Tabelle oder Blatt auswählen → Laden.
Warnung: Wenn Du im Navigator mehrere Tabellen auswählst, kann die Ladezeit steigen.
Häufige Probleme und Lösungen:
1. Problem: Falsche Spaltennamen → Lösung: Erste Zeile als Header setzen (siehe Modul 2).
2. Problem: Zahlen als Text → Lösung: Datentyp korrigieren (siehe Modul 2).
### 1.4 CSV importieren
1. Schritt: Reiter → Start → Daten abrufen → Text/CSV.
2. Schritt: Datei auswählen → Öffnen.
3. Schritt: Im Vorschaufenster Trennzeichen und Kodierung prüfen.
Warnung: In der Schweiz sind Umlaute oft nur mit UTF-8 korrekt. Stelle Kodierung auf UTF-8, falls nötig.
Hinweis: CSV hat keine Formeln oder Formatierungen nur Rohdaten.
### 1.5 SAP-Export importieren
1. Schritt: SAP-Export (z. B. TXT/CSV/XLSX) in einen lokalen Ordner speichern.
2. Schritt: Reiter → Start → Daten abrufen → Text/CSV oder Excel wählen.
3. Schritt: Im Navigator prüfen, ob die erste Zeile die Spaltenüberschriften enthält.
Tipp: Wenn die Überschriften fehlen, nutze Power Query → Erste Zeile als Überschriften.
## 2. MODUL 2: POWER QUERY EDITOR
### 2.1 Power Query öffnen
1. Schritt: Reiter → Start → Daten transformieren.
[Screenshot: Button 'Daten transformieren' im Menüband].
### 2.2 Erste Zeile als Header verwenden
1. Schritt: Reiter → Transformieren → Erste Zeile als Überschriften.
2. Schritt: Prüfe, ob die Spaltennamen korrekt sind.
### 2.3 Datentypen ändern
1. Schritt: Spalte auswählen (z. B. Eintrittsdatum).
2. Schritt: Reiter → Transformieren → Datentyp → Datum.
3. Schritt: Bei Zahlen Datentyp → Dezimalzahl oder Ganze Zahl.
Warnung: Schweizer Datumsformat (TT.MM.JJJJ) wird manchmal als Text erkannt. In diesem Fall zuerst Datentyp Text, dann Datum mit Gebietsschema Schweiz (Deutsch).
### 2.4 Spalten entfernen/behalten
1. Schritt: Unnötige Spalten markieren.
2. Schritt: Reiter → Start → Spalten entfernen.
Tipp: Nutze "Andere Spalten entfernen", um nur relevante Spalten zu behalten.
### 2.5 Zeilen filtern
1. Schritt: Filterpfeil in der Spalte Status.
2. Schritt: Nur aktive Mitarbeitende auswählen.
3. Schritt: Zeitraumfilter z. B. letztes Jahr.
### 2.6 Werte ersetzen
1. Schritt: Reiter → Transformieren → Werte ersetzen.
2. Schritt: null durch 0 ersetzen.
3. Schritt: Codes (z. B. 'A') durch Klartext (z. B. 'Aktiv') ersetzen.
### 2.7 Spalten teilen/zusammenführen
1. Schritt: Spalte auswählen.
2. Schritt: Reiter → Transformieren → Spalte teilen (nach Trennzeichen).
3. Schritt: Für Zusammenführen: Reiter → Transformieren → Spalten zusammenführen.
### 2.8 Berechnete Spalte hinzufügen
1. Schritt: Reiter → Spalte hinzufügen → Benutzerdefinierte Spalte.
2. Schritt: Formel eingeben (z. B. Beschäftigungsgrad/100).
### 2.9 Schliessen und Laden
1. Schritt: Reiter → Start → Schliessen & laden.
2. Schritt: Unterschied: "Laden" speichert in Modell, "Laden in" erlaubt gezielte Ziele (z. B. nur Verbindung).
## 3. MODUL 3: DATENMODELL
### 3.1 Zur Modellansicht wechseln
1. Schritt: Links auf die Modellansicht (Beziehungs-Icon) klicken.
### 3.2 Beziehungen verstehen
1. Schritt: 1:n = Eine Zeile in Tabelle A passt zu vielen Zeilen in Tabelle B.
2. Schritt: 1:1 = Jede Zeile passt genau zu einer anderen Zeile.
Warum wichtig: Beziehungen steuern, wie Filter zwischen Tabellen fliessen.
### 3.3 Beziehung erstellen
1. Schritt: Spalte in Tabelle A auf passende Spalte in Tabelle B ziehen (Drag & Drop).
2. Schritt: Beziehung prüfen → Kardinalität und Kreuzfilterrichtung einstellen.
Tipp: Nutze meistens Einweg-Filterrichtung, um Mehrdeutigkeiten zu vermeiden.
### 3.4 Datumstabelle erstellen
1. Schritt: Reiter → Modellierung → Neue Tabelle.
2. Schritt: DAX-Formel einfügen:
```
Datum = ADDCOLUMNS(CALENDAR(DATE(2020,1,1), TODAY()), "Jahr", YEAR([Date]), "Monat", MONTH([Date]), "MonatName", FORMAT([Date],"MMMM"), "Quartal", "Q" & QUARTER([Date]), "KW", WEEKNUM([Date]))
```
3. Schritt: Reiter → Tabellen-Tools → Als Datumstabelle markieren → Datum[Date] auswählen.
### 3.5 PERNR als Schlüssel
1. Schritt: Verwende die Personalnummer (PERNR) als Schlüssel zwischen allen SAP-Tabellen (PA0001, PA0002, PA0008, PA2001).
## 4. MODUL 4: DAX MEASURES
### 4.1 Was ist ein Measure vs. berechnete Spalte
1. Schritt: Measure berechnet sich dynamisch im Berichtskontext.
2. Schritt: Berechnete Spalte wird pro Zeile gespeichert und erhöht Modellgrösse.
### 4.2 Neues Measure erstellen
1. Schritt: Reiter → Modellierung → Neues Measure.
2. Schritt: Formel eingeben und mit Enter bestätigen.
### 4.3 Basis-Measures für HR
```
Headcount = COUNTROWS(Mitarbeiter)
FTE = SUMX(Mitarbeiter, Mitarbeiter[Beschäftigungsgrad]/100)
Krankheitstage = SUM(Abwesenheiten[Kalendertage])
Sollarbeitstage = [Headcount] * 21
Krankenquote = DIVIDE([Krankheitstage], [Sollarbeitstage], 0)
Krankenquote_ohne_LZ =
VAR KrankheitstageKurz = CALCULATE([Krankheitstage], FILTER(Abwesenheiten, Abwesenheiten[Kalendertage] <= 30))
RETURN DIVIDE(KrankheitstageKurz, [Sollarbeitstage], 0)
Austritte = CALCULATE(COUNTROWS(Mitarbeiter), Mitarbeiter[Austritt] <> BLANK())
Avg_Headcount = AVERAGEX(VALUES(Datum[Monat]), [Headcount])
Fluktuation = DIVIDE([Austritte], [Avg_Headcount], 0) * 100
```
### 4.4 Zeitintelligenz-Measures
```
Headcount_VJ = CALCULATE([Headcount], SAMEPERIODLASTYEAR(Datum[Date]))
Headcount_VM = CALCULATE([Headcount], PREVIOUSMONTH(Datum[Date]))
Headcount_YTD = TOTALYTD([Headcount], Datum[Date])
Delta_VJ = [Headcount] - [Headcount_VJ]
Delta_VJ_Proz = DIVIDE([Delta_VJ], [Headcount_VJ], 0)
```
### 4.5 Measures formatieren
1. Schritt: Measure auswählen.
2. Schritt: Reiter → Measure-Tools → Format → Prozent, Dezimalstellen, Währung einstellen.
## 5. MODUL 5: VISUALISIERUNGEN
### 5.1 Visualisierungstypen und wann verwenden
1. Karte/Card: Einzelne KPI-Zahl (Headcount, Krankenquote).
2. Balkendiagramm: Vergleiche (Abteilungen, Monate).
3. Liniendiagramm: Zeitverläufe (Headcount über 12 Monate).
4. Ringdiagramm: Anteile (Absenzen nach Typ).
5. Tachometer: Ziel vs Ist (Stellenplan-Erfüllung).
6. Tabelle/Matrix: Details mit Drill-down.
### 5.2 Erste Visualisierung erstellen
1. Schritt: Visualisierung im Bereich Visualisierungen auswählen.
2. Schritt: Felder per Drag & Drop in Achse/Werte ziehen.
3. Schritt: Visualisierung auf der Seite positionieren.
### 5.3 Visualisierung formatieren
1. Schritt: Visual auswählen → Reiter Visual → Format (Pinsel).
2. Schritt: Titel, Farben, Schriftgrössen anpassen.
### 5.4 Filter hinzufügen
1. Schritt: Filterbereich öffnen.
2. Schritt: Felder in Visualfilter, Seitenfilter oder Berichtsfilter ziehen.
### 5.5 Slicer erstellen
1. Schritt: Visualisierung → Datenschnitt (Slicer) wählen.
2. Schritt: Feld (z. B. Zeitraum, Abteilung) hinzufügen.
### 5.6 Bedingte Formatierung
1. Schritt: In Tabelle/Matrix auf Wertefeld klicken → Bedingte Formatierung.
2. Schritt: Regeln definieren (z. B. Rot/Grün je nach Wert).
Tipp: Ampel-Logik funktioniert gut für Krankenquote und Fluktuation.
## 6. MODUL 6: DASHBOARD BAUEN
### 6.1 Dashboard-Layout planen
1. Schritt: F-Muster beachten Wichtigstes oben links.
2. Schritt: Maximal 68 Visualisierungen pro Seite.
### 6.2 Seite 1: Management-Übersicht erstellen
1. Schritt: KPI-Karten oben: Headcount, Krankenquote, Fluktuation, Stellenplan.
2. Schritt: Trendlinie Headcount über 12 Monate.
3. Schritt: Absenzquote nach Typ als Ringdiagramm.
### 6.3 Seite 2: Detailanalyse erstellen
1. Schritt: Matrix mit Drill-down nach Abteilung.
2. Schritt: Filter für Zeitraum und Kostenstelle (Slicer).
### 6.4 Interaktionen zwischen Visualisierungen
1. Schritt: Reiter → Format → Interaktionen bearbeiten.
2. Schritt: Prüfen, ob Klick auf Balken andere Visuals filtert oder hervorhebt.
### 6.5 Design-Tipps
1. Schritt: Konsistente Farben (Firmen-CI).
2. Schritt: Genügend Weissraum.
3. Schritt: Beschriftungen gut lesbar.
## 7. MODUL 7: VERÖFFENTLICHEN & TEILEN
### 7.1 Power BI Service (app.powerbi.com)
1. Schritt: Konto erstellen/anmelden.
2. Schritt: Unterschied Desktop vs Service: Desktop = Modell/Bericht, Service = Teilen/Dashboard.
### 7.2 Bericht veröffentlichen
1. Schritt: Reiter → Datei → Veröffentlichen → Arbeitsbereich wählen.
### 7.3 Arbeitsbereich einrichten
1. Schritt: Im Service → Arbeitsbereich erstellen.
2. Schritt: Zugriffsrechte für Geschäftsleitung/Finanzbuchhaltung setzen.
### 7.4 Dashboard erstellen (aus Bericht)
1. Schritt: Im Service Visualisierung auswählen → Anheften.
2. Schritt: Neues Dashboard erstellen oder bestehendes wählen.
### 7.5 Bericht teilen
1. Schritt: Teilen → Link generieren.
2. Schritt: Zugriff verwalten (Rollen/Personen).
### 7.6 Automatische Aktualisierung einrichten
1. Schritt: Datensatz → Geplante Aktualisierung (täglich/wöchentlich).
2. Schritt: Für lokale Daten Gateway einrichten (IT einbeziehen).
### 7.7 Row-Level Security (RLS)
1. Schritt: Reiter → Modellierung → Rollen verwalten.
2. Schritt: Rolle erstellen, Filter setzen: [Abteilung] = USERPRINCIPALNAME().
Warnung: RLS muss im Service getestet werden (Als Rolle anzeigen).
## 8. TROUBLESHOOTING
### 8.1 Häufige Fehler beim Import
1. Problem: Encoding-Probleme (UTF-8) → Lösung: Kodierung im CSV-Import anpassen.
2. Problem: Dezimaltrennzeichen (Punkt vs Komma) → Lösung: Datentyp mit Gebietsschema Schweiz setzen.
3. Problem: Datum als Text → Lösung: Datentyp Datum und richtiges Gebietsschema.
### 8.2 Häufige DAX-Fehler
1. Problem: Zirkelbezug → Lösung: Berechnete Spalten vermeiden, Measures nutzen.
2. Problem: Division durch Null → Lösung: DIVIDE() verwenden.
3. Problem: Falscher Filterkontext → Lösung: Filter mit CALCULATE prüfen.
### 8.3 Beziehungsprobleme
1. Problem: Mehrdeutige Beziehungen → Lösung: Eine Beziehung aktiv, andere inaktiv setzen.
2. Problem: Fehlende Beziehung → Lösung: Schlüsselspalten prüfen (PERNR, Datum).
### 8.4 Performance-Probleme
1. Problem: Zu viele Spalten importiert → Lösung: Spalten reduzieren.
2. Problem: Zu viele berechnete Spalten → Lösung: Measures bevorzugen.
## 9. ANHANG
### 9.1 DAX Cheat Sheet (alle HR-Formeln)
```
Headcount = COUNTROWS(Mitarbeiter)
FTE = SUMX(Mitarbeiter, Mitarbeiter[Beschäftigungsgrad]/100)
Krankheitstage = SUM(Abwesenheiten[Kalendertage])
Sollarbeitstage = [Headcount] * 21
Krankenquote = DIVIDE([Krankheitstage], [Sollarbeitstage], 0)
Krankenquote_ohne_LZ = VAR KrankheitstageKurz = CALCULATE([Krankheitstage], FILTER(Abwesenheiten, Abwesenheiten[Kalendertage] <= 30))
RETURN DIVIDE(KrankheitstageKurz, [Sollarbeitstage], 0)
Austritte = CALCULATE(COUNTROWS(Mitarbeiter), Mitarbeiter[Austritt] <> BLANK())
Avg_Headcount = AVERAGEX(VALUES(Datum[Monat]), [Headcount])
Fluktuation = DIVIDE([Austritte], [Avg_Headcount], 0) * 100
Headcount_VJ = CALCULATE([Headcount], SAMEPERIODLASTYEAR(Datum[Date]))
Headcount_VM = CALCULATE([Headcount], PREVIOUSMONTH(Datum[Date]))
Headcount_YTD = TOTALYTD([Headcount], Datum[Date])
Delta_VJ = [Headcount] - [Headcount_VJ]
Delta_VJ_Proz = DIVIDE([Delta_VJ], [Headcount_VJ], 0)
```
### 9.2 Checkliste: Neuen Report erstellen
1. Schritt: Datenquellen klären (SAP, Rexx, Excel/CSV).
2. Schritt: Daten importieren (Modul 1).
3. Schritt: Daten bereinigen in Power Query (Modul 2).
4. Schritt: Beziehungen und Datumstabelle erstellen (Modul 3).
5. Schritt: Measures erstellen (Modul 4).
6. Schritt: Visuals bauen und formatieren (Modul 5).
7. Schritt: Dashboard layouten (Modul 6).
8. Schritt: Veröffentlichen und teilen (Modul 7).
### 9.3 Glossar
Power Query: Datenaufbereitungstool in Power BI.
DAX: Formelsprache für Berechnungen in Power BI.
Measure: Dynamische Kennzahl, abhängig vom Filterkontext.
Berechnete Spalte: Feste Berechnung pro Zeile.
RLS: Row-Level Security für zeilenbasierte Zugriffssteuerung.
@@ -0,0 +1,258 @@
<!DOCTYPE html>
<html lang="de-CH">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Power BI Schulungsleitfaden für Trainer (ABAP/Webservices)</title>
<style>
:root {
color-scheme: light;
--accent: #1f6feb;
--accent-soft: #e0f2fe;
--text: #0f172a;
--muted: #475569;
--bg: #f8fafc;
--card: #ffffff;
--border: #e2e8f0;
--warning: #f97316;
--success: #16a34a;
--code: #0b1020;
}
body {
margin: 0;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
color: var(--text);
background: var(--bg);
line-height: 1.7;
}
header {
background: linear-gradient(120deg, #e0f2fe 0%, #eef2ff 100%);
padding: 40px 24px 24px;
border-bottom: 1px solid var(--border);
}
header h1 {
margin: 0 0 8px 0;
font-size: 2.1rem;
}
header p {
margin: 6px 0;
color: var(--muted);
}
main {
max-width: 1050px;
margin: 0 auto;
padding: 24px;
}
section {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 26px;
margin-bottom: 22px;
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.05);
}
h2 {
margin-top: 0;
color: #111827;
border-bottom: 2px solid var(--border);
padding-bottom: 6px;
}
h3 {
margin-bottom: 6px;
color: #1e293b;
}
h4 {
margin: 14px 0 6px;
color: #1f2937;
}
ul, ol {
margin: 8px 0 16px 24px;
}
.callout {
border-left: 4px solid var(--accent);
background: #eef2ff;
padding: 12px 16px;
border-radius: 8px;
margin: 12px 0;
color: #1e293b;
}
.warning {
border-left-color: var(--warning);
background: #fff7ed;
}
.success {
border-left-color: var(--success);
background: #ecfdf3;
}
pre {
background: var(--code);
color: #e2e8f0;
padding: 16px;
border-radius: 10px;
overflow-x: auto;
}
code {
font-family: "Consolas", "Courier New", monospace;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
}
th, td {
border: 1px solid var(--border);
padding: 10px 12px;
text-align: left;
}
th {
background: #f1f5f9;
font-weight: 600;
}
.small {
font-size: 0.92rem;
color: var(--muted);
}
</style>
</head>
<body>
<header>
<h1>Power BI Schulungsleitfaden für Trainer (ABAP &amp; Daten-Webservices)</h1>
<p>Rolle: Trainer/IT/BI (ABAP-Expertise). Fokus auf Datenbereitstellung, Webservices und Betriebsübergabe.</p>
<p class="small">Zielgruppe der Schulung: HR-Konsumentinnen (Power BI Service, Filtern, Export).</p>
</header>
<main>
<section>
<h2>Zielbild</h2>
<ul>
<li>HR konsumiert fertige Dashboards im Power BI Service.</li>
<li>IT/BI stellt Datenquellen bereit, pflegt Modell, DAX, Refresh, Rechte.</li>
<li>Stabiler, dokumentierter Datenfluss (SAP → Webservice → Power BI).</li>
</ul>
<div class="callout success">Ergebnis: HR arbeitet schneller, IT/BI bleibt Owner von Datenqualität und Logik.</div>
</section>
<section>
<h2>Best Practice Rollenverteilung</h2>
<table>
<thead>
<tr>
<th>Aufgabe</th>
<th>HR</th>
<th>IT/BI-Team</th>
</tr>
</thead>
<tbody>
<tr><td>KPIs definieren</td><td></td><td></td></tr>
<tr><td>Daten interpretieren</td><td></td><td></td></tr>
<tr><td>Reports anfordern</td><td></td><td></td></tr>
<tr><td>Dashboards bauen</td><td></td><td></td></tr>
<tr><td>DAX/Measures schreiben</td><td></td><td></td></tr>
<tr><td>Datenmodell pflegen</td><td></td><td></td></tr>
<tr><td>Fertige Dashboards nutzen</td><td></td><td></td></tr>
<tr><td>Filter setzen, Drill-down</td><td></td><td></td></tr>
</tbody>
</table>
</section>
<section>
<h2>Datenquellen &amp; Webservices: Architektur</h2>
<p>Empfohlen für SAP-HR: OData/REST-Webservices aus SAP bereitstellen, dann in Power BI Service via Gateway anbinden.</p>
<ol>
<li>SAP HCM/HRM (PA0001/PA0002/PA0008/PA2001) → ABAP CDS/OData.</li>
<li>Rexx HR-System → REST/CSV-Exports oder DB-View.</li>
<li>Excel/CSV (Kununu, Refline) → SharePoint/OneDrive Ordner.</li>
<li>Power BI Dataset → Bericht → Dashboard.</li>
</ol>
<div class="callout">Ziel: Quellen entkoppeln, standardisierte Schnittstellen, minimale manuelle Exporte.</div>
</section>
<section>
<h2>SAP → Webservice: Vorgehen (ABAP)</h2>
<h3>1) CDS View mit sauberem Datenmodell</h3>
<ul>
<li>Erstelle CDS Views je Fachthema (z. B. Personalstamm, Absenzen, Lohn).</li>
<li>PERNR als Schlüssel, Datum als ISO-Format (YYYY-MM-DD).</li>
<li>Sprache und Mandant berücksichtigen.</li>
</ul>
<h3>2) OData Service veröffentlichen</h3>
<ul>
<li>Expose CDS als OData (Fiori Elements oder Gateway).</li>
<li>Aktiviere in /IWFND/MAINT_SERVICE.</li>
<li>Setze Authentifizierung (SAML/OAuth/Basic nach IT-Policy).</li>
</ul>
<h3>3) Performance &amp; Paging</h3>
<ul>
<li>Paging aktivieren, Delta-Logik prüfen.</li>
<li>Nur benötigte Felder liefern (Thin Views).</li>
<li>Filter serverseitig ermöglichen (Datum, Mandant, Status).</li>
</ul>
<div class="callout warning">Warnung: Zu viele Felder oder fehlende Filter führen zu langsamen Refreshs.</div>
</section>
<section>
<h2>Power BI Service: Datenanbindung</h2>
<h3>Gateway &amp; Authentifizierung</h3>
<ol>
<li>On-Premise Data Gateway installieren (IT/BI-Team).</li>
<li>Datenquelle registrieren (SAP OData/REST URL).</li>
<li>Zugangsdaten hinterlegen (Servicekonto).</li>
</ol>
<h3>Dataset Konfiguration</h3>
<ol>
<li>Power BI Desktop: Web/OData Connector nutzen.</li>
<li>Query-Parameter für Zeitraum/Delta definieren.</li>
<li>Dataset veröffentlichen → Service → geplante Aktualisierung.</li>
</ol>
<div class="callout success">Tipp: Einmalige Parameter (z. B. Startdatum) reduzieren Datenvolumen.</div>
</section>
<section>
<h2>Refresh-Strategie</h2>
<ul>
<li>Monatliche KPIs: Refresh täglich oder wöchentlich.</li>
<li>Wöchentliche KPIs: Refresh täglich (MoFr).</li>
<li>Jährliche KPIs: Refresh monatlich.</li>
</ul>
<div class="callout">Empfehlung: Einen fixen Refresh-Zeitpunkt kommunizieren (z. B. 06:00 Uhr).</div>
</section>
<section>
<h2>Security &amp; Datenschutz</h2>
<ul>
<li>Row-Level Security für Abteilungen (wenn nötig).</li>
<li>HR-Reports in separatem Workspace (Zugriffsgruppen).</li>
<li>Keine sensiblen Felder im Dataset (z. B. AHV-Nummern).</li>
</ul>
<div class="callout warning">Warnung: Personalnummern als Text behandeln (führende Nullen behalten).</div>
</section>
<section>
<h2>Trainer-Checkliste vor dem Kurs</h2>
<ul>
<li>Power BI Service Zugriff für HR geprüft.</li>
<li>Mindestens 1 Testbericht bereitgestellt.</li>
<li>Refresh läuft &amp; Daten aktuell.</li>
<li>Kurzanleitung für Filter/Export vorbereitet.</li>
</ul>
</section>
<section>
<h2>FAQ aus Sicht HR (Trainer-Antworten)</h2>
<h3>„Warum stimmen Zahlen nicht?“</h3>
<p>Meist ist ein Filter aktiv. Bitte Filter zurücksetzen und Zeitraum prüfen.</p>
<h3>„Warum sehe ich keine Daten?“</h3>
<p>Entweder fehlen Berechtigungen oder der Zeitraum ist zu eng gesetzt.</p>
<h3>„Kann ich Daten ändern?“</h3>
<p>Nein. HR konsumiert, Datenpflege erfolgt in SAP/Rexx/IT.</p>
</section>
</main>
</body>
</html>