Compare commits

..

92 Commits

Author SHA1 Message Date
admin ec827a4ce8 Add connection diagnostics and visual field transformation mapping 2026-04-13 11:52:05 +02:00
Claude af40d87213 Merge HANA SSL/MDC support and DLL reference fix from claude/blazor-sap-sales-exporter-9VrM0 2026-04-13 09:07:41 +00:00
Claude efcf7b180c Add SSL, MDC database, and custom HANA connection parameters
Fixes 'error while parsing protocol' HanaException by supporting
SSL/TLS encryption, Multi-Tenant Database Container (MDC) database
name, and arbitrary additional connection parameters.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

**Technische Änderungen:**

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

All elements now have data-en and data-de attributes for language switching
2026-01-18 13:23:33 +00:00
admin b9407a9f13 Merge pull request #28 from metacube2/claude/seecam-domain-config-njiwU
Add domain-based site configuration for seecam.ch
2026-01-18 10:08:40 +01:00
Claude 53ae1ba76f Add domain-based site configuration for seecam.ch
- Detect domain (seecam.ch vs aurora-weather-livecam.com) via PHP
- Load different logo (seecam.jpg vs logo.png) based on domain
- Dynamic meta tags (title, og:*, twitter:*, canonical URL)
- Dynamic Schema.org JSON-LD (WebSite, LocalBusiness, VideoObject)
- Dynamic site texts (welcome, about, blog title, footer, copyright)
- QR-code URL adapts to current domain
- Email sender name remains unchanged (Aurora Livecam)
2026-01-18 09:06:02 +00:00
admin 9a418aa213 Merge pull request #27 from metacube2/claude/add-video-download-sppLI
Claude/add video download spp li
2026-01-17 18:18:37 +01:00
Claude 9a88b5cad2 Add SEO-optimized index2.php with comprehensive improvements
- Add SEO meta tags (title, description, keywords, robots)
- Add Schema.org JSON-LD structured data (WebSite, LocalBusiness, VideoObject)
- Add local SEO geo tags for Zürich Oberland/Dürnten
- Improve Open Graph and Twitter Card meta tags
- Add optimized alt-texts for images
- Add social media links to footer (Instagram, Facebook, YouTube, TikTok)
- Add Patrouille Suisse dedicated section with training info
- Add Blog section with weather and webcam articles
- Add accessibility CSS for screen-readers
2026-01-17 17:13:46 +00:00
admin 3e6a584f4f Merge pull request #25 from metacube2/claude/add-macos-support-uYBaj
Add macOS Catalyst support for RollkofferSimulator
2026-01-15 15:24:17 +01:00
admin 6ad6167c52 Merge pull request #24 from metacube2/main-aurora
Main aurora
2026-01-15 15:10:46 +01:00
Claude f9b84e4d3c Add separate zoom wrapper layers for all video modes
- Added live-video-wrapper around webcam-player
- Added timelapse-wrapper inside timelapse-viewer
- Added daily-video-wrapper inside daily-video-player
- Zoom now applies to wrapper divs, not video elements directly
- Pan works by dragging inside video container when zoomed
- Double-click to reset zoom
- Cursor changes to grab when zoomed > 1x
- Touch support for mobile pan
2026-01-15 14:09:42 +00:00
admin 2e6fd332ac Merge pull request #23 from metacube2/claude/add-video-download-sppLI
Claude/add video download spp li
2026-01-15 14:54:33 +01:00
Claude b5376f46e5 Merge zoom & pan improvements from main-aurora 2026-01-15 13:51:42 +00:00
Claude 98f1fcae14 Add zoom & pan for all video modes
- Zoom now works for livestream, timelapse and daily videos
- Added pan function: drag to move zoomed area with mouse
- Added touch support for mobile pan
- Added +/- zoom buttons and reset button
- Reduced max zoom from 100x to 4x
- Dynamically detects active video element
- Pan limits based on zoom level
- Cursor changes to grab when zoomed
2026-01-15 13:51:25 +00:00
Claude 14c064de64 Fix video overlay issue - add z-index and disable zoom temporarily
- Added z-index: 10 to webcam-player (higher than overlays)
- Added z-index: 5 and display: none to timelapse/daily-video overlays
- Disabled zoom script temporarily for testing
2026-01-13 09:39:44 +00:00
Claude 313c2108a9 Fix live video player - add native HLS support and debugging
- Added display:block and background:#000 to video element
- Added native HLS detection for Safari (canPlayType check)
- Added console.log debugging for video loading
- Added error event handlers for better debugging
- Added fallback for browsers without HLS support
- Improved HLS.js error handling
2026-01-13 09:34:42 +00:00
admin c12ac16557 Merge pull request #22 from metacube2/claude/add-video-download-sppLI
Claude/add video download spp li
2026-01-13 10:27:17 +01:00
Claude b686d4506c Update aurora-livecam with new design and fixed zoom
- New design from main-aurora branch
- Fixed zoom: maxZoom reduced from 100 to 4
- Added zoom +/- buttons
- Added zoom slider with step 0.5
- Fixed video-zoom.js to not apply transform at 1x
2026-01-13 09:26:08 +00:00
admin c38bd130e5 Merge pull request #21 from metacube2/codex/fix-saving-changes-in-audora-project-tgc6un
Add 1–100x zoom controls for all video modes and wire video-zoom.js
2026-01-12 12:59:49 +01:00
admin de343364ad Merge branch 'main-aurora' into codex/fix-saving-changes-in-audora-project-tgc6un 2026-01-12 12:58:08 +01:00
admin e8385adb87 Add zoom controls for video modes 2026-01-12 12:40:22 +01:00
admin f7843e5e35 Merge pull request #20 from metacube2/codex/fix-saving-changes-in-audora-project-4bnojk
Add design switcher (Alpine/Modern) with Swiss-cross and sun overlay; harden settings save
2026-01-12 12:27:48 +01:00
admin 3a78d09399 Add design switcher themes 2026-01-12 12:27:32 +01:00
admin 28d2032f23 Merge pull request #19 from metacube2/codex/fix-saving-changes-in-audora-project
Fix settings save path and improve save reliability
2026-01-12 11:33:52 +01:00
Claude 9e175fdf56 Add indexmiau.php as copy of index.php 2026-01-11 03:23:53 +00:00
admin 13024c5ae8 Merge pull request #18 from metacube2/claude/add-video-download-sppLI
Claude/add video download spp li
2026-01-11 03:49:25 +01:00
Claude 42b12c5c36 Add zoom for all video modes and fix settings saving
- Zoom now works on live video, timelapse images, and archive video player
- Added zoom level indicator (shows percentage)
- Increased max zoom from 3x to 4x
- Fixed settings AJAX handler using FormData for reliable POST
- Settings event handlers now properly bound after DOM load
- Added error handling and visual feedback for settings changes
2026-01-11 02:48:33 +00:00
Claude a033d15912 Refactor index.php with cleaner AJAX handling and simplified code
- Inline AJAX settings handler for better control flow
- Simplify video download logic with condensed code
- Clean up domain redirect handling
- Remove redundant headers and verbose comments
2026-01-11 02:34:26 +00:00
admin 1f9bc08682 Merge pull request #17 from metacube2/main-aurora
Merge pull request #16 from metacube2/claude/mail-finetuning-webapp-0…
2026-01-10 11:49:50 +01:00
Claude 9363a2dd99 Add macOS Catalyst support for RollkofferSimulator
- Enable Mac Catalyst in Xcode project (SUPPORTS_MACCATALYST=YES)
- Set macOS deployment target to 13.0 (Ventura+)
- Add keyboard support for all scenes (Escape, Space, Enter)
- Add macOS menu bar with game commands (Cmd+P pause, Cmd+R restart)
- Configure window size restrictions for macOS
- Update Info.plist with macOS minimum version
2025-12-20 17:46:26 +00:00
123 changed files with 34113 additions and 99 deletions
+379
View File
@@ -0,0 +1,379 @@
<!DOCTYPE html>
<html lang="de" data-lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PromoMaster - Produkt-Promotion-Tool | Product Promotion Tool</title>
<!-- SEO Meta Tags -->
<meta name="description" content="Erstellen Sie automatisch professionelle Produktwerbung in Deutsch und Englisch. Generate professional product promotions in German and English.">
<meta name="keywords" content="Produktwerbung, Product Promotion, Marketing, Werbung, Advertising, Social Media, SEO, Product Launch">
<meta name="author" content="PromoMaster">
<meta name="robots" content="index, follow">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:title" content="PromoMaster - Automatische Produktwerbung">
<meta property="og:description" content="Erstellen Sie automatisch professionelle Produktwerbung in Deutsch und Englisch.">
<meta property="og:locale" content="de_DE">
<meta property="og:locale:alternate" content="en_US">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="PromoMaster - Product Promotion Tool">
<meta name="twitter:description" content="Generate professional product promotions in German and English automatically.">
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "PromoMaster",
"description": "Automatisches Produkt-Promotion-Tool in Deutsch und Englisch",
"applicationCategory": "Marketing",
"operatingSystem": "Web Browser",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "EUR"
}
}
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Language Toggle -->
<div class="lang-toggle">
<button id="btn-de" class="lang-btn active" onclick="setLanguage('de')">DE</button>
<button id="btn-en" class="lang-btn" onclick="setLanguage('en')">EN</button>
</div>
<!-- Hero Section -->
<header class="hero">
<div class="hero-bg"></div>
<div class="container">
<h1 class="hero-title">
<span class="hero-icon">&#9889;</span>
PromoMaster
</h1>
<p class="hero-subtitle" data-de="Dein Produkt. Weltweit bekannt. Vollautomatisch." data-en="Your Product. Worldwide Fame. Fully Automatic.">Dein Produkt. Weltweit bekannt. Vollautomatisch.</p>
</div>
</header>
<!-- Main App -->
<main class="container">
<!-- Step 1: Product Input -->
<section class="card step-card" id="step1">
<div class="step-header">
<span class="step-number">1</span>
<h2 data-de="Produkt-Informationen" data-en="Product Information"></h2>
</div>
<div class="form-grid">
<div class="form-group">
<label for="productName" data-de="Produktname *" data-en="Product Name *"></label>
<input type="text" id="productName" data-de-placeholder="z.B. SuperWidget Pro" data-en-placeholder="e.g. SuperWidget Pro">
</div>
<div class="form-group">
<label for="productCategory" data-de="Kategorie" data-en="Category"></label>
<select id="productCategory">
<option value="tech" data-de="Technologie" data-en="Technology"></option>
<option value="fashion" data-de="Mode & Bekleidung" data-en="Fashion & Apparel"></option>
<option value="food" data-de="Lebensmittel & Getr&auml;nke" data-en="Food & Beverages"></option>
<option value="health" data-de="Gesundheit & Wellness" data-en="Health & Wellness"></option>
<option value="home" data-de="Haus & Garten" data-en="Home & Garden"></option>
<option value="sport" data-de="Sport & Fitness" data-en="Sports & Fitness"></option>
<option value="beauty" data-de="Sch&ouml;nheit & Pflege" data-en="Beauty & Care"></option>
<option value="education" data-de="Bildung & Kurse" data-en="Education & Courses"></option>
<option value="software" data-de="Software & Apps" data-en="Software & Apps"></option>
<option value="other" data-de="Sonstiges" data-en="Other"></option>
</select>
</div>
<div class="form-group full-width">
<label for="productDescription" data-de="Kurzbeschreibung *" data-en="Short Description *"></label>
<textarea id="productDescription" rows="3" data-de-placeholder="Was macht dein Produkt besonders? (max. 200 Zeichen)" data-en-placeholder="What makes your product special? (max. 200 characters)" maxlength="200"></textarea>
<span class="char-count"><span id="charCount">0</span>/200</span>
</div>
<div class="form-group">
<label for="productPrice" data-de="Preis (optional)" data-en="Price (optional)"></label>
<input type="text" id="productPrice" data-de-placeholder="z.B. 29,99 EUR" data-en-placeholder="e.g. $29.99">
</div>
<div class="form-group">
<label for="productUrl" data-de="Website / Link (optional)" data-en="Website / Link (optional)"></label>
<input type="url" id="productUrl" data-de-placeholder="https://dein-produkt.de" data-en-placeholder="https://your-product.com">
</div>
<div class="form-group full-width">
<label for="productFeatures" data-de="Top-Features (kommagetrennt)" data-en="Top Features (comma-separated)"></label>
<input type="text" id="productFeatures" data-de-placeholder="z.B. Schnell, Zuverl&auml;ssig, Einfach zu bedienen" data-en-placeholder="e.g. Fast, Reliable, Easy to use">
</div>
<div class="form-group full-width">
<label for="targetAudience" data-de="Zielgruppe" data-en="Target Audience"></label>
<input type="text" id="targetAudience" data-de-placeholder="z.B. Unternehmer, Studenten, Eltern" data-en-placeholder="e.g. Entrepreneurs, Students, Parents">
</div>
</div>
</section>
<!-- Step 2: Promotion Style -->
<section class="card step-card" id="step2">
<div class="step-header">
<span class="step-number">2</span>
<h2 data-de="Werbestil w&auml;hlen" data-en="Choose Promotion Style"></h2>
</div>
<div class="style-grid">
<div class="style-option selected" data-style="professional" onclick="selectStyle(this)">
<span class="style-icon">&#128188;</span>
<span class="style-label" data-de="Professionell" data-en="Professional"></span>
</div>
<div class="style-option" data-style="casual" onclick="selectStyle(this)">
<span class="style-icon">&#128075;</span>
<span class="style-label" data-de="Locker & Freundlich" data-en="Casual & Friendly"></span>
</div>
<div class="style-option" data-style="urgent" onclick="selectStyle(this)">
<span class="style-icon">&#9889;</span>
<span class="style-label" data-de="Dringend & FOMO" data-en="Urgent & FOMO"></span>
</div>
<div class="style-option" data-style="luxury" onclick="selectStyle(this)">
<span class="style-icon">&#10024;</span>
<span class="style-label" data-de="Premium & Luxus" data-en="Premium & Luxury"></span>
</div>
<div class="style-option" data-style="fun" onclick="selectStyle(this)">
<span class="style-icon">&#127881;</span>
<span class="style-label" data-de="Spa&szlig;ig & Kreativ" data-en="Fun & Creative"></span>
</div>
<div class="style-option" data-style="minimal" onclick="selectStyle(this)">
<span class="style-icon">&#9711;</span>
<span class="style-label" data-de="Minimalistisch" data-en="Minimalist"></span>
</div>
</div>
</section>
<!-- Generate Button -->
<div class="generate-section">
<button class="btn-generate" onclick="generateAll()" id="generateBtn">
<span class="btn-icon">&#9889;</span>
<span data-de="Alle Werbematerialien generieren" data-en="Generate All Promotion Materials"></span>
</button>
</div>
<!-- Results Section -->
<section id="results" class="results-section hidden">
<!-- Social Media Posts -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#128241; Social Media Posts" data-en="&#128241; Social Media Posts"></h3>
<button class="btn-copy-all" onclick="copyAll('social')" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="result-tabs">
<button class="tab-btn active" onclick="switchTab(this, 'twitter')">Twitter/X</button>
<button class="tab-btn" onclick="switchTab(this, 'instagram')">Instagram</button>
<button class="tab-btn" onclick="switchTab(this, 'facebook')">Facebook</button>
<button class="tab-btn" onclick="switchTab(this, 'linkedin')">LinkedIn</button>
<button class="tab-btn" onclick="switchTab(this, 'tiktok')">TikTok</button>
</div>
<div class="tab-content" id="tab-twitter">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="twitter-de"></div>
<button class="btn-copy" onclick="copyText('twitter-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="twitter-en"></div>
<button class="btn-copy" onclick="copyText('twitter-en')">&#128203;</button>
</div>
</div>
</div>
<div class="tab-content hidden" id="tab-instagram">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="instagram-de"></div>
<button class="btn-copy" onclick="copyText('instagram-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="instagram-en"></div>
<button class="btn-copy" onclick="copyText('instagram-en')">&#128203;</button>
</div>
</div>
</div>
<div class="tab-content hidden" id="tab-facebook">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="facebook-de"></div>
<button class="btn-copy" onclick="copyText('facebook-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="facebook-en"></div>
<button class="btn-copy" onclick="copyText('facebook-en')">&#128203;</button>
</div>
</div>
</div>
<div class="tab-content hidden" id="tab-linkedin">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="linkedin-de"></div>
<button class="btn-copy" onclick="copyText('linkedin-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="linkedin-en"></div>
<button class="btn-copy" onclick="copyText('linkedin-en')">&#128203;</button>
</div>
</div>
</div>
<div class="tab-content hidden" id="tab-tiktok">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="tiktok-de"></div>
<button class="btn-copy" onclick="copyText('tiktok-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="tiktok-en"></div>
<button class="btn-copy" onclick="copyText('tiktok-en')">&#128203;</button>
</div>
</div>
</div>
</div>
<!-- Email Marketing -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#9993; E-Mail Marketing" data-en="&#9993; Email Marketing"></h3>
<button class="btn-copy-all" onclick="copyAll('email')" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="email-de"></div>
<button class="btn-copy" onclick="copyText('email-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="email-en"></div>
<button class="btn-copy" onclick="copyText('email-en')">&#128203;</button>
</div>
</div>
</div>
<!-- SEO Texts -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#128270; SEO-Texte & Metadaten" data-en="&#128270; SEO Texts & Metadata"></h3>
<button class="btn-copy-all" onclick="copyAll('seo')" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="seo-de"></div>
<button class="btn-copy" onclick="copyText('seo-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="seo-en"></div>
<button class="btn-copy" onclick="copyText('seo-en')">&#128203;</button>
</div>
</div>
</div>
<!-- Press Release -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#128240; Pressemitteilung" data-en="&#128240; Press Release"></h3>
<button class="btn-copy-all" onclick="copyAll('press')" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="press-de"></div>
<button class="btn-copy" onclick="copyText('press-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="press-en"></div>
<button class="btn-copy" onclick="copyText('press-en')">&#128203;</button>
</div>
</div>
</div>
<!-- Slogan Generator -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#128161; Slogans & Taglines" data-en="&#128161; Slogans & Taglines"></h3>
</div>
<div class="slogan-grid" id="sloganGrid"></div>
</div>
<!-- Hashtag Cloud -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="# Hashtag-Wolke" data-en="# Hashtag Cloud"></h3>
<button class="btn-copy-all" onclick="copyHashtags()" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="hashtag-cloud" id="hashtagCloud"></div>
</div>
<!-- Landing Page Preview -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#127760; Landing Page HTML" data-en="&#127760; Landing Page HTML"></h3>
<button class="btn-copy-all" onclick="copyText('landing-code')" data-de="HTML kopieren" data-en="Copy HTML"></button>
</div>
<div class="landing-preview-container">
<div class="preview-toggle">
<button class="preview-btn active" onclick="togglePreview('preview')" data-de="Vorschau" data-en="Preview"></button>
<button class="preview-btn" onclick="togglePreview('code')" data-de="HTML-Code" data-en="HTML Code"></button>
</div>
<div id="landing-preview" class="landing-preview"></div>
<pre id="landing-code" class="landing-code hidden"></pre>
</div>
</div>
<!-- Export Section -->
<div class="card result-card export-card">
<div class="result-header">
<h3 data-de="&#128229; Alles exportieren" data-en="&#128229; Export Everything"></h3>
</div>
<div class="export-grid">
<button class="btn-export" onclick="exportAs('txt')">
<span class="export-icon">&#128196;</span>
<span>TXT</span>
</button>
<button class="btn-export" onclick="exportAs('html')">
<span class="export-icon">&#127760;</span>
<span>HTML</span>
</button>
<button class="btn-export" onclick="exportAs('json')">
<span class="export-icon">&#128218;</span>
<span>JSON</span>
</button>
<button class="btn-export" onclick="exportAs('csv')">
<span class="export-icon">&#128202;</span>
<span>CSV</span>
</button>
</div>
</div>
</section>
</main>
<!-- Toast Notification -->
<div class="toast hidden" id="toast"></div>
<!-- Footer -->
<footer class="footer">
<p data-de="PromoMaster - Vollautomatisches Produkt-Promotion-Tool" data-en="PromoMaster - Fully Automatic Product Promotion Tool"></p>
</footer>
<script src="promo.js"></script>
</body>
</html>
+813
View File
@@ -0,0 +1,813 @@
// === PromoMaster - Product Promotion Tool ===
// Vollautomatisches Produkt-Promotion-Tool in Deutsch und Englisch
(function () {
'use strict';
// --- State ---
let currentLang = 'de';
let selectedStyle = 'professional';
let generatedData = null;
// --- Language System ---
function setLanguage(lang) {
currentLang = lang;
document.documentElement.setAttribute('data-lang', lang);
document.getElementById('btn-de').classList.toggle('active', lang === 'de');
document.getElementById('btn-en').classList.toggle('active', lang === 'en');
document.querySelectorAll('[data-de]').forEach(function (el) {
el.textContent = el.getAttribute('data-' + lang);
});
document.querySelectorAll('[data-de-placeholder]').forEach(function (el) {
el.placeholder = el.getAttribute('data-' + lang + '-placeholder');
});
document.querySelectorAll('select option').forEach(function (opt) {
var val = opt.getAttribute('data-' + lang);
if (val) opt.textContent = val;
});
}
window.setLanguage = setLanguage;
// --- Style Selection ---
function selectStyle(el) {
document.querySelectorAll('.style-option').forEach(function (s) {
s.classList.remove('selected');
});
el.classList.add('selected');
selectedStyle = el.getAttribute('data-style');
}
window.selectStyle = selectStyle;
// --- Character Counter ---
var descInput = document.getElementById('productDescription');
var charCount = document.getElementById('charCount');
if (descInput && charCount) {
descInput.addEventListener('input', function () {
charCount.textContent = descInput.value.length;
});
}
// --- Text Templates ---
var templates = {
professional: {
twitter: {
de: function (p) {
return 'Entdecken Sie ' + p.name + ' \u2013 ' + p.desc + (p.features.length ? '\n\n\u2705 ' + p.features.slice(0, 3).join('\n\u2705 ') : '') + (p.url ? '\n\n\ud83d\udc49 ' + p.url : '') + (p.price ? '\n\ud83d\udcb0 ' + p.price : '') + '\n\n' + p.hashtags.slice(0, 4).join(' ');
},
en: function (p) {
return 'Discover ' + p.name + ' \u2013 ' + p.descEn + (p.features.length ? '\n\n\u2705 ' + p.featuresEn.slice(0, 3).join('\n\u2705 ') : '') + (p.url ? '\n\n\ud83d\udc49 ' + p.url : '') + (p.price ? '\n\ud83d\udcb0 ' + p.price : '') + '\n\n' + p.hashtagsEn.slice(0, 4).join(' ');
}
},
instagram: {
de: function (p) {
return '\u2728 ' + p.name + ' \u2013 Die Zukunft beginnt jetzt!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Was ' + p.name + ' besonders macht:\n' + p.features.map(function (f) { return '\ud83d\udd39 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? '\ud83c\udfaf Perfekt f\u00fcr: ' + p.audience + '\n\n' : '') + (p.price ? '\ud83d\udcb0 Jetzt f\u00fcr nur ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in Bio \u2b06\ufe0f\n\n' : '') + p.hashtags.join(' ');
},
en: function (p) {
return '\u2728 ' + p.name + ' \u2013 The future starts now!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'What makes ' + p.name + ' special:\n' + p.featuresEn.map(function (f) { return '\ud83d\udd39 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? '\ud83c\udfaf Perfect for: ' + p.audienceEn + '\n\n' : '') + (p.price ? '\ud83d\udcb0 Now only ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in bio \u2b06\ufe0f\n\n' : '') + p.hashtagsEn.join(' ');
}
},
facebook: {
de: function (p) {
return '\ud83d\ude80 Neu: ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.features.length ? '\ud83d\udc47 Das sind die Highlights:\n\n' + p.features.map(function (f) { return '\u2714\ufe0f ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Perfekt f\u00fcr alle, die ' + p.audience + ' sind.\n\n' : '') + (p.price ? '\ud83d\udcb5 Preis: ' + p.price + '\n\n' : '') + (p.url ? '\ud83c\udf10 Mehr erfahren: ' + p.url + '\n\n' : '') + 'Was denkt ihr? Lasst es uns in den Kommentaren wissen! \ud83d\udc47\n\n' + p.hashtags.slice(0, 5).join(' ');
},
en: function (p) {
return '\ud83d\ude80 New: ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? '\ud83d\udc47 Here are the highlights:\n\n' + p.featuresEn.map(function (f) { return '\u2714\ufe0f ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Perfect for everyone who is ' + p.audienceEn + '.\n\n' : '') + (p.price ? '\ud83d\udcb5 Price: ' + p.price + '\n\n' : '') + (p.url ? '\ud83c\udf10 Learn more: ' + p.url + '\n\n' : '') + 'What do you think? Let us know in the comments! \ud83d\udc47\n\n' + p.hashtagsEn.slice(0, 5).join(' ');
}
},
linkedin: {
de: function (p) {
return '\ud83d\udca1 ' + p.name + ' \u2013 Innovation trifft Effizienz\n\nIch freue mich, Ihnen ' + p.name + ' vorzustellen.\n\n' + p.desc + '\n\n' + (p.features.length ? 'Die wichtigsten Vorteile:\n\n' + p.features.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Zielgruppe: ' + p.audience + '\n\n' : '') + (p.price ? 'Investition: ' + p.price + '\n\n' : '') + (p.url ? 'Erfahren Sie mehr: ' + p.url + '\n\n' : '') + '#Innovation #Business ' + p.hashtags.slice(0, 3).join(' ');
},
en: function (p) {
return '\ud83d\udca1 ' + p.name + ' \u2013 Innovation meets Efficiency\n\nI\'m excited to introduce ' + p.name + ' to you.\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Key benefits:\n\n' + p.featuresEn.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Target audience: ' + p.audienceEn + '\n\n' : '') + (p.price ? 'Investment: ' + p.price + '\n\n' : '') + (p.url ? 'Learn more: ' + p.url + '\n\n' : '') + '#Innovation #Business ' + p.hashtagsEn.slice(0, 3).join(' ');
}
},
tiktok: {
de: function (p) {
return '\ud83d\udd25 POV: Du entdeckst gerade ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.slice(0, 3).map(function (f) { return '\u2728 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in Bio!\n\n' : '') + p.hashtags.join(' ') + ' #fyp #viral #musthave';
},
en: function (p) {
return '\ud83d\udd25 POV: You just discovered ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.slice(0, 3).map(function (f) { return '\u2728 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in bio!\n\n' : '') + p.hashtagsEn.join(' ') + ' #fyp #viral #musthave';
}
}
},
casual: {
twitter: {
de: function (p) { return 'Hey Leute! \ud83d\udc4b Kennt ihr schon ' + p.name + '? ' + p.desc + (p.features.length ? '\n\nDas Beste daran:\n' + p.features.slice(0, 3).map(function (f) { return '\ud83d\udc4d ' + f; }).join('\n') : '') + (p.url ? '\n\nSchaut mal vorbei: ' + p.url : '') + '\n\n' + p.hashtags.slice(0, 4).join(' '); },
en: function (p) { return 'Hey everyone! \ud83d\udc4b Have you heard of ' + p.name + '? ' + p.descEn + (p.features.length ? '\n\nBest thing about it:\n' + p.featuresEn.slice(0, 3).map(function (f) { return '\ud83d\udc4d ' + f; }).join('\n') : '') + (p.url ? '\n\nCheck it out: ' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 4).join(' '); }
},
instagram: {
de: function (p) { return 'Schaut mal was ich gefunden habe! \ud83e\udd29\n\n' + p.name + ' \u2013 ' + p.desc + '\n\n' + (p.features.length ? 'Warum ich es liebe:\n' + p.features.map(function (f) { return '\u2764\ufe0f ' + f; }).join('\n') + '\n\n' : '') + 'Wer will es auch haben? \ud83d\ude4b\u200d\u2640\ufe0f\n\n' + p.hashtags.join(' '); },
en: function (p) { return 'Look what I found! \ud83e\udd29\n\n' + p.name + ' \u2013 ' + p.descEn + '\n\n' + (p.features.length ? 'Why I love it:\n' + p.featuresEn.map(function (f) { return '\u2764\ufe0f ' + f; }).join('\n') + '\n\n' : '') + 'Who else wants this? \ud83d\ude4b\u200d\u2640\ufe0f\n\n' + p.hashtagsEn.join(' '); }
},
facebook: {
de: function (p) { return 'Hey Freunde! \ud83d\udc4b\n\nIch muss euch unbedingt von ' + p.name + ' erz\u00e4hlen!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Was mich \u00fcberzeugt hat:\n' + p.features.map(function (f) { return '\ud83d\udc49 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'Und das f\u00fcr ' + p.price + ' \u2013 richtig fair!\n\n' : '') + (p.url ? 'Hier gehts lang: ' + p.url + '\n\n' : '') + 'Kennt ihr das schon? \ud83d\ude0d'; },
en: function (p) { return 'Hey friends! \ud83d\udc4b\n\nI have to tell you about ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'What convinced me:\n' + p.featuresEn.map(function (f) { return '\ud83d\udc49 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'And all that for ' + p.price + ' \u2013 such a great deal!\n\n' : '') + (p.url ? 'Check it here: ' + p.url + '\n\n' : '') + 'Have you heard of this? \ud83d\ude0d'; }
},
linkedin: {
de: function (p) { return 'Moin zusammen! \ud83d\ude4c\n\nDarf ich vorstellen: ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Das macht es so cool:\n' + p.features.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'Wer hat Lust, das mal auszuprobieren?\n\n' + p.hashtags.slice(0, 3).join(' '); },
en: function (p) { return 'Hey everyone! \ud83d\ude4c\n\nLet me introduce: ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'What makes it awesome:\n' + p.featuresEn.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'Who wants to give it a try?\n\n' + p.hashtagsEn.slice(0, 3).join(' '); }
},
tiktok: {
de: function (p) { return 'OK das m\u00fcsst ihr sehen!! \ud83d\ude31\n\n' + p.name + ' ist einfach WILD!\n' + p.desc + '\n\n' + (p.features.length ? p.features.slice(0, 2).map(function (f) { return '\ud83e\udd2f ' + f; }).join('\n') + '\n\n' : '') + 'Kommentiert wenn ihrs auch braucht! \ud83d\udc47\n\n' + p.hashtags.join(' ') + ' #fyp #musthave'; },
en: function (p) { return 'OK you NEED to see this!! \ud83d\ude31\n\n' + p.name + ' is absolutely WILD!\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.slice(0, 2).map(function (f) { return '\ud83e\udd2f ' + f; }).join('\n') + '\n\n' : '') + 'Comment if you need this too! \ud83d\udc47\n\n' + p.hashtagsEn.join(' ') + ' #fyp #musthave'; }
}
},
urgent: {
twitter: {
de: function (p) { return '\u26a0\ufe0f NUR F\u00dcR KURZE ZEIT: ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.price ? '\ud83d\udcb0 Jetzt zuschlagen: ' + p.price + '\n' : '') + '\u23f0 Begrenztes Angebot \u2013 nicht verpassen!\n\n' + (p.url ? '\ud83d\udc49 Sofort sichern: ' + p.url + '\n\n' : '') + p.hashtags.slice(0, 4).join(' '); },
en: function (p) { return '\u26a0\ufe0f LIMITED TIME ONLY: ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.price ? '\ud83d\udcb0 Get it now: ' + p.price + '\n' : '') + '\u23f0 Limited offer \u2013 don\'t miss out!\n\n' + (p.url ? '\ud83d\udc49 Grab yours: ' + p.url + '\n\n' : '') + p.hashtagsEn.slice(0, 4).join(' '); }
},
instagram: {
de: function (p) { return '\ud83d\udea8 ACHTUNG! \ud83d\udea8\n\n' + p.name + ' ist DA!\n\n' + p.desc + '\n\n' + (p.features.length ? '\ud83d\udd25 ' + p.features.join(' \u2022 ') + '\n\n' : '') + '\u23f3 Nur solange der Vorrat reicht!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + 'JETZT HANDELN bevor es zu sp\u00e4t ist! \ud83d\udc47\n\n' + p.hashtags.join(' '); },
en: function (p) { return '\ud83d\udea8 ATTENTION! \ud83d\udea8\n\n' + p.name + ' is HERE!\n\n' + p.descEn + '\n\n' + (p.features.length ? '\ud83d\udd25 ' + p.featuresEn.join(' \u2022 ') + '\n\n' : '') + '\u23f3 Only while supplies last!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + 'ACT NOW before it\'s too late! \ud83d\udc47\n\n' + p.hashtagsEn.join(' '); }
},
facebook: {
de: function (p) { return '\ud83d\udea8\ud83d\udea8\ud83d\udea8 EILMELDUNG \ud83d\udea8\ud83d\udea8\ud83d\udea8\n\n' + p.name + ' ist endlich verf\u00fcgbar!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Die Fakten:\n' + p.features.map(function (f) { return '\u26a1 ' + f; }).join('\n') + '\n\n' : '') + '\u23f0 ACHTUNG: Angebot endet bald!\n' + (p.price ? '\ud83d\udcb5 Nur ' + p.price + '\n' : '') + (p.url ? '\n\ud83d\udc49 SOFORT ZUSCHLAGEN: ' + p.url : ''); },
en: function (p) { return '\ud83d\udea8\ud83d\udea8\ud83d\udea8 BREAKING \ud83d\udea8\ud83d\udea8\ud83d\udea8\n\n' + p.name + ' is finally available!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'The facts:\n' + p.featuresEn.map(function (f) { return '\u26a1 ' + f; }).join('\n') + '\n\n' : '') + '\u23f0 WARNING: Offer ends soon!\n' + (p.price ? '\ud83d\udcb5 Only ' + p.price + '\n' : '') + (p.url ? '\n\ud83d\udc49 GRAB IT NOW: ' + p.url : ''); }
},
linkedin: {
de: function (p) { return '\ud83d\udea8 Dringende Marktchance: ' + p.name + '\n\n' + p.desc + '\n\n' + (p.features.length ? 'Schl\u00fcsselvorteile:\n' + p.features.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + 'Dieses Angebot ist zeitlich begrenzt. Wer jetzt nicht handelt, verpasst eine einmalige Gelegenheit.\n\n' + (p.url ? p.url + '\n\n' : '') + p.hashtags.slice(0, 3).join(' '); },
en: function (p) { return '\ud83d\udea8 Urgent Market Opportunity: ' + p.name + '\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Key advantages:\n' + p.featuresEn.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + 'This offer is time-limited. Those who don\'t act now will miss a unique opportunity.\n\n' + (p.url ? p.url + '\n\n' : '') + p.hashtagsEn.slice(0, 3).join(' '); }
},
tiktok: {
de: function (p) { return '\ud83d\udea8 STOP SCROLLING! \ud83d\udea8\n\n' + p.name + ' \u2013 ' + p.desc + '\n\n' + '\u23f0 Letzte Chance!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n' : '') + '\nLINK IN BIO BEVOR ES WEG IST!\n\n' + p.hashtags.join(' ') + ' #fyp #limitedoffer'; },
en: function (p) { return '\ud83d\udea8 STOP SCROLLING! \ud83d\udea8\n\n' + p.name + ' \u2013 ' + p.descEn + '\n\n' + '\u23f0 Last chance!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n' : '') + '\nLINK IN BIO BEFORE IT\'S GONE!\n\n' + p.hashtagsEn.join(' ') + ' #fyp #limitedoffer'; }
}
},
luxury: {
twitter: {
de: function (p) { return '\u2728 ' + p.name + '\n\nExklusivit\u00e4t neu definiert.\n' + p.desc + '\n\n' + (p.price ? 'Ab ' + p.price + '\n' : '') + (p.url ? '\n' + p.url : '') + '\n\n' + p.hashtags.slice(0, 3).join(' ') + ' #Luxus #Premium'; },
en: function (p) { return '\u2728 ' + p.name + '\n\nRedefining exclusivity.\n' + p.descEn + '\n\n' + (p.price ? 'From ' + p.price + '\n' : '') + (p.url ? '\n' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 3).join(' ') + ' #Luxury #Premium'; }
},
instagram: {
de: function (p) { return '\u2726 ' + p.name.toUpperCase() + ' \u2726\n\n' + p.desc + '\n\n' + (p.features.length ? 'Exklusive Merkmale:\n' + p.features.map(function (f) { return '\u2726 ' + f; }).join('\n') + '\n\n' : '') + 'F\u00fcr alle, die das Beste verdienen.\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtags.join(' ') + ' #Luxury #Exclusive'; },
en: function (p) { return '\u2726 ' + p.name.toUpperCase() + ' \u2726\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Exclusive features:\n' + p.featuresEn.map(function (f) { return '\u2726 ' + f; }).join('\n') + '\n\n' : '') + 'For those who deserve the finest.\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtagsEn.join(' ') + ' #Luxury #Exclusive'; }
},
facebook: {
de: function (p) { return '\u2014\u2014\u2014 ' + p.name.toUpperCase() + ' \u2014\u2014\u2014\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.map(function (f) { return '\u25c7 ' + f; }).join('\n') + '\n\n' : '') + 'Perfektion kennt keine Kompromisse.\n\n' + (p.price ? 'Ab ' + p.price + '\n' : '') + (p.url ? '\nEntdecken Sie mehr: ' + p.url : ''); },
en: function (p) { return '\u2014\u2014\u2014 ' + p.name.toUpperCase() + ' \u2014\u2014\u2014\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.map(function (f) { return '\u25c7 ' + f; }).join('\n') + '\n\n' : '') + 'Perfection knows no compromise.\n\n' + (p.price ? 'From ' + p.price + '\n' : '') + (p.url ? '\nDiscover more: ' + p.url : ''); }
},
linkedin: {
de: function (p) { return p.name + ' \u2013 Exzellenz in jeder Hinsicht\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'Wir setzen Ma\u00dfst\u00e4be f\u00fcr Premium-Qualit\u00e4t.\n\n' + (p.url ? p.url + '\n\n' : '') + '#Premium #Excellence ' + p.hashtags.slice(0, 2).join(' '); },
en: function (p) { return p.name + ' \u2013 Excellence in every way\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'We set the standard for premium quality.\n\n' + (p.url ? p.url + '\n\n' : '') + '#Premium #Excellence ' + p.hashtagsEn.slice(0, 2).join(' '); }
},
tiktok: {
de: function (p) { return '\u2728 ' + p.name + ' \u2013 Luxus der n\u00e4chsten Generation\n\n' + p.desc + '\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtags.join(' ') + ' #luxury #aesthetic #premium'; },
en: function (p) { return '\u2728 ' + p.name + ' \u2013 Next generation luxury\n\n' + p.descEn + '\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtagsEn.join(' ') + ' #luxury #aesthetic #premium'; }
}
},
fun: {
twitter: {
de: function (p) { return '\ud83c\udf89 YOOO! ' + p.name + ' ist da und es ist der HAMMER! \ud83d\udd28\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.slice(0, 3).map(function (f) { return '\ud83c\udf1f ' + f; }).join('\n') + '\n' : '') + (p.url ? '\n\ud83d\ude80 Ab gehts: ' + p.url : '') + '\n\n' + p.hashtags.slice(0, 4).join(' '); },
en: function (p) { return '\ud83c\udf89 YOOO! ' + p.name + ' is here and it\'s AMAZING! \ud83d\udd28\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.slice(0, 3).map(function (f) { return '\ud83c\udf1f ' + f; }).join('\n') + '\n' : '') + (p.url ? '\n\ud83d\ude80 Let\'s go: ' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 4).join(' '); }
},
instagram: {
de: function (p) { return '\ud83e\udd2f OKAY WOW \ud83e\udd2f\n\n' + p.name + ' hat mein Leben ver\u00e4ndert und ich bin NICHT dramatisch! \ud83d\ude02\n\n' + p.desc + '\n\n' + (p.features.length ? 'Reasons to love it:\n' + p.features.map(function (f) { return '\ud83d\udcab ' + f; }).join('\n') + '\n\n' : '') + 'Wer ist dabei?! \ud83d\ude4b\u200d\u2642\ufe0f\n\n' + p.hashtags.join(' '); },
en: function (p) { return '\ud83e\udd2f OKAY WOW \ud83e\udd2f\n\n' + p.name + ' changed my life and I\'m NOT being dramatic! \ud83d\ude02\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Reasons to love it:\n' + p.featuresEn.map(function (f) { return '\ud83d\udcab ' + f; }).join('\n') + '\n\n' : '') + 'Who\'s in?! \ud83d\ude4b\u200d\u2642\ufe0f\n\n' + p.hashtagsEn.join(' '); }
},
facebook: {
de: function (p) { return '\ud83c\udf89\ud83c\udf89\ud83c\udf89 ES IST SOWEIT! \ud83c\udf89\ud83c\udf89\ud83c\udf89\n\n' + p.name + ' ist gelandet und wir k\u00f6nnen nicht aufh\u00f6ren dar\u00fcber zu reden!\n\n' + p.desc + '\n\n' + (p.features.length ? '\ud83d\ude0d Das ist alles drin:\n' + p.features.map(function (f) { return '\ud83d\udca5 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'Und das Beste? Nur ' + p.price + '! \ud83e\udd11\n\n' : '') + 'TAGGT jemanden der das braucht! \ud83d\udc47\ud83d\udc47\ud83d\udc47'; },
en: function (p) { return '\ud83c\udf89\ud83c\udf89\ud83c\udf89 IT\'S HERE! \ud83c\udf89\ud83c\udf89\ud83c\udf89\n\n' + p.name + ' has landed and we can\'t stop talking about it!\n\n' + p.descEn + '\n\n' + (p.features.length ? '\ud83d\ude0d Here\'s what you get:\n' + p.featuresEn.map(function (f) { return '\ud83d\udca5 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'Best part? Only ' + p.price + '! \ud83e\udd11\n\n' : '') + 'TAG someone who needs this! \ud83d\udc47\ud83d\udc47\ud83d\udc47'; }
},
linkedin: {
de: function (p) { return '\ud83d\ude80 Plot Twist: ' + p.name + ' existiert jetzt!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Die Highlights (ja, es wird noch besser):\n' + p.features.map(function (f) { return '\ud83d\udcaa ' + f; }).join('\n') + '\n\n' : '') + 'Wer will mitmachen? Schreibt mir! \ud83d\ude0e\n\n' + p.hashtags.slice(0, 3).join(' '); },
en: function (p) { return '\ud83d\ude80 Plot Twist: ' + p.name + ' now exists!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'The highlights (yes, it gets even better):\n' + p.featuresEn.map(function (f) { return '\ud83d\udcaa ' + f; }).join('\n') + '\n\n' : '') + 'Who\'s in? DM me! \ud83d\ude0e\n\n' + p.hashtagsEn.slice(0, 3).join(' '); }
},
tiktok: {
de: function (p) { return '\ud83e\udee3 Wenn du ' + p.name + ' noch nicht kennst, lebst du unter einem Stein!\n\n' + p.desc + '\n\n' + (p.price ? '\ud83d\udcb0 ' + p.price + ' \u2013 SCHNAPPER!\n' : '') + '\nSpeichern & Teilen nicht vergessen! \ud83d\ude4f\n\n' + p.hashtags.join(' ') + ' #fyp #gamechanger'; },
en: function (p) { return '\ud83e\udee3 If you don\'t know ' + p.name + ' yet, you\'re living under a rock!\n\n' + p.descEn + '\n\n' + (p.price ? '\ud83d\udcb0 ' + p.price + ' \u2013 STEAL!\n' : '') + '\nSave & Share! \ud83d\ude4f\n\n' + p.hashtagsEn.join(' ') + ' #fyp #gamechanger'; }
}
},
minimal: {
twitter: {
de: function (p) { return p.name + '.\n' + p.desc + (p.url ? '\n\n' + p.url : '') + '\n\n' + p.hashtags.slice(0, 3).join(' '); },
en: function (p) { return p.name + '.\n' + p.descEn + (p.url ? '\n\n' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 3).join(' '); }
},
instagram: {
de: function (p) { return p.name + '\n\n' + p.desc + (p.features.length ? '\n\n' + p.features.join(' / ') : '') + '\n\n' + p.hashtags.join(' '); },
en: function (p) { return p.name + '\n\n' + p.descEn + (p.features.length ? '\n\n' + p.featuresEn.join(' / ') : '') + '\n\n' + p.hashtagsEn.join(' '); }
},
facebook: {
de: function (p) { return p.name + '\n\n' + p.desc + (p.features.length ? '\n\n' + p.features.join(' \u2022 ') : '') + (p.price ? '\n\n' + p.price : '') + (p.url ? '\n\n' + p.url : ''); },
en: function (p) { return p.name + '\n\n' + p.descEn + (p.features.length ? '\n\n' + p.featuresEn.join(' \u2022 ') : '') + (p.price ? '\n\n' + p.price : '') + (p.url ? '\n\n' + p.url : ''); }
},
linkedin: {
de: function (p) { return p.name + '\n\n' + p.desc + (p.features.length ? '\n\n' + p.features.map(function (f) { return '\u2192 ' + f; }).join('\n') : '') + (p.url ? '\n\n' + p.url : ''); },
en: function (p) { return p.name + '\n\n' + p.descEn + (p.features.length ? '\n\n' + p.featuresEn.map(function (f) { return '\u2192 ' + f; }).join('\n') : '') + (p.url ? '\n\n' + p.url : ''); }
},
tiktok: {
de: function (p) { return p.name + '\n' + p.desc + '\n\n' + p.hashtags.slice(0, 5).join(' ') + ' #minimal'; },
en: function (p) { return p.name + '\n' + p.descEn + '\n\n' + p.hashtagsEn.slice(0, 5).join(' ') + ' #minimal'; }
}
}
};
// --- Email Templates ---
var emailTemplates = {
de: function (p) {
return 'Betreff: Entdecken Sie ' + p.name + ' \u2013 ' + p.desc.substring(0, 60) + '...\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
'Sehr geehrte Damen und Herren,\n\n' +
'wir freuen uns, Ihnen ' + p.name + ' vorzustellen \u2013 ' + p.desc + '\n\n' +
(p.features.length ?
'Die wichtigsten Vorteile auf einen Blick:\n\n' +
p.features.map(function (f) { return ' \u2714 ' + f; }).join('\n') + '\n\n' : '') +
(p.audience ? 'Ideal f\u00fcr: ' + p.audience + '\n\n' : '') +
(p.price ? 'Unser Angebot: ' + p.price + '\n\n' : '') +
(p.url ? '\u27a1 Jetzt mehr erfahren: ' + p.url + '\n\n' : '') +
'Haben Sie Fragen? Antworten Sie einfach auf diese E-Mail \u2013 wir helfen Ihnen gerne weiter.\n\n' +
'Mit freundlichen Gr\u00fc\u00dfen,\n' +
'Ihr ' + p.name + ' Team\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n' +
'Sie erhalten diese E-Mail, weil Sie sich f\u00fcr ' + p.name + ' interessieren.\n' +
'Abmelden | Datenschutz | Impressum';
},
en: function (p) {
return 'Subject: Discover ' + p.name + ' \u2013 ' + p.descEn.substring(0, 60) + '...\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
'Dear Customer,\n\n' +
'We are excited to introduce ' + p.name + ' \u2013 ' + p.descEn + '\n\n' +
(p.features.length ?
'Key benefits at a glance:\n\n' +
p.featuresEn.map(function (f) { return ' \u2714 ' + f; }).join('\n') + '\n\n' : '') +
(p.audience ? 'Ideal for: ' + p.audienceEn + '\n\n' : '') +
(p.price ? 'Our offer: ' + p.price + '\n\n' : '') +
(p.url ? '\u27a1 Learn more: ' + p.url + '\n\n' : '') +
'Have questions? Simply reply to this email \u2013 we\'re happy to help.\n\n' +
'Best regards,\n' +
'The ' + p.name + ' Team\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n' +
'You received this email because you showed interest in ' + p.name + '.\n' +
'Unsubscribe | Privacy Policy | Legal';
}
};
// --- SEO Templates ---
var seoTemplates = {
de: function (p) {
return 'META TITLE:\n' + p.name + ' \u2013 ' + p.desc.substring(0, 50) + ' | Jetzt entdecken\n\n' +
'META DESCRIPTION:\n' + p.desc + (p.features.length ? ' \u2714 ' + p.features.slice(0, 3).join(' \u2714 ') : '') + (p.price ? ' Ab ' + p.price + '.' : '') + ' Jetzt informieren!\n\n' +
'SEO KEYWORDS:\n' + p.name + ', ' + p.name.toLowerCase() + ' kaufen, ' + p.name.toLowerCase() + ' test, ' + p.name.toLowerCase() + ' erfahrungen, ' +
(p.category ? p.categoryDe + ', ' : '') + 'beste ' + p.name.toLowerCase() + ', ' + p.name.toLowerCase() + ' vergleich, ' + p.name.toLowerCase() + ' angebot\n\n' +
'H1 \u00dcBERSCHRIFT:\n' + p.name + ' \u2013 ' + p.desc + '\n\n' +
'H2 \u00dcBERSCHRIFTEN:\n' +
'Warum ' + p.name + '?\n' +
'Funktionen & Vorteile\n' +
'F\u00fcr wen ist ' + p.name + ' geeignet?\n' +
'Jetzt ' + p.name + ' bestellen\n\n' +
'ALT-TEXT F\u00dcR BILDER:\n' +
p.name + ' Produktbild \u2013 ' + p.desc.substring(0, 60);
},
en: function (p) {
return 'META TITLE:\n' + p.name + ' \u2013 ' + p.descEn.substring(0, 50) + ' | Discover Now\n\n' +
'META DESCRIPTION:\n' + p.descEn + (p.features.length ? ' \u2714 ' + p.featuresEn.slice(0, 3).join(' \u2714 ') : '') + (p.price ? ' From ' + p.price + '.' : '') + ' Learn more now!\n\n' +
'SEO KEYWORDS:\n' + p.name + ', buy ' + p.name.toLowerCase() + ', ' + p.name.toLowerCase() + ' review, ' + p.name.toLowerCase() + ' features, ' +
(p.category ? p.categoryEn + ', ' : '') + 'best ' + p.name.toLowerCase() + ', ' + p.name.toLowerCase() + ' comparison, ' + p.name.toLowerCase() + ' deal\n\n' +
'H1 HEADING:\n' + p.name + ' \u2013 ' + p.descEn + '\n\n' +
'H2 HEADINGS:\n' +
'Why ' + p.name + '?\n' +
'Features & Benefits\n' +
'Who is ' + p.name + ' for?\n' +
'Order ' + p.name + ' Now\n\n' +
'IMAGE ALT TEXT:\n' +
p.name + ' product image \u2013 ' + p.descEn.substring(0, 60);
}
};
// --- Press Release Templates ---
var pressTemplates = {
de: function (p) {
var today = new Date().toLocaleDateString('de-DE', { year: 'numeric', month: 'long', day: 'numeric' });
return 'PRESSEMITTEILUNG\n' +
'Datum: ' + today + '\n' +
'Zur sofortigen Ver\u00f6ffentlichung\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
p.name + ': ' + p.desc + '\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
'Wir freuen uns, die Verf\u00fcgbarkeit von ' + p.name + ' bekannt zu geben. ' + p.desc + '\n\n' +
(p.features.length ?
'Hauptmerkmale von ' + p.name + ':\n\n' +
p.features.map(function (f) { return ' \u2022 ' + f; }).join('\n') + '\n\n' : '') +
(p.audience ? '"' + p.name + ' wurde speziell f\u00fcr ' + p.audience + ' entwickelt", erkl\u00e4rt das Entwicklerteam.\n\n' : '') +
(p.price ? 'Verf\u00fcgbarkeit & Preis:\n' + p.name + ' ist ab sofort zum Preis von ' + p.price + ' erh\u00e4ltlich.\n\n' : '') +
(p.url ? 'Weitere Informationen finden Sie unter: ' + p.url + '\n\n' : '') +
'Pressekontakt:\n' +
'E-Mail: presse@' + p.name.toLowerCase().replace(/\s+/g, '') + '.de\n' +
'Web: ' + (p.url || 'www.' + p.name.toLowerCase().replace(/\s+/g, '') + '.de') + '\n\n' +
'###';
},
en: function (p) {
var today = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
return 'PRESS RELEASE\n' +
'Date: ' + today + '\n' +
'For Immediate Release\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
p.name + ': ' + p.descEn + '\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
'We are pleased to announce the availability of ' + p.name + '. ' + p.descEn + '\n\n' +
(p.features.length ?
'Key Features of ' + p.name + ':\n\n' +
p.featuresEn.map(function (f) { return ' \u2022 ' + f; }).join('\n') + '\n\n' : '') +
(p.audience ? '"' + p.name + ' was specifically designed for ' + p.audienceEn + '," says the development team.\n\n' : '') +
(p.price ? 'Availability & Pricing:\n' + p.name + ' is available now at ' + p.price + '.\n\n' : '') +
(p.url ? 'For more information, visit: ' + p.url + '\n\n' : '') +
'Press Contact:\n' +
'Email: press@' + p.name.toLowerCase().replace(/\s+/g, '') + '.com\n' +
'Web: ' + (p.url || 'www.' + p.name.toLowerCase().replace(/\s+/g, '') + '.com') + '\n\n' +
'###';
}
};
// --- Category Mapping ---
var categoryNames = {
tech: { de: 'Technologie', en: 'Technology' },
fashion: { de: 'Mode & Bekleidung', en: 'Fashion & Apparel' },
food: { de: 'Lebensmittel & Getr\u00e4nke', en: 'Food & Beverages' },
health: { de: 'Gesundheit & Wellness', en: 'Health & Wellness' },
home: { de: 'Haus & Garten', en: 'Home & Garden' },
sport: { de: 'Sport & Fitness', en: 'Sports & Fitness' },
beauty: { de: 'Sch\u00f6nheit & Pflege', en: 'Beauty & Care' },
education: { de: 'Bildung & Kurse', en: 'Education & Courses' },
software: { de: 'Software & Apps', en: 'Software & Apps' },
other: { de: 'Sonstiges', en: 'Other' }
};
// --- Gather Product Data ---
function getProductData() {
var name = document.getElementById('productName').value.trim();
var desc = document.getElementById('productDescription').value.trim();
var price = document.getElementById('productPrice').value.trim();
var url = document.getElementById('productUrl').value.trim();
var features = document.getElementById('productFeatures').value.trim();
var audience = document.getElementById('targetAudience').value.trim();
var category = document.getElementById('productCategory').value;
if (!name || !desc) {
showToast(currentLang === 'de' ? 'Bitte Produktname und Beschreibung eingeben!' : 'Please enter product name and description!');
return null;
}
var featureList = features ? features.split(',').map(function (f) { return f.trim(); }).filter(Boolean) : [];
var catInfo = categoryNames[category] || { de: '', en: '' };
return {
name: name,
desc: desc,
descEn: desc, // User provides in their language; used as-is
price: price,
url: url,
features: featureList,
featuresEn: featureList,
audience: audience,
audienceEn: audience,
category: category,
categoryDe: catInfo.de,
categoryEn: catInfo.en,
hashtags: generateHashtags(name, featureList, category, 'de'),
hashtagsEn: generateHashtags(name, featureList, category, 'en')
};
}
// --- Generate Hashtags ---
function generateHashtags(name, features, category, lang) {
var tags = [];
tags.push('#' + name.replace(/\s+/g, ''));
var catTags = {
tech: { de: ['#Technologie', '#Innovation', '#TechNews', '#Digital', '#Gadget'], en: ['#Technology', '#Innovation', '#TechNews', '#Digital', '#Gadget'] },
fashion: { de: ['#Mode', '#Fashion', '#Style', '#OOTD', '#Trend'], en: ['#Fashion', '#Style', '#OOTD', '#Trend', '#Outfit'] },
food: { de: ['#Foodie', '#Lecker', '#Essen', '#Kochen', '#Genuss'], en: ['#Foodie', '#Delicious', '#FoodLover', '#Cooking', '#Yummy'] },
health: { de: ['#Gesundheit', '#Wellness', '#Fitness', '#Wohlbefinden'], en: ['#Health', '#Wellness', '#Fitness', '#Wellbeing'] },
home: { de: ['#Zuhause', '#Wohnen', '#Interior', '#HomeDecor'], en: ['#Home', '#Living', '#Interior', '#HomeDecor'] },
sport: { de: ['#Sport', '#Fitness', '#Training', '#Motivation'], en: ['#Sports', '#Fitness', '#Training', '#Motivation'] },
beauty: { de: ['#Beauty', '#Pflege', '#Skincare', '#Sch\u00f6nheit'], en: ['#Beauty', '#Skincare', '#SelfCare', '#Glow'] },
education: { de: ['#Bildung', '#Lernen', '#Wissen', '#Weiterbildung'], en: ['#Education', '#Learning', '#Knowledge', '#Growth'] },
software: { de: ['#Software', '#App', '#Digital', '#SaaS', '#Produktivit\u00e4t'], en: ['#Software', '#App', '#Digital', '#SaaS', '#Productivity'] },
other: { de: ['#Neu', '#MustHave', '#Empfehlung'], en: ['#New', '#MustHave', '#Recommended'] }
};
var ct = catTags[category];
if (ct) {
tags = tags.concat(ct[lang] || ct.en);
}
features.slice(0, 2).forEach(function (f) {
tags.push('#' + f.replace(/\s+/g, '').replace(/[^a-zA-Z0-9\u00c0-\u017e]/g, ''));
});
return tags.filter(function (t, i, arr) { return arr.indexOf(t) === i; });
}
// --- Generate Slogans ---
function generateSlogans(p) {
var slogans = [];
var sloganTemplatesDe = [
p.name + ' \u2013 Weil du das Beste verdienst.',
p.name + '. Einfach. Besser. Anders.',
'Die Zukunft hei\u00dft ' + p.name + '.',
p.name + ' \u2013 Dein n\u00e4chster Schritt nach vorn.',
'Erlebe den Unterschied mit ' + p.name + '.',
p.name + '. Mehr als du erwartest.'
];
var sloganTemplatesEn = [
p.name + ' \u2013 Because you deserve the best.',
p.name + '. Simple. Better. Different.',
'The future is called ' + p.name + '.',
p.name + ' \u2013 Your next step forward.',
'Experience the difference with ' + p.name + '.',
p.name + '. More than you expect.'
];
for (var i = 0; i < sloganTemplatesDe.length; i++) {
slogans.push({ de: sloganTemplatesDe[i], en: sloganTemplatesEn[i] });
}
return slogans;
}
// --- Generate Landing Page HTML ---
function generateLandingPage(p) {
var accentColor = '#6C5CE7';
return '<!DOCTYPE html>\n' +
'<html lang="de">\n<head>\n' +
' <meta charset="UTF-8">\n' +
' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n' +
' <title>' + p.name + ' \u2013 ' + p.desc.substring(0, 60) + '</title>\n' +
' <meta name="description" content="' + p.desc + '">\n' +
' <meta property="og:title" content="' + p.name + '">\n' +
' <meta property="og:description" content="' + p.desc + '">\n' +
' <style>\n' +
' * { margin: 0; padding: 0; box-sizing: border-box; }\n' +
' body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #333; }\n' +
' .hero { background: linear-gradient(135deg, ' + accentColor + ', #00CEC9); color: #fff; padding: 80px 20px; text-align: center; }\n' +
' .hero h1 { font-size: 3rem; margin-bottom: 16px; }\n' +
' .hero p { font-size: 1.3rem; opacity: 0.9; max-width: 600px; margin: 0 auto 32px; }\n' +
' .cta-btn { display: inline-block; padding: 16px 40px; background: #fff; color: ' + accentColor + '; font-size: 18px; font-weight: 700; border-radius: 50px; text-decoration: none; transition: transform 0.3s; }\n' +
' .cta-btn:hover { transform: scale(1.05); }\n' +
' .features { padding: 60px 20px; max-width: 800px; margin: 0 auto; }\n' +
' .features h2 { text-align: center; font-size: 2rem; margin-bottom: 40px; }\n' +
' .feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 30px; }\n' +
' .feature { text-align: center; padding: 24px; }\n' +
' .feature h3 { color: ' + accentColor + '; margin-bottom: 8px; }\n' +
(p.price ? ' .pricing { text-align: center; padding: 60px 20px; background: #f8f9fa; }\n' +
' .pricing h2 { font-size: 2rem; margin-bottom: 16px; }\n' +
' .price-tag { font-size: 3rem; font-weight: 900; color: ' + accentColor + '; }\n' : '') +
' .footer { text-align: center; padding: 30px; color: #888; font-size: 14px; }\n' +
' </style>\n' +
'</head>\n<body>\n' +
' <section class="hero">\n' +
' <h1>' + p.name + '</h1>\n' +
' <p>' + p.desc + '</p>\n' +
(p.url ? ' <a href="' + escapeHtml(p.url) + '" class="cta-btn">Jetzt entdecken / Discover Now</a>\n' : ' <a href="#features" class="cta-btn">Mehr erfahren / Learn More</a>\n') +
' </section>\n' +
(p.features.length ? ' <section class="features" id="features">\n' +
' <h2>Features</h2>\n' +
' <div class="feature-grid">\n' +
p.features.map(function (f) { return ' <div class="feature">\n <h3>' + escapeHtml(f) + '</h3>\n </div>'; }).join('\n') + '\n' +
' </div>\n' +
' </section>\n' : '') +
(p.price ? ' <section class="pricing">\n' +
' <h2>Preis / Price</h2>\n' +
' <div class="price-tag">' + escapeHtml(p.price) + '</div>\n' +
' </section>\n' : '') +
' <footer class="footer">\n' +
' &copy; ' + new Date().getFullYear() + ' ' + escapeHtml(p.name) + '. All rights reserved.\n' +
' </footer>\n' +
'</body>\n</html>';
}
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
// --- Main Generate Function ---
function generateAll() {
var p = getProductData();
if (!p) return;
var btn = document.getElementById('generateBtn');
btn.classList.add('loading');
setTimeout(function () {
var style = templates[selectedStyle] || templates.professional;
var platforms = ['twitter', 'instagram', 'facebook', 'linkedin', 'tiktok'];
platforms.forEach(function (platform) {
var tpl = style[platform];
if (tpl) {
setText(platform + '-de', tpl.de(p));
setText(platform + '-en', tpl.en(p));
}
});
// Email
setText('email-de', emailTemplates.de(p));
setText('email-en', emailTemplates.en(p));
// SEO
setText('seo-de', seoTemplates.de(p));
setText('seo-en', seoTemplates.en(p));
// Press Release
setText('press-de', pressTemplates.de(p));
setText('press-en', pressTemplates.en(p));
// Slogans
var slogans = generateSlogans(p);
var sloganGrid = document.getElementById('sloganGrid');
sloganGrid.innerHTML = '';
slogans.forEach(function (s) {
var div = document.createElement('div');
div.className = 'slogan-item';
div.innerHTML = '<span class="slogan-lang">DE</span>' + escapeHtml(s.de);
div.onclick = function () { copyToClipboard(s.de); };
sloganGrid.appendChild(div);
var divEn = document.createElement('div');
divEn.className = 'slogan-item';
divEn.innerHTML = '<span class="slogan-lang">EN</span>' + escapeHtml(s.en);
divEn.onclick = function () { copyToClipboard(s.en); };
sloganGrid.appendChild(divEn);
});
// Hashtags
var hashtagCloud = document.getElementById('hashtagCloud');
hashtagCloud.innerHTML = '';
var allTags = p.hashtags.concat(p.hashtagsEn).filter(function (t, i, arr) { return arr.indexOf(t) === i; });
allTags.forEach(function (tag) {
var span = document.createElement('span');
span.className = 'hashtag';
span.textContent = tag;
span.onclick = function () { copyToClipboard(tag); };
hashtagCloud.appendChild(span);
});
// Landing Page
var landingHtml = generateLandingPage(p);
document.getElementById('landing-code').textContent = landingHtml;
var iframe = document.createElement('iframe');
iframe.srcdoc = landingHtml;
var previewDiv = document.getElementById('landing-preview');
previewDiv.innerHTML = '';
previewDiv.appendChild(iframe);
// Store data for export
generatedData = {
product: p,
style: selectedStyle,
social: {},
email: { de: emailTemplates.de(p), en: emailTemplates.en(p) },
seo: { de: seoTemplates.de(p), en: seoTemplates.en(p) },
press: { de: pressTemplates.de(p), en: pressTemplates.en(p) },
slogans: slogans,
hashtags: allTags,
landingPage: landingHtml
};
platforms.forEach(function (platform) {
var tpl = style[platform];
if (tpl) {
generatedData.social[platform] = { de: tpl.de(p), en: tpl.en(p) };
}
});
// Show results
document.getElementById('results').classList.remove('hidden');
document.getElementById('results').scrollIntoView({ behavior: 'smooth', block: 'start' });
btn.classList.remove('loading');
showToast(currentLang === 'de' ? 'Alle Werbematerialien wurden generiert!' : 'All promotion materials generated!');
}, 600);
}
window.generateAll = generateAll;
// --- Helper Functions ---
function setText(id, text) {
var el = document.getElementById(id);
if (el) el.textContent = text;
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function () {
showToast(currentLang === 'de' ? 'Kopiert!' : 'Copied!');
}).catch(function () {
fallbackCopy(text);
});
}
function fallbackCopy(text) {
var textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast(currentLang === 'de' ? 'Kopiert!' : 'Copied!');
}
function copyText(id) {
var el = document.getElementById(id);
if (el) copyToClipboard(el.textContent);
}
window.copyText = copyText;
function copyAll(section) {
if (!generatedData) return;
var text = '';
if (section === 'social') {
Object.keys(generatedData.social).forEach(function (platform) {
text += '=== ' + platform.toUpperCase() + ' (DE) ===\n' + generatedData.social[platform].de + '\n\n';
text += '=== ' + platform.toUpperCase() + ' (EN) ===\n' + generatedData.social[platform].en + '\n\n';
});
} else if (section === 'email') {
text = '=== EMAIL (DE) ===\n' + generatedData.email.de + '\n\n=== EMAIL (EN) ===\n' + generatedData.email.en;
} else if (section === 'seo') {
text = '=== SEO (DE) ===\n' + generatedData.seo.de + '\n\n=== SEO (EN) ===\n' + generatedData.seo.en;
} else if (section === 'press') {
text = '=== PRESS (DE) ===\n' + generatedData.press.de + '\n\n=== PRESS (EN) ===\n' + generatedData.press.en;
}
copyToClipboard(text);
}
window.copyAll = copyAll;
function copyHashtags() {
if (generatedData) copyToClipboard(generatedData.hashtags.join(' '));
}
window.copyHashtags = copyHashtags;
// --- Tabs ---
function switchTab(btn, tab) {
var parent = btn.closest('.result-card');
parent.querySelectorAll('.tab-btn').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
parent.querySelectorAll('.tab-content').forEach(function (c) { c.classList.add('hidden'); });
document.getElementById('tab-' + tab).classList.remove('hidden');
}
window.switchTab = switchTab;
// --- Landing Page Preview Toggle ---
function togglePreview(mode) {
var preview = document.getElementById('landing-preview');
var code = document.getElementById('landing-code');
var buttons = document.querySelectorAll('.preview-btn');
buttons.forEach(function (b) { b.classList.remove('active'); });
if (mode === 'preview') {
preview.classList.remove('hidden');
code.classList.add('hidden');
buttons[0].classList.add('active');
} else {
preview.classList.add('hidden');
code.classList.remove('hidden');
buttons[1].classList.add('active');
}
}
window.togglePreview = togglePreview;
// --- Export Functions ---
function exportAs(format) {
if (!generatedData) {
showToast(currentLang === 'de' ? 'Bitte zuerst generieren!' : 'Please generate first!');
return;
}
var content = '';
var filename = 'promo-' + generatedData.product.name.replace(/\s+/g, '-').toLowerCase();
var mimeType = 'text/plain';
if (format === 'txt') {
content = buildTextExport();
filename += '.txt';
} else if (format === 'html') {
content = generatedData.landingPage;
filename += '-landingpage.html';
mimeType = 'text/html';
} else if (format === 'json') {
content = JSON.stringify(generatedData, null, 2);
filename += '.json';
mimeType = 'application/json';
} else if (format === 'csv') {
content = buildCsvExport();
filename += '.csv';
mimeType = 'text/csv';
}
downloadFile(content, filename, mimeType);
showToast((currentLang === 'de' ? 'Export als ' : 'Exported as ') + format.toUpperCase() + '!');
}
window.exportAs = exportAs;
function buildTextExport() {
var d = generatedData;
var lines = [];
lines.push('========================================');
lines.push('PROMOMASTER - WERBEMATERIALIEN / PROMOTION MATERIALS');
lines.push('Produkt / Product: ' + d.product.name);
lines.push('Erstellt am / Generated: ' + new Date().toLocaleString());
lines.push('========================================\n');
Object.keys(d.social).forEach(function (platform) {
lines.push('\n--- ' + platform.toUpperCase() + ' (DE) ---');
lines.push(d.social[platform].de);
lines.push('\n--- ' + platform.toUpperCase() + ' (EN) ---');
lines.push(d.social[platform].en);
});
lines.push('\n\n--- E-MAIL MARKETING (DE) ---');
lines.push(d.email.de);
lines.push('\n--- E-MAIL MARKETING (EN) ---');
lines.push(d.email.en);
lines.push('\n\n--- SEO (DE) ---');
lines.push(d.seo.de);
lines.push('\n--- SEO (EN) ---');
lines.push(d.seo.en);
lines.push('\n\n--- PRESSEMITTEILUNG / PRESS RELEASE (DE) ---');
lines.push(d.press.de);
lines.push('\n--- PRESS RELEASE (EN) ---');
lines.push(d.press.en);
lines.push('\n\n--- SLOGANS ---');
d.slogans.forEach(function (s) {
lines.push('DE: ' + s.de);
lines.push('EN: ' + s.en);
});
lines.push('\n\n--- HASHTAGS ---');
lines.push(d.hashtags.join(' '));
return lines.join('\n');
}
function buildCsvExport() {
var d = generatedData;
var rows = [['Platform', 'Language', 'Content']];
Object.keys(d.social).forEach(function (platform) {
rows.push([platform, 'DE', '"' + d.social[platform].de.replace(/"/g, '""') + '"']);
rows.push([platform, 'EN', '"' + d.social[platform].en.replace(/"/g, '""') + '"']);
});
rows.push(['email', 'DE', '"' + d.email.de.replace(/"/g, '""') + '"']);
rows.push(['email', 'EN', '"' + d.email.en.replace(/"/g, '""') + '"']);
rows.push(['seo', 'DE', '"' + d.seo.de.replace(/"/g, '""') + '"']);
rows.push(['seo', 'EN', '"' + d.seo.en.replace(/"/g, '""') + '"']);
rows.push(['press', 'DE', '"' + d.press.de.replace(/"/g, '""') + '"']);
rows.push(['press', 'EN', '"' + d.press.en.replace(/"/g, '""') + '"']);
d.slogans.forEach(function (s, i) {
rows.push(['slogan_' + (i + 1), 'DE', '"' + s.de.replace(/"/g, '""') + '"']);
rows.push(['slogan_' + (i + 1), 'EN', '"' + s.en.replace(/"/g, '""') + '"']);
});
return rows.map(function (r) { return r.join(','); }).join('\n');
}
function downloadFile(content, filename, mimeType) {
var blob = new Blob([content], { type: mimeType + ';charset=utf-8' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// --- Toast ---
function showToast(msg) {
var toast = document.getElementById('toast');
toast.textContent = msg;
toast.classList.remove('hidden');
toast.classList.add('show');
setTimeout(function () {
toast.classList.remove('show');
setTimeout(function () { toast.classList.add('hidden'); }, 400);
}, 2500);
}
// --- Initialize ---
setLanguage('de');
})();
+724
View File
@@ -0,0 +1,724 @@
/* === PromoMaster - Product Promotion Tool === */
:root {
--primary: #6C5CE7;
--primary-dark: #5A4BD1;
--primary-light: #A29BFE;
--accent: #00CEC9;
--accent-dark: #00B5B0;
--bg: #0F0F1A;
--bg-card: #1A1A2E;
--bg-card-hover: #222240;
--text: #EAEAEA;
--text-muted: #8B8BA3;
--border: #2D2D4A;
--success: #00E676;
--warning: #FFD93D;
--danger: #FF6B6B;
--radius: 16px;
--radius-sm: 10px;
--shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.4);
--glow: 0 0 30px rgba(108, 92, 231, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
overflow-x: hidden;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 0 20px;
}
/* === Language Toggle === */
.lang-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 100;
display: flex;
gap: 4px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 30px;
padding: 4px;
box-shadow: var(--shadow);
}
.lang-btn {
padding: 8px 16px;
border: none;
border-radius: 26px;
background: transparent;
color: var(--text-muted);
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
}
.lang-btn.active {
background: var(--primary);
color: #fff;
}
.lang-btn:hover:not(.active) {
color: var(--text);
background: var(--border);
}
/* === Hero === */
.hero {
position: relative;
padding: 80px 0 50px;
text-align: center;
overflow: hidden;
}
.hero-bg {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(ellipse at 50% 50%, rgba(108, 92, 231, 0.15) 0%, transparent 60%),
radial-gradient(ellipse at 80% 20%, rgba(0, 206, 201, 0.1) 0%, transparent 40%);
animation: heroPulse 8s ease-in-out infinite;
}
@keyframes heroPulse {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.05); opacity: 1; }
}
.hero-title {
font-size: clamp(2.5rem, 6vw, 4rem);
font-weight: 900;
letter-spacing: -2px;
position: relative;
background: linear-gradient(135deg, var(--primary-light), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-icon {
font-size: 0.8em;
-webkit-text-fill-color: initial;
}
.hero-subtitle {
font-size: clamp(1rem, 2.5vw, 1.3rem);
color: var(--text-muted);
margin-top: 12px;
position: relative;
font-weight: 300;
}
/* === Cards === */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 32px;
margin-bottom: 24px;
box-shadow: var(--shadow);
transition: border-color 0.3s ease;
}
.card:hover {
border-color: var(--primary);
}
/* === Step Header === */
.step-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 28px;
}
.step-number {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary), var(--accent));
border-radius: 12px;
font-weight: 800;
font-size: 20px;
color: #fff;
flex-shrink: 0;
}
.step-header h2 {
font-size: 1.4rem;
font-weight: 700;
}
/* === Form === */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-group label {
font-size: 14px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-group input,
.form-group textarea,
.form-group select {
padding: 14px 18px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: 15px;
transition: all 0.3s ease;
outline: none;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.2);
}
.form-group select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238B8BA3' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 16px center;
padding-right: 40px;
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.char-count {
font-size: 12px;
color: var(--text-muted);
text-align: right;
}
/* === Style Options === */
.style-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.style-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 20px 14px;
background: var(--bg);
border: 2px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
}
.style-option:hover {
border-color: var(--primary-light);
background: var(--bg-card-hover);
}
.style-option.selected {
border-color: var(--primary);
background: rgba(108, 92, 231, 0.1);
box-shadow: var(--glow);
}
.style-icon {
font-size: 32px;
}
.style-label {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
}
.style-option.selected .style-label {
color: var(--text);
}
/* === Generate Button === */
.generate-section {
text-align: center;
margin: 40px 0;
}
.btn-generate {
padding: 18px 48px;
font-size: 18px;
font-weight: 700;
font-family: inherit;
color: #fff;
background: linear-gradient(135deg, var(--primary), var(--accent));
border: none;
border-radius: 50px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 8px 30px rgba(108, 92, 231, 0.4);
display: inline-flex;
align-items: center;
gap: 10px;
}
.btn-generate:hover {
transform: translateY(-3px);
box-shadow: 0 12px 40px rgba(108, 92, 231, 0.5);
}
.btn-generate:active {
transform: translateY(0);
}
.btn-generate.loading {
pointer-events: none;
opacity: 0.8;
}
.btn-generate.loading .btn-icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.btn-icon {
font-size: 22px;
display: inline-block;
}
/* === Results === */
.results-section {
animation: fadeInUp 0.6s ease;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.result-card {
position: relative;
}
.result-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 10px;
}
.result-header h3 {
font-size: 1.2rem;
font-weight: 700;
}
.btn-copy-all {
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: var(--primary-light);
background: rgba(108, 92, 231, 0.1);
border: 1px solid var(--primary);
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-copy-all:hover {
background: var(--primary);
color: #fff;
}
/* === Tabs === */
.result-tabs {
display: flex;
gap: 6px;
margin-bottom: 20px;
overflow-x: auto;
padding-bottom: 4px;
}
.tab-btn {
padding: 10px 20px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: var(--text-muted);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
}
.tab-btn:hover {
color: var(--text);
border-color: var(--primary-light);
}
.tab-btn.active {
color: #fff;
background: var(--primary);
border-color: var(--primary);
}
.tab-content {
transition: all 0.3s ease;
}
/* === Language Results === */
.lang-results {
display: flex;
flex-direction: column;
gap: 16px;
}
.lang-result {
position: relative;
padding: 20px;
padding-left: 56px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.lang-badge {
position: absolute;
top: 20px;
left: 16px;
padding: 3px 10px;
font-size: 11px;
font-weight: 800;
color: #fff;
background: linear-gradient(135deg, #E74C3C, #C0392B);
border-radius: 6px;
letter-spacing: 1px;
}
.lang-badge.en {
background: linear-gradient(135deg, #2980B9, #2471A3);
}
.result-text {
font-size: 14px;
line-height: 1.8;
white-space: pre-wrap;
color: var(--text);
padding-right: 40px;
}
.btn-copy {
position: absolute;
top: 16px;
right: 16px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s ease;
color: var(--text);
}
.btn-copy:hover {
background: var(--primary);
border-color: var(--primary);
}
/* === Slogans === */
.slogan-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.slogan-item {
padding: 18px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
text-align: center;
font-weight: 600;
font-size: 15px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.slogan-item:hover {
border-color: var(--accent);
background: var(--bg-card-hover);
}
.slogan-item .slogan-lang {
font-size: 10px;
font-weight: 800;
color: var(--text-muted);
letter-spacing: 1px;
display: block;
margin-bottom: 6px;
}
/* === Hashtags === */
.hashtag-cloud {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.hashtag {
padding: 8px 16px;
background: rgba(108, 92, 231, 0.1);
border: 1px solid var(--primary);
border-radius: 20px;
font-size: 14px;
font-weight: 600;
color: var(--primary-light);
cursor: pointer;
transition: all 0.3s ease;
}
.hashtag:hover {
background: var(--primary);
color: #fff;
}
/* === Landing Page Preview === */
.landing-preview-container {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.preview-toggle {
display: flex;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.preview-btn {
flex: 1;
padding: 12px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: var(--text-muted);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.3s ease;
}
.preview-btn.active {
color: var(--text);
background: var(--bg-card);
border-bottom: 2px solid var(--primary);
}
.landing-preview {
min-height: 300px;
background: #fff;
}
.landing-preview iframe {
width: 100%;
height: 500px;
border: none;
}
.landing-code {
padding: 20px;
background: var(--bg);
color: var(--accent);
font-size: 13px;
line-height: 1.6;
overflow-x: auto;
max-height: 400px;
white-space: pre;
margin: 0;
cursor: pointer;
}
/* === Export === */
.export-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
.btn-export {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 24px 16px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-export:hover {
border-color: var(--accent);
background: var(--bg-card-hover);
transform: translateY(-2px);
}
.export-icon {
font-size: 28px;
}
/* === Toast === */
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%) translateY(20px);
padding: 14px 28px;
background: var(--success);
color: #000;
font-weight: 600;
font-size: 14px;
border-radius: 30px;
z-index: 1000;
opacity: 0;
transition: all 0.4s ease;
box-shadow: 0 8px 30px rgba(0, 230, 118, 0.3);
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* === Footer === */
.footer {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
font-size: 14px;
border-top: 1px solid var(--border);
margin-top: 60px;
}
/* === Hidden === */
.hidden {
display: none !important;
}
/* === Responsive === */
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
}
.style-grid {
grid-template-columns: repeat(2, 1fr);
}
.slogan-grid {
grid-template-columns: 1fr;
}
.export-grid {
grid-template-columns: repeat(2, 1fr);
}
.card {
padding: 24px 18px;
}
.hero {
padding: 60px 0 30px;
}
.lang-toggle {
top: 12px;
right: 12px;
}
.result-tabs {
gap: 4px;
}
.tab-btn {
padding: 8px 14px;
font-size: 12px;
}
}
@media (max-width: 480px) {
.style-grid {
grid-template-columns: 1fr;
}
.export-grid {
grid-template-columns: 1fr 1fr;
}
.btn-generate {
padding: 16px 32px;
font-size: 16px;
}
}
+54
View File
@@ -14,6 +14,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
#if targetEnvironment(macCatalyst)
// Configure for macOS
configureMacOS()
#endif
return true return true
} }
@@ -33,10 +37,60 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidBecomeActive(_ application: UIApplication) { func applicationDidBecomeActive(_ application: UIApplication) {
// Resume game if needed // Resume game if needed
} }
#if targetEnvironment(macCatalyst)
// MARK: - macOS Configuration
private func configureMacOS() {
// Set minimum window size for macOS
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.forEach { windowScene in
windowScene.sizeRestrictions?.minimumSize = CGSize(width: 400, height: 600)
windowScene.sizeRestrictions?.maximumSize = CGSize(width: 600, height: 900)
}
}
override func buildMenu(with builder: UIMenuBuilder) {
super.buildMenu(with: builder)
// Remove unnecessary menus for a game
builder.remove(menu: .format)
builder.remove(menu: .edit)
// Add Game menu
let pauseCommand = UIKeyCommand(
title: "Pause",
action: #selector(handlePauseCommand),
input: "p",
modifierFlags: .command
)
let restartCommand = UIKeyCommand(
title: "Neustart",
action: #selector(handleRestartCommand),
input: "r",
modifierFlags: .command
)
let gameMenu = UIMenu(
title: "Spiel",
children: [pauseCommand, restartCommand]
)
builder.insertSibling(gameMenu, afterMenu: .file)
}
@objc private func handlePauseCommand() {
NotificationCenter.default.post(name: .pauseGame, object: nil)
}
@objc private func handleRestartCommand() {
NotificationCenter.default.post(name: .restartGame, object: nil)
}
#endif
} }
// MARK: - Notification Names // MARK: - Notification Names
extension Notification.Name { extension Notification.Name {
static let pauseGame = Notification.Name("pauseGame") static let pauseGame = Notification.Name("pauseGame")
static let resumeGame = Notification.Name("resumeGame") static let resumeGame = Notification.Name("resumeGame")
static let restartGame = Notification.Name("restartGame")
} }
@@ -36,6 +36,10 @@ class GameViewController: UIViewController {
// Setup notification observers // Setup notification observers
setupNotificationObservers() setupNotificationObservers()
#if targetEnvironment(macCatalyst)
setupMacCatalyst()
#endif
} }
private func setupNotificationObservers() { private func setupNotificationObservers() {
@@ -45,6 +49,13 @@ class GameViewController: UIViewController {
name: .pauseGame, name: .pauseGame,
object: nil object: nil
) )
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRestartNotification),
name: .restartGame,
object: nil
)
} }
@objc private func handlePauseNotification() { @objc private func handlePauseNotification() {
@@ -57,12 +68,46 @@ class GameViewController: UIViewController {
// This is just a notification that the app is going to background // This is just a notification that the app is going to background
} }
@objc private func handleRestartNotification() {
guard let skView = self.view as? SKView else { return }
let menuScene = MenuScene(size: skView.bounds.size)
menuScene.scaleMode = .aspectFill
let transition = SKTransition.fade(withDuration: 0.5)
skView.presentScene(menuScene, transition: transition)
}
#if targetEnvironment(macCatalyst)
private func setupMacCatalyst() {
// Configure window appearance for macOS
if let windowScene = view.window?.windowScene {
windowScene.title = "Rollkoffer Simulator"
// Set window style
if let titlebar = windowScene.titlebar {
titlebar.titleVisibility = .visible
titlebar.toolbarStyle = .unified
}
}
}
// Enable keyboard input
override var canBecomeFirstResponder: Bool {
return true
}
#endif
override var supportedInterfaceOrientations: UIInterfaceOrientationMask { override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
#if targetEnvironment(macCatalyst)
return .all
#else
if UIDevice.current.userInterfaceIdiom == .phone { if UIDevice.current.userInterfaceIdiom == .phone {
return .portrait return .portrait
} else { } else {
return .all return .all
} }
#endif
} }
override var prefersStatusBarHidden: Bool { override var prefersStatusBarHidden: Bool {
+4
View File
@@ -50,5 +50,9 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright 2024 Ingo K. All rights reserved.</string>
</dict> </dict>
</plist> </plist>
@@ -381,6 +381,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Info.plist; INFOPLIST_FILE = Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -393,9 +394,12 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator; PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@@ -409,6 +413,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Info.plist; INFOPLIST_FILE = Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -421,9 +426,12 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator; PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@@ -223,6 +223,27 @@ class GameOverScene: SKScene {
} }
} }
// MARK: - Keyboard Handling (macOS)
#if targetEnvironment(macCatalyst)
override var canBecomeFirstResponder: Bool { true }
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let key = presses.first?.key else {
super.pressesBegan(presses, with: event)
return
}
switch key.keyCode {
case .keyboardSpacebar, .keyboardReturnOrEnter:
retryGame()
case .keyboardEscape:
returnToMenu()
default:
super.pressesBegan(presses, with: event)
}
}
#endif
private func retryGame() { private func retryGame() {
let pressDown = SKAction.scale(to: 0.9, duration: 0.1) let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
let pressUp = SKAction.scale(to: 1.0, duration: 0.1) let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
@@ -318,6 +318,29 @@ class GameScene: SKScene {
isDragging = false isDragging = false
} }
// MARK: - Keyboard Handling (macOS)
#if targetEnvironment(macCatalyst)
override var canBecomeFirstResponder: Bool { true }
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let key = presses.first?.key else {
super.pressesBegan(presses, with: event)
return
}
switch key.keyCode {
case .keyboardEscape:
togglePause()
case .keyboardSpacebar:
if gameState.currentState == .paused {
resumeGame()
}
default:
super.pressesBegan(presses, with: event)
}
}
#endif
// MARK: - Pause Handling // MARK: - Pause Handling
private func togglePause() { private func togglePause() {
if gameState.currentState == .playing { if gameState.currentState == .playing {
@@ -245,6 +245,25 @@ class MenuScene: SKScene {
} }
} }
// MARK: - Keyboard Handling (macOS)
#if targetEnvironment(macCatalyst)
override var canBecomeFirstResponder: Bool { true }
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let key = presses.first?.key else {
super.pressesBegan(presses, with: event)
return
}
switch key.keyCode {
case .keyboardSpacebar, .keyboardReturnOrEnter:
startGame()
default:
super.pressesBegan(presses, with: event)
}
}
#endif
private func startGame() { private func startGame() {
// Button press effect // Button press effect
let pressDown = SKAction.scale(to: 0.9, duration: 0.1) let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
@@ -280,6 +280,27 @@ class VictoryScene: SKScene {
} }
} }
// MARK: - Keyboard Handling (macOS)
#if targetEnvironment(macCatalyst)
override var canBecomeFirstResponder: Bool { true }
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let key = presses.first?.key else {
super.pressesBegan(presses, with: event)
return
}
switch key.keyCode {
case .keyboardSpacebar, .keyboardReturnOrEnter:
playAgain()
case .keyboardEscape:
returnToMenu()
default:
super.pressesBegan(presses, with: event)
}
}
#endif
private func playAgain() { private func playAgain() {
let pressDown = SKAction.scale(to: 0.9, duration: 0.1) let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
let pressUp = SKAction.scale(to: 1.0, duration: 0.1) let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
+8
View File
@@ -0,0 +1,8 @@
# Build artifacts
bin/
obj/
# Visual Studio user/IDE files
.vs/
*.user
*.suo
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trafag Sales Exporter</title>
<base href="/" />
<link href="css/app.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<HeadOutlet @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
</head>
<body>
<Routes @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
<script src="_framework/blazor.web.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
</body>
</html>
@@ -0,0 +1,38 @@
@inherits LayoutComponentBase
<MudThemeProvider Theme="_theme" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<MudLayout>
<MudAppBar Elevation="1" Color="Color.Primary">
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
OnClick="ToggleDrawer" />
<MudText Typo="Typo.h6" Class="ml-3">Trafag Sales Exporter</MudText>
</MudAppBar>
<MudDrawer @bind-Open="_drawerOpen" Elevation="2" ClipMode="DrawerClipMode.Always">
<NavMenu />
</MudDrawer>
<MudMainContent Class="pa-4">
@Body
</MudMainContent>
</MudLayout>
@code {
private bool _drawerOpen = true;
private readonly MudTheme _theme = new()
{
PaletteLight = new PaletteLight
{
Primary = "#1565C0",
Secondary = "#00897B",
AppbarBackground = "#1565C0"
}
};
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
}
@@ -0,0 +1,17 @@
<MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
Dashboard
</MudNavLink>
<MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn">
Standorte
</MudNavLink>
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
Transformationen
</MudNavLink>
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
Settings
</MudNavLink>
<MudNavLink Href="/logs" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">
Logs
</MudNavLink>
</MudNavMenu>
@@ -0,0 +1,193 @@
@page "/"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject ExportOrchestrationService Orchestrator
@inject TimerBackgroundService TimerService
@inject ISnackbar Snackbar
@implements IDisposable
<PageTitle>Dashboard</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Dashboard</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Spacing="4">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow"
OnClick="ExportAll" Disabled="_anyRunning">
Alle exportieren
</MudButton>
<MudText Typo="Typo.body1">
@if (TimerService.NextRun < DateTime.MaxValue)
{
<MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Small" Class="mr-1" />
@($"Nächster automatischer Lauf: {TimerService.NextRun:dd.MM.yyyy HH:mm}")
}
else
{
<MudIcon Icon="@Icons.Material.Filled.TimerOff" Size="Size.Small" Class="mr-1" />
@("Timer deaktiviert")
}
</MudText>
</MudStack>
</MudPaper>
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>Land</MudTh>
<MudTh>TSC</MudTh>
<MudTh>Schema</MudTh>
<MudTh>Server</MudTh>
<MudTh>Status</MudTh>
<MudTh>Zeilen</MudTh>
<MudTh>Letzter Lauf</MudTh>
<MudTh>Dauer</MudTh>
<MudTh>Aktion</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.ServerName</MudTd>
<MudTd>
@if (Orchestrator.IsExporting(context.SiteId))
{
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
<MudText Typo="Typo.caption">@Orchestrator.GetExportStatus(context.SiteId)</MudText>
}
else if (context.LastStatus == "OK")
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else if (context.LastStatus == "Error")
{
<MudTooltip Text="@context.ErrorMessage">
<MudIcon Icon="@Icons.Material.Filled.Error" Color="Color.Error" Size="Size.Small" />
</MudTooltip>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
<MudTd>@(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
<MudTd>@(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-")</MudTd>
<MudTd>
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.FileDownload"
OnClick="() => ExportSingle(context.SiteId)"
Disabled="Orchestrator.IsExporting(context.SiteId)">
Export
</MudButton>
</MudTd>
</RowTemplate>
</MudTable>
@code {
private List<DashboardRow> _dashboardRows = new();
private bool _loading = true;
private bool _anyRunning;
protected override async Task OnInitializedAsync()
{
Orchestrator.OnExportStatusChanged += HandleStatusChanged;
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
using var db = await DbFactory.CreateDbContextAsync();
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
var logs = await db.ExportLogs
.GroupBy(l => l.SiteId)
.Select(g => g.OrderByDescending(l => l.Timestamp).First())
.ToListAsync();
_dashboardRows = sites.Select(s =>
{
var log = logs.FirstOrDefault(l => l.SiteId == s.Id);
return new DashboardRow
{
SiteId = s.Id,
Land = s.Land,
TSC = s.TSC,
Schema = s.Schema,
ServerName = s.HanaServer?.Name ?? "",
LastStatus = log?.Status ?? "",
RowCount = log?.RowCount ?? 0,
LastRun = log?.Timestamp,
DurationSeconds = log?.DurationSeconds ?? 0,
ErrorMessage = log?.ErrorMessage ?? ""
};
}).ToList();
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId));
_loading = false;
}
private async Task ExportAll()
{
_anyRunning = true;
_ = Task.Run(async () =>
{
await Orchestrator.ExportAllAsync();
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
});
Snackbar.Add("Export für alle Standorte gestartet", Severity.Info);
}
private void ExportSingle(int siteId)
{
_ = Task.Run(async () =>
{
await Orchestrator.ExportSiteByIdAsync(siteId);
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
});
Snackbar.Add("Export gestartet", Severity.Info);
}
private async void HandleStatusChanged()
{
await InvokeAsync(async () =>
{
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId));
StateHasChanged();
if (!_anyRunning)
{
await LoadDataAsync();
StateHasChanged();
}
});
}
public void Dispose()
{
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
}
private class DashboardRow
{
public int SiteId { get; set; }
public string Land { get; set; } = "";
public string TSC { get; set; } = "";
public string Schema { get; set; } = "";
public string ServerName { get; set; } = "";
public string LastStatus { get; set; } = "";
public int RowCount { get; set; }
public DateTime? LastRun { get; set; }
public double DurationSeconds { get; set; }
public string ErrorMessage { get; set; } = "";
}
}
@@ -0,0 +1,134 @@
@page "/logs"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@inject IDbContextFactory<AppDbContext> DbFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<PageTitle>Logs</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Export Logs</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Spacing="3">
<MudSelect @bind-Value="_filterLand" Label="Land" Clearable Dense Style="max-width:200px;">
@foreach (var land in _availableLands)
{
<MudSelectItem Value="@land">@land</MudSelectItem>
}
</MudSelect>
<MudSelect @bind-Value="_filterStatus" Label="Status" Clearable Dense Style="max-width:150px;">
<MudSelectItem Value="@("OK")">OK</MudSelectItem>
<MudSelectItem Value="@("Error")">Error</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="_filterDate" Label="Datum" Clearable Dense Style="max-width:200px;" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ApplyFilter"
StartIcon="@Icons.Material.Filled.FilterAlt">
Filtern
</MudButton>
<MudSpacer />
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteOldLogs"
StartIcon="@Icons.Material.Filled.DeleteSweep">
Alte Logs löschen
</MudButton>
</MudStack>
</MudPaper>
<MudTable Items="_logs" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>Zeitpunkt</MudTh>
<MudTh>Land</MudTh>
<MudTh>TSC</MudTh>
<MudTh>Status</MudTh>
<MudTh>Zeilen</MudTh>
<MudTh>Dauer</MudTh>
<MudTh>Dateiname</MudTh>
<MudTh>Fehler</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Timestamp.ToString("dd.MM.yyyy HH:mm:ss")</MudTd>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>
@if (context.Status == "OK")
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">OK</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Error">Error</MudChip>
}
</MudTd>
<MudTd>@context.RowCount.ToString("N0")</MudTd>
<MudTd>@($"{context.DurationSeconds:F1}s")</MudTd>
<MudTd>@context.FileName</MudTd>
<MudTd>
@if (!string.IsNullOrEmpty(context.ErrorMessage))
{
<MudTooltip Text="@context.ErrorMessage">
<MudText Typo="Typo.caption" Color="Color.Error" Style="max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
@context.ErrorMessage
</MudText>
</MudTooltip>
}
</MudTd>
</RowTemplate>
</MudTable>
@code {
private List<ExportLog> _logs = new();
private List<string> _availableLands = new();
private string? _filterLand;
private string? _filterStatus;
private DateTime? _filterDate;
private bool _loading = true;
protected override async Task OnInitializedAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_availableLands = await db.ExportLogs.Select(l => l.Land).Distinct().OrderBy(l => l).ToListAsync();
await LoadLogsAsync();
}
private async Task LoadLogsAsync()
{
_loading = true;
using var db = await DbFactory.CreateDbContextAsync();
IQueryable<ExportLog> query = db.ExportLogs.OrderByDescending(l => l.Timestamp);
if (!string.IsNullOrEmpty(_filterLand))
query = query.Where(l => l.Land == _filterLand);
if (!string.IsNullOrEmpty(_filterStatus))
query = query.Where(l => l.Status == _filterStatus);
if (_filterDate.HasValue)
query = query.Where(l => l.Timestamp.Date == _filterDate.Value.Date);
_logs = await query.Take(500).ToListAsync();
_loading = false;
}
private async Task ApplyFilter()
{
await LoadLogsAsync();
}
private async Task DeleteOldLogs()
{
var result = await DialogService.ShowMessageBox(
"Alte Logs löschen",
"Logs älter als 90 Tage löschen?",
yesText: "Löschen", cancelText: "Abbrechen");
if (result != true) return;
using var db = await DbFactory.CreateDbContextAsync();
var cutoff = DateTime.Now.AddDays(-90);
var oldLogs = await db.ExportLogs.Where(l => l.Timestamp < cutoff).ToListAsync();
db.ExportLogs.RemoveRange(oldLogs);
var count = await db.SaveChangesAsync();
await LoadLogsAsync();
Snackbar.Add($"{oldLogs.Count} alte Logs gelöscht", Severity.Info);
}
}
@@ -0,0 +1,164 @@
@page "/settings"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject SharePointUploadService SpService
@inject TimerBackgroundService TimerService
@inject ISnackbar Snackbar
<PageTitle>Settings</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Settings</MudText>
@* SharePoint Config *@
<MudText Typo="Typo.h5" Class="mb-2">SharePoint Konfiguration</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_spConfig.SiteUrl" Label="Site URL" />
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_spConfig.ExportFolder" Label="Export Folder" />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_spConfig.TenantId" Label="Tenant ID" />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_spConfig.ClientId" Label="Client ID" />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_spConfig.ClientSecret" Label="Client Secret" InputType="InputType.Password" />
</MudItem>
<MudItem xs="12">
<MudStack Row Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSharePoint"
StartIcon="@Icons.Material.Filled.Save">
Speichern
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="TestSharePoint"
StartIcon="@Icons.Material.Filled.NetworkCheck" Disabled="_testingSp">
@if (_testingSp)
{
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
@("Teste...")
}
else
{
@("SharePoint Verbindung testen")
}
</MudButton>
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
@* Export Settings *@
<MudText Typo="Typo.h5" Class="mb-2">Export Einstellungen</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_exportSettings.DateFilter" Label="Datum-Filter (ab)"
HelperText="Format: yyyy-MM-dd" />
</MudItem>
<MudItem xs="12" md="2">
<MudNumericField @bind-Value="_exportSettings.TimerHour" Label="Timer Stunde" Min="0" Max="23" />
</MudItem>
<MudItem xs="12" md="2">
<MudNumericField @bind-Value="_exportSettings.TimerMinute" Label="Timer Minute" Min="0" Max="59" />
</MudItem>
<MudItem xs="12" md="4">
<MudSwitch @bind-Value="_exportSettings.TimerEnabled" Label="Timer aktiviert" Color="Color.Primary" />
</MudItem>
<MudItem xs="12">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExportSettings"
StartIcon="@Icons.Material.Filled.Save">
Speichern
</MudButton>
</MudItem>
</MudGrid>
</MudPaper>
@* Filename Preview *@
<MudText Typo="Typo.h5" Class="mb-2">Dateiname Vorschau</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.body1">
<MudIcon Icon="@Icons.Material.Filled.InsertDriveFile" Size="Size.Small" Class="mr-1" />
Sales_{"{TSC}"}_{DateTime.Now:yyyy-MM-dd}.xlsx
</MudText>
<MudText Typo="Typo.caption" Class="mt-1">
Beispiel: Sales_TRFR_@(DateTime.Now.ToString("yyyy-MM-dd")).xlsx
</MudText>
</MudPaper>
@code {
private SharePointConfig _spConfig = new();
private ExportSettings _exportSettings = new();
private bool _testingSp;
protected override async Task OnInitializedAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig();
_exportSettings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
}
private async Task SaveSharePoint()
{
using var db = await DbFactory.CreateDbContextAsync();
var existing = await db.SharePointConfigs.FirstOrDefaultAsync();
if (existing is null)
{
db.SharePointConfigs.Add(_spConfig);
}
else
{
existing.SiteUrl = _spConfig.SiteUrl;
existing.ExportFolder = _spConfig.ExportFolder;
existing.TenantId = _spConfig.TenantId;
existing.ClientId = _spConfig.ClientId;
existing.ClientSecret = _spConfig.ClientSecret;
}
await db.SaveChangesAsync();
Snackbar.Add("SharePoint Konfiguration gespeichert", Severity.Success);
}
private async Task TestSharePoint()
{
_testingSp = true;
try
{
await SpService.TestConnectionAsync(
_spConfig.TenantId, _spConfig.ClientId, _spConfig.ClientSecret, _spConfig.SiteUrl);
Snackbar.Add("SharePoint Verbindung erfolgreich!", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Verbindung fehlgeschlagen: {ex.Message}", Severity.Error);
}
finally
{
_testingSp = false;
}
}
private async Task SaveExportSettings()
{
using var db = await DbFactory.CreateDbContextAsync();
var existing = await db.ExportSettings.FirstOrDefaultAsync();
if (existing is null)
{
db.ExportSettings.Add(_exportSettings);
}
else
{
existing.DateFilter = _exportSettings.DateFilter;
existing.TimerHour = _exportSettings.TimerHour;
existing.TimerMinute = _exportSettings.TimerMinute;
existing.TimerEnabled = _exportSettings.TimerEnabled;
}
await db.SaveChangesAsync();
TimerService.Recalculate();
Snackbar.Add("Export Einstellungen gespeichert", Severity.Success);
}
}
@@ -0,0 +1,353 @@
@page "/standorte"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject HanaQueryService HanaService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<PageTitle>Standorte</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Standorte</MudText>
<MudText Typo="Typo.h5" Class="mb-2">HANA Server</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
OnClick="AddServer" Class="mb-3">
Server hinzufügen
</MudButton>
<MudTable Items="_servers" Dense Hover Striped>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Host</MudTh>
<MudTh>Port</MudTh>
<MudTh>Username</MudTh>
<MudTh>Verbindungsstatus</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Name</MudTd>
<MudTd>@context.Host</MudTd>
<MudTd>@context.Port</MudTd>
<MudTd>@context.Username</MudTd>
<MudTd>
@if (_connectionStatus.TryGetValue(context.Id, out var status))
{
<MudTooltip Text="@BuildStatusTooltip(status)">
<MudChip Color="@(status.Success ? Color.Success : Color.Error)" Variant="Variant.Filled" Size="Size.Small">
@(status.Success ? "OK" : "Fehler") - @status.Stage
</MudChip>
</MudTooltip>
}
else
{
<MudChip Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small">Nicht getestet</MudChip>
}
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small"
OnClick="() => EditServer(context)" />
<MudIconButton Icon="@Icons.Material.Filled.NetworkCheck" Size="Size.Small" Color="Color.Info"
OnClick="() => TestServerConnection(context)" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="() => DeleteServer(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
<MudText Typo="Typo.h5" Class="mb-2">Standorte (Sites)</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
OnClick="AddSite" Class="mb-3">
Neuen Standort hinzufügen
</MudButton>
<MudTable Items="_sites" Dense Hover Striped>
<HeaderContent>
<MudTh>Land</MudTh>
<MudTh>TSC</MudTh>
<MudTh>Schema</MudTh>
<MudTh>Quellsystem</MudTh>
<MudTh>Server</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.SourceSystem</MudTd>
<MudTd>@(context.HanaServer?.Name ?? "-")</MudTd>
<MudTd>
@if (context.IsActive)
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else
{
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Default" Size="Size.Small" />
}
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small"
OnClick="() => EditSite(context)" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="() => DeleteSite(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
<MudDialog @bind-Visible="_serverDialogVisible" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_editingServer.Id == 0 ? "Server hinzufügen" : "Server bearbeiten")</MudText>
</TitleContent>
<DialogContent>
<MudTextField @bind-Value="_editingServer.Name" Label="Name" Required />
<MudTextField @bind-Value="_editingServer.Host" Label="Host" Required
HelperText="IP oder Hostname (ohne Protokoll)" />
<MudNumericField @bind-Value="_editingServer.Port" Label="Port"
HelperText="Typisch 30015 (Tenant), 30013 (SystemDB), 3xx15 für Instanz xx" />
<MudTextField @bind-Value="_editingServer.Username" Label="Username" />
<MudTextField @bind-Value="_editingServer.Password" Label="Password" InputType="InputType.Password" />
<MudTextField @bind-Value="_editingServer.DatabaseName" Label="Database Name (MDC)"
HelperText="Nur bei Multi-Tenant Setup angeben, sonst leer lassen" />
<MudSwitch @bind-Value="_editingServer.UseSsl" Label="SSL/TLS verwenden (encrypt=true)" Color="Color.Primary" />
<MudSwitch @bind-Value="_editingServer.ValidateCertificate" Label="SSL-Zertifikat validieren" Color="Color.Primary"
Disabled="!_editingServer.UseSsl" />
<MudTextField @bind-Value="_editingServer.AdditionalParams" Label="Zusätzliche Parameter"
HelperText="Optional, z.B. sslCryptoProvider=openssl;communicationTimeout=0" />
</DialogContent>
<DialogActions>
<MudButton OnClick="() => _serverDialogVisible = false">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveServer">Speichern</MudButton>
</DialogActions>
</MudDialog>
<MudDialog @bind-Visible="_siteDialogVisible" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_editingSite.Id == 0 ? "Standort hinzufügen" : "Standort bearbeiten")</MudText>
</TitleContent>
<DialogContent>
<MudSelect @bind-Value="_editingSite.HanaServerId" Label="Server" Required>
@foreach (var s in _servers)
{
<MudSelectItem Value="s.Id">@s.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="_editingSite.Schema" Label="Schema" Required />
<MudTextField @bind-Value="_editingSite.TSC" Label="TSC" Required />
<MudTextField @bind-Value="_editingSite.Land" Label="Land" Required />
<MudSelect @bind-Value="_editingSite.SourceSystem" Label="Quellsystem" Required>
@foreach (var system in _sourceSystems)
{
<MudSelectItem Value="system">@system</MudSelectItem>
}
</MudSelect>
<MudCheckBox @bind-Value="_editingSite.IsActive" Label="Aktiv" />
</DialogContent>
<DialogActions>
<MudButton OnClick="() => _siteDialogVisible = false">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite">Speichern</MudButton>
</DialogActions>
</MudDialog>
@code {
private readonly string[] _sourceSystems = ["SAP", "BI1", "SAGE"];
private readonly Dictionary<int, ConnectionTestResult> _connectionStatus = new();
private List<HanaServer> _servers = new();
private List<Site> _sites = new();
private HanaServer _editingServer = new();
private Site _editingSite = new();
private bool _serverDialogVisible;
private bool _siteDialogVisible;
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_servers = await db.HanaServers.OrderBy(s => s.Name).ToListAsync();
_sites = await db.Sites.Include(s => s.HanaServer).OrderBy(s => s.Land).ToListAsync();
}
private void AddServer()
{
_editingServer = new HanaServer { Port = 30015 };
_serverDialogVisible = true;
}
private void EditServer(HanaServer server)
{
_editingServer = new HanaServer
{
Id = server.Id,
Name = server.Name,
Host = server.Host,
Port = server.Port,
Username = server.Username,
Password = server.Password,
DatabaseName = server.DatabaseName,
UseSsl = server.UseSsl,
ValidateCertificate = server.ValidateCertificate,
AdditionalParams = server.AdditionalParams
};
_serverDialogVisible = true;
}
private async Task SaveServer()
{
using var db = await DbFactory.CreateDbContextAsync();
if (_editingServer.Id == 0)
{
db.HanaServers.Add(_editingServer);
}
else
{
var existing = await db.HanaServers.FindAsync(_editingServer.Id);
if (existing is not null)
{
existing.Name = _editingServer.Name;
existing.Host = _editingServer.Host;
existing.Port = _editingServer.Port;
existing.Username = _editingServer.Username;
existing.Password = _editingServer.Password;
existing.DatabaseName = _editingServer.DatabaseName;
existing.UseSsl = _editingServer.UseSsl;
existing.ValidateCertificate = _editingServer.ValidateCertificate;
existing.AdditionalParams = _editingServer.AdditionalParams;
}
}
await db.SaveChangesAsync();
_serverDialogVisible = false;
await LoadDataAsync();
Snackbar.Add("Server gespeichert", Severity.Success);
}
private async Task DeleteServer(HanaServer server)
{
var result = await DialogService.ShowMessageBox(
"Server löschen",
$"Server '{server.Name}' wirklich löschen?",
yesText: "Löschen", cancelText: "Abbrechen");
if (result != true) return;
using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.HanaServers.FindAsync(server.Id);
if (entity is not null)
{
db.HanaServers.Remove(entity);
await db.SaveChangesAsync();
}
await LoadDataAsync();
Snackbar.Add("Server gelöscht", Severity.Info);
}
private async Task TestServerConnection(HanaServer server)
{
var result = await Task.Run(() => HanaService.TestConnectionDetailed(server));
_connectionStatus[server.Id] = result;
if (result.Success)
{
Snackbar.Add($"Verbindung zu '{server.Name}' erfolgreich.", Severity.Success);
}
else
{
Snackbar.Add($"{server.Name}: {result.ExceptionType} - {result.ErrorMessage}", Severity.Error);
}
}
private static string BuildStatusTooltip(ConnectionTestResult status)
{
var stamp = status.TestedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss");
if (status.Success)
return $"Letzter Test: {stamp}\nStage: {status.Stage}\n{status.ConnectionStringPreview}";
return $"Letzter Test: {stamp}\nStage: {status.Stage}\nFehler: {status.ErrorMessage}\n{status.ConnectionStringPreview}";
}
private void AddSite()
{
_editingSite = new Site
{
IsActive = true,
SourceSystem = "SAP",
HanaServerId = _servers.FirstOrDefault()?.Id ?? 0
};
_siteDialogVisible = true;
}
private void EditSite(Site site)
{
_editingSite = new Site
{
Id = site.Id,
HanaServerId = site.HanaServerId,
Schema = site.Schema,
TSC = site.TSC,
Land = site.Land,
SourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem,
IsActive = site.IsActive
};
_siteDialogVisible = true;
}
private async Task SaveSite()
{
using var db = await DbFactory.CreateDbContextAsync();
if (_editingSite.Id == 0)
{
db.Sites.Add(_editingSite);
}
else
{
var existing = await db.Sites.FindAsync(_editingSite.Id);
if (existing is not null)
{
existing.HanaServerId = _editingSite.HanaServerId;
existing.Schema = _editingSite.Schema;
existing.TSC = _editingSite.TSC;
existing.Land = _editingSite.Land;
existing.SourceSystem = _editingSite.SourceSystem;
existing.IsActive = _editingSite.IsActive;
}
}
await db.SaveChangesAsync();
_siteDialogVisible = false;
await LoadDataAsync();
Snackbar.Add("Standort gespeichert", Severity.Success);
}
private async Task DeleteSite(Site site)
{
var result = await DialogService.ShowMessageBox(
"Standort löschen",
$"Standort '{site.Land}' wirklich löschen?",
yesText: "Löschen", cancelText: "Abbrechen");
if (result != true) return;
using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.Sites.FindAsync(site.Id);
if (entity is not null)
{
db.Sites.Remove(entity);
await db.SaveChangesAsync();
}
await LoadDataAsync();
Snackbar.Add("Standort gelöscht", Severity.Info);
}
}
@@ -0,0 +1,137 @@
@page "/transformations"
@using Microsoft.EntityFrameworkCore
@using System.Reflection
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Models
@inject IDbContextFactory<AppDbContext> DbFactory
@inject ISnackbar Snackbar
<PageTitle>Transformationen</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Transformer Ansicht</MudText>
<MudText Typo="Typo.body1" Class="mb-4">Definiere pro Quellsystem (SAP, BI1, SAGE) Feld-Remapping und Transformationen.</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudStack Row="true" Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="AddRule">
Regel hinzufügen
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAllAsync">
Alle speichern
</MudButton>
</MudStack>
<MudTable Items="_rules" Dense Hover Striped>
<HeaderContent>
<MudTh>Aktiv</MudTh>
<MudTh>System</MudTh>
<MudTh>Source</MudTh>
<MudTh>Target</MudTh>
<MudTh>Typ</MudTh>
<MudTh>Argument</MudTh>
<MudTh>Sort</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudCheckBox @bind-Value="context.IsActive" /></MudTd>
<MudTd>
<MudSelect T="string" Value="@context.SourceSystem" ValueChanged="@(v => context.SourceSystem = v)" Dense>
@foreach (var system in _systems)
{
<MudSelectItem Value="system">@system</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" Value="@context.SourceField" ValueChanged="@(v => context.SourceField = v)" Dense>
@foreach (var field in _recordFields)
{
<MudSelectItem Value="field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" Value="@context.TargetField" ValueChanged="@(v => context.TargetField = v)" Dense>
@foreach (var field in _recordFields)
{
<MudSelectItem Value="field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" Value="@context.TransformationType" ValueChanged="@(v => context.TransformationType = v)" Dense>
@foreach (var type in _types)
{
<MudSelectItem Value="type">@type</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudTextField Value="@context.Argument" ValueChanged="@(v => context.Argument = v)" Dense
HelperText="Replace: alt=>neu" />
</MudTd>
<MudTd>
<MudNumericField T="int" Value="@context.SortOrder" ValueChanged="@(v => context.SortOrder = v)" Dense />
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
OnClick="() => RemoveRule(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
@code {
private readonly string[] _systems = ["SAP", "BI1", "SAGE"];
private readonly string[] _types = ["Copy", "Uppercase", "Lowercase", "Prefix", "Suffix", "Replace", "Constant"];
private readonly string[] _recordFields = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name)
.OrderBy(n => n)
.ToArray();
private List<FieldTransformationRule> _rules = new();
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_rules = await db.FieldTransformationRules.OrderBy(r => r.SortOrder).ThenBy(r => r.Id).ToListAsync();
}
private void AddRule()
{
var nextSort = _rules.Count == 0 ? 10 : _rules.Max(r => r.SortOrder) + 10;
_rules.Add(new FieldTransformationRule
{
SourceSystem = "SAP",
SourceField = nameof(SalesRecord.Material),
TargetField = nameof(SalesRecord.Material),
TransformationType = "Copy",
SortOrder = nextSort,
IsActive = true
});
}
private void RemoveRule(FieldTransformationRule rule)
{
_rules.Remove(rule);
}
private async Task SaveAllAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
db.FieldTransformationRules.RemoveRange(db.FieldTransformationRules);
await db.SaveChangesAsync();
db.FieldTransformationRules.AddRange(_rules);
await db.SaveChangesAsync();
Snackbar.Add("Transformationsregeln gespeichert.", Severity.Success);
await LoadAsync();
}
}
@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@@ -0,0 +1,9 @@
@using System.Net.Http
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@using MudBlazor
@using TrafagSalesExporter.Components
@using TrafagSalesExporter.Components.Layout
@using TrafagSalesExporter.Models
+115
View File
@@ -0,0 +1,115 @@
using System.Data;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<HanaServer> HanaServers => Set<HanaServer>();
public DbSet<Site> Sites => Set<Site>();
public DbSet<SharePointConfig> SharePointConfigs => Set<SharePointConfig>();
public DbSet<ExportSettings> ExportSettings => Set<ExportSettings>();
public DbSet<ExportLog> ExportLogs => Set<ExportLog>();
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
/// <summary>
/// Fügt Spalten zu existierenden Tabellen hinzu, die bei neueren Versionen
/// hinzugekommen sind. EnsureCreated aktualisiert das Schema nicht automatisch.
/// </summary>
public static void EnsureSchema(AppDbContext db)
{
AddColumnIfMissing(db, "HanaServers", "DatabaseName", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "HanaServers", "UseSsl", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "HanaServers", "ValidateCertificate", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "HanaServers", "AdditionalParams", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SourceSystem", "TEXT NOT NULL DEFAULT 'SAP'");
EnsureTransformationTable(db);
}
private static void AddColumnIfMissing(AppDbContext db, string table, string column, string type)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) conn.Open();
bool exists = false;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"PRAGMA table_info({table})";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
if (string.Equals(reader["name"]?.ToString(), column, StringComparison.OrdinalIgnoreCase))
{
exists = true;
break;
}
}
}
if (!exists)
{
using var alter = conn.CreateCommand();
alter.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {type}";
alter.ExecuteNonQuery();
}
}
private static void EnsureTransformationTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS FieldTransformationRules (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SourceSystem TEXT NOT NULL DEFAULT 'SAP',
SourceField TEXT NOT NULL,
TargetField TEXT NOT NULL,
TransformationType TEXT NOT NULL,
Argument TEXT NOT NULL DEFAULT '',
SortOrder INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1
);";
cmd.ExecuteNonQuery();
}
public static void SeedIfEmpty(AppDbContext db)
{
if (db.HanaServers.Any()) return;
var serverInternal = new HanaServer { Name = "Internal", Host = "travtrp0", Port = 30015, Username = "", Password = "" };
var serverIndia = new HanaServer { Name = "India", Host = "20.197.20.60", Port = 30015, Username = "", Password = "" };
db.HanaServers.AddRange(serverInternal, serverIndia);
db.SaveChanges();
db.Sites.AddRange(
new Site { HanaServerId = serverInternal.Id, Schema = "fr01_p", TSC = "TRFR", Land = "Frankreich", IsActive = true },
new Site { HanaServerId = serverInternal.Id, Schema = "it01_p", TSC = "TRIT", Land = "Italien", IsActive = true },
new Site { HanaServerId = serverInternal.Id, Schema = "us01_p", TSC = "TRUS", Land = "USA", IsActive = true },
new Site { HanaServerId = serverIndia.Id, Schema = "TRAFAG_LIVE", TSC = "TRIN", Land = "Indien", IsActive = true }
);
db.SharePointConfigs.Add(new SharePointConfig
{
SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform",
ExportFolder = "/Shared Documents/Exports/",
TenantId = "",
ClientId = "",
ClientSecret = ""
});
db.ExportSettings.Add(new ExportSettings
{
DateFilter = "2025-01-01",
TimerHour = 3,
TimerMinute = 0,
TimerEnabled = true
});
db.SaveChanges();
}
}
+21
View File
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class ExportLog
{
public int Id { get; set; }
public DateTime Timestamp { get; set; }
public int SiteId { get; set; }
[ForeignKey(nameof(SiteId))]
public Site? Site { get; set; }
public string Land { get; set; } = string.Empty;
public string TSC { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public int RowCount { get; set; }
public string? ErrorMessage { get; set; }
public string FileName { get; set; } = string.Empty;
public double DurationSeconds { get; set; }
}
@@ -0,0 +1,10 @@
namespace TrafagSalesExporter.Models;
public class ExportSettings
{
public int Id { get; set; }
public string DateFilter { get; set; } = "2025-01-01";
public int TimerHour { get; set; } = 3;
public int TimerMinute { get; set; }
public bool TimerEnabled { get; set; } = true;
}
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace TrafagSalesExporter.Models;
public class FieldTransformationRule
{
public int Id { get; set; }
[Required]
public string SourceSystem { get; set; } = "SAP";
[Required]
public string SourceField { get; set; } = nameof(SalesRecord.Material);
[Required]
public string TargetField { get; set; } = nameof(SalesRecord.Material);
[Required]
public string TransformationType { get; set; } = "Copy";
public string Argument { get; set; } = string.Empty;
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
+84
View File
@@ -0,0 +1,84 @@
using System.ComponentModel.DataAnnotations;
namespace TrafagSalesExporter.Models;
public class HanaServer
{
public int Id { get; set; }
[Required]
public string Name { get; set; } = string.Empty;
[Required]
public string Host { get; set; } = string.Empty;
public int Port { get; set; } = 30015;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
/// <summary>
/// Name der Tenant-Datenbank bei Multi-Tenant Database Container (MDC) Setups.
/// Leer lassen, wenn direkt auf einen Tenant-Port verbunden wird.
/// </summary>
public string DatabaseName { get; set; } = string.Empty;
/// <summary>
/// SSL/TLS Verschlüsselung aktivieren (encrypt=true).
/// </summary>
public bool UseSsl { get; set; }
/// <summary>
/// SSL-Zertifikat validieren. Bei self-signed Zertifikaten auf false setzen.
/// </summary>
public bool ValidateCertificate { get; set; }
/// <summary>
/// Zusätzliche Verbindungsparameter (Semikolon-getrennt), z.B. "sslCryptoProvider=openssl".
/// </summary>
public string AdditionalParams { get; set; } = string.Empty;
public string BuildConnectionString()
{
var parts = new List<string>
{
$"ServerNode={Host}:{Port}",
$"UserName={Username}",
$"Password={Password}"
};
if (!string.IsNullOrWhiteSpace(DatabaseName))
parts.Add($"DatabaseName={DatabaseName}");
if (UseSsl)
{
parts.Add("encrypt=true");
parts.Add($"sslValidateCertificate={(ValidateCertificate ? "true" : "false")}");
}
if (!string.IsNullOrWhiteSpace(AdditionalParams))
parts.Add(AdditionalParams.Trim().Trim(';'));
return string.Join(";", parts);
}
public string GetConnectionStringPreview()
{
var pwdMasked = string.IsNullOrEmpty(Password) ? "" : "***";
var copy = new HanaServer
{
Host = Host,
Port = Port,
Username = Username,
Password = pwdMasked,
DatabaseName = DatabaseName,
UseSsl = UseSsl,
ValidateCertificate = ValidateCertificate,
AdditionalParams = AdditionalParams
};
return copy.BuildConnectionString();
}
}
+31
View File
@@ -0,0 +1,31 @@
namespace TrafagSalesExporter.Models;
public class SalesRecord
{
public DateTime ExtractionDate { get; set; }
public string Tsc { get; set; } = string.Empty;
public string InvoiceNumber { get; set; } = string.Empty;
public int PositionOnInvoice { get; set; }
public string Material { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string ProductGroup { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public string SupplierNumber { get; set; } = string.Empty;
public string SupplierName { get; set; } = string.Empty;
public string SupplierCountry { get; set; } = string.Empty;
public string CustomerNumber { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string CustomerCountry { get; set; } = string.Empty;
public string CustomerIndustry { get; set; } = string.Empty;
public decimal StandardCost { get; set; }
public string StandardCostCurrency { get; set; } = string.Empty;
public string PurchaseOrderNumber { get; set; } = string.Empty;
public decimal SalesPriceValue { get; set; }
public string SalesCurrency { get; set; } = string.Empty;
public string Incoterms2020 { get; set; } = string.Empty;
public string SalesResponsibleEmployee { get; set; } = string.Empty;
public DateTime? InvoiceDate { get; set; }
public DateTime? OrderDate { get; set; }
public string Land { get; set; } = string.Empty;
public string DocumentType { get; set; } = string.Empty;
}
@@ -0,0 +1,11 @@
namespace TrafagSalesExporter.Models;
public class SharePointConfig
{
public int Id { get; set; }
public string SiteUrl { get; set; } = string.Empty;
public string ExportFolder { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
}
+28
View File
@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class Site
{
public int Id { get; set; }
public int HanaServerId { get; set; }
[ForeignKey(nameof(HanaServerId))]
public HanaServer? HanaServer { get; set; }
[Required]
public string Schema { get; set; } = string.Empty;
[Required]
public string TSC { get; set; } = string.Empty;
[Required]
public string Land { get; set; } = string.Empty;
[Required]
public string SourceSystem { get; set; } = "SAP";
public bool IsActive { get; set; } = true;
}
+46
View File
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore;
using MudBlazor.Services;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddMudServices();
builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite("Data Source=trafag_exporter.db"));
builder.Services.AddSingleton<HanaQueryService>();
builder.Services.AddSingleton<ExcelExportService>();
builder.Services.AddSingleton<SharePointUploadService>();
builder.Services.AddSingleton<RecordTransformationService>();
builder.Services.AddSingleton<ExportOrchestrationService>();
builder.Services.AddSingleton<TimerBackgroundService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<TimerBackgroundService>());
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
using var db = await dbFactory.CreateDbContextAsync();
await db.Database.EnsureCreatedAsync();
AppDbContext.EnsureSchema(db);
AppDbContext.SeedIfEmpty(db);
}
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<TrafagSalesExporter.Components.App>()
.AddInteractiveServerRenderMode();
app.Run();
@@ -0,0 +1,89 @@
using ClosedXML.Excel;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class ExcelExportService
{
public string CreateExcelFile(string outputDirectory, string tsc, DateTime fileDate, List<SalesRecord> records)
{
Directory.CreateDirectory(outputDirectory);
var fileName = $"Sales_{tsc}_{fileDate:yyyy-MM-dd}.xlsx";
var fullPath = Path.Combine(outputDirectory, fileName);
using var workbook = new XLWorkbook();
var ws = workbook.Worksheets.Add("Sales");
var headers = new[]
{
"extraction date",
"TSC",
"Invoice Number",
"Position on invoice",
"Material",
"Name",
"Product Group",
"Quantity",
"Supplier number",
"Supplier name",
"Supplier country",
"Customer number",
"Customer name",
"Customer country",
"Customer Industry",
"Standard cost",
"Standard Cost Currency",
"Purchase Order number",
"Sales Price/Value",
"Sales Currency",
"Incoterms 2020",
"Sales responsible employee",
"invoice date",
"order date",
"Land",
"Document Type"
};
for (var i = 0; i < headers.Length; i++)
{
ws.Cell(1, i + 1).Value = headers[i];
ws.Cell(1, i + 1).Style.Font.Bold = true;
}
var row = 2;
foreach (var record in records)
{
ws.Cell(row, 1).Value = record.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss");
ws.Cell(row, 2).Value = record.Tsc;
ws.Cell(row, 3).Value = record.InvoiceNumber;
ws.Cell(row, 4).Value = record.PositionOnInvoice;
ws.Cell(row, 5).Value = record.Material;
ws.Cell(row, 6).Value = record.Name;
ws.Cell(row, 7).Value = record.ProductGroup;
ws.Cell(row, 8).Value = record.Quantity;
ws.Cell(row, 9).Value = record.SupplierNumber;
ws.Cell(row, 10).Value = record.SupplierName;
ws.Cell(row, 11).Value = record.SupplierCountry;
ws.Cell(row, 12).Value = record.CustomerNumber;
ws.Cell(row, 13).Value = record.CustomerName;
ws.Cell(row, 14).Value = record.CustomerCountry;
ws.Cell(row, 15).Value = record.CustomerIndustry;
ws.Cell(row, 16).Value = record.StandardCost;
ws.Cell(row, 17).Value = record.StandardCostCurrency;
ws.Cell(row, 18).Value = record.PurchaseOrderNumber;
ws.Cell(row, 19).Value = record.SalesPriceValue;
ws.Cell(row, 20).Value = record.SalesCurrency;
ws.Cell(row, 21).Value = record.Incoterms2020;
ws.Cell(row, 22).Value = record.SalesResponsibleEmployee;
ws.Cell(row, 23).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 24).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 25).Value = record.Land;
ws.Cell(row, 26).Value = record.DocumentType;
row++;
}
ws.Columns().AdjustToContents();
workbook.SaveAs(fullPath);
return fullPath;
}
}
@@ -0,0 +1,171 @@
using Microsoft.EntityFrameworkCore;
using System.Diagnostics;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class ExportOrchestrationService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly HanaQueryService _hanaService;
private readonly ExcelExportService _excelService;
private readonly SharePointUploadService _sharePointService;
private readonly RecordTransformationService _transformationService;
private readonly ILogger<ExportOrchestrationService> _logger;
public event Action? OnExportStatusChanged;
private readonly Dictionary<int, string> _runningExports = new();
private readonly object _lock = new();
public ExportOrchestrationService(
IDbContextFactory<AppDbContext> dbFactory,
HanaQueryService hanaService,
ExcelExportService excelService,
SharePointUploadService sharePointService,
RecordTransformationService transformationService,
ILogger<ExportOrchestrationService> logger)
{
_dbFactory = dbFactory;
_hanaService = hanaService;
_excelService = excelService;
_sharePointService = sharePointService;
_transformationService = transformationService;
_logger = logger;
}
public bool IsExporting(int siteId)
{
lock (_lock)
{
return _runningExports.ContainsKey(siteId);
}
}
public string GetExportStatus(int siteId)
{
lock (_lock)
{
return _runningExports.TryGetValue(siteId, out var status) ? status : string.Empty;
}
}
public async Task ExportAllAsync()
{
using var db = await _dbFactory.CreateDbContextAsync();
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
foreach (var site in sites)
{
await ExportSiteAsync(site);
}
}
public async Task ExportSiteByIdAsync(int siteId)
{
using var db = await _dbFactory.CreateDbContextAsync();
var site = await db.Sites.Include(s => s.HanaServer).FirstOrDefaultAsync(s => s.Id == siteId);
if (site is null) return;
await ExportSiteAsync(site);
}
private async Task ExportSiteAsync(Site site)
{
if (site.HanaServer is null) return;
lock (_lock)
{
if (_runningExports.ContainsKey(site.Id)) return;
_runningExports[site.Id] = "HANA Abfrage...";
}
NotifyChanged();
var sw = Stopwatch.StartNew();
var log = new ExportLog
{
Timestamp = DateTime.Now,
SiteId = site.Id,
Land = site.Land,
TSC = site.TSC
};
try
{
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
UpdateStatus(site.Id, "HANA Abfrage...");
var records = await Task.Run(() => _hanaService.GetSalesRecords(
site.HanaServer, site.Schema, site.TSC, site.Land, settings.DateFilter));
UpdateStatus(site.Id, "Transformationen anwenden...");
var rules = await db.FieldTransformationRules
.Where(r => r.IsActive && r.SourceSystem == (string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem))
.OrderBy(r => r.SortOrder)
.ToListAsync();
_transformationService.Apply(records, rules);
UpdateStatus(site.Id, "Excel erstellen...");
var outputDir = Path.Combine(AppContext.BaseDirectory, "output");
var filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
var fileName = Path.GetFileName(filePath);
if (spConfig is not null &&
!string.IsNullOrWhiteSpace(spConfig.TenantId) &&
!string.IsNullOrWhiteSpace(spConfig.ClientId) &&
!string.IsNullOrWhiteSpace(spConfig.ClientSecret))
{
UpdateStatus(site.Id, "SharePoint Upload...");
await _sharePointService.UploadAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, spConfig.ExportFolder, site.Land, filePath);
}
sw.Stop();
log.Status = "OK";
log.RowCount = records.Count;
log.FileName = fileName;
log.DurationSeconds = sw.Elapsed.TotalSeconds;
_logger.LogInformation("Export OK: {Land} ({TSC}) - {Rows} Zeilen in {Duration:F1}s",
site.Land, site.TSC, records.Count, sw.Elapsed.TotalSeconds);
}
catch (Exception ex)
{
sw.Stop();
log.Status = "Error";
log.ErrorMessage = ex.Message;
log.FileName = string.Empty;
log.DurationSeconds = sw.Elapsed.TotalSeconds;
_logger.LogError(ex, "Export Fehler: {Land} ({TSC})", site.Land, site.TSC);
}
finally
{
using var db = await _dbFactory.CreateDbContextAsync();
db.ExportLogs.Add(log);
await db.SaveChangesAsync();
lock (_lock)
{
_runningExports.Remove(site.Id);
}
NotifyChanged();
}
}
private void UpdateStatus(int siteId, string status)
{
lock (_lock)
{
_runningExports[siteId] = status;
}
NotifyChanged();
}
private void NotifyChanged()
{
OnExportStatusChanged?.Invoke();
}
}
@@ -0,0 +1,217 @@
using Sap.Data.Hana;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class HanaQueryService
{
public List<SalesRecord> GetSalesRecords(HanaServer server,
string schema, string tsc, string land, string dateFilter)
{
var connectionString = server.BuildConnectionString();
var result = new List<SalesRecord>();
using var connection = new HanaConnection(connectionString);
connection.Open();
var invoiceQuery = GetInvoiceQuery(schema, tsc, dateFilter);
var creditNoteQuery = GetCreditNoteQuery(schema, tsc, dateFilter);
result.AddRange(ReadRecords(connection, invoiceQuery, land));
result.AddRange(ReadRecords(connection, creditNoteQuery, land));
foreach (var record in result)
{
if (record.Material.Contains('/'))
{
var parts = record.Material.Split('/');
record.Material = parts[^1];
}
}
return result;
}
public ConnectionTestResult TestConnectionDetailed(HanaServer server)
{
var testResult = new ConnectionTestResult
{
TestedAtUtc = DateTime.UtcNow,
ConnectionStringPreview = server.GetConnectionStringPreview(),
Stage = "Verbindungsaufbau"
};
try
{
var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString);
connection.Open();
testResult.Stage = "Ping-Query";
using var command = new HanaCommand("SELECT 1 FROM DUMMY", connection);
command.ExecuteScalar();
testResult.Success = true;
testResult.Stage = "OK";
return testResult;
}
catch (Exception ex)
{
testResult.Success = false;
testResult.ErrorMessage = ex.Message;
testResult.ExceptionType = ex.GetType().Name;
return testResult;
}
}
public void TestConnection(HanaServer server)
{
var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString);
connection.Open();
}
private static List<SalesRecord> ReadRecords(HanaConnection connection, string query, string land)
{
var records = new List<SalesRecord>();
using var command = new HanaCommand(query, connection);
using var reader = command.ExecuteReader();
while (reader.Read())
{
records.Add(new SalesRecord
{
ExtractionDate = reader.GetDateTime(reader.GetOrdinal("extraction_date")),
Tsc = reader.GetString(reader.GetOrdinal("tsc")),
InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty,
PositionOnInvoice = Convert.ToInt32(reader["invoice_position"]),
InvoiceDate = reader.IsDBNull(reader.GetOrdinal("invoice_date")) ? null : reader.GetDateTime(reader.GetOrdinal("invoice_date")),
Material = reader["material"]?.ToString() ?? string.Empty,
Name = reader["material_name"]?.ToString() ?? string.Empty,
ProductGroup = reader["product_group"]?.ToString() ?? string.Empty,
Quantity = Convert.ToDecimal(reader["quantity"]),
SupplierNumber = reader["supplier_number"]?.ToString() ?? string.Empty,
SupplierName = reader["supplier_name"]?.ToString() ?? string.Empty,
SupplierCountry = reader["supplier_country"]?.ToString() ?? string.Empty,
CustomerNumber = reader["customer_number"]?.ToString() ?? string.Empty,
CustomerName = reader["customer_name"]?.ToString() ?? string.Empty,
CustomerCountry = reader["customer_country"]?.ToString() ?? string.Empty,
CustomerIndustry = reader["customer_industry"]?.ToString() ?? string.Empty,
StandardCost = Convert.ToDecimal(reader["standard_cost"]),
StandardCostCurrency = reader["standard_cost_currency"]?.ToString() ?? string.Empty,
PurchaseOrderNumber = reader["purchase_order_number"]?.ToString() ?? string.Empty,
SalesPriceValue = Convert.ToDecimal(reader["sales_value"]),
SalesCurrency = reader["sales_currency"]?.ToString() ?? string.Empty,
Incoterms2020 = reader["incoterms_2020"]?.ToString() ?? string.Empty,
SalesResponsibleEmployee = reader["sales_responsible"]?.ToString() ?? string.Empty,
OrderDate = reader.IsDBNull(reader.GetOrdinal("order_date")) ? null : reader.GetDateTime(reader.GetOrdinal("order_date")),
Land = land,
DocumentType = reader["doc_type"]?.ToString() ?? string.Empty
});
}
return records;
}
private static string GetInvoiceQuery(string schema, string tsc, string dateFilter) => $@"
SELECT
CURRENT_TIMESTAMP AS extraction_date,
'{tsc}' AS tsc,
h.""DocNum"" AS invoice_number,
p.""LineNum"" AS invoice_position,
h.""DocDate"" AS invoice_date,
p.""ItemCode"" AS material,
p.""Dscription"" AS material_name,
COALESCE(grp.""ItmsGrpNam"", '') AS product_group,
p.""Quantity"" AS quantity,
COALESCE(itm.""CardCode"", '') AS supplier_number,
COALESCE(sup.""CardName"", '') AS supplier_name,
COALESCE(sup_adr.""Country"", '') AS supplier_country,
h.""CardCode"" AS customer_number,
h.""CardName"" AS customer_name,
COALESCE(cust_adr.""Country"", '') AS customer_country,
COALESCE(ind.""IndName"", '') AS customer_industry,
p.""StockPrice"" AS standard_cost,
COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency,
CASE WHEN p.""BaseType"" = 22
THEN CAST(p.""BaseRef"" AS NVARCHAR(20))
ELSE '' END AS purchase_order_number,
p.""LineTotal"" AS sales_value,
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency,
'' AS incoterms_2020,
COALESCE(emp.""SlpName"", '') AS sales_responsible,
CASE WHEN p.""BaseType"" = 17
THEN (SELECT o.""DocDate"" FROM {schema}.""ORDR"" o
WHERE o.""DocEntry"" = p.""BaseEntry"")
ELSE NULL END AS order_date,
'INV' AS doc_type
FROM {schema}.""OINV"" h
INNER JOIN {schema}.""INV1"" p ON h.""DocEntry"" = p.""DocEntry""
LEFT JOIN {schema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
LEFT JOIN {schema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
LEFT JOIN {schema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
LEFT JOIN {schema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode""
LEFT JOIN {schema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
AND sup.""CardType"" = 'S'
LEFT JOIN {schema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode""
AND sup_adr.""AdresType"" = 'B'
LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}'
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
private static string GetCreditNoteQuery(string schema, string tsc, string dateFilter) => $@"
SELECT
CURRENT_TIMESTAMP AS extraction_date,
'{tsc}' AS tsc,
h.""DocNum"" AS invoice_number,
p.""LineNum"" AS invoice_position,
h.""DocDate"" AS invoice_date,
p.""ItemCode"" AS material,
p.""Dscription"" AS material_name,
COALESCE(grp.""ItmsGrpNam"", '') AS product_group,
p.""Quantity"" * -1 AS quantity,
COALESCE(itm.""CardCode"", '') AS supplier_number,
COALESCE(sup.""CardName"", '') AS supplier_name,
COALESCE(sup_adr.""Country"", '') AS supplier_country,
h.""CardCode"" AS customer_number,
h.""CardName"" AS customer_name,
COALESCE(cust_adr.""Country"", '') AS customer_country,
COALESCE(ind.""IndName"", '') AS customer_industry,
p.""StockPrice"" AS standard_cost,
COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency,
'' AS purchase_order_number,
p.""LineTotal"" * -1 AS sales_value,
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency,
'' AS incoterms_2020,
COALESCE(emp.""SlpName"", '') AS sales_responsible,
NULL AS order_date,
'CRN' AS doc_type
FROM {schema}.""ORIN"" h
INNER JOIN {schema}.""RIN1"" p ON h.""DocEntry"" = p.""DocEntry""
LEFT JOIN {schema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
LEFT JOIN {schema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
LEFT JOIN {schema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
LEFT JOIN {schema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode""
LEFT JOIN {schema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
AND sup.""CardType"" = 'S'
LEFT JOIN {schema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode""
AND sup_adr.""AdresType"" = 'B'
LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}'
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
}
public class ConnectionTestResult
{
public bool Success { get; set; }
public DateTime TestedAtUtc { get; set; }
public string Stage { get; set; } = string.Empty;
public string ErrorMessage { get; set; } = string.Empty;
public string ExceptionType { get; set; } = string.Empty;
public string ConnectionStringPreview { get; set; } = string.Empty;
}
@@ -0,0 +1,92 @@
using System.Reflection;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class RecordTransformationService
{
private static readonly Dictionary<string, PropertyInfo> PropertyMap = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.ToDictionary(p => p.Name, p => p, StringComparer.OrdinalIgnoreCase);
public void Apply(List<SalesRecord> records, IEnumerable<FieldTransformationRule> rules)
{
var orderedRules = rules.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToList();
if (orderedRules.Count == 0 || records.Count == 0) return;
foreach (var record in records)
{
foreach (var rule in orderedRules)
{
ApplyRule(record, rule);
}
}
}
private static void ApplyRule(SalesRecord record, FieldTransformationRule rule)
{
if (!PropertyMap.TryGetValue(rule.SourceField, out var sourceProp)) return;
if (!PropertyMap.TryGetValue(rule.TargetField, out var targetProp)) return;
var sourceValue = sourceProp.GetValue(record);
object? result = rule.TransformationType switch
{
"Copy" => sourceValue,
"Uppercase" => sourceValue?.ToString()?.ToUpperInvariant(),
"Lowercase" => sourceValue?.ToString()?.ToLowerInvariant(),
"Prefix" => $"{rule.Argument}{sourceValue}",
"Suffix" => $"{sourceValue}{rule.Argument}",
"Replace" => ApplyReplace(sourceValue?.ToString(), rule.Argument),
"Constant" => rule.Argument,
_ => sourceValue
};
SetPropertyValue(record, targetProp, result);
}
private static string ApplyReplace(string? input, string? argument)
{
if (string.IsNullOrEmpty(input)) return string.Empty;
if (string.IsNullOrWhiteSpace(argument)) return input;
var parts = argument.Split("=>", 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2) return input;
return input.Replace(parts[0], parts[1], StringComparison.OrdinalIgnoreCase);
}
private static void SetPropertyValue(SalesRecord record, PropertyInfo property, object? value)
{
try
{
if (property.PropertyType == typeof(string))
{
property.SetValue(record, value?.ToString() ?? string.Empty);
return;
}
if (property.PropertyType == typeof(int))
{
if (int.TryParse(value?.ToString(), out var parsedInt)) property.SetValue(record, parsedInt);
return;
}
if (property.PropertyType == typeof(decimal))
{
if (decimal.TryParse(value?.ToString(), out var parsedDecimal)) property.SetValue(record, parsedDecimal);
return;
}
if (property.PropertyType == typeof(DateTime?) || property.PropertyType == typeof(DateTime))
{
if (DateTime.TryParse(value?.ToString(), out var parsedDate)) property.SetValue(record, parsedDate);
return;
}
property.SetValue(record, value);
}
catch
{
// skip invalid conversion to keep export running
}
}
}
@@ -0,0 +1,45 @@
using Azure.Identity;
using Microsoft.Graph;
namespace TrafagSalesExporter.Services;
public class SharePointUploadService
{
public async Task UploadAsync(string tenantId, string clientId, string clientSecret,
string siteUrl, string exportFolder, string land, string localFilePath)
{
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
var uri = new Uri(siteUrl);
var sitePath = uri.AbsolutePath;
var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
if (site?.Id is null)
throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden.");
var drive = await graphClient.Sites[site.Id].Drive.GetAsync();
if (drive?.Id is null)
throw new InvalidOperationException("SharePoint Dokumentenbibliothek konnte nicht gefunden werden.");
var fileName = Path.GetFileName(localFilePath);
var folderPath = exportFolder.Trim('/').Trim();
var remotePath = $"{folderPath}/{land}/{fileName}";
await using var stream = File.OpenRead(localFilePath);
await graphClient.Drives[drive.Id].Root.ItemWithPath(remotePath).Content.PutAsync(stream);
}
public async Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl)
{
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
var uri = new Uri(siteUrl);
var sitePath = uri.AbsolutePath;
var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
if (site?.Id is null)
throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden.");
}
}
@@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
namespace TrafagSalesExporter.Services;
public class TimerBackgroundService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<TimerBackgroundService> _logger;
private DateTime _nextRun = DateTime.MaxValue;
public DateTime NextRun => _nextRun;
public TimerBackgroundService(IServiceProvider serviceProvider, ILogger<TimerBackgroundService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
public void Recalculate()
{
_ = RecalculateNextRunAsync();
}
private async Task RecalculateNextRunAsync()
{
var dbFactory = _serviceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
using var db = await dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync();
if (settings is null || !settings.TimerEnabled)
{
_nextRun = DateTime.MaxValue;
return;
}
var now = DateTime.Now;
var todayRun = new DateTime(now.Year, now.Month, now.Day, settings.TimerHour, settings.TimerMinute, 0);
_nextRun = todayRun <= now ? todayRun.AddDays(1) : todayRun;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await RecalculateNextRunAsync();
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
if (DateTime.Now < _nextRun) continue;
_logger.LogInformation("Timer-Export gestartet um {Time}", DateTime.Now);
try
{
var orchestrator = _serviceProvider.GetRequiredService<ExportOrchestrationService>();
await orchestrator.ExportAllAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Fehler beim Timer-Export");
}
await RecalculateNextRunAsync();
}
}
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!--
Pfad zur SAP HANA Client DLL (wird mit dem SAP HANA Client installiert).
Standard-Pfad nach Installation: C:\Program Files\sap\hdbclient\dotnetcore\v2.1\
Kann bei Bedarf via MSBuild-Property überschrieben werden:
dotnet build /p:HanaClientDll="D:\pfad\zu\Sap.Data.Hana.Core.v2.1.dll"
-->
<HanaClientDll Condition="'$(HanaClientDll)' == ''">C:\Program Files\sap\hdbclient\dotnetcore\v2.1\Sap.Data.Hana.Core.v2.1.dll</HanaClientDll>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.104.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Graph" Version="5.80.0" />
<PackageReference Include="Azure.Identity" Version="1.13.1" />
<PackageReference Include="MudBlazor" Version="7.15.0" />
</ItemGroup>
<ItemGroup>
<Reference Include="Sap.Data.Hana.Core.v2.1">
<HintPath>$(HanaClientDll)</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
<Target Name="CheckHanaClient" BeforeTargets="ResolveAssemblyReferences">
<Warning Condition="!Exists('$(HanaClientDll)')"
Text="SAP HANA Client DLL nicht gefunden: $(HanaClientDll). Bitte SAP HANA Client installieren (https://tools.hana.ondemand.com) oder MSBuild-Property 'HanaClientDll' setzen." />
</Target>
</Project>
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+3
View File
@@ -0,0 +1,3 @@
html, body {
font-family: 'Roboto', sans-serif;
}
+26
View File
@@ -0,0 +1,26 @@
# Configuration (contains secrets)
config.php
# Cache files
weather_cache.json
active_viewers.json
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Vendor (if using composer)
# vendor/
# Uploads (user content)
uploads/
+269 -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,
'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,180 @@ 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') !== true;
}
// Auto-Screenshot Helper
public function isAutoScreenshotEnabled() {
return $this->get('auto_screenshot.enabled') === true;
}
public function getAutoScreenshotInterval() {
return $this->get('auto_screenshot.interval_minutes') ?? 10;
}
public function getAutoScreenshotMaxImages() {
return $this->get('auto_screenshot.max_images') ?? 144;
}
// Sharing Helper
public function isEmailSharingEnabled() {
return $this->get('sharing.email_enabled') === true;
}
public function getShareLinkExpiryHours() {
return $this->get('sharing.share_link_expiry_hours') ?? 24;
}
// SEO Helper
public function getCustomTitle() {
$title = $this->get('seo.custom_title');
return !empty($title) ? $title : null;
}
public function getMetaDescription() {
return $this->get('seo.meta_description') ?? '';
}
public function getMetaKeywords() {
return $this->get('seo.meta_keywords') ?? '';
}
// Weather Helper
public function isWeatherEnabled() {
return $this->get('weather.enabled') === true;
}
public function getWeatherLocation() {
return $this->get('weather.location') ?? 'Oberdürnten,CH';
}
public function getWeatherCoords() {
return [
'lat' => $this->get('weather.lat') ?? '47.2833',
'lon' => $this->get('weather.lon') ?? '8.7167'
];
}
public function getWeatherUpdateInterval() {
return $this->get('weather.update_interval') ?? 5;
}
public function getWeatherUnits() {
return $this->get('weather.units') ?? 'metric';
}
// SaaS Feature Helper
public function isMultiTenantEnabled() {
return $this->get('saas_features.multi_tenant_enabled') === true;
}
public function isSelfRegistrationEnabled() {
return $this->get('saas_features.self_registration_enabled') === true;
}
public function isBillingEnabled() {
return $this->get('saas_features.billing_enabled') === true;
}
public function isStripeEnabled() {
return $this->get('saas_features.stripe_enabled') === true;
}
public function isTenantDashboardEnabled() {
return $this->get('saas_features.tenant_dashboard_enabled') === true;
}
public function isAnalyticsEnabled() {
return $this->get('saas_features.analytics_enabled') === true;
}
public function isCustomDomainEnabled() {
return $this->get('saas_features.custom_domain_enabled') === true;
}
public function isCustomBrandingEnabled() {
return $this->get('saas_features.custom_branding_enabled') === true;
}
public function isLandingPageEnabled() {
return $this->get('saas_features.landing_page_enabled') === true;
}
public function getTrialDays() {
return $this->get('saas_features.trial_days') ?? 14;
}
public function getDefaultMaxViewers() {
return $this->get('saas_features.default_max_viewers') ?? 50;
}
} }
+286
View File
@@ -0,0 +1,286 @@
<?php
/**
* WeatherManager - Holt und cached Wetterdaten von Open-Meteo (kostenlos!)
* Keine API Key nötig!
*/
class WeatherManager {
private $settingsManager;
private $cacheFile = 'weather_cache.json';
private $cacheTime = 300; // 5 Minuten in Sekunden
public function __construct($settingsManager) {
$this->settingsManager = $settingsManager;
}
/**
* Holt aktuelle Wetterdaten (cached)
*/
public function getCurrentWeather() {
// Prüfe ob Weather aktiviert ist
if (!$this->settingsManager->isWeatherEnabled()) {
return null;
}
// Prüfe Cache
$cached = $this->getCache();
if ($cached !== null) {
return $cached;
}
$coords = $this->settingsManager->getWeatherCoords();
$apiKey = trim($this->settingsManager->getWeatherApiKey());
$weather = $apiKey !== ''
? $this->fetchOpenWeather($coords, $apiKey)
: $this->fetchOpenMeteo($coords);
if (isset($weather['error'])) {
return $weather;
}
// Cache speichern
$this->saveCache($weather);
return $weather;
}
private function fetchOpenMeteo($coords) {
// Open-Meteo API URL - komplett kostenlos, kein API Key!
$url = "https://api.open-meteo.com/v1/forecast?" . http_build_query([
'latitude' => $coords['lat'],
'longitude' => $coords['lon'],
'current' => 'temperature_2m,relative_humidity_2m,precipitation,weather_code,wind_speed_10m,wind_direction_10m,pressure_msl,cloud_cover',
'timezone' => 'Europe/Zurich'
]);
$response = $this->fetchUrl($url);
if ($response === null) {
return ['error' => 'API Fehler'];
}
$data = json_decode($response, true);
if (!$data || !isset($data['current'])) {
return ['error' => 'Ungültige API Antwort'];
}
$current = $data['current'];
return [
'temp' => round($current['temperature_2m'], 1),
'feels_like' => round($current['temperature_2m'], 1), // Open-Meteo hat keine "feels like"
'humidity' => $current['relative_humidity_2m'],
'pressure' => round($current['pressure_msl'], 0),
'wind_speed' => round($current['wind_speed_10m'], 1), // Schon in km/h!
'wind_deg' => $current['wind_direction_10m'],
'wind_direction' => $this->getWindDirection($current['wind_direction_10m']),
'clouds' => $current['cloud_cover'] ?? 0,
'description' => $this->getWeatherDescription($current['weather_code']),
'icon' => $this->getWeatherIcon($current['weather_code']),
'rain_1h' => $current['precipitation'] ?? 0,
'snow_1h' => 0, // Open-Meteo gibt Niederschlag gesamt
'location' => $this->settingsManager->getWeatherLocation(),
'timestamp' => time()
];
}
private function fetchOpenWeather($coords, $apiKey) {
$units = $this->settingsManager->getWeatherUnits();
$url = "https://api.openweathermap.org/data/2.5/weather?" . http_build_query([
'lat' => $coords['lat'],
'lon' => $coords['lon'],
'appid' => $apiKey,
'units' => $units,
'lang' => 'de'
]);
$response = $this->fetchUrl($url);
if ($response === null) {
return ['error' => 'API Fehler'];
}
$data = json_decode($response, true);
if (!$data || !isset($data['main'], $data['weather'][0], $data['wind'])) {
return ['error' => 'Ungültige API Antwort'];
}
$windSpeed = $data['wind']['speed'];
if ($units === 'metric') {
$windSpeed = $windSpeed * 3.6; // m/s -> km/h
}
return [
'temp' => round($data['main']['temp'], 1),
'feels_like' => round($data['main']['feels_like'], 1),
'humidity' => $data['main']['humidity'],
'pressure' => round($data['main']['pressure'], 0),
'wind_speed' => round($windSpeed, 1),
'wind_deg' => $data['wind']['deg'] ?? 0,
'wind_direction' => $this->getWindDirection($data['wind']['deg'] ?? 0),
'clouds' => $data['clouds']['all'] ?? 0,
'description' => ucfirst($data['weather'][0]['description']),
'icon' => $data['weather'][0]['icon'] ?? '01d',
'rain_1h' => $data['rain']['1h'] ?? 0,
'snow_1h' => $data['snow']['1h'] ?? 0,
'location' => $data['name'] ?? $this->settingsManager->getWeatherLocation(),
'timestamp' => time()
];
}
private function fetchUrl($url) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || !$response) {
return null;
}
return $response;
}
/**
* Wandelt WMO Weather Code in Beschreibung um
* https://open-meteo.com/en/docs
*/
private function getWeatherDescription($code) {
$descriptions = [
0 => 'Klar',
1 => 'Überwiegend klar',
2 => 'Teilweise bewölkt',
3 => 'Bewölkt',
45 => 'Neblig',
48 => 'Nebel mit Reifablagerung',
51 => 'Leichter Nieselregen',
53 => 'Mäßiger Nieselregen',
55 => 'Dichter Nieselregen',
61 => 'Leichter Regen',
63 => 'Mäßiger Regen',
65 => 'Starker Regen',
71 => 'Leichter Schneefall',
73 => 'Mäßiger Schneefall',
75 => 'Starker Schneefall',
77 => 'Schneegraupeln',
80 => 'Leichte Regenschauer',
81 => 'Mäßige Regenschauer',
82 => 'Starke Regenschauer',
85 => 'Leichte Schneeschauer',
86 => 'Starke Schneeschauer',
95 => 'Gewitter',
96 => 'Gewitter mit leichtem Hagel',
99 => 'Gewitter mit starkem Hagel'
];
return $descriptions[$code] ?? 'Unbekannt';
}
/**
* Wandelt WMO Weather Code in Icon-Code um (OpenWeatherMap kompatibel)
*/
private function getWeatherIcon($code) {
if ($code == 0) return '01d'; // Klar
if ($code >= 1 && $code <= 2) return '02d'; // Teilweise bewölkt
if ($code == 3) return '04d'; // Bewölkt
if ($code >= 45 && $code <= 48) return '50d'; // Nebel
if ($code >= 51 && $code <= 55) return '09d'; // Nieselregen
if ($code >= 61 && $code <= 65) return '10d'; // Regen
if ($code >= 71 && $code <= 77) return '13d'; // Schnee
if ($code >= 80 && $code <= 82) return '09d'; // Regenschauer
if ($code >= 85 && $code <= 86) return '13d'; // Schneeschauer
if ($code >= 95 && $code <= 99) return '11d'; // Gewitter
return '01d'; // Default
}
/**
* Wandelt Windrichtung (Grad) in Kompassrichtung um
*/
private function getWindDirection($deg) {
$directions = ['N', 'NNO', 'NO', 'ONO', 'O', 'OSO', 'SO', 'SSO', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
$index = round($deg / 22.5) % 16;
return $directions[$index];
}
/**
* Holt Daten aus Cache (wenn noch gültig)
*/
private function getCache() {
if (!file_exists($this->cacheFile)) {
return null;
}
$content = file_get_contents($this->cacheFile);
$data = json_decode($content, true);
if (!$data || !isset($data['timestamp'])) {
return null;
}
// Fehler nicht aus Cache zurückgeben (z.B. alter "API Key fehlt" Error)
if (isset($data['error'])) {
@unlink($this->cacheFile); // Cache mit Fehler löschen
return null;
}
// Update-Intervall aus Settings holen (in Minuten)
$updateInterval = $this->settingsManager->getWeatherUpdateInterval() * 60; // Minuten -> Sekunden
// Prüfe ob Cache noch gültig
if (time() - $data['timestamp'] < $updateInterval) {
return $data;
}
return null;
}
/**
* Speichert Daten im Cache (nur wenn kein Fehler)
*/
private function saveCache($data) {
// Fehler nicht cachen
if (isset($data['error'])) {
return;
}
$json = json_encode($data, JSON_PRETTY_PRINT);
file_put_contents($this->cacheFile, $json, LOCK_EX);
}
/**
* Gibt Wetter-Icon-Emoji zurück
*/
public function getWeatherEmoji($iconCode) {
$map = [
'01d' => '☀️', '01n' => '🌙',
'02d' => '⛅', '02n' => '☁️',
'03d' => '☁️', '03n' => '☁️',
'04d' => '☁️', '04n' => '☁️',
'09d' => '🌧️', '09n' => '🌧️',
'10d' => '🌦️', '10n' => '🌧️',
'11d' => '⛈️', '11n' => '⛈️',
'13d' => '❄️', '13n' => '❄️',
'50d' => '🌫️', '50n' => '🌫️'
];
return $map[$iconCode] ?? '🌤️';
}
/**
* AJAX Handler für Wetter-Updates
*/
public function handleAjax() {
if ($_SERVER['REQUEST_METHOD'] !== 'GET') return;
if (!isset($_GET['weather_action'])) return;
header('Content-Type: application/json');
if ($_GET['weather_action'] === 'get') {
$weather = $this->getCurrentWeather();
echo json_encode(['success' => true, 'data' => $weather]);
exit;
}
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
/**
* Auto-Screenshot API
*
* Kann als Cron-Job aufgerufen werden:
* */10 * * * * curl -s http://localhost/api/auto-screenshot.php?key=YOUR_SECRET_KEY
*
* Oder via Webhook/Timer
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
header('Content-Type: application/json');
$settingsManager = new SettingsManager();
// Prüfe ob Feature aktiviert
if (!$settingsManager->isAutoScreenshotEnabled()) {
echo json_encode(['success' => false, 'error' => 'Auto-Screenshot deaktiviert']);
exit;
}
// Optionale API-Key Validierung
$configFile = dirname(__DIR__) . '/config.php';
if (file_exists($configFile)) {
$config = require $configFile;
$apiKey = $config['auto_screenshot_key'] ?? '';
if (!empty($apiKey) && ($_GET['key'] ?? '') !== $apiKey) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Ungültiger API-Key']);
exit;
}
}
// Galerie-Verzeichnis erstellen
$galleryDir = dirname(__DIR__) . '/gallery/auto/';
if (!is_dir($galleryDir)) {
mkdir($galleryDir, 0755, true);
}
// Screenshot-Dateiname
$filename = 'auto_' . date('Y-m-d_H-i-s') . '.jpg';
$filepath = $galleryDir . $filename;
// Video-Stream URL
$streamUrl = 'test_video.m3u8';
$logoPath = dirname(__DIR__) . '/logo.png';
// FFmpeg-Befehl zum Erstellen des Screenshots
$command = sprintf(
'ffmpeg -i %s -vframes 1 -q:v 2 %s 2>&1',
escapeshellarg($streamUrl),
escapeshellarg($filepath)
);
exec($command, $output, $returnVar);
if ($returnVar !== 0 || !file_exists($filepath)) {
echo json_encode([
'success' => false,
'error' => 'Screenshot fehlgeschlagen',
'command' => $command,
'output' => implode("\n", $output)
]);
exit;
}
// Alte Screenshots aufräumen (max. Anzahl einhalten)
$maxImages = $settingsManager->getAutoScreenshotMaxImages();
$existingFiles = glob($galleryDir . 'auto_*.jpg');
rsort($existingFiles); // Neueste zuerst
if (count($existingFiles) > $maxImages) {
$filesToDelete = array_slice($existingFiles, $maxImages);
foreach ($filesToDelete as $file) {
@unlink($file);
}
}
// Metadaten speichern
$metaFile = $galleryDir . 'metadata.json';
$metadata = [];
if (file_exists($metaFile)) {
$metadata = json_decode(file_get_contents($metaFile), true) ?? [];
}
$metadata[$filename] = [
'created_at' => date('Y-m-d H:i:s'),
'timestamp' => time(),
'size' => filesize($filepath)
];
// Nur die letzten maxImages behalten
$metadata = array_slice($metadata, -$maxImages, null, true);
file_put_contents($metaFile, json_encode($metadata, JSON_PRETTY_PRINT));
echo json_encode([
'success' => true,
'file' => $filename,
'path' => '/gallery/auto/' . $filename,
'total_images' => count(glob($galleryDir . 'auto_*.jpg'))
]);
+97
View File
@@ -0,0 +1,97 @@
<?php
/**
* Gallery API
*
* GET /api/gallery.php - Liste alle Galerie-Bilder
* GET /api/gallery.php?date=2024-01-30 - Bilder eines bestimmten Datums
* GET /api/gallery.php?from=2024-01-01&to=2024-01-31 - Bilder in einem Zeitraum
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
$settingsManager = new SettingsManager();
$galleryDir = dirname(__DIR__) . '/gallery/auto/';
// Prüfe ob Galerie existiert
if (!is_dir($galleryDir)) {
echo json_encode(['success' => true, 'images' => [], 'total' => 0]);
exit;
}
// Parameter
$date = $_GET['date'] ?? null;
$from = $_GET['from'] ?? null;
$to = $_GET['to'] ?? null;
$limit = min(100, (int)($_GET['limit'] ?? 50));
$offset = max(0, (int)($_GET['offset'] ?? 0));
// Alle Bilder holen
$allFiles = glob($galleryDir . 'auto_*.jpg');
rsort($allFiles); // Neueste zuerst
$images = [];
foreach ($allFiles as $file) {
$filename = basename($file);
// Extrahiere Datum aus Dateinamen: auto_2024-01-30_14-30-00.jpg
if (preg_match('/auto_(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})\.jpg/', $filename, $matches)) {
$fileDate = $matches[1];
$fileTime = str_replace('-', ':', $matches[2]);
// Datumsfilter
if ($date !== null && $fileDate !== $date) {
continue;
}
if ($from !== null && $fileDate < $from) {
continue;
}
if ($to !== null && $fileDate > $to) {
continue;
}
$images[] = [
'filename' => $filename,
'path' => '/gallery/auto/' . $filename,
'date' => $fileDate,
'time' => $fileTime,
'datetime' => $fileDate . ' ' . $fileTime,
'timestamp' => strtotime($fileDate . ' ' . $fileTime),
'size' => filesize($file)
];
}
}
$total = count($images);
// Pagination
$images = array_slice($images, $offset, $limit);
// Verfügbare Daten (für Kalender/Filter)
$availableDates = [];
foreach (glob($galleryDir . 'auto_*.jpg') as $file) {
if (preg_match('/auto_(\d{4}-\d{2}-\d{2})/', basename($file), $m)) {
$availableDates[$m[1]] = ($availableDates[$m[1]] ?? 0) + 1;
}
}
krsort($availableDates);
echo json_encode([
'success' => true,
'images' => $images,
'total' => $total,
'offset' => $offset,
'limit' => $limit,
'available_dates' => $availableDates,
'filters' => [
'date' => $date,
'from' => $from,
'to' => $to
]
]);
+315
View File
@@ -0,0 +1,315 @@
<?php
/**
* Share API - Teilen von Bildern/Videos per E-Mail
*
* POST /api/share.php
* Body: { email: "friend@example.com", type: "video|image", path: "/videos/...", message: "Schau dir das an!" }
*/
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
$settingsManager = new SettingsManager();
// Prüfe ob Feature aktiviert
if (!$settingsManager->isEmailSharingEnabled()) {
echo json_encode(['success' => false, 'error' => 'E-Mail-Sharing ist deaktiviert']);
exit;
}
// Config laden
$configFile = dirname(__DIR__) . '/config.php';
$config = file_exists($configFile) ? require $configFile : [];
$mailConfig = $config['mail'] ?? [];
if (empty($mailConfig['host']) || empty($mailConfig['username'])) {
echo json_encode(['success' => false, 'error' => 'E-Mail-Server nicht konfiguriert']);
exit;
}
// === GET: Share-Link generieren ===
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['generate'])) {
$path = $_GET['path'] ?? '';
$type = $_GET['type'] ?? 'video';
if (empty($path)) {
echo json_encode(['success' => false, 'error' => 'Kein Pfad angegeben']);
exit;
}
// Token generieren
$expiryHours = $settingsManager->getShareLinkExpiryHours();
$expiry = time() + ($expiryHours * 3600);
$token = hash_hmac('sha256', $path . $expiry, session_id() . 'share_secret');
// Share-Link speichern
$shareDir = dirname(__DIR__) . '/data/shares/';
if (!is_dir($shareDir)) {
mkdir($shareDir, 0755, true);
}
$shareId = bin2hex(random_bytes(16));
$shareData = [
'id' => $shareId,
'path' => $path,
'type' => $type,
'token' => $token,
'expiry' => $expiry,
'created_at' => date('Y-m-d H:i:s')
];
file_put_contents($shareDir . $shareId . '.json', json_encode($shareData));
// URL generieren
$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST'];
$shareUrl = $baseUrl . '/api/share.php?view=' . $shareId;
echo json_encode([
'success' => true,
'share_url' => $shareUrl,
'share_id' => $shareId,
'expires_at' => date('Y-m-d H:i:s', $expiry)
]);
exit;
}
// === GET: Share-Link anzeigen ===
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['view'])) {
$shareId = preg_replace('/[^a-f0-9]/', '', $_GET['view']);
$shareFile = dirname(__DIR__) . '/data/shares/' . $shareId . '.json';
if (!file_exists($shareFile)) {
header('Content-Type: text/html; charset=utf-8');
echo '<!DOCTYPE html><html><head><title>Link ungültig</title></head><body style="font-family:sans-serif;text-align:center;padding:50px;"><h1>❌ Link nicht gefunden</h1><p>Dieser Share-Link existiert nicht oder wurde bereits gelöscht.</p></body></html>';
exit;
}
$shareData = json_decode(file_get_contents($shareFile), true);
// Ablauf prüfen
if (time() > $shareData['expiry']) {
@unlink($shareFile);
header('Content-Type: text/html; charset=utf-8');
echo '<!DOCTYPE html><html><head><title>Link abgelaufen</title></head><body style="font-family:sans-serif;text-align:center;padding:50px;"><h1>⏰ Link abgelaufen</h1><p>Dieser Share-Link ist abgelaufen. Bitte fordere einen neuen Link an.</p></body></html>';
exit;
}
// Datei existiert?
$filePath = dirname(__DIR__) . $shareData['path'];
if (!file_exists($filePath)) {
header('Content-Type: text/html; charset=utf-8');
echo '<!DOCTYPE html><html><head><title>Datei nicht gefunden</title></head><body style="font-family:sans-serif;text-align:center;padding:50px;"><h1>📭 Datei nicht gefunden</h1><p>Die geteilte Datei existiert nicht mehr.</p></body></html>';
exit;
}
// Redirect zur Datei oder HTML-Seite mit eingebettetem Player
$isVideo = in_array(pathinfo($filePath, PATHINFO_EXTENSION), ['mp4', 'webm', 'mov']);
$isImage = in_array(pathinfo($filePath, PATHINFO_EXTENSION), ['jpg', 'jpeg', 'png', 'gif', 'webp']);
$siteName = $config['app']['name'] ?? 'Aurora Livecam';
$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST'];
header('Content-Type: text/html; charset=utf-8');
echo '<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Geteilte ' . ($isVideo ? 'Video' : 'Bild') . ' - ' . htmlspecialchars($siteName) . '</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 16px;
padding: 30px;
max-width: 900px;
width: 100%;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
h1 { font-size: 1.5rem; margin-bottom: 20px; color: #333; }
video, img {
width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 8px;
background: #000;
}
.download-btn {
display: inline-block;
margin-top: 20px;
padding: 12px 30px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
}
.download-btn:hover { opacity: 0.9; }
.footer {
margin-top: 20px;
color: rgba(255,255,255,0.8);
font-size: 0.9rem;
}
.footer a { color: white; }
</style>
</head>
<body>
<div class="container">
<h1>📤 Geteilte' . ($isVideo ? 's Video' : 's Bild') . '</h1>';
if ($isVideo) {
echo '<video controls autoplay><source src="' . htmlspecialchars($shareData['path']) . '" type="video/mp4">Ihr Browser unterstützt kein Video.</video>';
} else {
echo '<img src="' . htmlspecialchars($shareData['path']) . '" alt="Geteiltes Bild">';
}
echo '
<a href="' . htmlspecialchars($shareData['path']) . '" download class="download-btn">⬇️ Herunterladen</a>
</div>
<div class="footer">
Geteilt von <a href="' . $baseUrl . '">' . htmlspecialchars($siteName) . '</a>
</div>
</body>
</html>';
exit;
}
// === POST: E-Mail senden ===
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Nur POST erlaubt']);
exit;
}
// JSON-Body parsen
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
$input = $_POST;
}
$email = filter_var($input['email'] ?? '', FILTER_VALIDATE_EMAIL);
$path = $input['path'] ?? '';
$type = $input['type'] ?? 'video';
$message = htmlspecialchars($input['message'] ?? '');
$senderName = htmlspecialchars($input['sender_name'] ?? 'Ein Freund');
if (!$email) {
echo json_encode(['success' => false, 'error' => 'Ungültige E-Mail-Adresse']);
exit;
}
if (empty($path)) {
echo json_encode(['success' => false, 'error' => 'Kein Pfad angegeben']);
exit;
}
// Share-Link generieren
$expiryHours = $settingsManager->getShareLinkExpiryHours();
$expiry = time() + ($expiryHours * 3600);
$shareDir = dirname(__DIR__) . '/data/shares/';
if (!is_dir($shareDir)) {
mkdir($shareDir, 0755, true);
}
$shareId = bin2hex(random_bytes(16));
$shareData = [
'id' => $shareId,
'path' => $path,
'type' => $type,
'expiry' => $expiry,
'created_at' => date('Y-m-d H:i:s'),
'shared_to' => $email
];
file_put_contents($shareDir . $shareId . '.json', json_encode($shareData));
$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST'];
$shareUrl = $baseUrl . '/api/share.php?view=' . $shareId;
$siteName = $config['app']['name'] ?? 'Aurora Livecam';
// E-Mail senden
try {
$mail = new PHPMailer(true);
// SMTP Konfiguration
$mail->isSMTP();
$mail->Host = $mailConfig['host'];
$mail->SMTPAuth = true;
$mail->Username = $mailConfig['username'];
$mail->Password = $mailConfig['password'];
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = $mailConfig['port'] ?? 587;
$mail->CharSet = 'UTF-8';
// Absender/Empfänger
$mail->setFrom($mailConfig['from_address'], $mailConfig['from_name'] ?? $siteName);
$mail->addAddress($email);
// Inhalt
$mail->isHTML(true);
$mail->Subject = $senderName . ' hat ' . ($type === 'video' ? 'ein Video' : 'ein Bild') . ' mit dir geteilt';
$mail->Body = '
<div style="font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 12px 12px 0 0; text-align: center;">
<h1 style="color: white; margin: 0; font-size: 24px;">📤 ' . htmlspecialchars($siteName) . '</h1>
</div>
<div style="background: #f7f7f7; padding: 30px; border-radius: 0 0 12px 12px;">
<p style="font-size: 18px; color: #333; margin-bottom: 20px;">
<strong>' . htmlspecialchars($senderName) . '</strong> hat ' . ($type === 'video' ? 'ein Video' : 'ein Bild') . ' mit dir geteilt!
</p>
' . (!empty($message) ? '<div style="background: white; padding: 15px; border-radius: 8px; border-left: 4px solid #667eea; margin-bottom: 20px;"><em>"' . nl2br($message) . '"</em></div>' : '') . '
<a href="' . htmlspecialchars($shareUrl) . '" style="display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px 30px; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 16px;">
▶️ Jetzt ansehen
</a>
<p style="margin-top: 20px; color: #888; font-size: 12px;">
Dieser Link ist ' . $expiryHours . ' Stunden gültig.
</p>
</div>
</div>';
$mail->AltBody = $senderName . ' hat ' . ($type === 'video' ? 'ein Video' : 'ein Bild') . ' mit dir geteilt: ' . $shareUrl;
$mail->send();
echo json_encode([
'success' => true,
'message' => 'E-Mail wurde gesendet',
'share_url' => $shareUrl
]);
} catch (Exception $e) {
error_log('Share email error: ' . $e->getMessage());
echo json_encode([
'success' => false,
'error' => 'E-Mail konnte nicht gesendet werden',
'share_url' => $shareUrl // URL trotzdem zurückgeben
]);
}
+56
View File
@@ -0,0 +1,56 @@
<?php
/**
* Stripe Webhook Endpoint
*
* URL: /api/stripe-webhook.php
* Konfigurieren Sie diesen Endpoint in Ihrem Stripe Dashboard
*/
// Keine Session, keine Ausgabe vor JSON
error_reporting(0);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/vendor/autoload.php';
if (file_exists(dirname(__DIR__) . '/src/bootstrap.php')) {
require_once dirname(__DIR__) . '/src/bootstrap.php';
}
use AuroraLivecam\Billing\WebhookHandler;
// Nur POST erlaubt
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
// Payload lesen
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
if (empty($payload)) {
http_response_code(400);
echo json_encode(['error' => 'Empty payload']);
exit;
}
// Webhook verarbeiten
try {
$handler = new WebhookHandler();
$result = $handler->handle($payload, $signature);
if ($result['success']) {
http_response_code(200);
} else {
http_response_code(400);
}
header('Content-Type: application/json');
echo json_encode($result);
} catch (\Exception $e) {
error_log('Stripe Webhook Error: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => 'Internal server error']);
}
+192
View File
@@ -0,0 +1,192 @@
<?php
/**
* Video Search API
*
* Suche nach Videos nach Datum und Uhrzeit
*
* GET /api/video-search.php?date=2024-01-30
* GET /api/video-search.php?date=2024-01-30&time=14:30
* GET /api/video-search.php?from=2024-01-01&to=2024-01-31
* GET /api/video-search.php?time_from=08:00&time_to=18:00
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
$settingsManager = new SettingsManager();
$videoDir = dirname(__DIR__) . '/videos/';
$aiDir = dirname(__DIR__) . '/ai/';
// Parameter
$date = $_GET['date'] ?? null; // Format: YYYY-MM-DD
$time = $_GET['time'] ?? null; // Format: HH:MM
$fromDate = $_GET['from'] ?? null;
$toDate = $_GET['to'] ?? null;
$timeFrom = $_GET['time_from'] ?? null;
$timeTo = $_GET['time_to'] ?? null;
$type = $_GET['type'] ?? 'all'; // all, daily, ai
$aiCategory = $_GET['ai_category'] ?? null;
$limit = min(100, (int)($_GET['limit'] ?? 50));
$results = [
'daily_videos' => [],
'ai_videos' => [],
'gallery_images' => []
];
// AI-Kategorien
$aiCategories = ['sunny', 'rainy', 'snowy', 'planes', 'birds', 'sunset', 'sunrise', 'rainbow'];
// === TAGESVIDEOS SUCHEN ===
if ($type === 'all' || $type === 'daily') {
$pattern = $videoDir . 'daily_video_*.mp4';
$dailyVideos = glob($pattern);
foreach ($dailyVideos as $video) {
$filename = basename($video);
// Extrahiere Datum aus Dateinamen: daily_video_YYYYMMDD_HHMMSS.mp4
if (preg_match('/daily_video_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})\.mp4/', $filename, $matches)) {
$videoDate = $matches[1] . '-' . $matches[2] . '-' . $matches[3];
$videoTime = $matches[4] . ':' . $matches[5];
$videoDateTime = $videoDate . ' ' . $videoTime . ':' . $matches[6];
// Datumsfilter
if ($date !== null && $videoDate !== $date) {
continue;
}
if ($fromDate !== null && $videoDate < $fromDate) {
continue;
}
if ($toDate !== null && $videoDate > $toDate) {
continue;
}
// Uhrzeitfilter
if ($timeFrom !== null && $videoTime < $timeFrom) {
continue;
}
if ($timeTo !== null && $videoTime > $timeTo) {
continue;
}
// Spezifische Uhrzeit (mit 30 Min Toleranz)
if ($time !== null) {
$searchMinutes = intval(substr($time, 0, 2)) * 60 + intval(substr($time, 3, 2));
$videoMinutes = intval($matches[4]) * 60 + intval($matches[5]);
if (abs($searchMinutes - $videoMinutes) > 30) {
continue;
}
}
$results['daily_videos'][] = [
'type' => 'daily',
'filename' => $filename,
'path' => '/videos/' . $filename,
'date' => $videoDate,
'time' => $videoTime,
'datetime' => $videoDateTime,
'timestamp' => strtotime($videoDateTime),
'size' => filesize($video),
'size_mb' => round(filesize($video) / (1024 * 1024), 2)
];
}
}
}
// === AI-VIDEOS SUCHEN ===
if ($type === 'all' || $type === 'ai') {
$searchCategories = $aiCategory ? [$aiCategory] : $aiCategories;
foreach ($searchCategories as $category) {
$categoryDir = $aiDir . $category . '/';
if (!is_dir($categoryDir)) continue;
$pattern = $categoryDir . $category . '_*.mp4';
$aiVideos = glob($pattern);
foreach ($aiVideos as $video) {
$filename = basename($video);
// Extrahiere Datum aus Dateinamen: category_YYYYMMDD_HHMMSS.mp4
if (preg_match('/' . $category . '_(\d{4})(\d{2})(\d{2})_?(\d{2})?(\d{2})?(\d{2})?\.mp4/', $filename, $matches)) {
$videoDate = $matches[1] . '-' . $matches[2] . '-' . $matches[3];
$videoTime = isset($matches[4]) ? ($matches[4] . ':' . ($matches[5] ?? '00')) : '00:00';
$videoDateTime = $videoDate . ' ' . $videoTime;
// Datumsfilter
if ($date !== null && $videoDate !== $date) {
continue;
}
if ($fromDate !== null && $videoDate < $fromDate) {
continue;
}
if ($toDate !== null && $videoDate > $toDate) {
continue;
}
// Uhrzeitfilter
if ($timeFrom !== null && $videoTime < $timeFrom) {
continue;
}
if ($timeTo !== null && $videoTime > $timeTo) {
continue;
}
$results['ai_videos'][] = [
'type' => 'ai',
'category' => $category,
'filename' => $filename,
'path' => '/ai/' . $category . '/' . $filename,
'date' => $videoDate,
'time' => $videoTime,
'datetime' => $videoDateTime,
'timestamp' => strtotime($videoDateTime),
'size' => filesize($video),
'size_mb' => round(filesize($video) / (1024 * 1024), 2)
];
}
}
}
}
// Sortieren nach Datum/Zeit (neueste zuerst)
usort($results['daily_videos'], fn($a, $b) => $b['timestamp'] - $a['timestamp']);
usort($results['ai_videos'], fn($a, $b) => $b['timestamp'] - $a['timestamp']);
// Limit anwenden
$results['daily_videos'] = array_slice($results['daily_videos'], 0, $limit);
$results['ai_videos'] = array_slice($results['ai_videos'], 0, $limit);
// Statistiken
$results['stats'] = [
'total_daily' => count($results['daily_videos']),
'total_ai' => count($results['ai_videos']),
'total' => count($results['daily_videos']) + count($results['ai_videos'])
];
$results['filters'] = [
'date' => $date,
'time' => $time,
'from' => $fromDate,
'to' => $toDate,
'time_from' => $timeFrom,
'time_to' => $timeTo,
'type' => $type,
'ai_category' => $aiCategory
];
$results['success'] = true;
echo json_encode($results, JSON_PRETTY_PRINT);
+15
View File
@@ -0,0 +1,15 @@
<?php
// Clear PHP OPcache
if (function_exists('opcache_reset')) {
opcache_reset();
echo "OPcache cleared successfully!\n";
} else {
echo "OPcache not available\n";
}
// Clear realpath cache
clearstatcache(true);
echo "Realpath cache cleared!\n";
echo "\nNow reload the page with CTRL+F5 (hard refresh)\n";
?>
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* Aurora Livecam - Konfigurationsdatei
*
* Kopiere diese Datei zu config.php und passe die Werte an.
* WICHTIG: config.php niemals in Git committen!
*/
return [
// Datenbank-Konfiguration
'database' => [
'host' => 'localhost',
'port' => 3306,
'database' => 'aurora_livecam',
'username' => 'root',
'password' => '',
'charset' => 'utf8mb4',
],
// Anwendungs-Einstellungen
'app' => [
'name' => 'Aurora Livecam',
'url' => 'https://aurora-weather-livecam.com',
'debug' => false,
'timezone' => 'Europe/Zurich',
],
// Multi-Tenant Einstellungen
'tenant' => [
'default_subdomain_suffix' => '.aurora-livecam.com',
'allow_custom_domains' => true,
'trial_days' => 14,
],
// Stripe (für Billing)
'stripe' => [
'public_key' => '',
'secret_key' => '',
'webhook_secret' => '',
'currency' => 'chf',
],
// E-Mail Einstellungen (für Onboarding)
'mail' => [
'host' => 'smtp.example.com',
'port' => 587,
'username' => '',
'password' => '',
'from_address' => 'noreply@aurora-livecam.com',
'from_name' => 'Aurora Livecam',
],
// Sicherheit
'security' => [
'session_lifetime' => 7200, // 2 Stunden
'remember_me_days' => 30,
'password_min_length' => 8,
],
];
+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;
+1786 -96
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+721
View File
@@ -0,0 +1,721 @@
<?php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
require __DIR__ . '/vendor/autoload.php';
require_once 'SettingsManager.php';
// SettingsManager initialisieren
$settingsManager = new SettingsManager();
// AJAX-Handler für Settings (MUSS ganz am Anfang sein!)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['settings_action'])) {
header('Content-Type: application/json');
switch ($_POST['settings_action']) {
case 'get':
echo json_encode(['success' => true, 'settings' => $settingsManager->get()]);
exit;
case 'update':
$key = $_POST['key'] ?? null;
$value = $_POST['value'] ?? null;
if ($value === 'true') $value = true;
if ($value === 'false') $value = false;
if (is_numeric($value)) $value = intval($value);
if ($key && $settingsManager->set($key, $value)) {
echo json_encode(['success' => true, 'message' => 'Gespeichert']);
} else {
echo json_encode(['success' => false, 'message' => 'Fehler']);
}
exit;
}
}
if (isset($_GET['download_video'])) {
$videoDir = './videos/';
$latestVideo = null;
$latestTime = 0;
foreach (glob($videoDir . '*.mp4') as $video) {
$mtime = filemtime($video);
if ($mtime > $latestTime) { $latestTime = $mtime; $latestVideo = $video; }
}
if ($latestVideo) {
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="'.basename($latestVideo).'"');
header('Content-Length: ' . filesize($latestVideo));
readfile($latestVideo);
exit;
}
echo "Kein Video gefunden.";
exit;
}
$oldDomains = ['www.aurora-wetter-lifecam.ch', 'www.aurora-wetter-livecam.ch'];
$newDomain = 'www.aurora-weather-livecam.com';
if (in_array($_SERVER['HTTP_HOST'] ?? '', $oldDomains)) {
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
header("HTTP/1.1 301 Moved Permanently");
header("Location: " . $protocol . '://' . $newDomain . $_SERVER['REQUEST_URI']);
exit;
}
session_start();
error_reporting(E_ALL);
ini_set('display_errors', 0);
$imageDir = "./image";
$imageFiles = glob("$imageDir/screenshot_*.jpg");
if ($imageFiles) rsort($imageFiles);
$imageFilesJson = json_encode($imageFiles ?: []);
class ViewerCounter {
private $file = 'active_viewers.json';
private $timeout = 30;
public function handleHeartbeat() {
$ip = md5($_SERVER['REMOTE_ADDR'] . ($_SERVER['HTTP_USER_AGENT'] ?? ''));
$now = time();
$viewers = file_exists($this->file) ? json_decode(file_get_contents($this->file), true) ?? [] : [];
$viewers[$ip] = $now;
$active = [];
foreach ($viewers as $u => $t) { if ($now - $t < $this->timeout) $active[$u] = $t; }
file_put_contents($this->file, json_encode($active));
header('Content-Type: application/json');
echo json_encode(['count' => count($active)]);
exit;
}
public function getInitialCount() {
if (file_exists($this->file)) {
return max(1, count(json_decode(file_get_contents($this->file), true) ?? []));
}
return 1;
}
}
$viewerCounter = new ViewerCounter();
class WebcamManager {
private $videoSrc = 'test_video.m3u8';
public function displayWebcam() {
return '<video id="webcam-player" autoplay muted playsinline></video>';
}
public function displayStreamStats() {
return '<div class="info-badge tech-stat" id="bitrate-display" style="display:none;">
<i class="fas fa-tachometer-alt"></i> <span id="bitrate-value">0.00</span> MBit/s
</div>';
}
public function getImageFiles() {
$f = glob("image/screenshot_*.jpg");
if ($f) rsort($f);
return json_encode($f ?: []);
}
public function getJavaScript() {
return "
document.addEventListener('DOMContentLoaded', function () {
var video = document.getElementById('webcam-player');
var videoSrc = '{$this->videoSrc}';
if(video && typeof Hls !== 'undefined' && Hls.isSupported()) {
var hls = new Hls();
hls.loadSource(videoSrc);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function () { video.play().catch(()=>{}); });
} else if (video) {
video.src = videoSrc;
video.play().catch(()=>{});
}
});";
}
}
class VisualCalendarManager {
private $videoDir, $settingsManager;
private $months = [1=>'Jan',2=>'Feb',3=>'Mär',4=>'Apr',5=>'Mai',6=>'Jun',7=>'Jul',8=>'Aug',9=>'Sep',10=>'Okt',11=>'Nov',12=>'Dez'];
public function __construct($videoDir = './videos/', $sm = null) {
$this->videoDir = $videoDir;
$this->settingsManager = $sm;
}
public function hasVideosForDate($y, $m, $d) {
return count(glob($this->videoDir . sprintf("daily_video_%04d%02d%02d_*.mp4", $y, $m, $d))) > 0;
}
public function getVideosForDate($y, $m, $d) {
$vids = [];
foreach (glob($this->videoDir . sprintf("daily_video_%04d%02d%02d_*.mp4", $y, $m, $d)) as $v) {
$vids[] = ['path' => $v, 'name' => basename($v), 'size' => filesize($v), 'time' => date('H:i', filemtime($v))];
}
return $vids;
}
public function displayVisualCalendar() {
$cy = isset($_GET['cal_year']) ? intval($_GET['cal_year']) : date('Y');
$cm = isset($_GET['cal_month']) ? intval($_GET['cal_month']) : date('n');
$sd = isset($_GET['cal_day']) ? intval($_GET['cal_day']) : null;
$pip = $this->settingsManager ? $this->settingsManager->get('video_mode.play_in_player') : true;
$dl = $this->settingsManager ? $this->settingsManager->get('video_mode.allow_download') : true;
$o = '<div class="calendar-box">';
$o .= '<div class="cal-nav"><button onclick="chgM('.$cy.','.($cm-1).')">&laquo;</button><span>'.$this->months[$cm].' '.$cy.'</span><button onclick="chgM('.$cy.','.($cm+1).')">&raquo;</button></div>';
$o .= '<div class="cal-grid">';
foreach(['Mo','Di','Mi','Do','Fr','Sa','So'] as $wd) $o .= '<div class="cal-hd">'.$wd.'</div>';
$fd = mktime(0,0,0,$cm,1,$cy);
$dim = date('t', $fd);
$dow = date('N', $fd) - 1;
for ($i=0; $i<$dow; $i++) $o .= '<div class="cal-day empty"></div>';
for ($d=1; $d<=$dim; $d++) {
$hv = $this->hasVideosForDate($cy,$cm,$d);
$sel = $sd==$d;
$td = ($cy==date('Y') && $cm==date('n') && $d==date('j'));
$cls = 'cal-day' . ($hv?' has-vid':'') . ($sel?' sel':'') . ($td?' today':'');
$o .= '<div class="'.$cls.'" onclick="selD('.$cy.','.$cm.','.$d.')"><span>'.$d.'</span>'.($hv?'<small>📹</small>':'').'</div>';
}
$o .= '</div>';
if ($sd) {
$vids = $this->getVideosForDate($cy,$cm,$sd);
$o .= '<div class="day-vids"><h4>📅 '.sprintf('%02d.%02d.%04d',$sd,$cm,$cy).'</h4>';
if ($vids) {
$o .= '<ul>';
foreach ($vids as $v) {
$sz = round($v['size']/1024/1024,1);
$tk = hash_hmac('sha256', $v['path'], session_id());
$o .= '<li><span>🕐 '.$v['time'].'</span><span>'.$sz.' MB</span><span class="vid-btns">';
if ($pip) $o .= '<a href="#" onclick="playVid(\''.htmlspecialchars($v['path']).'\');return false;" class="btn-play">▶️</a>';
if ($dl) $o .= '<a href="?download_specific_video='.urlencode($v['path']).'&token='.$tk.'" class="btn-dl">⬇️</a>';
$o .= '</span></li>';
}
$o .= '</ul>';
} else {
$o .= '<p>Keine Videos.</p>';
}
$o .= '</div>';
}
$o .= '</div>';
return $o;
}
}
class GuestbookManager {
private $entries = [], $file = 'guestbook.json';
public function __construct() { if (file_exists($this->file)) $this->entries = json_decode(file_get_contents($this->file), true) ?? []; }
public function handleFormSubmission() {
if (isset($_POST['guestbook'],$_POST['guest-name'],$_POST['guest-message'])) {
$this->entries[] = ['name'=>htmlspecialchars($_POST['guest-name']),'message'=>htmlspecialchars($_POST['guest-message']),'date'=>date('Y-m-d H:i:s')];
file_put_contents($this->file, json_encode($this->entries));
}
}
public function deleteEntry($i) { if (isset($this->entries[$i])) { unset($this->entries[$i]); $this->entries = array_values($this->entries); file_put_contents($this->file, json_encode($this->entries)); return true; } return false; }
public function displayForm() { return '<form method="post"><input type="hidden" name="guestbook" value="1"><label>Name:</label><input name="guest-name" required><label>Nachricht:</label><textarea name="guest-message" required></textarea><button type="submit">Senden</button></form>'; }
public function displayEntries($admin=false) {
$o = '<div class="gb-entries">';
foreach ($this->entries as $i=>$e) {
$o .= '<div class="gb-entry"><h4>'.$e['name'].'</h4><p>'.$e['message'].'</p><small>'.$e['date'].'</small>';
if ($admin) $o .= '<form method="post" style="display:inline"><input type="hidden" name="action" value="delete_guestbook"><input type="hidden" name="delete_entry" value="'.$i.'"><button class="del-btn">X</button></form>';
$o .= '</div>';
}
return $o.'</div>';
}
}
class ContactManager {
private $file = 'feedbacks.json';
public function displayForm() { return '<form method="post" id="contact-form"><input type="hidden" name="contact" value="1"><label>Name:</label><input name="name" required><label>E-Mail:</label><input type="email" name="email" required><label>Nachricht:</label><textarea name="message" required></textarea><button type="submit">Senden</button></form><div id="contact-fb"></div>'; }
public function handleSubmission($n,$e,$m) {
if (!$n||!$e||!$m) return ['success'=>false,'message'=>'Alle Felder ausfüllen'];
$fb = ['name'=>htmlspecialchars($n),'email'=>filter_var($e,FILTER_SANITIZE_EMAIL),'message'=>htmlspecialchars($m),'date'=>date('Y-m-d H:i:s'),'ip'=>$_SERVER['REMOTE_ADDR']??''];
$all = file_exists($this->file) ? json_decode(file_get_contents($this->file),true) : [];
$all[] = $fb;
file_put_contents($this->file, json_encode($all, JSON_PRETTY_PRINT));
return ['success'=>true,'message'=>'Nachricht gesendet!'];
}
public function deleteFeedback($i) { $all = json_decode(file_get_contents($this->file),true); if (isset($all[$i])) { unset($all[$i]); file_put_contents($this->file, json_encode(array_values($all),JSON_PRETTY_PRINT)); return true; } return false; }
}
class AdminManager {
public function isAdmin() { return isset($_SESSION['admin']) && $_SESSION['admin'] === true; }
public function handleLogin($u,$p) { if ($u==='admin' && $p==='sonne4000$$$$Q') { $_SESSION['admin']=true; return true; } return false; }
public function displayLoginForm() { return '<form method="post"><input type="hidden" name="admin-login" value="1"><label>User:</label><input name="username" required><label>Pass:</label><input type="password" name="password" required><button type="submit">Login</button></form>'; }
public function displayAdminContent() {
global $settingsManager;
$o = '<div class="admin-panel">';
$o .= '<h3>⚙️ Einstellungen</h3>';
$o .= '<div class="setting"><label>Zuschauer anzeigen</label><input type="checkbox" id="s-viewer" '.($settingsManager->get('viewer_display.enabled')?'checked':'').'></div>';
$o .= '<div class="setting"><label>Mindestanzahl</label><input type="number" id="s-min" value="'.$settingsManager->get('viewer_display.min_viewers').'" min="1" max="100"></div>';
$o .= '<div class="setting"><label>Im Player abspielen</label><input type="checkbox" id="s-play" '.($settingsManager->get('video_mode.play_in_player')?'checked':'').'></div>';
$o .= '<div class="setting"><label>Download erlauben</label><input type="checkbox" id="s-dl" '.($settingsManager->get('video_mode.allow_download')?'checked':'').'></div>';
$o .= '</div>';
$o .= '<div class="admin-panel"><h3>📩 Nachrichten</h3>';
$msgs = file_exists('feedbacks.json') ? json_decode(file_get_contents('feedbacks.json'),true) : [];
foreach ($msgs as $i=>$m) {
$o .= '<div class="msg"><strong>'.$m['name'].'</strong> ('.$m['email'].')<p>'.$m['message'].'</p><small>'.$m['date'].'</small>';
$o .= '<form method="post" style="display:inline"><input type="hidden" name="action" value="delete_feedback"><input type="hidden" name="delete_index" value="'.$i.'"><button class="del-btn">X</button></form></div>';
}
if (!$msgs) $o .= '<p>Keine Nachrichten.</p>';
$o .= '</div>';
return $o;
}
public function displayGalleryImages() {
$o = '<div class="gallery">';
foreach (glob("uploads/*.{jpg,jpeg,png,gif}",GLOB_BRACE) as $f) $o .= '<img src="'.$f.'" onclick="openImg(this.src)">';
return $o.'</div>';
}
}
class VideoArchiveManager {
private $dir;
public function __construct($d='./videos/') { $this->dir = $d; }
public function handleSpecificVideoDownload() {
if (isset($_GET['download_specific_video'],$_GET['token'])) {
$p = $_GET['download_specific_video'];
if (!hash_equals(hash_hmac('sha256',$p,session_id()), $_GET['token'])) { echo "Invalid"; exit; }
$rp = realpath($p);
$rd = realpath($this->dir);
if ($rp && strpos($rp,$rd)===0 && file_exists($rp)) {
header('Content-Type: video/mp4');
header('Content-Disposition: attachment; filename="'.basename($rp).'"');
header('Content-Length: '.filesize($rp));
readfile($rp);
exit;
}
echo "Not found"; exit;
}
}
}
$webcamManager = new WebcamManager();
$guestbookManager = new GuestbookManager();
$contactManager = new ContactManager();
$adminManager = new AdminManager();
$videoArchiveManager = new VideoArchiveManager('./videos/');
$videoArchiveManager->handleSpecificVideoDownload();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['action']) && $_POST['action'] === 'viewer_heartbeat') $viewerCounter->handleHeartbeat();
if (isset($_POST['guestbook'])) { $guestbookManager->handleFormSubmission(); header("Location: ".$_SERVER['PHP_SELF']."#guestbook"); exit; }
if (isset($_POST['contact'])) {
$r = $contactManager->handleSubmission($_POST['name'],$_POST['email'],$_POST['message']);
if (isset($_SERVER['HTTP_X_REQUESTED_WITH'])) { header('Content-Type: application/json'); echo json_encode($r); exit; }
header('Location: '.$_SERVER['PHP_SELF'].'#kontakt'); exit;
}
if (isset($_POST['admin-login'])) { $adminManager->handleLogin($_POST['username'],$_POST['password']); header('Location: '.$_SERVER['PHP_SELF'].'#admin'); exit; }
if ($adminManager->isAdmin()) {
if (isset($_POST['action']) && $_POST['action']==='delete_guestbook') { $guestbookManager->deleteEntry(intval($_POST['delete_entry'])); header("Location: ".$_SERVER['PHP_SELF']."#guestbook"); exit; }
if (isset($_POST['action']) && $_POST['action']==='delete_feedback') { $contactManager->deleteFeedback(intval($_POST['delete_index'])); header("Location: ".$_SERVER['PHP_SELF']."#admin"); exit; }
}
}
$vc = $viewerCounter->getInitialCount();
$sv = $settingsManager->get('viewer_display.enabled') && $vc >= $settingsManager->get('viewer_display.min_viewers');
$mv = $settingsManager->get('viewer_display.min_viewers');
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=5,user-scalable=yes">
<title>Aurora Livecam</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Arial,sans-serif;background:#f0f0f0;color:#333;line-height:1.6}
.container{max-width:1100px;margin:0 auto;padding:0 15px}
.section{padding:50px 0;background:#fff;margin-bottom:15px}
.section h2{text-align:center;margin-bottom:25px;font-size:28px}
header{background:#fff;padding:12px 0;position:sticky;top:0;z-index:100;box-shadow:0 2px 8px rgba(0,0,0,0.1)}
.header-inner{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px}
.logo img{height:45px}
nav ul{list-style:none;display:flex;flex-wrap:wrap;gap:5px}
nav a{text-decoration:none;color:#333;padding:8px 14px;border-radius:5px;font-weight:bold;transition:.3s}
nav a:hover{background:#4CAF50;color:#fff}
.hero{text-align:center;padding:40px 15px;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff}
.hero h1{font-size:2em;margin-bottom:10px}
.video-box{max-width:900px;margin:0 auto 20px}
.video-wrap{position:relative;padding-bottom:56.25%;background:#000;border-radius:10px;overflow:hidden}
.video-wrap video,.video-wrap img,.video-wrap #dvp{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:contain}
#tlv,#dvp{display:none;background:#000}
#dvp video{width:100%;height:100%}
.zoom-btns{position:absolute;bottom:15px;right:15px;display:flex;gap:8px;z-index:100}
.zoom-btns button{width:44px;height:44px;border:none;border-radius:50%;background:rgba(255,255,255,.95);font-size:20px;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.3);transition:.2s}
.zoom-btns button:hover{transform:scale(1.1);background:#fff}
.info-bar{display:flex;justify-content:center;gap:15px;margin:15px 0;flex-wrap:wrap}
.badge{background:#fff;padding:8px 18px;border-radius:25px;font-weight:bold;display:flex;align-items:center;gap:8px;box-shadow:0 2px 8px rgba(0,0,0,.1)}
.badge.live{background:#fff5f5;color:#d32f2f}
.dot{width:8px;height:8px;background:#f44;border-radius:50%;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(244,67,54,.6)}50%{box-shadow:0 0 0 8px transparent}}
.btns{display:flex;justify-content:center;gap:10px;flex-wrap:wrap;margin:15px 0}
.btn{padding:10px 20px;background:linear-gradient(135deg,#4CAF50,#45a049);color:#fff;border:none;border-radius:6px;font-weight:bold;cursor:pointer;text-decoration:none;transition:.3s}
.btn:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(76,175,80,.4)}
.btn.purple{background:linear-gradient(135deg,#667eea,#764ba2)}
#tl-ctrl{display:none;background:#fff;padding:12px 20px;border-radius:30px;margin:15px auto;max-width:700px;box-shadow:0 3px 10px rgba(0,0,0,.1)}
.tl-bar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:center}
.tl-btn{width:40px;height:40px;border:none;border-radius:50%;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;cursor:pointer;font-size:14px}
.tl-btn.on{background:linear-gradient(135deg,#4CAF50,#45a049)}
.tl-btn.wide{width:auto;padding:0 15px;border-radius:20px}
#tl-slider{flex:1;min-width:120px;max-width:250px}
#tl-time{font-family:monospace;background:#f5f5f5;padding:6px 12px;border-radius:15px}
#back-live{display:none}
.calendar-box{max-width:700px;margin:0 auto;background:#fff;border-radius:10px;padding:20px;box-shadow:0 3px 15px rgba(0,0,0,.1)}
.cal-nav{display:flex;justify-content:space-between;align-items:center;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;padding:12px 15px;border-radius:8px;margin-bottom:15px}
.cal-nav button{background:rgba(255,255,255,.2);border:none;color:#fff;padding:8px 15px;border-radius:5px;font-size:18px;cursor:pointer}
.cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:5px}
.cal-hd{text-align:center;font-weight:bold;padding:8px;background:#f5f5f5;border-radius:4px;font-size:12px}
.cal-day{aspect-ratio:1;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#fff;border:2px solid #e0e0e0;border-radius:8px;cursor:pointer;transition:.2s;position:relative;font-size:14px}
.cal-day:hover:not(.empty){transform:scale(1.05);border-color:#667eea}
.cal-day.empty{background:transparent;border:none;cursor:default}
.cal-day.has-vid{background:linear-gradient(135deg,#e3f2fd,#bbdefb);border-color:#2196F3}
.cal-day.sel{background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;transform:scale(1.08)}
.cal-day.today{border:2px solid #4CAF50}
.cal-day small{position:absolute;bottom:2px;right:2px;font-size:10px}
.day-vids{background:#f9f9f9;border-radius:8px;padding:15px;margin-top:15px}
.day-vids h4{margin-bottom:10px;border-bottom:2px solid #667eea;padding-bottom:8px}
.day-vids ul{list-style:none}
.day-vids li{display:flex;justify-content:space-between;align-items:center;padding:10px;background:#fff;margin-bottom:8px;border-radius:6px;flex-wrap:wrap;gap:8px}
.vid-btns{display:flex;gap:8px}
.btn-play,.btn-dl{padding:6px 12px;border-radius:15px;text-decoration:none;color:#fff;font-size:13px}
.btn-play{background:linear-gradient(135deg,#667eea,#764ba2)}
.btn-dl{background:linear-gradient(135deg,#4CAF50,#45a049)}
form{display:grid;gap:12px;background:#f9f9f9;padding:20px;border-radius:8px;max-width:500px;margin:0 auto}
input,textarea{width:100%;padding:10px;border:2px solid #ddd;border-radius:6px;font-size:15px}
input:focus,textarea:focus{border-color:#667eea;outline:none}
button[type=submit]{padding:10px 20px;background:linear-gradient(135deg,#4CAF50,#45a049);color:#fff;border:none;border-radius:6px;font-weight:bold;cursor:pointer}
.gb-entries{max-width:600px;margin:20px auto 0}
.gb-entry{background:#fff;border-left:4px solid #4CAF50;padding:15px;margin-bottom:10px;border-radius:6px;box-shadow:0 2px 6px rgba(0,0,0,.08)}
.gb-entry h4{margin-bottom:5px}
.gb-entry small{color:#888}
.gallery{display:flex;gap:10px;overflow-x:auto;padding:10px 0}
.gallery img{width:200px;height:140px;object-fit:cover;border-radius:8px;cursor:pointer;flex-shrink:0}
.admin-panel{background:#fff;padding:20px;border-radius:10px;margin-bottom:20px}
.admin-panel h3{margin-bottom:15px;border-bottom:2px solid #667eea;padding-bottom:8px}
.setting{display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid #eee}
.setting:last-child{border-bottom:none}
.setting input[type=checkbox]{width:20px;height:20px}
.setting input[type=number]{width:60px;padding:5px;text-align:center}
.msg{background:#f9f9f9;padding:12px;border-left:3px solid #667eea;margin-bottom:8px;border-radius:4px}
.del-btn{background:#f44;color:#fff;border:none;padding:4px 10px;border-radius:4px;cursor:pointer}
footer{background:#333;color:#fff;padding:30px 0;text-align:center}
footer a{color:#fff;margin:0 10px}
.modal{display:none;position:fixed;z-index:1000;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,.9);align-items:center;justify-content:center}
.modal img{max-width:95%;max-height:90%}
.modal .close{position:absolute;top:15px;right:25px;color:#fff;font-size:35px;cursor:pointer}
@media(max-width:600px){
.header-inner{flex-direction:column}
nav ul{justify-content:center}
.hero h1{font-size:1.5em}
.btns{flex-direction:column}
.btn{width:100%}
.tl-bar{flex-direction:column}
#tl-slider{width:100%;max-width:none}
}
</style>
</head>
<body>
<header>
<div class="container header-inner">
<div class="logo"><img src="logo.png" alt="Logo"></div>
<nav><ul>
<li><a href="#cam">Webcam</a></li>
<li><a href="#archive">Archiv</a></li>
<li><a href="#guestbook">Gästebuch</a></li>
<li><a href="#kontakt">Kontakt</a></li>
<?php if($adminManager->isAdmin()): ?><li><a href="#admin">Admin</a></li><?php endif; ?>
</ul></nav>
</div>
</header>
<section class="hero">
<h1>Aurora Wetter Livecam</h1>
<p>Faszinierende Ausblicke aus dem Zürcher Oberland</p>
</section>
<section id="cam" class="section">
<div class="container">
<div class="video-box">
<div class="video-wrap" id="vw">
<?php echo $webcamManager->displayWebcam(); ?>
<div id="tlv"><img id="tl-img"><div id="tl-overlay" style="position:absolute;top:10px;left:10px;background:rgba(0,0,0,.7);color:#fff;padding:6px 12px;border-radius:4px;font-family:monospace"></div></div>
<div id="dvp"><video id="dv" controls playsinline></video></div>
<div class="zoom-btns">
<button onclick="zoom(-1)"></button>
<button onclick="zoom(0)">⟲</button>
<button onclick="zoom(1)">+</button>
</div>
</div>
</div>
<div id="tl-ctrl">
<div class="tl-bar">
<button class="tl-btn" id="tl-play"><i class="fas fa-play"></i></button>
<button class="tl-btn" id="tl-rev"><i class="fas fa-backward"></i></button>
<input type="range" id="tl-slider" min="0" value="0">
<span id="tl-time">--:--:--</span>
<button class="tl-btn wide" id="tl-spd">1x</button>
<button class="tl-btn wide on" id="tl-back"><i class="fas fa-video"></i> Live</button>
</div>
</div>
<button class="btn purple" id="back-live" onclick="toLive()"><i class="fas fa-video"></i> Zurück zu Live</button>
<div class="info-bar">
<?php echo $webcamManager->displayStreamStats(); ?>
<?php if($sv): ?><div class="badge live"><span class="dot"></span><strong id="vc"><?php echo $vc; ?></strong> Zuschauer</div><?php endif; ?>
</div>
<div class="btns">
<a href="?action=snapshot" class="btn">📷 Snapshot</a>
<button class="btn" id="tl-btn">🎬 Zeitraffer</button>
<a href="?download_video=1" class="btn">⬇️ Tagesvideo</a>
</div>
</div>
</section>
<section id="archive" class="section">
<div class="container">
<h2>📅 Videoarchiv</h2>
<?php $cal = new VisualCalendarManager('./videos/', $settingsManager); echo $cal->displayVisualCalendar(); ?>
</div>
</section>
<section id="guestbook" class="section">
<div class="container">
<h2>Gästebuch</h2>
<?php echo $guestbookManager->displayForm(); echo $guestbookManager->displayEntries($adminManager->isAdmin()); ?>
</div>
</section>
<section id="kontakt" class="section">
<div class="container">
<h2>Kontakt</h2>
<?php echo $contactManager->displayForm(); ?>
</div>
</section>
<section id="gallery" class="section">
<div class="container">
<h2>Galerie</h2>
<?php echo $adminManager->displayGalleryImages(); ?>
</div>
</section>
<?php if($adminManager->isAdmin()): ?>
<section id="admin" class="section">
<div class="container">
<h2>⚙️ Admin</h2>
<?php echo $adminManager->displayAdminContent(); ?>
</div>
</section>
<?php else: ?>
<section id="admin" class="section">
<div class="container">
<h2>Admin Login</h2>
<?php echo $adminManager->displayLoginForm(); ?>
</div>
</section>
<?php endif; ?>
<footer>
<a href="#cam">Webcam</a>
<a href="#archive">Archiv</a>
<a href="#kontakt">Kontakt</a>
<p style="margin-top:15px">&copy; 2024 Aurora Livecam</p>
</footer>
<div class="modal" id="modal" onclick="this.style.display='none'">
<span class="close">&times;</span>
<img id="modal-img">
</div>
<script>
<?php echo $webcamManager->getJavaScript(); ?>
let zoomLvl=1;
function zoom(d){
if(d===0) zoomLvl=1;
else zoomLvl=Math.max(1,Math.min(4,zoomLvl+d*0.5));
// Alle Video-Elemente in allen Modi
const targets=['#webcam-player','#tl-img','#dv'];
targets.forEach(sel=>{
const el=document.querySelector(sel);
if(el){
el.style.transform='scale('+zoomLvl+')';
el.style.transformOrigin='center center';
el.style.transition='transform 0.2s ease';
}
});
// Zoom-Level Anzeige
showZoomLevel();
}
function showZoomLevel(){
let ind=document.getElementById('zoom-ind');
if(!ind){
ind=document.createElement('div');
ind.id='zoom-ind';
ind.style.cssText='position:absolute;top:15px;left:15px;background:rgba(0,0,0,0.7);color:#fff;padding:8px 14px;border-radius:20px;font-weight:bold;z-index:100;transition:opacity 0.3s';
document.getElementById('vw').appendChild(ind);
}
ind.textContent='🔍 '+Math.round(zoomLvl*100)+'%';
ind.style.opacity='1';
clearTimeout(ind.hideTimer);
ind.hideTimer=setTimeout(()=>{ind.style.opacity='0';},1500);
}
const TL={
imgs:<?php echo $imageFilesJson; ?>,
idx:0,playing:false,rev:false,spd:1,spds:[1,10,100],iv:null,
init(){
document.getElementById('tl-play').onclick=()=>this.toggle();
document.getElementById('tl-rev').onclick=()=>this.toggleRev();
document.getElementById('tl-spd').onclick=()=>this.cycleSpd();
document.getElementById('tl-back').onclick=()=>toLive();
document.getElementById('tl-slider').max=this.imgs.length-1;
document.getElementById('tl-slider').oninput=e=>this.seek(+e.target.value);
},
show(){
document.getElementById('webcam-player').style.display='none';
document.getElementById('dvp').style.display='none';
document.getElementById('tlv').style.display='block';
document.getElementById('tl-ctrl').style.display='block';
document.getElementById('back-live').style.display='none';
this.idx=0;this.frame();
},
toggle(){
this.playing=!this.playing;
document.getElementById('tl-play').innerHTML=this.playing?'<i class="fas fa-pause"></i>':'<i class="fas fa-play"></i>';
if(this.playing)this.play();else this.stop();
},
toggleRev(){this.rev=!this.rev;document.getElementById('tl-rev').classList.toggle('on',this.rev);},
cycleSpd(){const i=this.spds.indexOf(this.spd);this.spd=this.spds[(i+1)%this.spds.length];document.getElementById('tl-spd').textContent=this.spd+'x';if(this.playing){this.stop();this.play();}},
play(){this.iv=setInterval(()=>this.next(),200/this.spd);},
stop(){clearInterval(this.iv);},
next(){this.idx+=this.rev?-1:1;if(this.idx<0)this.idx=this.imgs.length-1;if(this.idx>=this.imgs.length)this.idx=0;this.frame();},
seek(i){this.idx=i;this.frame();},
frame(){
const img=this.imgs[this.idx];if(!img)return;
document.getElementById('tl-img').src=img;
document.getElementById('tl-slider').value=this.idx;
const m=img.match(/(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/);
if(m){const t=m[3]+'.'+m[2]+'.'+m[1]+' '+m[4]+':'+m[5]+':'+m[6];document.getElementById('tl-time').textContent=t;document.getElementById('tl-overlay').textContent=t;}
}
};
function playVid(p){
document.getElementById('webcam-player').style.display='none';
document.getElementById('tlv').style.display='none';
document.getElementById('tl-ctrl').style.display='none';
document.getElementById('dvp').style.display='block';
document.getElementById('back-live').style.display='block';
const v=document.getElementById('dv');v.src=p;v.play();
document.getElementById('cam').scrollIntoView({behavior:'smooth'});
}
function toLive(){
TL.stop();TL.playing=false;
document.getElementById('tl-play').innerHTML='<i class="fas fa-play"></i>';
document.getElementById('tlv').style.display='none';
document.getElementById('tl-ctrl').style.display='none';
document.getElementById('dvp').style.display='none';
document.getElementById('back-live').style.display='none';
document.getElementById('webcam-player').style.display='block';
document.getElementById('tl-btn').textContent='🎬 Zeitraffer';
document.getElementById('dv').pause();document.getElementById('dv').src='';
zoomLvl=1;zoom(0);
}
function chgM(y,m){if(m<1){m=12;y--;}if(m>12){m=1;y++;}location.href='?cal_year='+y+'&cal_month='+m+'#archive';}
function selD(y,m,d){location.href='?cal_year='+y+'&cal_month='+m+'&cal_day='+d+'#archive';}
function openImg(s){document.getElementById('modal-img').src=s;document.getElementById('modal').style.display='flex';}
function updV(){
fetch(location.href,{method:'POST',body:new URLSearchParams({action:'viewer_heartbeat'})})
.then(r=>r.json()).then(d=>{const e=document.getElementById('vc');if(e&&d.count)e.textContent=d.count;});
}
<?php if($adminManager->isAdmin()): ?>
function saveSetting(key, value) {
const formData = new FormData();
formData.append('settings_action', 'update');
formData.append('key', key);
formData.append('value', value);
fetch(window.location.pathname, {
method: 'POST',
body: formData
})
.then(r => r.json())
.then(data => {
const toast = document.createElement('div');
toast.innerHTML = data.success ? '✓ Gespeichert' : '✗ Fehler: ' + (data.message || '');
toast.style.cssText = 'position:fixed;top:20px;right:20px;padding:15px 25px;border-radius:8px;background:' +
(data.success ? '#4CAF50' : '#f44336') + ';color:#fff;font-weight:bold;z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,0.3);';
document.body.appendChild(toast);
setTimeout(() => { toast.style.opacity = '0'; toast.style.transition = 'opacity 0.3s'; }, 1500);
setTimeout(() => toast.remove(), 2000);
})
.catch(err => {
console.error('Settings save error:', err);
alert('Fehler beim Speichern: ' + err.message);
});
}
// Settings Event-Handler nach DOM-Load binden
document.addEventListener('DOMContentLoaded', function() {
const sViewer = document.getElementById('s-viewer');
const sMin = document.getElementById('s-min');
const sPlay = document.getElementById('s-play');
const sDl = document.getElementById('s-dl');
if (sViewer) sViewer.addEventListener('change', function() {
saveSetting('viewer_display.enabled', this.checked ? 'true' : 'false');
});
if (sMin) sMin.addEventListener('change', function() {
saveSetting('viewer_display.min_viewers', this.value);
});
if (sPlay) sPlay.addEventListener('change', function() {
saveSetting('video_mode.play_in_player', this.checked ? 'true' : 'false');
});
if (sDl) sDl.addEventListener('change', function() {
saveSetting('video_mode.allow_download', this.checked ? 'true' : 'false');
});
});
<?php endif; ?>
document.addEventListener('DOMContentLoaded',()=>{
TL.init();
document.getElementById('tl-btn').onclick=()=>{
if(document.getElementById('tlv').style.display==='block'){toLive();}
else{TL.show();document.getElementById('tl-btn').textContent='↩️ Zurück zu Live';}
};
setTimeout(updV,2000);setInterval(updV,10000);
});
</script>
</body>
</html>
+215
View File
@@ -0,0 +1,215 @@
/**
* Video Zoom & Pan Controller
* Zoomt auf Wrapper-Layer statt direkt auf Video-Elemente
*/
(() => {
const config = window.zoomConfig || {};
if (!config.enabled) return;
let currentZoom = 1;
let panX = 0;
let panY = 0;
let isDragging = false;
let lastX = 0;
let lastY = 0;
const minZoom = Number(config.minZoom || 1);
const maxZoom = Number(config.maxZoom || 4);
const slider = document.getElementById('zoom-range');
const valueEl = document.getElementById('zoom-value');
// Wrapper-IDs für jeden Modus
const wrapperIds = ['live-video-wrapper', 'timelapse-wrapper', 'daily-video-wrapper'];
// Finde den aktuell sichtbaren Wrapper
function getActiveWrapper() {
// Prüfe daily-video-player
const dailyPlayer = document.getElementById('daily-video-player');
if (dailyPlayer && dailyPlayer.style.display !== 'none') {
return document.getElementById('daily-video-wrapper');
}
// Prüfe timelapse-viewer
const timelapseViewer = document.getElementById('timelapse-viewer');
if (timelapseViewer && timelapseViewer.style.display !== 'none') {
return document.getElementById('timelapse-wrapper');
}
// Fallback: Live-Video
return document.getElementById('live-video-wrapper');
}
// Wende Transform auf ALLE Wrapper an (damit beim Wechsel der Zoom erhalten bleibt)
function applyTransform() {
// Bei Zoom 1x: Kein Pan
if (currentZoom <= 1) {
panX = 0;
panY = 0;
}
// Pan begrenzen basierend auf Zoom
const maxPan = (currentZoom - 1) * 50;
panX = Math.max(-maxPan, Math.min(maxPan, panX));
panY = Math.max(-maxPan, Math.min(maxPan, panY));
// Transform auf alle Wrapper anwenden
wrapperIds.forEach(id => {
const wrapper = document.getElementById(id);
if (wrapper) {
wrapper.style.transform = `scale(${currentZoom}) translate(${panX}%, ${panY}%)`;
wrapper.style.transition = isDragging ? 'none' : 'transform 0.15s ease-out';
}
});
// UI Update
if (valueEl) valueEl.textContent = `${currentZoom.toFixed(1)}x`;
if (slider) slider.value = currentZoom;
// Cursor Update
updateCursor();
}
function updateCursor() {
const container = document.querySelector('.video-container');
if (container) {
if (currentZoom > 1) {
container.classList.add('zoomed');
} else {
container.classList.remove('zoomed');
}
}
}
// Zoom setzen
function setZoom(value) {
currentZoom = Math.max(minZoom, Math.min(maxZoom, value));
applyTransform();
}
// Zoom anpassen
function adjustZoom(delta) {
setZoom(currentZoom + delta);
}
// Zoom zurücksetzen
function resetZoom() {
currentZoom = 1;
panX = 0;
panY = 0;
applyTransform();
}
// Mouse Events für Pan
function setupPanEvents() {
const container = document.querySelector('.video-container');
if (!container) return;
// Mousedown - Start dragging
container.addEventListener('mousedown', (e) => {
if (currentZoom <= 1) return;
// Ignoriere Klicks auf Controls
if (e.target.closest('.zoom-controls, button, a')) return;
isDragging = true;
lastX = e.clientX;
lastY = e.clientY;
e.preventDefault();
});
// Mousemove - Dragging
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const deltaX = e.clientX - lastX;
const deltaY = e.clientY - lastY;
// Sensitivität basierend auf Zoom
const sensitivity = 0.15 / currentZoom;
panX += deltaX * sensitivity;
panY += deltaY * sensitivity;
lastX = e.clientX;
lastY = e.clientY;
applyTransform();
});
// Mouseup - Stop dragging
document.addEventListener('mouseup', () => {
isDragging = false;
});
// Mouse leave
document.addEventListener('mouseleave', () => {
isDragging = false;
});
// Touch Events für Mobile
container.addEventListener('touchstart', (e) => {
if (currentZoom <= 1 || e.touches.length !== 1) return;
if (e.target.closest('.zoom-controls, button, a')) return;
isDragging = true;
lastX = e.touches[0].clientX;
lastY = e.touches[0].clientY;
}, { passive: true });
container.addEventListener('touchmove', (e) => {
if (!isDragging || e.touches.length !== 1) return;
const deltaX = e.touches[0].clientX - lastX;
const deltaY = e.touches[0].clientY - lastY;
const sensitivity = 0.15 / currentZoom;
panX += deltaX * sensitivity;
panY += deltaY * sensitivity;
lastX = e.touches[0].clientX;
lastY = e.touches[0].clientY;
applyTransform();
}, { passive: true });
container.addEventListener('touchend', () => {
isDragging = false;
});
// Doppelklick zum Zurücksetzen
container.addEventListener('dblclick', (e) => {
if (e.target.closest('.zoom-controls, button, a')) return;
resetZoom();
});
}
// Slider Setup
function setupSlider() {
if (!slider) return;
slider.min = minZoom;
slider.max = maxZoom;
slider.step = 0.5;
slider.value = 1;
slider.addEventListener('input', (e) => {
setZoom(Number(e.target.value));
});
}
// Globale Funktionen
window.adjustZoom = adjustZoom;
window.resetZoom = resetZoom;
window.setZoom = setZoom;
// Initialisierung
document.addEventListener('DOMContentLoaded', () => {
setupSlider();
setupPanEvents();
// Initial State
currentZoom = 1;
applyTransform();
console.log('Video Zoom & Pan initialized');
});
})();
+422
View File
@@ -0,0 +1,422 @@
<?php
/**
* Landing Page - Marketing Seite
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
$settingsManager = new SettingsManager();
// Prüfe ob Landing Page aktiviert
if (!$settingsManager->isLandingPageEnabled()) {
header('Location: /');
exit;
}
$trialDays = $settingsManager->getTrialDays();
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aurora Livecam - Ihre Webcam als Service</title>
<meta name="description" content="Erstellen Sie Ihre eigene Live-Webcam in wenigen Minuten. Wetter-Widget, Timelapse, Analytics und mehr. Jetzt kostenlos testen!">
<link rel="stylesheet" href="/dashboard/assets/dashboard.css">
<style>
:root {
--gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #1a202c;
}
/* Header */
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
background: rgba(255,255,255,0.95);
backdrop-filter: blur(10px);
z-index: 100;
border-bottom: 1px solid #e2e8f0;
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
background: var(--gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-decoration: none;
}
.nav-links {
display: flex;
gap: 2rem;
align-items: center;
}
.nav-links a {
color: #4a5568;
text-decoration: none;
font-weight: 500;
transition: color 0.2s;
}
.nav-links a:hover {
color: #667eea;
}
/* Hero */
.hero {
padding: 8rem 2rem 6rem;
background: var(--gradient);
color: white;
text-align: center;
}
.hero h1 {
font-size: 3rem;
font-weight: 800;
margin-bottom: 1.5rem;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.hero p {
font-size: 1.25rem;
opacity: 0.9;
max-width: 600px;
margin: 0 auto 2rem;
}
.hero-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.btn-hero {
padding: 1rem 2rem;
border-radius: 0.5rem;
font-size: 1.1rem;
font-weight: 600;
text-decoration: none;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn-hero-primary {
background: white;
color: #667eea;
}
.btn-hero-secondary {
background: rgba(255,255,255,0.2);
color: white;
border: 2px solid rgba(255,255,255,0.5);
}
.btn-hero:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0,0,0,0.2);
}
.trial-badge {
display: inline-block;
background: rgba(255,255,255,0.2);
padding: 0.5rem 1rem;
border-radius: 2rem;
margin-top: 2rem;
font-size: 0.9rem;
}
/* Features */
.features {
padding: 6rem 2rem;
background: #f7fafc;
}
.section-title {
text-align: center;
margin-bottom: 4rem;
}
.section-title h2 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.section-title p {
color: #718096;
font-size: 1.1rem;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.feature-card {
background: white;
padding: 2rem;
border-radius: 1rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
transition: transform 0.2s, box-shadow 0.2s;
}
.feature-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}
.feature-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.feature-card h3 {
font-size: 1.25rem;
margin-bottom: 0.75rem;
}
.feature-card p {
color: #718096;
}
/* How it works */
.how-it-works {
padding: 6rem 2rem;
max-width: 1000px;
margin: 0 auto;
}
.steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 2rem;
margin-top: 3rem;
}
.step {
text-align: center;
}
.step-number {
width: 60px;
height: 60px;
background: var(--gradient);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: 700;
margin: 0 auto 1rem;
}
.step h4 {
margin-bottom: 0.5rem;
}
.step p {
color: #718096;
font-size: 0.9rem;
}
/* CTA */
.cta {
padding: 6rem 2rem;
background: var(--gradient);
color: white;
text-align: center;
}
.cta h2 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.cta p {
font-size: 1.1rem;
opacity: 0.9;
margin-bottom: 2rem;
}
/* Footer */
.footer {
background: #1a202c;
color: #a0aec0;
padding: 3rem 2rem;
}
.footer-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 2rem;
}
.footer-links a {
color: #a0aec0;
text-decoration: none;
margin-right: 1.5rem;
}
.footer-links a:hover {
color: white;
}
/* Responsive */
@media (max-width: 768px) {
.hero h1 { font-size: 2rem; }
.nav-links { display: none; }
.features-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<!-- Header -->
<header class="header">
<div class="header-inner">
<a href="/" class="logo">Aurora Livecam</a>
<nav class="nav-links">
<a href="#features">Features</a>
<a href="/landing/pricing.php">Preise</a>
<a href="/dashboard/login.php">Login</a>
<a href="/onboarding/register.php" class="btn btn-primary btn-sm">Kostenlos starten</a>
</nav>
</div>
</header>
<!-- Hero -->
<section class="hero">
<h1>Ihre Webcam als Service - in 5 Minuten online</h1>
<p>Erstellen Sie Ihre eigene Live-Webcam-Website mit Wetter-Widget, Timelapse, Analytics und mehr. Keine Programmierkenntnisse erforderlich.</p>
<div class="hero-buttons">
<a href="/onboarding/register.php" class="btn-hero btn-hero-primary">
Jetzt starten
</a>
<a href="#features" class="btn-hero btn-hero-secondary">
Features ansehen
</a>
</div>
<div class="trial-badge">
<?php echo $trialDays; ?> Tage kostenlos testen - Keine Kreditkarte erforderlich
</div>
</section>
<!-- Features -->
<section class="features" id="features">
<div class="section-title">
<h2>Alles was Sie brauchen</h2>
<p>Professionelle Features für Ihre Live-Webcam</p>
</div>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">📹</div>
<h3>Live-Streaming</h3>
<p>HLS, RTMP oder WebRTC - verbinden Sie jeden Stream in Sekunden. Automatische Qualitätsanpassung inklusive.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🌤️</div>
<h3>Wetter-Widget</h3>
<p>Zeigen Sie Temperatur, Wind, Luftdruck und mehr an. Kostenlose Open-Meteo Integration ohne API-Key.</p>
</div>
<div class="feature-card">
<div class="feature-icon">⏱️</div>
<h3>Timelapse</h3>
<p>Automatische Zeitraffer-Erstellung. Scrubben Sie durch den ganzen Tag mit variabler Geschwindigkeit.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔍</div>
<h3>Zoom & Pan</h3>
<p>Lassen Sie Besucher in Ihren Stream hineinzoomen. Unterstützt Touch-Gesten und Maus-Steuerung.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📊</div>
<h3>Analytics</h3>
<p>Sehen Sie wer Ihre Webcam besucht. Echtzeit-Zuschauerzähler und detaillierte Statistiken.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🎨</div>
<h3>Custom Branding</h3>
<p>Ihr Logo, Ihre Farben, Ihre Domain. Machen Sie die Webcam zu Ihrer eigenen.</p>
</div>
</div>
</section>
<!-- How it works -->
<section class="how-it-works">
<div class="section-title">
<h2>So einfach geht's</h2>
<p>In 3 Schritten zur eigenen Livecam</p>
</div>
<div class="steps">
<div class="step">
<div class="step-number">1</div>
<h4>Registrieren</h4>
<p>Erstellen Sie in 30 Sekunden Ihr kostenloses Konto.</p>
</div>
<div class="step">
<div class="step-number">2</div>
<h4>Stream verbinden</h4>
<p>Fügen Sie Ihre Stream-URL ein. Wir unterstützen alle gängigen Formate.</p>
</div>
<div class="step">
<div class="step-number">3</div>
<h4>Anpassen & Teilen</h4>
<p>Personalisieren Sie Ihre Seite und teilen Sie den Link.</p>
</div>
</div>
</section>
<!-- CTA -->
<section class="cta">
<h2>Bereit loszulegen?</h2>
<p><?php echo $trialDays; ?> Tage kostenlos testen - keine Kreditkarte erforderlich</p>
<a href="/onboarding/register.php" class="btn-hero btn-hero-primary">
Jetzt kostenlos starten
</a>
</section>
<!-- Footer -->
<footer class="footer">
<div class="footer-inner">
<div>
© <?php echo date('Y'); ?> Aurora Livecam. Alle Rechte vorbehalten.
</div>
<div class="footer-links">
<a href="/terms">AGB</a>
<a href="/privacy">Datenschutz</a>
<a href="/imprint">Impressum</a>
<a href="mailto:support@aurora-livecam.com">Kontakt</a>
</div>
</div>
</footer>
</body>
</html>
+497
View File
@@ -0,0 +1,497 @@
<?php
/**
* Landing Page - Preise
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
if (file_exists(dirname(__DIR__) . '/src/bootstrap.php')) {
require_once dirname(__DIR__) . '/src/bootstrap.php';
}
use AuroraLivecam\Billing\SubscriptionManager;
$settingsManager = new SettingsManager();
// Pläne laden
$plans = [];
try {
$subscriptions = new SubscriptionManager();
$plans = $subscriptions->getPlans();
} catch (\Exception $e) {
// Fallback-Pläne
$plans = [
['name' => 'Free', 'slug' => 'free', 'price_monthly' => 0, 'features' => ['max_viewers' => 10, 'weather_widget' => true]],
['name' => 'Basic', 'slug' => 'basic', 'price_monthly' => 19, 'features' => ['max_viewers' => 50, 'weather_widget' => true, 'timelapse' => true, 'analytics' => true]],
['name' => 'Professional', 'slug' => 'professional', 'price_monthly' => 49, 'features' => ['max_viewers' => 200, 'custom_domain' => true, 'weather_widget' => true, 'timelapse' => true, 'analytics' => true, 'branding' => true]],
['name' => 'Enterprise', 'slug' => 'enterprise', 'price_monthly' => 149, 'features' => ['max_viewers' => -1, 'custom_domain' => true, 'weather_widget' => true, 'timelapse' => true, 'analytics' => true, 'branding' => true, 'priority_support' => true]],
];
}
$trialDays = $settingsManager->getTrialDays();
// Feature-Labels
$featureLabels = [
'max_viewers' => 'Gleichzeitige Zuschauer',
'storage_gb' => 'Speicherplatz',
'custom_domain' => 'Eigene Domain',
'weather_widget' => 'Wetter-Widget',
'timelapse' => 'Timelapse',
'analytics' => 'Analytics & Statistiken',
'branding' => 'Custom Branding',
'priority_support' => 'Priority Support',
];
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Preise - Aurora Livecam</title>
<link rel="stylesheet" href="/dashboard/assets/dashboard.css">
<style>
:root {
--gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #1a202c;
background: #f7fafc;
}
.header {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 1rem 2rem;
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
background: var(--gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-decoration: none;
}
.nav-links a {
color: #4a5568;
text-decoration: none;
margin-left: 1.5rem;
}
.page-header {
text-align: center;
padding: 4rem 2rem;
background: var(--gradient);
color: white;
}
.page-header h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.page-header p {
font-size: 1.1rem;
opacity: 0.9;
}
.pricing-toggle {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 2rem;
align-items: center;
}
.pricing-toggle span {
font-size: 0.9rem;
}
.pricing-toggle .active {
font-weight: 600;
}
.toggle-switch {
width: 60px;
height: 30px;
background: rgba(255,255,255,0.3);
border-radius: 15px;
position: relative;
cursor: pointer;
}
.toggle-switch::after {
content: '';
position: absolute;
width: 26px;
height: 26px;
background: white;
border-radius: 50%;
top: 2px;
left: 2px;
transition: 0.3s;
}
.toggle-switch.yearly::after {
left: 32px;
}
.save-badge {
background: #48bb78;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
}
.pricing-container {
max-width: 1200px;
margin: -3rem auto 4rem;
padding: 0 2rem;
}
.pricing-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.pricing-card {
background: white;
border-radius: 1rem;
padding: 2rem;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
position: relative;
display: flex;
flex-direction: column;
}
.pricing-card.featured {
border: 2px solid #667eea;
transform: scale(1.05);
}
.pricing-card.featured::before {
content: 'Beliebt';
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
background: var(--gradient);
color: white;
padding: 0.25rem 1rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 600;
}
.pricing-card h3 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.pricing-card .price {
font-size: 3rem;
font-weight: 800;
margin: 1rem 0;
}
.pricing-card .price span {
font-size: 1rem;
font-weight: 400;
color: #718096;
}
.pricing-card .price-yearly {
display: none;
}
.yearly-mode .price-monthly { display: none; }
.yearly-mode .price-yearly { display: block; }
.pricing-card ul {
list-style: none;
flex: 1;
margin: 1.5rem 0;
}
.pricing-card li {
padding: 0.5rem 0;
color: #4a5568;
display: flex;
align-items: center;
gap: 0.5rem;
}
.pricing-card li.included::before {
content: '✓';
color: #48bb78;
font-weight: bold;
}
.pricing-card li.not-included {
color: #a0aec0;
text-decoration: line-through;
}
.pricing-card li.not-included::before {
content: '✗';
color: #e53e3e;
}
.pricing-card .btn {
width: 100%;
padding: 1rem;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
text-align: center;
transition: all 0.2s;
}
.pricing-card .btn-primary {
background: var(--gradient);
color: white;
}
.pricing-card .btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.pricing-card .btn-secondary {
background: #e2e8f0;
color: #4a5568;
}
.faq {
max-width: 800px;
margin: 0 auto 4rem;
padding: 0 2rem;
}
.faq h2 {
text-align: center;
margin-bottom: 2rem;
}
.faq-item {
background: white;
border-radius: 0.5rem;
margin-bottom: 1rem;
overflow: hidden;
}
.faq-question {
padding: 1.25rem;
font-weight: 600;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.faq-answer {
padding: 0 1.25rem 1.25rem;
color: #718096;
display: none;
}
.faq-item.open .faq-answer {
display: block;
}
.footer {
background: #1a202c;
color: #a0aec0;
padding: 2rem;
text-align: center;
}
@media (max-width: 768px) {
.pricing-card.featured {
transform: none;
}
.pricing-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<header class="header">
<div class="header-inner">
<a href="/landing/" class="logo">Aurora Livecam</a>
<nav class="nav-links">
<a href="/landing/">Home</a>
<a href="/dashboard/login.php">Login</a>
<a href="/onboarding/register.php" class="btn btn-primary btn-sm">Kostenlos starten</a>
</nav>
</div>
</header>
<section class="page-header">
<h1>Einfache, transparente Preise</h1>
<p><?php echo $trialDays; ?> Tage kostenlos testen - jederzeit kündbar</p>
<div class="pricing-toggle">
<span class="monthly-label active">Monatlich</span>
<div class="toggle-switch" id="billing-toggle"></div>
<span class="yearly-label">Jährlich</span>
<span class="save-badge">2 Monate gratis</span>
</div>
</section>
<div class="pricing-container" id="pricing-container">
<div class="pricing-grid">
<?php foreach ($plans as $index => $plan): ?>
<?php $isFeatured = $plan['slug'] === 'professional'; ?>
<div class="pricing-card <?php echo $isFeatured ? 'featured' : ''; ?>">
<h3><?php echo htmlspecialchars($plan['name']); ?></h3>
<div class="price price-monthly">
<?php if ($plan['price_monthly'] > 0): ?>
CHF <?php echo number_format($plan['price_monthly'], 0); ?><span>/Monat</span>
<?php else: ?>
Kostenlos
<?php endif; ?>
</div>
<div class="price price-yearly">
<?php if (isset($plan['price_yearly']) && $plan['price_yearly'] > 0): ?>
CHF <?php echo number_format($plan['price_yearly'] / 12, 0); ?><span>/Monat</span>
<div style="font-size: 0.875rem; color: #718096;">
CHF <?php echo number_format($plan['price_yearly'], 0); ?> jährlich
</div>
<?php elseif ($plan['price_monthly'] > 0): ?>
CHF <?php echo number_format($plan['price_monthly'] * 10 / 12, 0); ?><span>/Monat</span>
<div style="font-size: 0.875rem; color: #718096;">
CHF <?php echo number_format($plan['price_monthly'] * 10, 0); ?> jährlich
</div>
<?php else: ?>
Kostenlos
<?php endif; ?>
</div>
<ul>
<?php
$features = is_array($plan['features']) ? $plan['features'] : json_decode($plan['features'], true) ?? [];
$allFeatures = ['max_viewers', 'weather_widget', 'timelapse', 'analytics', 'custom_domain', 'branding', 'priority_support'];
foreach ($allFeatures as $feature):
$hasFeature = !empty($features[$feature]);
$value = $features[$feature] ?? null;
?>
<li class="<?php echo $hasFeature ? 'included' : 'not-included'; ?>">
<?php
if ($feature === 'max_viewers' && $value) {
echo $value === -1 ? 'Unbegrenzte Zuschauer' : "Bis $value Zuschauer";
} elseif ($feature === 'storage_gb' && $value) {
echo "$value GB Speicher";
} else {
echo $featureLabels[$feature] ?? ucfirst(str_replace('_', ' ', $feature));
}
?>
</li>
<?php endforeach; ?>
</ul>
<a href="/onboarding/register.php?plan=<?php echo $plan['slug']; ?>"
class="btn <?php echo $isFeatured || $plan['price_monthly'] > 0 ? 'btn-primary' : 'btn-secondary'; ?>">
<?php echo $plan['price_monthly'] > 0 ? 'Jetzt starten' : 'Kostenlos starten'; ?>
</a>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- FAQ -->
<section class="faq">
<h2>Häufige Fragen</h2>
<div class="faq-item">
<div class="faq-question">
Kann ich jederzeit wechseln oder kündigen?
<span>+</span>
</div>
<div class="faq-answer">
Ja! Sie können Ihren Plan jederzeit upgraden oder downgraden. Bei einer Kündigung bleibt Ihr Zugang bis zum Ende der Abrechnungsperiode aktiv.
</div>
</div>
<div class="faq-item">
<div class="faq-question">
Was passiert nach dem Trial?
<span>+</span>
</div>
<div class="faq-answer">
Nach Ablauf der <?php echo $trialDays; ?> Tage werden Sie automatisch auf den kostenlosen Plan umgestellt, sofern Sie kein Abo abschliessen. Keine Sorge, Ihre Daten bleiben erhalten.
</div>
</div>
<div class="faq-item">
<div class="faq-question">
Welche Zahlungsmethoden werden akzeptiert?
<span>+</span>
</div>
<div class="faq-answer">
Wir akzeptieren alle gängigen Kreditkarten (Visa, Mastercard, American Express) sowie TWINT und Banküberweisung bei Jahresabos.
</div>
</div>
<div class="faq-item">
<div class="faq-question">
Brauche ich technisches Wissen?
<span>+</span>
</div>
<div class="faq-answer">
Nein! Unser Onboarding-Wizard führt Sie Schritt für Schritt durch die Einrichtung. Sie benötigen lediglich eine Stream-URL (HLS/m3u8) von Ihrem Kamera-Anbieter.
</div>
</div>
</section>
<footer class="footer">
© <?php echo date('Y'); ?> Aurora Livecam. Alle Rechte vorbehalten.
</footer>
<script>
// Billing toggle
const toggle = document.getElementById('billing-toggle');
const container = document.getElementById('pricing-container');
toggle.addEventListener('click', () => {
toggle.classList.toggle('yearly');
container.classList.toggle('yearly-mode');
document.querySelector('.monthly-label').classList.toggle('active');
document.querySelector('.yearly-label').classList.toggle('active');
});
// FAQ accordion
document.querySelectorAll('.faq-question').forEach(q => {
q.addEventListener('click', () => {
q.parentElement.classList.toggle('open');
q.querySelector('span').textContent = q.parentElement.classList.contains('open') ? '' : '+';
});
});
</script>
</body>
</html>
+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>
+46 -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,50 @@
}, },
"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
},
"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!";
?>
+4001
View File
File diff suppressed because it is too large Load Diff
+215
View File
@@ -0,0 +1,215 @@
"""
Phase-Locked Timestretcher
==========================
High-quality offline time-stretching using a phase-locked phase vocoder.
This approach keeps the original spectral texture by propagating peak phases
and locking surrounding bins to preserve vertical phase coherence.
Usage:
python phase_locked_vocoder.py input.wav output.wav 10.0
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from typing import Tuple
import numpy as np
from scipy import signal
try:
import soundfile as sf
except ImportError: # pragma: no cover - optional dependency
sf = None
@dataclass
class StretchConfig:
stretch_factor: float = 10.0
window_size: int = 4096
hop_size: int = 1024
peak_threshold_db: float = -60.0
peak_min_distance: int = 3
def stft(audio: np.ndarray, window_size: int, hop_size: int) -> np.ndarray:
window = signal.windows.hann(window_size, sym=False)
n_frames = 1 + (len(audio) - window_size) // hop_size
frames = np.lib.stride_tricks.as_strided(
audio,
shape=(n_frames, window_size),
strides=(audio.strides[0] * hop_size, audio.strides[0]),
writeable=False,
)
windowed = frames * window[None, :]
return np.fft.rfft(windowed, axis=1).T
def istft(stft_matrix: np.ndarray, window_size: int, hop_size: int, length: int) -> np.ndarray:
window = signal.windows.hann(window_size, sym=False)
n_frames = stft_matrix.shape[1]
output = np.zeros(hop_size * (n_frames - 1) + window_size)
window_sums = np.zeros_like(output)
for i in range(n_frames):
frame = np.fft.irfft(stft_matrix[:, i], n=window_size)
start = i * hop_size
output[start:start + window_size] += frame * window
window_sums[start:start + window_size] += window**2
nonzero = window_sums > 1e-8
output[nonzero] /= window_sums[nonzero]
return output[:length]
def detect_peaks(magnitude: np.ndarray, threshold_db: float, min_distance: int) -> np.ndarray:
mag_db = 20 * np.log10(magnitude + 1e-12)
candidates = np.where(
(mag_db[1:-1] > threshold_db)
& (mag_db[1:-1] > mag_db[:-2])
& (mag_db[1:-1] > mag_db[2:])
)[0] + 1
if candidates.size == 0:
return np.array([], dtype=int)
# Enforce minimum distance between peaks
peaks = [candidates[0]]
for idx in candidates[1:]:
if idx - peaks[-1] >= min_distance:
peaks.append(idx)
return np.array(peaks, dtype=int)
def phase_locked_vocoder(
stft_matrix: np.ndarray,
hop_size: int,
stretch_factor: float,
peak_threshold_db: float,
peak_min_distance: int,
) -> np.ndarray:
n_bins, n_frames = stft_matrix.shape
if n_frames < 2:
return stft_matrix
time_steps = np.arange(0, n_frames - 1, 1 / stretch_factor)
output = np.zeros((n_bins, len(time_steps)), dtype=np.complex128)
phase_acc = np.angle(stft_matrix[:, 0])
expected_phase = 2 * np.pi * hop_size * np.arange(n_bins) / (2 * (n_bins - 1))
for t, step in enumerate(time_steps):
idx = int(np.floor(step))
frac = step - idx
if idx + 1 >= n_frames:
break
mag1 = np.abs(stft_matrix[:, idx])
mag2 = np.abs(stft_matrix[:, idx + 1])
mag = (1 - frac) * mag1 + frac * mag2
phase1 = np.angle(stft_matrix[:, idx])
phase2 = np.angle(stft_matrix[:, idx + 1])
phase_diff = phase2 - phase1 - expected_phase
phase_diff = (phase_diff + np.pi) % (2 * np.pi) - np.pi
true_freq = expected_phase + phase_diff
phase_acc += true_freq
peaks = detect_peaks(mag, threshold_db=peak_threshold_db, min_distance=peak_min_distance)
if peaks.size == 0:
output[:, t] = mag * np.exp(1j * phase_acc)
continue
output_phase = phase_acc.copy()
peak_phases = phase_acc[peaks]
analysis_phases = phase1
# Determine regions between peaks
boundaries = [0]
boundaries += [int((peaks[i] + peaks[i + 1]) / 2) for i in range(len(peaks) - 1)]
boundaries.append(n_bins - 1)
for i, peak in enumerate(peaks):
start = boundaries[i]
end = boundaries[i + 1]
if end <= start:
continue
relative_phase = analysis_phases[start:end + 1] - analysis_phases[peak]
output_phase[start:end + 1] = peak_phases[i] + relative_phase
output[:, t] = mag * np.exp(1j * output_phase)
return output
def stretch_audio(audio: np.ndarray, sample_rate: int, config: StretchConfig) -> np.ndarray:
if audio.ndim > 1:
audio = np.mean(audio, axis=1)
audio = audio.astype(np.float64)
audio /= np.max(np.abs(audio)) + 1e-12
if len(audio) < config.window_size:
raise ValueError("Audio is shorter than the analysis window.")
padded = np.pad(audio, (config.window_size // 2, config.window_size // 2), mode="reflect")
stft_matrix = stft(padded, config.window_size, config.hop_size)
stretched_stft = phase_locked_vocoder(
stft_matrix,
hop_size=config.hop_size,
stretch_factor=config.stretch_factor,
peak_threshold_db=config.peak_threshold_db,
peak_min_distance=config.peak_min_distance,
)
output_length = int(len(audio) * config.stretch_factor)
output = istft(stretched_stft, config.window_size, config.hop_size, output_length + config.window_size)
output = output[config.window_size // 2:config.window_size // 2 + output_length]
peak = np.max(np.abs(output))
if peak > 0:
output = 0.95 * output / peak
return output
def stretch_file(input_path: str, output_path: str, config: StretchConfig) -> None:
if sf is None:
raise RuntimeError("soundfile is required for file IO. Install with `pip install soundfile`.")
audio, sr = sf.read(input_path)
result = stretch_audio(audio, sr, config)
sf.write(output_path, result, sr)
def parse_args() -> Tuple[str, str, StretchConfig]:
parser = argparse.ArgumentParser(description="Phase-locked time-stretching")
parser.add_argument("input", help="Input WAV file")
parser.add_argument("output", help="Output WAV file")
parser.add_argument("stretch", type=float, help="Stretch factor (e.g., 10.0)")
parser.add_argument("--window", type=int, default=4096)
parser.add_argument("--hop", type=int, default=1024)
parser.add_argument("--peak-db", type=float, default=-60.0)
parser.add_argument("--peak-distance", type=int, default=3)
args = parser.parse_args()
config = StretchConfig(
stretch_factor=args.stretch,
window_size=args.window,
hop_size=args.hop,
peak_threshold_db=args.peak_db,
peak_min_distance=args.peak_distance,
)
return args.input, args.output, config
def main() -> None:
input_path, output_path, config = parse_args()
stretch_file(input_path, output_path, config)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+772
View File
@@ -0,0 +1,772 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PhotoPro Tools</title>
<link rel="stylesheet" href="css/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<!-- Navigation -->
<nav class="navbar" id="navbar">
<div class="nav-container">
<a href="#hero" class="nav-logo">
<span class="logo-icon">&#9673;</span> PhotoPro Tools
</a>
<!-- Language Switcher -->
<div class="lang-switcher" id="langSwitcher">
<button class="lang-current" id="langCurrent" aria-label="Language">
<span class="lang-flag" id="langFlag">DE</span>
<svg width="10" height="6" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4" stroke="currentColor" fill="none" stroke-width="1.5"/></svg>
</button>
<div class="lang-dropdown" id="langDropdown">
<button class="lang-option active" data-lang="de"><span class="lang-code">DE</span> Deutsch</button>
<button class="lang-option" data-lang="en"><span class="lang-code">EN</span> English</button>
<button class="lang-option" data-lang="fr"><span class="lang-code">FR</span> Fran&ccedil;ais</button>
<button class="lang-option" data-lang="it"><span class="lang-code">IT</span> Italiano</button>
<button class="lang-option" data-lang="sr"><span class="lang-code">SR</span> Srpski</button>
<button class="lang-option" data-lang="sq"><span class="lang-code">SQ</span> Shqip</button>
<button class="lang-option" data-lang="tr"><span class="lang-code">TR</span> T&uuml;rk&ccedil;e</button>
<button class="lang-option" data-lang="sv"><span class="lang-code">SV</span> Svenska</button>
</div>
</div>
<button class="nav-toggle" id="navToggle" aria-label="Menu">
<span></span><span></span><span></span>
</button>
<ul class="nav-menu" id="navMenu">
<li><a href="#lens-calc" class="nav-link" data-i18n="nav.lens">Linsenrechner</a></li>
<li><a href="#composition" class="nav-link" data-i18n="nav.composition">Komposition</a></li>
<li><a href="#motif" class="nav-link" data-i18n="nav.motif">Motiverkennung</a></li>
<li><a href="#exposure" class="nav-link" data-i18n="nav.exposure">Belichtung</a></li>
<li><a href="#quiz" class="nav-link" data-i18n="nav.quiz">Quiz</a></li>
<li><a href="#simtools" class="nav-link" data-i18n="nav.simtools">Simulation</a></li>
</ul>
</div>
</nav>
<!-- Hero Section -->
<section class="hero" id="hero">
<div class="hero-bg">
<div class="hero-particles" id="heroParticles"></div>
</div>
<div class="hero-content">
<h1 class="hero-title">
<span class="hero-subtitle" data-i18n="hero.welcome">Willkommen bei</span>
PhotoPro Tools
</h1>
<p class="hero-description" data-i18n="hero.desc">
Dein ultimativer Fotografie-Werkzeugkasten. Linsenberechnungen, Kompositionsregeln,
Motiverkennung und interaktive Quizze &mdash; alles an einem Ort.
</p>
<div class="hero-stats">
<div class="stat">
<span class="stat-number" data-target="12">0</span>
<span class="stat-label" data-i18n="hero.calculators">Rechner</span>
</div>
<div class="stat">
<span class="stat-number" data-target="8">0</span>
<span class="stat-label" data-i18n="hero.rules">Regeln</span>
</div>
<div class="stat">
<span class="stat-number" data-target="50">0</span>
<span class="stat-label" data-i18n="hero.questions">Quiz-Fragen</span>
</div>
</div>
<a href="#lens-calc" class="hero-cta" data-i18n="hero.cta">Jetzt starten</a>
</div>
<div class="scroll-indicator">
<div class="scroll-arrow"></div>
</div>
</section>
<!-- ==================== LENS CALCULATOR ==================== -->
<section class="section" id="lens-calc">
<div class="container">
<div class="section-header">
<span class="section-tag" data-i18n="lens.tag">Werkzeuge</span>
<h2 class="section-title" data-i18n="lens.title">Linsenrechner</h2>
<p class="section-desc" data-i18n="lens.desc">Berechne Scharfentiefe, Bildwinkel, Crop-Faktor und mehr.</p>
</div>
<div class="calc-tabs">
<button class="calc-tab active" data-calc="dof" data-i18n="lens.tab.dof">Scharfentiefe (DOF)</button>
<button class="calc-tab" data-calc="fov" data-i18n="lens.tab.fov">Bildwinkel (FOV)</button>
<button class="calc-tab" data-calc="crop" data-i18n="lens.tab.crop">Crop-Faktor</button>
<button class="calc-tab" data-calc="hyperfocal" data-i18n="lens.tab.hyper">Hyperfokale Distanz</button>
<button class="calc-tab" data-calc="flash" data-i18n="lens.tab.flash">Blitz-Reichweite</button>
<button class="calc-tab" data-calc="magnification" data-i18n="lens.tab.mag">Abbildungsmassstab</button>
</div>
<!-- DOF Calculator -->
<div class="calc-panel active" id="calc-dof">
<div class="calc-grid">
<div class="calc-inputs">
<h3 data-i18n="dof.title">Scharfentiefe berechnen</h3>
<p class="calc-info" data-i18n="dof.info">Die Scharfentiefe (Depth of Field) gibt den Bereich an, der im Bild scharf abgebildet wird.</p>
<div class="input-group">
<label data-i18n="calc.focal">Brennweite (mm)</label>
<input type="number" id="dof-focal" value="50" min="1" max="2000" step="1">
<input type="range" id="dof-focal-range" value="50" min="1" max="800" step="1">
</div>
<div class="input-group">
<label data-i18n="calc.aperture">Blende (f/)</label>
<input type="number" id="dof-aperture" value="2.8" min="0.7" max="64" step="0.1">
<input type="range" id="dof-aperture-range" value="2.8" min="0.7" max="64" step="0.1">
</div>
<div class="input-group">
<label data-i18n="dof.distance">Entfernung zum Motiv (m)</label>
<input type="number" id="dof-distance" value="5" min="0.1" max="10000" step="0.1">
<input type="range" id="dof-distance-range" value="5" min="0.1" max="100" step="0.1">
</div>
<div class="input-group">
<label data-i18n="calc.sensor">Sensorgr&ouml;sse</label>
<select id="dof-sensor">
<option value="0.043" selected data-i18n-opt="sensor.ff">Vollformat (36x24mm)</option>
<option value="0.029" data-i18n-opt="sensor.apsc">APS-C (23.5x15.6mm)</option>
<option value="0.023" data-i18n-opt="sensor.apsc_canon">APS-C Canon (22.3x14.9mm)</option>
<option value="0.019" data-i18n-opt="sensor.m43">Micro 4/3 (17.3x13mm)</option>
<option value="0.011" data-i18n-opt="sensor.1inch">1 Zoll (13.2x8.8mm)</option>
<option value="0.006" data-i18n-opt="sensor.small">1/2.3 Zoll (6.17x4.55mm)</option>
</select>
</div>
<button class="btn-calculate" onclick="calculateDOF()" data-i18n="calc.calculate">Berechnen</button>
</div>
<div class="calc-results">
<h3 data-i18n="calc.results">Ergebnisse</h3>
<div class="result-card">
<div class="result-item highlight">
<span class="result-label" data-i18n="dof.total">Scharfentiefe</span>
<span class="result-value" id="dof-total">--</span>
</div>
<div class="result-item">
<span class="result-label" data-i18n="dof.near">Nahpunkt</span>
<span class="result-value" id="dof-near">--</span>
</div>
<div class="result-item">
<span class="result-label" data-i18n="dof.far">Fernpunkt</span>
<span class="result-value" id="dof-far">--</span>
</div>
<div class="result-item">
<span class="result-label" data-i18n="dof.coc">Zerstreuungskreis</span>
<span class="result-value" id="dof-coc">--</span>
</div>
</div>
<div class="dof-visualization">
<div class="dof-bar">
<div class="dof-sharp" id="dof-sharp-zone"></div>
<div class="dof-subject" id="dof-subject-marker"></div>
</div>
<div class="dof-labels">
<span id="dof-label-near">0m</span>
<span id="dof-label-subject">5m</span>
<span id="dof-label-far">&infin;</span>
</div>
</div>
</div>
</div>
</div>
<!-- FOV Calculator -->
<div class="calc-panel" id="calc-fov">
<div class="calc-grid">
<div class="calc-inputs">
<h3 data-i18n="fov.title">Bildwinkel berechnen</h3>
<p class="calc-info" data-i18n="fov.info">Der Bildwinkel bestimmt, wie viel der Szene die Kamera erfasst.</p>
<div class="input-group">
<label data-i18n="calc.focal">Brennweite (mm)</label>
<input type="number" id="fov-focal" value="50" min="1" max="2000">
<input type="range" id="fov-focal-range" value="50" min="1" max="800">
</div>
<div class="input-group">
<label data-i18n="calc.sensor">Sensorgr&ouml;sse</label>
<select id="fov-sensor">
<option value="36x24" selected data-i18n-opt="sensor.ff">Vollformat (36x24mm)</option>
<option value="23.5x15.6" data-i18n-opt="sensor.apsc">APS-C (23.5x15.6mm)</option>
<option value="22.3x14.9" data-i18n-opt="sensor.apsc_canon">APS-C Canon (22.3x14.9mm)</option>
<option value="17.3x13" data-i18n-opt="sensor.m43">Micro 4/3 (17.3x13mm)</option>
<option value="13.2x8.8" data-i18n-opt="sensor.1inch">1 Zoll (13.2x8.8mm)</option>
</select>
</div>
<button class="btn-calculate" onclick="calculateFOV()" data-i18n="calc.calculate">Berechnen</button>
</div>
<div class="calc-results">
<h3 data-i18n="calc.results">Ergebnisse</h3>
<div class="result-card">
<div class="result-item highlight">
<span class="result-label" data-i18n="fov.horizontal">Horizontaler Bildwinkel</span>
<span class="result-value" id="fov-horizontal">--</span>
</div>
<div class="result-item">
<span class="result-label" data-i18n="fov.vertical">Vertikaler Bildwinkel</span>
<span class="result-value" id="fov-vertical">--</span>
</div>
<div class="result-item">
<span class="result-label" data-i18n="fov.diagonal">Diagonaler Bildwinkel</span>
<span class="result-value" id="fov-diagonal">--</span>
</div>
<div class="result-item">
<span class="result-label" data-i18n="fov.lenstype">Objektivtyp</span>
<span class="result-value" id="fov-type">--</span>
</div>
</div>
<div class="fov-visualization">
<canvas id="fov-canvas" width="400" height="300"></canvas>
</div>
</div>
</div>
</div>
<!-- Crop Factor Calculator -->
<div class="calc-panel" id="calc-crop">
<div class="calc-grid">
<div class="calc-inputs">
<h3 data-i18n="crop.title">Crop-Faktor berechnen</h3>
<p class="calc-info" data-i18n="crop.info">Der Crop-Faktor zeigt den &auml;quivalenten Bildausschnitt im Vergleich zum Vollformat.</p>
<div class="input-group">
<label data-i18n="crop.lensfocal">Brennweite am Objektiv (mm)</label>
<input type="number" id="crop-focal" value="50" min="1" max="2000">
</div>
<div class="input-group">
<label data-i18n="crop.lensaperture">Blende am Objektiv (f/)</label>
<input type="number" id="crop-aperture" value="1.8" min="0.7" max="64" step="0.1">
</div>
<div class="input-group">
<label data-i18n="crop.camerasensor">Kamerasensor</label>
<select id="crop-sensor">
<option value="1.0" data-i18n-opt="sensor.ff_1x">Vollformat (1.0x)</option>
<option value="1.5" selected data-i18n-opt="sensor.apsc_nikon">APS-C Nikon/Sony (1.5x)</option>
<option value="1.6" data-i18n-opt="sensor.apsc_canon_1_6">APS-C Canon (1.6x)</option>
<option value="2.0" data-i18n-opt="sensor.m43_2x">Micro 4/3 (2.0x)</option>
<option value="2.7" data-i18n-opt="sensor.1inch_2_7">1 Zoll (2.7x)</option>
<option value="5.6" data-i18n-opt="sensor.small_5_6">1/2.3 Zoll (5.6x)</option>
</select>
</div>
<button class="btn-calculate" onclick="calculateCrop()" data-i18n="calc.calculate">Berechnen</button>
</div>
<div class="calc-results">
<h3 data-i18n="calc.results">Ergebnisse</h3>
<div class="result-card">
<div class="result-item highlight">
<span class="result-label" data-i18n="crop.equivfocal">&#196;quivalente Brennweite (KB)</span>
<span class="result-value" id="crop-equiv-focal">--</span>
</div>
<div class="result-item">
<span class="result-label" data-i18n="crop.equivaperture">&#196;quivalente Blende (KB)</span>
<span class="result-value" id="crop-equiv-aperture">--</span>
</div>
<div class="result-item">
<span class="result-label" data-i18n="crop.factor">Crop-Faktor</span>
<span class="result-value" id="crop-factor-result">--</span>
</div>
</div>
<div class="crop-comparison">
<div class="crop-frame fullframe">
<span data-i18n="crop.fullframe">Vollformat</span>
</div>
<div class="crop-frame cropped" id="crop-overlay">
<span id="crop-overlay-label">APS-C</span>
</div>
</div>
</div>
</div>
</div>
<!-- Hyperfocal Distance Calculator -->
<div class="calc-panel" id="calc-hyperfocal">
<div class="calc-grid">
<div class="calc-inputs">
<h3 data-i18n="hyper.title">Hyperfokale Distanz</h3>
<p class="calc-info" data-i18n="hyper.info">Die Entfernung, ab der alles bis unendlich scharf ist.</p>
<div class="input-group">
<label data-i18n="calc.focal">Brennweite (mm)</label>
<input type="number" id="hyper-focal" value="24" min="1" max="2000">
</div>
<div class="input-group">
<label data-i18n="calc.aperture">Blende (f/)</label>
<input type="number" id="hyper-aperture" value="11" min="0.7" max="64" step="0.1">
</div>
<div class="input-group">
<label data-i18n="calc.sensor">Sensorgr&ouml;sse</label>
<select id="hyper-sensor">
<option value="0.030" selected data-i18n-opt="sensor.ff_short">Vollformat</option>
<option value="0.020" data-i18n-opt="sensor.apsc_short">APS-C</option>
<option value="0.015" data-i18n-opt="sensor.m43_short">Micro 4/3</option>
</select>
</div>
<button class="btn-calculate" onclick="calculateHyperfocal()" data-i18n="calc.calculate">Berechnen</button>
</div>
<div class="calc-results">
<h3 data-i18n="calc.results">Ergebnisse</h3>
<div class="result-card">
<div class="result-item highlight">
<span class="result-label" data-i18n="hyper.distance">Hyperfokale Distanz</span>
<span class="result-value" id="hyper-distance">--</span>
</div>
<div class="result-item">
<span class="result-label" data-i18n="hyper.near">Nahpunkt (bei Fokus auf H)</span>
<span class="result-value" id="hyper-near">--</span>
</div>
<div class="result-item">
<span class="result-label" data-i18n="hyper.tiplabel">Tipp</span>
<span class="result-value small" id="hyper-tip" data-i18n="hyper.tip">Fokussiere auf die hyperfokale Distanz f&uuml;r maximale Sch&auml;rfe.</span>
</div>
</div>
</div>
</div>
</div>
<!-- Flash Range Calculator -->
<div class="calc-panel" id="calc-flash">
<div class="calc-grid">
<div class="calc-inputs">
<h3 data-i18n="flash.title">Blitz-Reichweite</h3>
<p class="calc-info" data-i18n="flash.info">Berechne die maximale Blitzreichweite basierend auf Leitzahl und Einstellungen.</p>
<div class="input-group">
<label data-i18n="flash.gn">Leitzahl (GN)</label>
<input type="number" id="flash-gn" value="58" min="1" max="200">
</div>
<div class="input-group">
<label data-i18n="calc.aperture">Blende (f/)</label>
<input type="number" id="flash-aperture" value="5.6" min="0.7" max="64" step="0.1">
</div>
<div class="input-group">
<label>ISO</label>
<select id="flash-iso">
<option value="100" selected>ISO 100</option>
<option value="200">ISO 200</option>
<option value="400">ISO 400</option>
<option value="800">ISO 800</option>
<option value="1600">ISO 1600</option>
<option value="3200">ISO 3200</option>
</select>
</div>
<button class="btn-calculate" onclick="calculateFlash()" data-i18n="calc.calculate">Berechnen</button>
</div>
<div class="calc-results">
<h3 data-i18n="calc.results">Ergebnisse</h3>
<div class="result-card">
<div class="result-item highlight">
<span class="result-label" data-i18n="flash.maxrange">Maximale Reichweite</span>
<span class="result-value" id="flash-range">--</span>
</div>
<div class="result-item">
<span class="result-label" data-i18n="flash.at100">Bei ISO 100</span>
<span class="result-value" id="flash-range-100">--</span>
</div>
</div>
</div>
</div>
</div>
<!-- Magnification Calculator -->
<div class="calc-panel" id="calc-magnification">
<div class="calc-grid">
<div class="calc-inputs">
<h3 data-i18n="mag.title">Abbildungsmassstab</h3>
<p class="calc-info" data-i18n="mag.info">Berechne den Abbildungsmassstab f&uuml;r Makrofotografie.</p>
<div class="input-group">
<label data-i18n="calc.focal">Brennweite (mm)</label>
<input type="number" id="mag-focal" value="100" min="1" max="2000">
</div>
<div class="input-group">
<label data-i18n="mag.mindist">Naheinstellgrenze (cm)</label>
<input type="number" id="mag-min-focus" value="30" min="1" max="10000">
</div>
<div class="input-group">
<label data-i18n="mag.sensorwidth">Sensorbreite (mm)</label>
<input type="number" id="mag-sensor-w" value="36" min="1" max="100" step="0.1">
</div>
<button class="btn-calculate" onclick="calculateMagnification()" data-i18n="calc.calculate">Berechnen</button>
</div>
<div class="calc-results">
<h3 data-i18n="calc.results">Ergebnisse</h3>
<div class="result-card">
<div class="result-item highlight">
<span class="result-label" data-i18n="mag.ratio">Abbildungsmassstab</span>
<span class="result-value" id="mag-ratio">--</span>
</div>
<div class="result-item">
<span class="result-label" data-i18n="mag.field">Erfasstes Feld (Breite)</span>
<span class="result-value" id="mag-field">--</span>
</div>
<div class="result-item">
<span class="result-label" data-i18n="mag.macro">Makro-Tauglichkeit</span>
<span class="result-value" id="mag-macro">--</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ==================== COMPOSITION RULES ==================== -->
<section class="section section-dark" id="composition">
<div class="container">
<div class="section-header">
<span class="section-tag" data-i18n="comp.tag">Komposition</span>
<h2 class="section-title" data-i18n="comp.title">Fotografie-Regeln</h2>
<p class="section-desc" data-i18n="comp.desc">Beherrsche die wichtigsten Kompositionsregeln f&uuml;r beeindruckende Fotos.</p>
</div>
<div class="rules-grid" id="rulesGrid">
<!-- Generated by JS for i18n -->
</div>
</div>
</section>
<!-- ==================== MOTIF RECOGNITION ==================== -->
<section class="section" id="motif">
<div class="container">
<div class="section-header">
<span class="section-tag" data-i18n="motif.tag">Motiverkennung</span>
<h2 class="section-title" data-i18n="motif.title">Motiverkennung &amp; Genres</h2>
<p class="section-desc" data-i18n="motif.desc">Lerne verschiedene Fotografie-Genres und ihre optimalen Einstellungen kennen.</p>
</div>
<div class="motif-filter" id="motifFilter">
<!-- Generated by JS for i18n -->
</div>
<div class="motif-grid" id="motifGrid">
<!-- Generated by JS -->
</div>
</div>
</section>
<!-- ==================== EXPOSURE TRIANGLE ==================== -->
<section class="section section-dark" id="exposure">
<div class="container">
<div class="section-header">
<span class="section-tag" data-i18n="exp.tag">Grundlagen</span>
<h2 class="section-title" data-i18n="exp.title">Belichtungsdreieck</h2>
<p class="section-desc" data-i18n="exp.desc">Verstehe das Zusammenspiel von Blende, Verschlusszeit und ISO.</p>
</div>
<div class="exposure-interactive">
<div class="exposure-triangle-visual">
<canvas id="exposure-canvas" width="500" height="450"></canvas>
</div>
<div class="exposure-controls">
<div class="exposure-param">
<div class="param-header">
<h3 data-i18n="exp.aperture">Blende (Aperture)</h3>
<span class="param-value" id="exp-aperture-val">f/5.6</span>
</div>
<input type="range" id="exp-aperture" min="0" max="9" step="1" value="4">
<div class="param-scale">
<span>f/1.4</span><span>f/2</span><span>f/2.8</span><span>f/4</span><span>f/5.6</span><span>f/8</span><span>f/11</span><span>f/16</span><span>f/22</span><span>f/32</span>
</div>
<p class="param-desc" id="exp-aperture-desc" data-i18n="exp.aperture.mid">Mittlere Blende &ndash; guter Kompromiss aus Sch&auml;rfe und Licht.</p>
</div>
<div class="exposure-param">
<div class="param-header">
<h3 data-i18n="exp.shutter">Verschlusszeit (Shutter)</h3>
<span class="param-value" id="exp-shutter-val">1/125s</span>
</div>
<input type="range" id="exp-shutter" min="0" max="11" step="1" value="5">
<div class="param-scale">
<span>30s</span><span>15s</span><span>1s</span><span>1/4</span><span>1/30</span><span>1/125</span><span>1/250</span><span>1/500</span><span>1/1000</span><span>1/2000</span><span>1/4000</span><span>1/8000</span>
</div>
<p class="param-desc" id="exp-shutter-desc" data-i18n="exp.shutter.mid">Standard-Verschlusszeit &ndash; friert die meisten Bewegungen ein.</p>
</div>
<div class="exposure-param">
<div class="param-header">
<h3>ISO</h3>
<span class="param-value" id="exp-iso-val">ISO 400</span>
</div>
<input type="range" id="exp-iso" min="0" max="8" step="1" value="2">
<div class="param-scale">
<span>100</span><span>200</span><span>400</span><span>800</span><span>1600</span><span>3200</span><span>6400</span><span>12800</span><span>25600</span>
</div>
<p class="param-desc" id="exp-iso-desc" data-i18n="exp.iso.low">Niedriges ISO &ndash; minimales Rauschen, beste Qualit&auml;t.</p>
</div>
<div class="exposure-result">
<div class="ev-meter">
<div class="ev-bar">
<div class="ev-indicator" id="ev-indicator"></div>
</div>
<div class="ev-labels">
<span>-3 EV</span><span>-2</span><span>-1</span><span>0</span><span>+1</span><span>+2</span><span>+3 EV</span>
</div>
</div>
<p class="ev-text" id="ev-text" data-i18n="exp.correct">Korrekte Belichtung</p>
</div>
</div>
</div>
</div>
</section>
<!-- ==================== QUIZ SECTION ==================== -->
<section class="section" id="quiz">
<div class="container">
<div class="section-header">
<span class="section-tag" data-i18n="quiz.tag">Lernkontrolle</span>
<h2 class="section-title" data-i18n="quiz.title">Fotografie-Quiz</h2>
<p class="section-desc" data-i18n="quiz.desc">Teste dein Wissen mit interaktiven Quizfragen zu allen Themen.</p>
</div>
<div class="quiz-categories" id="quizCategories">
<!-- Generated by JS for i18n -->
</div>
<div class="quiz-container" id="quizContainer">
<div class="quiz-start" id="quizStart">
<div class="quiz-start-icon">&#127909;</div>
<h3 data-i18n="quiz.ready">Bereit f&uuml;r das Quiz?</h3>
<p data-i18n="quiz.choose">W&auml;hle eine Kategorie und teste dein Fotografie-Wissen!</p>
<p class="quiz-info" data-i18n="quiz.info">10 Fragen pro Runde &bull; Multiple Choice &bull; Sofortiges Feedback</p>
<button class="btn-quiz-start" onclick="startQuiz('all')" data-i18n="quiz.start">Quiz starten</button>
</div>
<div class="quiz-active" id="quizActive" style="display:none">
<div class="quiz-progress">
<div class="quiz-progress-bar">
<div class="quiz-progress-fill" id="quizProgress"></div>
</div>
<span class="quiz-progress-text" id="quizProgressText">1 / 10</span>
</div>
<div class="quiz-score-bar">
<span class="quiz-score" id="quizScore"></span>
<span class="quiz-timer" id="quizTimer">00:00</span>
</div>
<div class="quiz-question-card" id="quizQuestionCard">
<span class="quiz-category-badge" id="quizCategoryBadge"></span>
<h3 class="quiz-question" id="quizQuestion"></h3>
<div class="quiz-options" id="quizOptions"></div>
<div class="quiz-explanation" id="quizExplanation" style="display:none">
<p id="quizExplanationText"></p>
</div>
<button class="btn-quiz-next" id="quizNextBtn" style="display:none" onclick="nextQuestion()" data-i18n="quiz.next">N&auml;chste Frage</button>
</div>
</div>
<div class="quiz-results" id="quizResults" style="display:none">
<div class="results-circle">
<svg viewBox="0 0 120 120">
<circle cx="60" cy="60" r="54" class="results-circle-bg"/>
<circle cx="60" cy="60" r="54" class="results-circle-fill" id="resultsCircle"/>
</svg>
<span class="results-percent" id="resultsPercent">0%</span>
</div>
<h3 class="results-title" id="resultsTitle" data-i18n="quiz.finished">Quiz beendet!</h3>
<p class="results-text" id="resultsText"></p>
<div class="results-breakdown" id="resultsBreakdown"></div>
<div class="results-actions">
<button class="btn-quiz-restart" onclick="startQuiz('all')" data-i18n="quiz.playagain">Nochmal spielen</button>
<button class="btn-quiz-review" onclick="reviewAnswers()" data-i18n="quiz.review">Antworten ansehen</button>
</div>
</div>
</div>
</div>
</section>
<!-- ==================== SIMULATION TOOLS ==================== -->
<section class="section section-dark" id="simtools">
<div class="container">
<div class="section-header">
<span class="section-tag" data-i18n="sim.tag">Simulation</span>
<h2 class="section-title" data-i18n="sim.title">Foto-Simulationen</h2>
<p class="section-desc" data-i18n="sim.desc">Simuliere verschiedene Foto-Effekte in Echtzeit.</p>
</div>
<div class="sim-tabs">
<button class="sim-tab active" data-sim="bokeh" data-i18n="sim.bokeh">Bokeh</button>
<button class="sim-tab" data-sim="longexp" data-i18n="sim.longexp">Langzeitbelichtung</button>
<button class="sim-tab" data-sim="wb" data-i18n="sim.wb">Wei&szlig;abgleich</button>
<button class="sim-tab" data-sim="noise" data-i18n="sim.noise">ISO-Rauschen</button>
<button class="sim-tab" data-sim="perspective" data-i18n="sim.perspective">Perspektive</button>
<button class="sim-tab" data-sim="histogram" data-i18n="sim.histogram">Histogramm</button>
</div>
<!-- Bokeh Simulator -->
<div class="sim-panel active" id="sim-bokeh">
<div class="sim-grid">
<div class="sim-controls">
<h3 data-i18n="sim.bokeh.title">Bokeh-Simulator</h3>
<p class="sim-info" data-i18n="sim.bokeh.info">Sieh wie sich Bokeh mit verschiedenen Blenden und Brennweiten ver&auml;ndert.</p>
<div class="input-group">
<label data-i18n="calc.aperture">Blende (f/)</label>
<input type="range" id="sim-bokeh-aperture" min="1.4" max="22" step="0.1" value="2.8">
<span class="range-value" id="sim-bokeh-aperture-val">f/2.8</span>
</div>
<div class="input-group">
<label data-i18n="sim.bokeh.blades">Blendenlamellen</label>
<input type="range" id="sim-bokeh-blades" min="5" max="11" step="1" value="7">
<span class="range-value" id="sim-bokeh-blades-val">7</span>
</div>
<div class="input-group">
<label data-i18n="sim.bokeh.intensity">Bokeh-Intensit&auml;t</label>
<input type="range" id="sim-bokeh-intensity" min="0" max="100" step="1" value="70">
<span class="range-value" id="sim-bokeh-intensity-val">70%</span>
</div>
</div>
<div class="sim-canvas-wrap">
<canvas id="sim-bokeh-canvas" width="500" height="400"></canvas>
</div>
</div>
</div>
<!-- Long Exposure Simulator -->
<div class="sim-panel" id="sim-longexp">
<div class="sim-grid">
<div class="sim-controls">
<h3 data-i18n="sim.longexp.title">Langzeitbelichtungs-Simulator</h3>
<p class="sim-info" data-i18n="sim.longexp.info">Sieh den Effekt verschiedener Belichtungszeiten auf Bewegung.</p>
<div class="input-group">
<label data-i18n="sim.longexp.time">Belichtungszeit (s)</label>
<input type="range" id="sim-longexp-time" min="0" max="8" step="1" value="3">
<span class="range-value" id="sim-longexp-time-val">1/4s</span>
</div>
<div class="input-group">
<label data-i18n="sim.longexp.speed">Bewegungsgeschwindigkeit</label>
<input type="range" id="sim-longexp-speed" min="1" max="10" step="1" value="5">
<span class="range-value" id="sim-longexp-speed-val">5</span>
</div>
</div>
<div class="sim-canvas-wrap">
<canvas id="sim-longexp-canvas" width="500" height="400"></canvas>
</div>
</div>
</div>
<!-- White Balance Simulator -->
<div class="sim-panel" id="sim-wb">
<div class="sim-grid">
<div class="sim-controls">
<h3 data-i18n="sim.wb.title">Wei&szlig;abgleich-Simulator</h3>
<p class="sim-info" data-i18n="sim.wb.info">Sieh wie die Farbtemperatur dein Bild beeinflusst.</p>
<div class="input-group">
<label data-i18n="sim.wb.kelvin">Temperatur (K)</label>
<input type="range" id="sim-wb-kelvin" min="2000" max="12000" step="100" value="5500">
<span class="range-value" id="sim-wb-kelvin-val">5500K</span>
</div>
<div class="input-group">
<label data-i18n="sim.wb.tint">T&ouml;nung</label>
<input type="range" id="sim-wb-tint" min="-50" max="50" step="1" value="0">
<span class="range-value" id="sim-wb-tint-val">0</span>
</div>
</div>
<div class="sim-canvas-wrap">
<canvas id="sim-wb-canvas" width="500" height="400"></canvas>
</div>
</div>
</div>
<!-- ISO Noise Simulator -->
<div class="sim-panel" id="sim-noise">
<div class="sim-grid">
<div class="sim-controls">
<h3 data-i18n="sim.noise.title">ISO-Rausch-Simulator</h3>
<p class="sim-info" data-i18n="sim.noise.info">Sieh wie digitales Rauschen mit h&ouml;heren ISO-Werten zunimmt.</p>
<div class="input-group">
<label>ISO</label>
<input type="range" id="sim-noise-iso" min="0" max="8" step="1" value="2">
<span class="range-value" id="sim-noise-iso-val">ISO 400</span>
</div>
<div class="input-group">
<label data-i18n="sim.noise.level">Rausch-Level</label>
<input type="range" id="sim-noise-level" min="0" max="100" step="1" value="50">
<span class="range-value" id="sim-noise-level-val">50%</span>
</div>
</div>
<div class="sim-canvas-wrap">
<canvas id="sim-noise-canvas" width="500" height="400"></canvas>
</div>
</div>
</div>
<!-- Perspective Simulator -->
<div class="sim-panel" id="sim-perspective">
<div class="sim-grid">
<div class="sim-controls">
<h3 data-i18n="sim.perspective.title">Perspektiv-Simulator</h3>
<p class="sim-info" data-i18n="sim.perspective.info">Sieh wie die Brennweite die Perspektive beeinflusst.</p>
<div class="input-group">
<label data-i18n="calc.focal">Brennweite (mm)</label>
<input type="range" id="sim-perspective-focal" min="14" max="200" step="1" value="50">
<span class="range-value" id="sim-perspective-focal-val">50mm</span>
</div>
<div class="input-group">
<label data-i18n="sim.perspective.type">Verzerrungstyp</label>
<select id="sim-perspective-type">
<option value="barrel">Barrel</option>
<option value="pincushion">Pincushion</option>
<option value="none" selected>None</option>
</select>
</div>
</div>
<div class="sim-canvas-wrap">
<canvas id="sim-perspective-canvas" width="500" height="400"></canvas>
</div>
</div>
</div>
<!-- Histogram Simulator -->
<div class="sim-panel" id="sim-histogram">
<div class="sim-grid">
<div class="sim-controls">
<h3 data-i18n="sim.histogram.title">Histogramm-Simulator</h3>
<p class="sim-info" data-i18n="sim.histogram.info">Sieh das Histogramm basierend auf Belichtungseinstellungen.</p>
<div class="input-group">
<label data-i18n="exp.aperture">Blende</label>
<input type="range" id="sim-hist-aperture" min="0" max="9" step="1" value="4">
<span class="range-value" id="sim-hist-aperture-val">f/5.6</span>
</div>
<div class="input-group">
<label data-i18n="exp.shutter">Verschlusszeit</label>
<input type="range" id="sim-hist-shutter" min="0" max="11" step="1" value="5">
<span class="range-value" id="sim-hist-shutter-val">1/125s</span>
</div>
<div class="input-group">
<label>ISO</label>
<input type="range" id="sim-hist-iso" min="0" max="8" step="1" value="2">
<span class="range-value" id="sim-hist-iso-val">ISO 400</span>
</div>
</div>
<div class="sim-canvas-wrap">
<canvas id="sim-histogram-canvas" width="500" height="400"></canvas>
</div>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="container">
<div class="footer-grid">
<div class="footer-brand">
<span class="logo-icon">&#9673;</span> PhotoPro Tools
<p data-i18n="footer.brand">Dein kostenloser Fotografie-Werkzeugkasten f&uuml;r bessere Bilder.</p>
</div>
<div class="footer-links">
<h4 data-i18n="footer.tools">Werkzeuge</h4>
<a href="#lens-calc" data-i18n="nav.lens">Linsenrechner</a>
<a href="#composition" data-i18n="footer.comprules">Kompositionsregeln</a>
<a href="#motif" data-i18n="nav.motif">Motiverkennung</a>
<a href="#exposure" data-i18n="footer.exptriangle">Belichtungsdreieck</a>
<a href="#quiz">Quiz</a>
<a href="#simtools" data-i18n="nav.simtools">Simulation</a>
</div>
<div class="footer-links">
<h4 data-i18n="footer.calcs">Rechner</h4>
<a href="#lens-calc" data-i18n="dof.total">Scharfentiefe</a>
<a href="#lens-calc" data-i18n="fov.title">Bildwinkel</a>
<a href="#lens-calc" data-i18n="crop.factor">Crop-Faktor</a>
<a href="#lens-calc" data-i18n="hyper.title">Hyperfokale Distanz</a>
<a href="#lens-calc" data-i18n="flash.title">Blitz-Reichweite</a>
</div>
</div>
<div class="footer-bottom">
<p data-i18n="footer.copy">&copy; 2026 PhotoPro Tools &mdash; Erstellt mit Leidenschaft f&uuml;r die Fotografie.</p>
</div>
</div>
</footer>
<script src="js/i18n.js"></script>
<script src="js/app.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+371
View File
@@ -0,0 +1,371 @@
/* ==================== i18n - Internationalization System ==================== */
window.currentLang = 'de';
window.I18N = {
/* ===== DEUTSCH ===== */
de: {
"nav.lens":"Linsenrechner","nav.composition":"Komposition","nav.motif":"Motiverkennung","nav.exposure":"Belichtung","nav.quiz":"Quiz",
"hero.welcome":"Willkommen bei","hero.desc":"Dein ultimativer Fotografie-Werkzeugkasten. Linsenberechnungen, Kompositionsregeln, Motiverkennung und interaktive Quizze \u2014 alles an einem Ort.","hero.calculators":"Rechner","hero.rules":"Regeln","hero.questions":"Quiz-Fragen","hero.cta":"Jetzt starten",
"lens.tag":"Werkzeuge","lens.title":"Linsenrechner","lens.desc":"Berechne Sch\u00e4rfentiefe, Bildwinkel, Crop-Faktor und mehr.","lens.tab.dof":"Sch\u00e4rfentiefe (DOF)","lens.tab.fov":"Bildwinkel (FOV)","lens.tab.crop":"Crop-Faktor","lens.tab.hyper":"Hyperfokale Distanz","lens.tab.flash":"Blitz-Reichweite","lens.tab.mag":"Abbildungsma\u00dfstab",
"calc.focal":"Brennweite (mm)","calc.aperture":"Blende (f/)","calc.sensor":"Sensorgr\u00f6\u00dfe","calc.calculate":"Berechnen","calc.results":"Ergebnisse",
"dof.title":"Sch\u00e4rfentiefe berechnen","dof.info":"Die Sch\u00e4rfentiefe (Depth of Field) gibt den Bereich an, der im Bild scharf abgebildet wird.","dof.distance":"Entfernung zum Motiv (m)","dof.total":"Sch\u00e4rfentiefe","dof.near":"Nahpunkt","dof.far":"Fernpunkt","dof.coc":"Zerstreuungskreis",
"fov.title":"Bildwinkel berechnen","fov.info":"Der Bildwinkel bestimmt, wie viel der Szene die Kamera erfasst.","fov.horizontal":"Horizontaler Bildwinkel","fov.vertical":"Vertikaler Bildwinkel","fov.diagonal":"Diagonaler Bildwinkel","fov.lenstype":"Objektivtyp",
"crop.title":"Crop-Faktor berechnen","crop.info":"Der Crop-Faktor zeigt den \u00e4quivalenten Bildausschnitt im Vergleich zum Vollformat.","crop.lensfocal":"Brennweite am Objektiv (mm)","crop.lensaperture":"Blende am Objektiv (f/)","crop.camerasensor":"Kamerasensor","crop.equivfocal":"\u00c4quivalente Brennweite (KB)","crop.equivaperture":"\u00c4quivalente Blende (KB)","crop.factor":"Crop-Faktor","crop.fullframe":"Vollformat",
"hyper.title":"Hyperfokale Distanz","hyper.info":"Die Entfernung, ab der alles bis unendlich scharf ist.","hyper.distance":"Hyperfokale Distanz","hyper.near":"Nahpunkt (bei Fokus auf H)","hyper.tiplabel":"Tipp","hyper.tip":"Fokussiere auf die hyperfokale Distanz f\u00fcr maximale Sch\u00e4rfe.",
"flash.title":"Blitz-Reichweite","flash.info":"Berechne die maximale Blitzreichweite basierend auf Leitzahl und Einstellungen.","flash.gn":"Leitzahl (GN)","flash.maxrange":"Maximale Reichweite","flash.at100":"Bei ISO 100",
"mag.title":"Abbildungsma\u00dfstab","mag.info":"Berechne den Abbildungsma\u00dfstab f\u00fcr Makrofotografie.","mag.mindist":"Naheinstellgrenze (cm)","mag.sensorwidth":"Sensorbreite (mm)","mag.ratio":"Abbildungsma\u00dfstab","mag.field":"Erfasstes Feld (Breite)","mag.macro":"Makro-Tauglichkeit",
"comp.tag":"Komposition","comp.title":"Fotografie-Regeln","comp.desc":"Beherrsche die wichtigsten Kompositionsregeln f\u00fcr beeindruckende Fotos.","comp.demo":"Demo anzeigen",
"motif.tag":"Motiverkennung","motif.title":"Motiverkennung & Genres","motif.desc":"Lerne verschiedene Fotografie-Genres und ihre optimalen Einstellungen kennen.","motif.all":"Alle","motif.portrait":"Portr\u00e4t","motif.landscape":"Landschaft","motif.street":"Street","motif.macro":"Makro","motif.night":"Nacht","motif.sport":"Sport","motif.architecture":"Architektur","motif.wildlife":"Wildlife",
"exp.tag":"Grundlagen","exp.title":"Belichtungsdreieck","exp.desc":"Verstehe das Zusammenspiel von Blende, Verschlusszeit und ISO.","exp.aperture":"Blende (Aperture)","exp.shutter":"Verschlusszeit (Shutter)","exp.aperture.mid":"Mittlere Blende \u2013 guter Kompromiss aus Sch\u00e4rfe und Licht.","exp.shutter.mid":"Standard-Verschlusszeit \u2013 friert die meisten Bewegungen ein.","exp.iso.low":"Niedriges ISO \u2013 minimales Rauschen, beste Qualit\u00e4t.","exp.correct":"Korrekte Belichtung","exp.under":"Unterbelichtet","exp.over":"\u00dcberbelichtet","exp.slightunder":"Leicht unterbelichtet","exp.slightover":"Leicht \u00fcberbelichtet",
"quiz.tag":"Lernkontrolle","quiz.title":"Fotografie-Quiz","quiz.desc":"Teste dein Wissen mit interaktiven Quizfragen zu allen Themen.","quiz.all":"Alle Themen","quiz.basics":"Grundlagen","quiz.composition":"Komposition","quiz.lenses":"Objektive","quiz.exposure":"Belichtung","quiz.genres":"Genres","quiz.ready":"Bereit f\u00fcr das Quiz?","quiz.choose":"W\u00e4hle eine Kategorie und teste dein Fotografie-Wissen!","quiz.info":"10 Fragen pro Runde \u2022 Multiple Choice \u2022 Sofortiges Feedback","quiz.start":"Quiz starten","quiz.next":"N\u00e4chste Frage","quiz.finished":"Quiz beendet!","quiz.playagain":"Nochmal spielen","quiz.review":"Antworten ansehen","quiz.score":"Punkte","quiz.resulttext":"Du hast {0} von {1} Fragen richtig beantwortet.","quiz.excellent":"Ausgezeichnet! Du bist ein Fotografie-Profi!","quiz.good":"Gut gemacht! Solides Wissen!","quiz.ok":"Nicht schlecht! Weiter \u00fcben!","quiz.needwork":"Da geht noch mehr! Lerne die Grundlagen nochmal.",
"footer.brand":"Dein kostenloser Fotografie-Werkzeugkasten f\u00fcr bessere Bilder.","footer.tools":"Werkzeuge","footer.calcs":"Rechner","footer.comprules":"Kompositionsregeln","footer.exptriangle":"Belichtungsdreieck","footer.copy":"\u00a9 2026 PhotoPro Tools \u2014 Erstellt mit Leidenschaft f\u00fcr die Fotografie.",
"sensor.ff":"Vollformat (36x24mm)","sensor.apsc":"APS-C (23.5x15.6mm)","sensor.apsc_canon":"APS-C Canon (22.3x14.9mm)","sensor.m43":"Micro 4/3 (17.3x13mm)","sensor.1inch":"1 Zoll (13.2x8.8mm)","sensor.small":"1/2.3 Zoll (6.17x4.55mm)","sensor.ff_short":"Vollformat","sensor.apsc_short":"APS-C","sensor.m43_short":"Micro 4/3","sensor.ff_1x":"Vollformat (1.0x)","sensor.apsc_nikon":"APS-C Nikon/Sony (1.5x)","sensor.apsc_canon_1_6":"APS-C Canon (1.6x)","sensor.m43_2x":"Micro 4/3 (2.0x)","sensor.1inch_2_7":"1 Zoll (2.7x)","sensor.small_5_6":"1/2.3 Zoll (5.6x)",
"lenstype.superwide":"Super-Weitwinkel","lenstype.wide":"Weitwinkel","lenstype.normal":"Normalobjektiv","lenstype.tele":"Teleobjektiv","lenstype.supertele":"Super-Teleobjektiv",
"macro.true":"Echtes Makro (1:1+)","macro.half":"Halbes Makro (~1:2)","macro.close":"Nahaufnahme","macro.no":"Kein Makro",
"rule.thirds.name":"Drittel-Regel","rule.thirds.desc":"Teile das Bild in 9 gleiche Felder. Platziere wichtige Elemente auf den Linien oder Schnittpunkten.","rule.thirds.tip1":"Horizont auf obere oder untere Drittellinie","rule.thirds.tip2":"Augen des Motivs auf obere Schnittpunkte","rule.thirds.tip3":"Hauptmotiv nie genau in die Mitte",
"rule.golden.name":"Goldener Schnitt","rule.golden.desc":"Das Verh\u00e4ltnis von 1:1,618 \u2013 die perfekte Proportion der Natur.","rule.golden.tip1":"Teilt das Bild im Verh\u00e4ltnis ca. 62% zu 38%","rule.golden.tip2":"Wirkt harmonischer als die Drittelregel","rule.golden.tip3":"In der Natur \u00fcberall zu finden (Muscheln, Blumen)",
"rule.leading.name":"F\u00fchrende Linien","rule.leading.desc":"Nat\u00fcrliche Linien leiten den Blick des Betrachters zum Hauptmotiv.","rule.leading.tip1":"Stra\u00dfen, Fl\u00fcsse, Z\u00e4une als Linien nutzen","rule.leading.tip2":"Linien sollten ins Bild hineinf\u00fchren","rule.leading.tip3":"Konvergierende Linien erzeugen Tiefe",
"rule.symmetry.name":"Symmetrie & Muster","rule.symmetry.desc":"Symmetrische Kompositionen strahlen Ruhe und Perfektion aus.","rule.symmetry.tip1":"Spiegelungen in Wasser perfekt f\u00fcr Symmetrie","rule.symmetry.tip2":"Architektur bietet nat\u00fcrliche Symmetrie","rule.symmetry.tip3":"Bewusstes Brechen der Symmetrie als Stilmittel",
"rule.framing.name":"Nat\u00fcrlicher Rahmen","rule.framing.desc":"Verwende Elemente in der Szene, um dein Motiv einzurahmen.","rule.framing.tip1":"T\u00fcrb\u00f6gen, Fenster, \u00c4ste als Rahmen","rule.framing.tip2":"Lenkt den Blick auf das Hauptmotiv","rule.framing.tip3":"Erzeugt Tiefe und Kontext",
"rule.negative.name":"Negativer Raum","rule.negative.desc":"Leerer Raum um das Motiv erzeugt Wirkung und Dramatik.","rule.negative.tip1":"Weniger ist mehr \u2013 Minimalismus nutzen","rule.negative.tip2":"Gibt dem Motiv Luft zum Atmen","rule.negative.tip3":"Besonders wirkungsvoll bei Portr\u00e4ts",
"rule.diagonal.name":"Diagonalen","rule.diagonal.desc":"Diagonale Linien erzeugen Dynamik und Spannung im Bild.","rule.diagonal.tip1":"Von Ecke zu Ecke f\u00fcr maximale Dynamik","rule.diagonal.tip2":"Schr\u00e4ge Perspektiven nutzen","rule.diagonal.tip3":"Bewegungsrichtung entlang der Diagonale",
"rule.color.name":"Farbtheorie","rule.color.desc":"Komplement\u00e4rfarben und Farbharmonien f\u00fcr starke Bildwirkung.","rule.color.tip1":"Komplement\u00e4rfarben f\u00fcr Kontrast (Blau/Orange)","rule.color.tip2":"Analoge Farben f\u00fcr Harmonie","rule.color.tip3":"Warme Farben im Vordergrund, kalte im Hintergrund",
"motif.portrait.title":"Portr\u00e4tfotografie","motif.portrait.desc":"Menschen und Gesichter perfekt in Szene setzen.","motif.landscape.title":"Landschaftsfotografie","motif.landscape.desc":"Weite Landschaften und Naturszenen einfangen.","motif.street.title":"Street Photography","motif.street.desc":"Das Leben auf der Stra\u00dfe authentisch dokumentieren.","motif.macro.title":"Makrofotografie","motif.macro.desc":"Kleine Dinge ganz gro\u00df darstellen.","motif.night.title":"Nachtfotografie","motif.night.desc":"Sterne, Stadtlichter und n\u00e4chtliche Szenen.","motif.sport.title":"Sportfotografie","motif.sport.desc":"Schnelle Bewegungen und Action einfrieren.","motif.architecture.title":"Architekturfotografie","motif.architecture.desc":"Geb\u00e4ude und Strukturen in Perfektion.","motif.wildlife.title":"Tierfotografie","motif.wildlife.desc":"Tiere in ihrer nat\u00fcrlichen Umgebung.",
"motif.setting.lens":"Objektiv","motif.setting.aperture":"Blende","motif.setting.iso":"ISO","motif.setting.shutter":"Verschluss",
"nav.simtools":"Simulation","sim.tag":"Simulation","sim.title":"Foto-Simulationen","sim.desc":"Simuliere verschiedene Foto-Effekte in Echtzeit.",
"sim.bokeh":"Bokeh","sim.longexp":"Langzeitbelichtung","sim.wb":"Weißabgleich","sim.noise":"ISO-Rauschen","sim.perspective":"Perspektive","sim.histogram":"Histogramm",
"sim.bokeh.title":"Bokeh-Simulator","sim.bokeh.info":"Sieh wie sich Bokeh mit verschiedenen Blenden und Brennweiten verändert.","sim.bokeh.blades":"Blendenlamellen","sim.bokeh.intensity":"Bokeh-Intensität",
"sim.longexp.title":"Langzeitbelichtungs-Simulator","sim.longexp.info":"Sieh den Effekt verschiedener Belichtungszeiten auf Bewegung.","sim.longexp.speed":"Bewegungsgeschwindigkeit","sim.longexp.time":"Belichtungszeit (s)",
"sim.wb.title":"Weißabgleich-Simulator","sim.wb.info":"Sieh wie die Farbtemperatur dein Bild beeinflusst.","sim.wb.kelvin":"Temperatur (K)","sim.wb.tint":"Tönung",
"sim.noise.title":"ISO-Rausch-Simulator","sim.noise.info":"Sieh wie digitales Rauschen mit höheren ISO-Werten zunimmt.","sim.noise.level":"Rausch-Level",
"sim.perspective.title":"Perspektiv-Simulator","sim.perspective.info":"Sieh wie die Brennweite die Perspektive beeinflusst.","sim.perspective.type":"Verzerrungstyp",
"sim.histogram.title":"Histogramm-Simulator","sim.histogram.info":"Sieh das Histogramm basierend auf Belichtungseinstellungen."
},
/* ===== ENGLISH ===== */
en: {
"nav.lens":"Lens Calculator","nav.composition":"Composition","nav.motif":"Subject Recognition","nav.exposure":"Exposure","nav.quiz":"Quiz",
"hero.welcome":"Welcome to","hero.desc":"Your ultimate photography toolkit. Lens calculations, composition rules, subject recognition and interactive quizzes \u2014 all in one place.","hero.calculators":"Calculators","hero.rules":"Rules","hero.questions":"Quiz Questions","hero.cta":"Get Started",
"lens.tag":"Tools","lens.title":"Lens Calculator","lens.desc":"Calculate depth of field, field of view, crop factor and more.","lens.tab.dof":"Depth of Field (DOF)","lens.tab.fov":"Field of View (FOV)","lens.tab.crop":"Crop Factor","lens.tab.hyper":"Hyperfocal Distance","lens.tab.flash":"Flash Range","lens.tab.mag":"Magnification",
"calc.focal":"Focal Length (mm)","calc.aperture":"Aperture (f/)","calc.sensor":"Sensor Size","calc.calculate":"Calculate","calc.results":"Results",
"dof.title":"Calculate Depth of Field","dof.info":"Depth of Field (DOF) describes the range that appears sharp in the image.","dof.distance":"Distance to Subject (m)","dof.total":"Depth of Field","dof.near":"Near Point","dof.far":"Far Point","dof.coc":"Circle of Confusion",
"fov.title":"Calculate Field of View","fov.info":"The field of view determines how much of the scene the camera captures.","fov.horizontal":"Horizontal FOV","fov.vertical":"Vertical FOV","fov.diagonal":"Diagonal FOV","fov.lenstype":"Lens Type",
"crop.title":"Calculate Crop Factor","crop.info":"The crop factor shows the equivalent field of view compared to full frame.","crop.lensfocal":"Lens Focal Length (mm)","crop.lensaperture":"Lens Aperture (f/)","crop.camerasensor":"Camera Sensor","crop.equivfocal":"Equivalent Focal Length (FF)","crop.equivaperture":"Equivalent Aperture (FF)","crop.factor":"Crop Factor","crop.fullframe":"Full Frame",
"hyper.title":"Hyperfocal Distance","hyper.info":"The distance at which everything from half that distance to infinity is sharp.","hyper.distance":"Hyperfocal Distance","hyper.near":"Near Point (focused at H)","hyper.tiplabel":"Tip","hyper.tip":"Focus at the hyperfocal distance for maximum sharpness.",
"flash.title":"Flash Range","flash.info":"Calculate maximum flash range based on guide number and settings.","flash.gn":"Guide Number (GN)","flash.maxrange":"Maximum Range","flash.at100":"At ISO 100",
"mag.title":"Magnification","mag.info":"Calculate the magnification ratio for macro photography.","mag.mindist":"Minimum Focus Distance (cm)","mag.sensorwidth":"Sensor Width (mm)","mag.ratio":"Magnification Ratio","mag.field":"Captured Field (Width)","mag.macro":"Macro Capability",
"comp.tag":"Composition","comp.title":"Photography Rules","comp.desc":"Master the most important composition rules for stunning photos.","comp.demo":"Show Demo",
"motif.tag":"Subject Recognition","motif.title":"Subject Recognition & Genres","motif.desc":"Learn different photography genres and their optimal settings.","motif.all":"All","motif.portrait":"Portrait","motif.landscape":"Landscape","motif.street":"Street","motif.macro":"Macro","motif.night":"Night","motif.sport":"Sport","motif.architecture":"Architecture","motif.wildlife":"Wildlife",
"exp.tag":"Basics","exp.title":"Exposure Triangle","exp.desc":"Understand the interplay of aperture, shutter speed and ISO.","exp.aperture":"Aperture","exp.shutter":"Shutter Speed","exp.aperture.mid":"Medium aperture \u2013 good compromise between sharpness and light.","exp.shutter.mid":"Standard shutter speed \u2013 freezes most motion.","exp.iso.low":"Low ISO \u2013 minimal noise, best quality.","exp.correct":"Correct Exposure","exp.under":"Underexposed","exp.over":"Overexposed","exp.slightunder":"Slightly underexposed","exp.slightover":"Slightly overexposed",
"quiz.tag":"Knowledge Check","quiz.title":"Photography Quiz","quiz.desc":"Test your knowledge with interactive quiz questions on all topics.","quiz.all":"All Topics","quiz.basics":"Basics","quiz.composition":"Composition","quiz.lenses":"Lenses","quiz.exposure":"Exposure","quiz.genres":"Genres","quiz.ready":"Ready for the Quiz?","quiz.choose":"Choose a category and test your photography knowledge!","quiz.info":"10 questions per round \u2022 Multiple choice \u2022 Instant feedback","quiz.start":"Start Quiz","quiz.next":"Next Question","quiz.finished":"Quiz Complete!","quiz.playagain":"Play Again","quiz.review":"Review Answers","quiz.score":"Score","quiz.resulttext":"You answered {0} of {1} questions correctly.","quiz.excellent":"Excellent! You are a photography pro!","quiz.good":"Well done! Solid knowledge!","quiz.ok":"Not bad! Keep practicing!","quiz.needwork":"Room for improvement! Review the basics.",
"footer.brand":"Your free photography toolkit for better images.","footer.tools":"Tools","footer.calcs":"Calculators","footer.comprules":"Composition Rules","footer.exptriangle":"Exposure Triangle","footer.copy":"\u00a9 2026 PhotoPro Tools \u2014 Made with passion for photography.",
"sensor.ff":"Full Frame (36x24mm)","sensor.apsc":"APS-C (23.5x15.6mm)","sensor.apsc_canon":"APS-C Canon (22.3x14.9mm)","sensor.m43":"Micro 4/3 (17.3x13mm)","sensor.1inch":"1 Inch (13.2x8.8mm)","sensor.small":"1/2.3 Inch (6.17x4.55mm)","sensor.ff_short":"Full Frame","sensor.apsc_short":"APS-C","sensor.m43_short":"Micro 4/3","sensor.ff_1x":"Full Frame (1.0x)","sensor.apsc_nikon":"APS-C Nikon/Sony (1.5x)","sensor.apsc_canon_1_6":"APS-C Canon (1.6x)","sensor.m43_2x":"Micro 4/3 (2.0x)","sensor.1inch_2_7":"1 Inch (2.7x)","sensor.small_5_6":"1/2.3 Inch (5.6x)",
"lenstype.superwide":"Super Wide-Angle","lenstype.wide":"Wide-Angle","lenstype.normal":"Normal Lens","lenstype.tele":"Telephoto","lenstype.supertele":"Super Telephoto",
"macro.true":"True Macro (1:1+)","macro.half":"Half Macro (~1:2)","macro.close":"Close-up","macro.no":"Not Macro",
"rule.thirds.name":"Rule of Thirds","rule.thirds.desc":"Divide the image into 9 equal parts. Place key elements on the lines or intersections.","rule.thirds.tip1":"Place the horizon on the upper or lower third line","rule.thirds.tip2":"Position subject's eyes on upper intersections","rule.thirds.tip3":"Never place the main subject dead center",
"rule.golden.name":"Golden Ratio","rule.golden.desc":"The ratio of 1:1.618 \u2013 nature's perfect proportion.","rule.golden.tip1":"Divides the image at approximately 62% to 38%","rule.golden.tip2":"More harmonious than the rule of thirds","rule.golden.tip3":"Found everywhere in nature (shells, flowers)",
"rule.leading.name":"Leading Lines","rule.leading.desc":"Natural lines in the image guide the viewer's eye to the main subject.","rule.leading.tip1":"Use roads, rivers, fences as lines","rule.leading.tip2":"Lines should lead into the image","rule.leading.tip3":"Converging lines create depth",
"rule.symmetry.name":"Symmetry & Patterns","rule.symmetry.desc":"Symmetric compositions radiate calm and perfection.","rule.symmetry.tip1":"Reflections in water perfect for symmetry","rule.symmetry.tip2":"Architecture offers natural symmetry","rule.symmetry.tip3":"Deliberately breaking symmetry as a style element",
"rule.framing.name":"Natural Framing","rule.framing.desc":"Use elements in the scene to frame your subject.","rule.framing.tip1":"Archways, windows, branches as frames","rule.framing.tip2":"Directs attention to the main subject","rule.framing.tip3":"Creates depth and context",
"rule.negative.name":"Negative Space","rule.negative.desc":"Empty space around the subject creates impact and drama.","rule.negative.tip1":"Less is more \u2013 use minimalism","rule.negative.tip2":"Give the subject room to breathe","rule.negative.tip3":"Especially effective in portraits",
"rule.diagonal.name":"Diagonals","rule.diagonal.desc":"Diagonal lines create dynamics and tension in the image.","rule.diagonal.tip1":"Corner to corner for maximum dynamics","rule.diagonal.tip2":"Use tilted perspectives","rule.diagonal.tip3":"Direction of movement along the diagonal",
"rule.color.name":"Color Theory","rule.color.desc":"Complementary colors and color harmonies for strong visual impact.","rule.color.tip1":"Complementary colors for contrast (blue/orange)","rule.color.tip2":"Analogous colors for harmony","rule.color.tip3":"Warm colors foreground, cool colors background",
"motif.portrait.title":"Portrait Photography","motif.portrait.desc":"Perfectly capture people and faces.","motif.landscape.title":"Landscape Photography","motif.landscape.desc":"Capture wide landscapes and nature scenes.","motif.street.title":"Street Photography","motif.street.desc":"Authentically document life on the streets.","motif.macro.title":"Macro Photography","motif.macro.desc":"Make small things appear large.","motif.night.title":"Night Photography","motif.night.desc":"Stars, city lights and nighttime scenes.","motif.sport.title":"Sports Photography","motif.sport.desc":"Freeze fast movements and action.","motif.architecture.title":"Architecture Photography","motif.architecture.desc":"Buildings and structures in perfection.","motif.wildlife.title":"Wildlife Photography","motif.wildlife.desc":"Animals in their natural habitat.",
"motif.setting.lens":"Lens","motif.setting.aperture":"Aperture","motif.setting.iso":"ISO","motif.setting.shutter":"Shutter",
"nav.simtools":"Simulation","sim.tag":"Simulation","sim.title":"Photo Simulations","sim.desc":"Simulate various photo effects in real-time.",
"sim.bokeh":"Bokeh","sim.longexp":"Long Exposure","sim.wb":"White Balance","sim.noise":"ISO Noise","sim.perspective":"Perspective","sim.histogram":"Histogram",
"sim.bokeh.title":"Bokeh Simulator","sim.bokeh.info":"See how bokeh changes with different apertures and focal lengths.","sim.bokeh.blades":"Aperture Blades","sim.bokeh.intensity":"Bokeh Intensity",
"sim.longexp.title":"Long Exposure Simulator","sim.longexp.info":"See the effect of different exposure times on motion.","sim.longexp.speed":"Motion Speed","sim.longexp.time":"Exposure Time (s)",
"sim.wb.title":"White Balance Simulator","sim.wb.info":"See how color temperature affects your image.","sim.wb.kelvin":"Temperature (K)","sim.wb.tint":"Tint",
"sim.noise.title":"ISO Noise Simulator","sim.noise.info":"See how digital noise increases with higher ISO values.","sim.noise.level":"Noise Level",
"sim.perspective.title":"Perspective Simulator","sim.perspective.info":"See how focal length affects perspective.","sim.perspective.type":"Distortion Type",
"sim.histogram.title":"Histogram Simulator","sim.histogram.info":"View the histogram based on exposure settings."
},
/* ===== FRAN\u00c7AIS ===== */
fr: {
"nav.lens":"Calculateur","nav.composition":"Composition","nav.motif":"Reconnaissance","nav.exposure":"Exposition","nav.quiz":"Quiz",
"hero.welcome":"Bienvenue sur","hero.desc":"Votre bo\u00eete \u00e0 outils photo ultime. Calculs d'objectifs, r\u00e8gles de composition, reconnaissance de sujets et quiz interactifs \u2014 tout en un seul endroit.","hero.calculators":"Calculateurs","hero.rules":"R\u00e8gles","hero.questions":"Questions Quiz","hero.cta":"Commencer",
"lens.tag":"Outils","lens.title":"Calculateur d'Objectifs","lens.desc":"Calculez la profondeur de champ, l'angle de vue, le facteur de recadrage et plus.","lens.tab.dof":"Profondeur de Champ","lens.tab.fov":"Angle de Vue","lens.tab.crop":"Facteur de Recadrage","lens.tab.hyper":"Distance Hyperfocale","lens.tab.flash":"Port\u00e9e du Flash","lens.tab.mag":"Grossissement",
"calc.focal":"Distance Focale (mm)","calc.aperture":"Ouverture (f/)","calc.sensor":"Taille du Capteur","calc.calculate":"Calculer","calc.results":"R\u00e9sultats",
"dof.title":"Calculer la Profondeur de Champ","dof.info":"La profondeur de champ d\u00e9crit la zone qui appara\u00eet nette dans l'image.","dof.distance":"Distance au Sujet (m)","dof.total":"Profondeur de Champ","dof.near":"Point Proche","dof.far":"Point \u00c9loign\u00e9","dof.coc":"Cercle de Confusion",
"fov.title":"Calculer l'Angle de Vue","fov.info":"L'angle de vue d\u00e9termine la portion de sc\u00e8ne captur\u00e9e.","fov.horizontal":"Angle Horizontal","fov.vertical":"Angle Vertical","fov.diagonal":"Angle Diagonal","fov.lenstype":"Type d'Objectif",
"crop.title":"Calculer le Facteur de Recadrage","crop.info":"Le facteur de recadrage montre l'\u00e9quivalent par rapport au plein format.","crop.lensfocal":"Focale de l'Objectif (mm)","crop.lensaperture":"Ouverture de l'Objectif (f/)","crop.camerasensor":"Capteur","crop.equivfocal":"Focale \u00c9quivalente (FF)","crop.equivaperture":"Ouverture \u00c9quivalente (FF)","crop.factor":"Facteur de Recadrage","crop.fullframe":"Plein Format",
"hyper.title":"Distance Hyperfocale","hyper.info":"La distance \u00e0 partir de laquelle tout est net jusqu'\u00e0 l'infini.","hyper.distance":"Distance Hyperfocale","hyper.near":"Point Proche (\u00e0 H)","hyper.tiplabel":"Conseil","hyper.tip":"Faites la mise au point sur la distance hyperfocale pour une nettet\u00e9 maximale.",
"flash.title":"Port\u00e9e du Flash","flash.info":"Calculez la port\u00e9e maximale du flash selon le nombre guide.","flash.gn":"Nombre Guide (NG)","flash.maxrange":"Port\u00e9e Maximale","flash.at100":"\u00c0 ISO 100",
"mag.title":"Grossissement","mag.info":"Calculez le rapport de grossissement pour la macrophotographie.","mag.mindist":"Distance Min. de Mise au Point (cm)","mag.sensorwidth":"Largeur du Capteur (mm)","mag.ratio":"Rapport de Grossissement","mag.field":"Champ Captur\u00e9 (Largeur)","mag.macro":"Capacit\u00e9 Macro",
"comp.tag":"Composition","comp.title":"R\u00e8gles de Photographie","comp.desc":"Ma\u00eetrisez les r\u00e8gles de composition les plus importantes.","comp.demo":"Voir la D\u00e9mo",
"motif.tag":"Reconnaissance","motif.title":"Reconnaissance de Sujets & Genres","motif.desc":"D\u00e9couvrez les diff\u00e9rents genres photo et leurs r\u00e9glages optimaux.","motif.all":"Tous","motif.portrait":"Portrait","motif.landscape":"Paysage","motif.street":"Street","motif.macro":"Macro","motif.night":"Nuit","motif.sport":"Sport","motif.architecture":"Architecture","motif.wildlife":"Animalier",
"exp.tag":"Bases","exp.title":"Triangle d'Exposition","exp.desc":"Comprenez l'interaction entre ouverture, vitesse d'obturation et ISO.","exp.aperture":"Ouverture","exp.shutter":"Vitesse d'Obturation","exp.aperture.mid":"Ouverture moyenne \u2013 bon compromis nettet\u00e9/lumi\u00e8re.","exp.shutter.mid":"Vitesse standard \u2013 fige la plupart des mouvements.","exp.iso.low":"ISO bas \u2013 bruit minimal, meilleure qualit\u00e9.","exp.correct":"Exposition Correcte","exp.under":"Sous-expos\u00e9","exp.over":"Surexpos\u00e9","exp.slightunder":"L\u00e9g\u00e8rement sous-expos\u00e9","exp.slightover":"L\u00e9g\u00e8rement surexpos\u00e9",
"quiz.tag":"Contr\u00f4le","quiz.title":"Quiz Photo","quiz.desc":"Testez vos connaissances avec des questions interactives.","quiz.all":"Tous les Th\u00e8mes","quiz.basics":"Bases","quiz.composition":"Composition","quiz.lenses":"Objectifs","quiz.exposure":"Exposition","quiz.genres":"Genres","quiz.ready":"Pr\u00eat pour le Quiz ?","quiz.choose":"Choisissez une cat\u00e9gorie et testez vos connaissances !","quiz.info":"10 questions par tour \u2022 Choix multiples \u2022 Feedback instantan\u00e9","quiz.start":"Lancer le Quiz","quiz.next":"Question Suivante","quiz.finished":"Quiz Termin\u00e9 !","quiz.playagain":"Rejouer","quiz.review":"Voir les R\u00e9ponses","quiz.score":"Points","quiz.resulttext":"Vous avez r\u00e9pondu correctement \u00e0 {0} questions sur {1}.","quiz.excellent":"Excellent ! Vous \u00eates un pro de la photo !","quiz.good":"Bien jou\u00e9 ! Connaissances solides !","quiz.ok":"Pas mal ! Continuez \u00e0 pratiquer !","quiz.needwork":"Des progr\u00e8s \u00e0 faire ! R\u00e9visez les bases.",
"footer.brand":"Votre bo\u00eete \u00e0 outils photo gratuite pour de meilleures images.","footer.tools":"Outils","footer.calcs":"Calculateurs","footer.comprules":"R\u00e8gles de Composition","footer.exptriangle":"Triangle d'Exposition","footer.copy":"\u00a9 2026 PhotoPro Tools \u2014 Cr\u00e9\u00e9 avec passion pour la photographie.",
"sensor.ff":"Plein Format (36x24mm)","sensor.apsc":"APS-C (23.5x15.6mm)","sensor.apsc_canon":"APS-C Canon (22.3x14.9mm)","sensor.m43":"Micro 4/3 (17.3x13mm)","sensor.1inch":"1 Pouce (13.2x8.8mm)","sensor.small":"1/2.3 Pouce (6.17x4.55mm)","sensor.ff_short":"Plein Format","sensor.apsc_short":"APS-C","sensor.m43_short":"Micro 4/3","sensor.ff_1x":"Plein Format (1.0x)","sensor.apsc_nikon":"APS-C Nikon/Sony (1.5x)","sensor.apsc_canon_1_6":"APS-C Canon (1.6x)","sensor.m43_2x":"Micro 4/3 (2.0x)","sensor.1inch_2_7":"1 Pouce (2.7x)","sensor.small_5_6":"1/2.3 Pouce (5.6x)",
"lenstype.superwide":"Super Grand-Angle","lenstype.wide":"Grand-Angle","lenstype.normal":"Objectif Normal","lenstype.tele":"T\u00e9l\u00e9objectif","lenstype.supertele":"Super T\u00e9l\u00e9objectif",
"macro.true":"Vrai Macro (1:1+)","macro.half":"Demi Macro (~1:2)","macro.close":"Gros Plan","macro.no":"Pas Macro",
"rule.thirds.name":"R\u00e8gle des Tiers","rule.thirds.desc":"Divisez l'image en 9 parties \u00e9gales. Placez les \u00e9l\u00e9ments cl\u00e9s sur les lignes ou intersections.","rule.thirds.tip1":"Placez l'horizon sur la ligne du tiers sup\u00e9rieur ou inf\u00e9rieur","rule.thirds.tip2":"Yeux du sujet sur les intersections sup\u00e9rieures","rule.thirds.tip3":"Ne jamais placer le sujet principal au centre exact",
"rule.golden.name":"Nombre d'Or","rule.golden.desc":"Le rapport de 1:1,618 \u2013 la proportion parfaite de la nature.","rule.golden.tip1":"Divise l'image \u00e0 environ 62% / 38%","rule.golden.tip2":"Plus harmonieux que la r\u00e8gle des tiers","rule.golden.tip3":"Pr\u00e9sent partout dans la nature (coquillages, fleurs)",
"rule.leading.name":"Lignes Directrices","rule.leading.desc":"Les lignes naturelles guident le regard du spectateur vers le sujet.","rule.leading.tip1":"Utilisez routes, rivi\u00e8res, cl\u00f4tures comme lignes","rule.leading.tip2":"Les lignes doivent mener dans l'image","rule.leading.tip3":"Les lignes convergentes cr\u00e9ent de la profondeur",
"rule.symmetry.name":"Sym\u00e9trie & Motifs","rule.symmetry.desc":"Les compositions sym\u00e9triques rayonnent de calme et perfection.","rule.symmetry.tip1":"Reflets dans l'eau parfaits pour la sym\u00e9trie","rule.symmetry.tip2":"L'architecture offre une sym\u00e9trie naturelle","rule.symmetry.tip3":"Briser d\u00e9lib\u00e9r\u00e9ment la sym\u00e9trie comme effet de style",
"rule.framing.name":"Cadrage Naturel","rule.framing.desc":"Utilisez des \u00e9l\u00e9ments de la sc\u00e8ne pour encadrer votre sujet.","rule.framing.tip1":"Arches, fen\u00eatres, branches comme cadres","rule.framing.tip2":"Dirige l'attention vers le sujet principal","rule.framing.tip3":"Cr\u00e9e profondeur et contexte",
"rule.negative.name":"Espace N\u00e9gatif","rule.negative.desc":"L'espace vide autour du sujet cr\u00e9e impact et dramatisme.","rule.negative.tip1":"Moins c'est plus \u2013 utilisez le minimalisme","rule.negative.tip2":"Donnez au sujet de l'espace pour respirer","rule.negative.tip3":"Particuli\u00e8rement efficace en portrait",
"rule.diagonal.name":"Diagonales","rule.diagonal.desc":"Les lignes diagonales cr\u00e9ent dynamisme et tension dans l'image.","rule.diagonal.tip1":"D'un coin \u00e0 l'autre pour un maximum de dynamisme","rule.diagonal.tip2":"Utilisez des perspectives inclin\u00e9es","rule.diagonal.tip3":"Direction du mouvement le long de la diagonale",
"rule.color.name":"Th\u00e9orie des Couleurs","rule.color.desc":"Couleurs compl\u00e9mentaires et harmonies pour un fort impact visuel.","rule.color.tip1":"Couleurs compl\u00e9mentaires pour le contraste (bleu/orange)","rule.color.tip2":"Couleurs analogues pour l'harmonie","rule.color.tip3":"Couleurs chaudes au premier plan, froides \u00e0 l'arri\u00e8re",
"motif.portrait.title":"Photographie de Portrait","motif.portrait.desc":"Mettre parfaitement en sc\u00e8ne les personnes.","motif.landscape.title":"Photographie de Paysage","motif.landscape.desc":"Capturer de vastes paysages et sc\u00e8nes naturelles.","motif.street.title":"Photographie de Rue","motif.street.desc":"Documenter la vie urbaine de mani\u00e8re authentique.","motif.macro.title":"Macrophotographie","motif.macro.desc":"Montrer les petites choses en grand.","motif.night.title":"Photographie de Nuit","motif.night.desc":"\u00c9toiles, lumi\u00e8res de la ville et sc\u00e8nes nocturnes.","motif.sport.title":"Photographie Sportive","motif.sport.desc":"Figer les mouvements rapides et l'action.","motif.architecture.title":"Photographie d'Architecture","motif.architecture.desc":"B\u00e2timents et structures en perfection.","motif.wildlife.title":"Photographie Animalier","motif.wildlife.desc":"Animaux dans leur habitat naturel.",
"motif.setting.lens":"Objectif","motif.setting.aperture":"Ouverture","motif.setting.iso":"ISO","motif.setting.shutter":"Obturation",
"nav.simtools":"Simulation","sim.tag":"Simulation","sim.title":"Simulations Photo","sim.desc":"Simulez divers effets photo en temps réel.",
"sim.bokeh":"Bokeh","sim.longexp":"Longue Exposition","sim.wb":"Balance des Blancs","sim.noise":"Bruit ISO","sim.perspective":"Perspective","sim.histogram":"Histogramme",
"sim.bokeh.title":"Simulateur de Bokeh","sim.bokeh.info":"Voyez comment le bokeh change avec différentes ouvertures et focales.","sim.bokeh.blades":"Lamelles d'Ouverture","sim.bokeh.intensity":"Intensité du Bokeh",
"sim.longexp.title":"Simulateur de Longue Exposition","sim.longexp.info":"Voyez l'effet de différents temps d'exposition sur le mouvement.","sim.longexp.speed":"Vitesse de Mouvement","sim.longexp.time":"Temps d'Exposition (s)",
"sim.wb.title":"Simulateur de Balance des Blancs","sim.wb.info":"Voyez comment la température de couleur affecte votre image.","sim.wb.kelvin":"Température (K)","sim.wb.tint":"Teinte",
"sim.noise.title":"Simulateur de Bruit ISO","sim.noise.info":"Voyez comment le bruit numérique augmente avec des valeurs ISO élevées.","sim.noise.level":"Niveau de Bruit",
"sim.perspective.title":"Simulateur de Perspective","sim.perspective.info":"Voyez comment la focale affecte la perspective.","sim.perspective.type":"Type de Distorsion",
"sim.histogram.title":"Simulateur d'Histogramme","sim.histogram.info":"Voyez l'histogramme selon les réglages d'exposition."
},
/* ===== ITALIANO ===== */
it: {
"nav.lens":"Calcolatore","nav.composition":"Composizione","nav.motif":"Riconoscimento","nav.exposure":"Esposizione","nav.quiz":"Quiz",
"hero.welcome":"Benvenuto su","hero.desc":"Il tuo toolkit fotografico definitivo. Calcoli degli obiettivi, regole di composizione, riconoscimento soggetti e quiz interattivi \u2014 tutto in un unico posto.","hero.calculators":"Calcolatori","hero.rules":"Regole","hero.questions":"Domande Quiz","hero.cta":"Inizia Ora",
"lens.tag":"Strumenti","lens.title":"Calcolatore Obiettivi","lens.desc":"Calcola profondit\u00e0 di campo, angolo di campo, fattore di crop e altro.","lens.tab.dof":"Profondit\u00e0 di Campo","lens.tab.fov":"Angolo di Campo","lens.tab.crop":"Fattore di Crop","lens.tab.hyper":"Distanza Iperfocale","lens.tab.flash":"Portata Flash","lens.tab.mag":"Ingrandimento",
"calc.focal":"Lunghezza Focale (mm)","calc.aperture":"Apertura (f/)","calc.sensor":"Dimensione Sensore","calc.calculate":"Calcola","calc.results":"Risultati",
"dof.title":"Calcola Profondit\u00e0 di Campo","dof.info":"La profondit\u00e0 di campo descrive la zona che appare nitida nell'immagine.","dof.distance":"Distanza dal Soggetto (m)","dof.total":"Profondit\u00e0 di Campo","dof.near":"Punto Vicino","dof.far":"Punto Lontano","dof.coc":"Cerchio di Confusione",
"fov.title":"Calcola Angolo di Campo","fov.info":"L'angolo di campo determina quanta scena cattura la fotocamera.","fov.horizontal":"Angolo Orizzontale","fov.vertical":"Angolo Verticale","fov.diagonal":"Angolo Diagonale","fov.lenstype":"Tipo di Obiettivo",
"crop.title":"Calcola Fattore di Crop","crop.info":"Il fattore di crop mostra l'equivalente rispetto al pieno formato.","crop.lensfocal":"Focale dell'Obiettivo (mm)","crop.lensaperture":"Apertura dell'Obiettivo (f/)","crop.camerasensor":"Sensore Fotocamera","crop.equivfocal":"Focale Equivalente (FF)","crop.equivaperture":"Apertura Equivalente (FF)","crop.factor":"Fattore di Crop","crop.fullframe":"Pieno Formato",
"hyper.title":"Distanza Iperfocale","hyper.info":"La distanza dalla quale tutto \u00e8 nitido fino all'infinito.","hyper.distance":"Distanza Iperfocale","hyper.near":"Punto Vicino (a fuoco su H)","hyper.tiplabel":"Consiglio","hyper.tip":"Metti a fuoco sulla distanza iperfocale per la massima nitidezza.",
"flash.title":"Portata Flash","flash.info":"Calcola la portata massima del flash in base al numero guida.","flash.gn":"Numero Guida (NG)","flash.maxrange":"Portata Massima","flash.at100":"A ISO 100",
"mag.title":"Ingrandimento","mag.info":"Calcola il rapporto di ingrandimento per la macrofotografia.","mag.mindist":"Distanza Min. di Messa a Fuoco (cm)","mag.sensorwidth":"Larghezza Sensore (mm)","mag.ratio":"Rapporto di Ingrandimento","mag.field":"Campo Catturato (Larghezza)","mag.macro":"Capacit\u00e0 Macro",
"comp.tag":"Composizione","comp.title":"Regole Fotografiche","comp.desc":"Padroneggia le regole di composizione pi\u00f9 importanti per foto straordinarie.","comp.demo":"Mostra Demo",
"motif.tag":"Riconoscimento","motif.title":"Riconoscimento Soggetti & Generi","motif.desc":"Scopri i diversi generi fotografici e le loro impostazioni ottimali.","motif.all":"Tutti","motif.portrait":"Ritratto","motif.landscape":"Paesaggio","motif.street":"Street","motif.macro":"Macro","motif.night":"Notturna","motif.sport":"Sport","motif.architecture":"Architettura","motif.wildlife":"Natura",
"exp.tag":"Basi","exp.title":"Triangolo dell'Esposizione","exp.desc":"Comprendi l'interazione tra apertura, tempo di posa e ISO.","exp.aperture":"Apertura","exp.shutter":"Tempo di Posa","exp.aperture.mid":"Apertura media \u2013 buon compromesso tra nitidezza e luce.","exp.shutter.mid":"Tempo di posa standard \u2013 congela la maggior parte dei movimenti.","exp.iso.low":"ISO basso \u2013 rumore minimo, migliore qualit\u00e0.","exp.correct":"Esposizione Corretta","exp.under":"Sottoesposto","exp.over":"Sovraesposto","exp.slightunder":"Leggermente sottoesposto","exp.slightover":"Leggermente sovraesposto",
"quiz.tag":"Verifica","quiz.title":"Quiz Fotografico","quiz.desc":"Metti alla prova le tue conoscenze con domande interattive.","quiz.all":"Tutti i Temi","quiz.basics":"Basi","quiz.composition":"Composizione","quiz.lenses":"Obiettivi","quiz.exposure":"Esposizione","quiz.genres":"Generi","quiz.ready":"Pronto per il Quiz?","quiz.choose":"Scegli una categoria e metti alla prova le tue conoscenze!","quiz.info":"10 domande per turno \u2022 Scelta multipla \u2022 Feedback istantaneo","quiz.start":"Inizia Quiz","quiz.next":"Prossima Domanda","quiz.finished":"Quiz Completato!","quiz.playagain":"Gioca Ancora","quiz.review":"Rivedi Risposte","quiz.score":"Punti","quiz.resulttext":"Hai risposto correttamente a {0} domande su {1}.","quiz.excellent":"Eccellente! Sei un professionista della fotografia!","quiz.good":"Ben fatto! Conoscenze solide!","quiz.ok":"Non male! Continua a esercitarti!","quiz.needwork":"C'\u00e8 margine di miglioramento! Ripassa le basi.",
"footer.brand":"Il tuo toolkit fotografico gratuito per immagini migliori.","footer.tools":"Strumenti","footer.calcs":"Calcolatori","footer.comprules":"Regole di Composizione","footer.exptriangle":"Triangolo dell'Esposizione","footer.copy":"\u00a9 2026 PhotoPro Tools \u2014 Creato con passione per la fotografia.",
"sensor.ff":"Pieno Formato (36x24mm)","sensor.apsc":"APS-C (23.5x15.6mm)","sensor.apsc_canon":"APS-C Canon (22.3x14.9mm)","sensor.m43":"Micro 4/3 (17.3x13mm)","sensor.1inch":"1 Pollice (13.2x8.8mm)","sensor.small":"1/2.3 Pollice (6.17x4.55mm)","sensor.ff_short":"Pieno Formato","sensor.apsc_short":"APS-C","sensor.m43_short":"Micro 4/3","sensor.ff_1x":"Pieno Formato (1.0x)","sensor.apsc_nikon":"APS-C Nikon/Sony (1.5x)","sensor.apsc_canon_1_6":"APS-C Canon (1.6x)","sensor.m43_2x":"Micro 4/3 (2.0x)","sensor.1inch_2_7":"1 Pollice (2.7x)","sensor.small_5_6":"1/2.3 Pollice (5.6x)",
"lenstype.superwide":"Super Grandangolo","lenstype.wide":"Grandangolo","lenstype.normal":"Obiettivo Normale","lenstype.tele":"Teleobiettivo","lenstype.supertele":"Super Teleobiettivo",
"macro.true":"Vero Macro (1:1+)","macro.half":"Mezzo Macro (~1:2)","macro.close":"Primo Piano","macro.no":"Non Macro",
"rule.thirds.name":"Regola dei Terzi","rule.thirds.desc":"Dividi l'immagine in 9 parti uguali. Posiziona gli elementi chiave sulle linee o intersezioni.","rule.thirds.tip1":"Posiziona l'orizzonte sulla linea del terzo superiore o inferiore","rule.thirds.tip2":"Occhi del soggetto sulle intersezioni superiori","rule.thirds.tip3":"Mai posizionare il soggetto principale esattamente al centro",
"rule.golden.name":"Sezione Aurea","rule.golden.desc":"Il rapporto 1:1,618 \u2013 la proporzione perfetta della natura.","rule.golden.tip1":"Divide l'immagine circa 62% a 38%","rule.golden.tip2":"Pi\u00f9 armonioso della regola dei terzi","rule.golden.tip3":"Presente ovunque in natura (conchiglie, fiori)",
"rule.leading.name":"Linee Guida","rule.leading.desc":"Le linee naturali guidano lo sguardo dello spettatore verso il soggetto.","rule.leading.tip1":"Usa strade, fiumi, recinzioni come linee","rule.leading.tip2":"Le linee devono condurre dentro l'immagine","rule.leading.tip3":"Le linee convergenti creano profondit\u00e0",
"rule.symmetry.name":"Simmetria & Motivi","rule.symmetry.desc":"Le composizioni simmetriche irradiano calma e perfezione.","rule.symmetry.tip1":"Riflessi nell'acqua perfetti per la simmetria","rule.symmetry.tip2":"L'architettura offre simmetria naturale","rule.symmetry.tip3":"Rompere deliberatamente la simmetria come elemento stilistico",
"rule.framing.name":"Cornice Naturale","rule.framing.desc":"Usa elementi della scena per incorniciare il soggetto.","rule.framing.tip1":"Archi, finestre, rami come cornici","rule.framing.tip2":"Dirige l'attenzione sul soggetto principale","rule.framing.tip3":"Crea profondit\u00e0 e contesto",
"rule.negative.name":"Spazio Negativo","rule.negative.desc":"Lo spazio vuoto intorno al soggetto crea impatto e drammaticit\u00e0.","rule.negative.tip1":"Meno \u00e8 di pi\u00f9 \u2013 usa il minimalismo","rule.negative.tip2":"Dai al soggetto spazio per respirare","rule.negative.tip3":"Particolarmente efficace nei ritratti",
"rule.diagonal.name":"Diagonali","rule.diagonal.desc":"Le linee diagonali creano dinamismo e tensione nell'immagine.","rule.diagonal.tip1":"Da angolo ad angolo per il massimo dinamismo","rule.diagonal.tip2":"Usa prospettive inclinate","rule.diagonal.tip3":"Direzione del movimento lungo la diagonale",
"rule.color.name":"Teoria del Colore","rule.color.desc":"Colori complementari e armonie cromatiche per un forte impatto visivo.","rule.color.tip1":"Colori complementari per il contrasto (blu/arancione)","rule.color.tip2":"Colori analoghi per l'armonia","rule.color.tip3":"Colori caldi in primo piano, freddi sullo sfondo",
"motif.portrait.title":"Fotografia Ritrattistica","motif.portrait.desc":"Mettere perfettamente in scena le persone.","motif.landscape.title":"Fotografia Paesaggistica","motif.landscape.desc":"Catturare vasti paesaggi e scene naturali.","motif.street.title":"Street Photography","motif.street.desc":"Documentare autenticamente la vita di strada.","motif.macro.title":"Macrofotografia","motif.macro.desc":"Mostrare le piccole cose in grande.","motif.night.title":"Fotografia Notturna","motif.night.desc":"Stelle, luci della citt\u00e0 e scene notturne.","motif.sport.title":"Fotografia Sportiva","motif.sport.desc":"Congelare movimenti veloci e azione.","motif.architecture.title":"Fotografia d'Architettura","motif.architecture.desc":"Edifici e strutture alla perfezione.","motif.wildlife.title":"Fotografia Naturalistica","motif.wildlife.desc":"Animali nel loro habitat naturale.",
"motif.setting.lens":"Obiettivo","motif.setting.aperture":"Apertura","motif.setting.iso":"ISO","motif.setting.shutter":"Otturatore",
"nav.simtools":"Simulazione","sim.tag":"Simulazione","sim.title":"Simulazioni Foto","sim.desc":"Simula vari effetti fotografici in tempo reale.",
"sim.bokeh":"Bokeh","sim.longexp":"Lunga Esposizione","sim.wb":"Bilanciamento del Bianco","sim.noise":"Rumore ISO","sim.perspective":"Prospettiva","sim.histogram":"Istogramma",
"sim.bokeh.title":"Simulatore Bokeh","sim.bokeh.info":"Guarda come cambia il bokeh con diverse aperture e focali.","sim.bokeh.blades":"Lamelle del Diaframma","sim.bokeh.intensity":"Intensità del Bokeh",
"sim.longexp.title":"Simulatore Lunga Esposizione","sim.longexp.info":"Guarda l'effetto di diversi tempi di esposizione sul movimento.","sim.longexp.speed":"Velocità di Movimento","sim.longexp.time":"Tempo di Esposizione (s)",
"sim.wb.title":"Simulatore Bilanciamento del Bianco","sim.wb.info":"Guarda come la temperatura del colore influenza la tua immagine.","sim.wb.kelvin":"Temperatura (K)","sim.wb.tint":"Tonalità",
"sim.noise.title":"Simulatore Rumore ISO","sim.noise.info":"Guarda come il rumore digitale aumenta con valori ISO più alti.","sim.noise.level":"Livello di Rumore",
"sim.perspective.title":"Simulatore di Prospettiva","sim.perspective.info":"Guarda come la focale influenza la prospettiva.","sim.perspective.type":"Tipo di Distorsione",
"sim.histogram.title":"Simulatore di Istogramma","sim.histogram.info":"Visualizza l'istogramma in base alle impostazioni di esposizione."
},
/* ===== SRPSKI (Latin) ===== */
sr: {
"nav.lens":"Kalkulator","nav.composition":"Kompozicija","nav.motif":"Prepoznavanje","nav.exposure":"Ekspozicija","nav.quiz":"Kviz",
"hero.welcome":"Dobrodo\u0161li na","hero.desc":"Va\u0161 ultimativni fotografski alat. Prora\u010duni objektiva, pravila kompozicije, prepoznavanje motiva i interaktivni kvizovi \u2014 sve na jednom mestu.","hero.calculators":"Kalkulatori","hero.rules":"Pravila","hero.questions":"Pitanja","hero.cta":"Po\u010dni",
"lens.tag":"Alati","lens.title":"Kalkulator Objektiva","lens.desc":"Izra\u010dunajte dubinu o\u0161trine, ugao snimanja, crop faktor i vi\u0161e.","lens.tab.dof":"Dubina O\u0161trine","lens.tab.fov":"Ugao Snimanja","lens.tab.crop":"Crop Faktor","lens.tab.hyper":"Hiperfokalna Daljina","lens.tab.flash":"Domet Blica","lens.tab.mag":"Uve\u0107anje",
"calc.focal":"\u017di\u017ena Daljina (mm)","calc.aperture":"Otvor Blende (f/)","calc.sensor":"Veli\u010dina Senzora","calc.calculate":"Izra\u010dunaj","calc.results":"Rezultati",
"dof.title":"Izra\u010dunaj Dubinu O\u0161trine","dof.info":"Dubina o\u0161trine opisuje opseg koji se pojavljuje o\u0161tar na slici.","dof.distance":"Udaljenost do Motiva (m)","dof.total":"Dubina O\u0161trine","dof.near":"Bli\u017ea Ta\u010dka","dof.far":"Dalja Ta\u010dka","dof.coc":"Krug Konfuzije",
"fov.title":"Izra\u010dunaj Ugao Snimanja","fov.info":"Ugao snimanja odre\u0111uje koliko scene kamera obuhvata.","fov.horizontal":"Horizontalni Ugao","fov.vertical":"Vertikalni Ugao","fov.diagonal":"Dijagonalni Ugao","fov.lenstype":"Tip Objektiva",
"crop.title":"Izra\u010dunaj Crop Faktor","crop.info":"Crop faktor pokazuje ekvivalentni ise\u010dak u pore\u0111enju sa punim formatom.","crop.lensfocal":"\u017di\u017ena Daljina Objektiva (mm)","crop.lensaperture":"Blenda Objektiva (f/)","crop.camerasensor":"Senzor Kamere","crop.equivfocal":"Ekvivalentna \u017di\u017ena Daljina","crop.equivaperture":"Ekvivalentna Blenda","crop.factor":"Crop Faktor","crop.fullframe":"Pun Format",
"hyper.title":"Hiperfokalna Daljina","hyper.info":"Udaljenost od koje je sve o\u0161tro do beskona\u010dnosti.","hyper.distance":"Hiperfokalna Daljina","hyper.near":"Bli\u017ea Ta\u010dka (fokus na H)","hyper.tiplabel":"Savet","hyper.tip":"Fokusirajte na hiperfokalnu daljinu za maksimalnu o\u0161trinu.",
"flash.title":"Domet Blica","flash.info":"Izra\u010dunajte maksimalni domet blica na osnovu vodenog broja.","flash.gn":"Vode\u0107i Broj (GN)","flash.maxrange":"Maksimalni Domet","flash.at100":"Pri ISO 100",
"mag.title":"Uve\u0107anje","mag.info":"Izra\u010dunajte odnos uve\u0107anja za makro fotografiju.","mag.mindist":"Min. Udaljenost Fokusa (cm)","mag.sensorwidth":"\u0160irina Senzora (mm)","mag.ratio":"Odnos Uve\u0107anja","mag.field":"Obuhva\u0107eno Polje (\u0160irina)","mag.macro":"Makro Sposobnost",
"comp.tag":"Kompozicija","comp.title":"Pravila Fotografije","comp.desc":"Savladajte najva\u017enija pravila kompozicije za impresivne fotografije.","comp.demo":"Prika\u017ei Demo",
"motif.tag":"Prepoznavanje","motif.title":"Prepoznavanje Motiva & \u017danrovi","motif.desc":"Nau\u010dite razli\u010dite fotografske \u017eanrove i njihova optimalna pode\u0161avanja.","motif.all":"Svi","motif.portrait":"Portret","motif.landscape":"Pejza\u017e","motif.street":"Ulica","motif.macro":"Makro","motif.night":"No\u0107","motif.sport":"Sport","motif.architecture":"Arhitektura","motif.wildlife":"Divljina",
"exp.tag":"Osnove","exp.title":"Trougao Ekspozicije","exp.desc":"Razumite interakciju izme\u0111u blende, brzine zatva\u010da i ISO.","exp.aperture":"Blenda","exp.shutter":"Brzina Zatva\u010da","exp.aperture.mid":"Srednja blenda \u2013 dobar kompromis izme\u0111u o\u0161trine i svetlosti.","exp.shutter.mid":"Standardna brzina \u2013 zamrzava ve\u0107inu pokreta.","exp.iso.low":"Nizak ISO \u2013 minimalan \u0161um, najbolji kvalitet.","exp.correct":"Korektna Ekspozicija","exp.under":"Podeksponirana","exp.over":"Preeksponirana","exp.slightunder":"Blago podeksponirana","exp.slightover":"Blago preeksponirana",
"quiz.tag":"Provera Znanja","quiz.title":"Fotografski Kviz","quiz.desc":"Testirajte svoje znanje sa interaktivnim pitanjima.","quiz.all":"Sve Teme","quiz.basics":"Osnove","quiz.composition":"Kompozicija","quiz.lenses":"Objektivi","quiz.exposure":"Ekspozicija","quiz.genres":"\u017danrovi","quiz.ready":"Spremni za Kviz?","quiz.choose":"Izaberite kategoriju i testirajte svoje znanje!","quiz.info":"10 pitanja po rundi \u2022 Vi\u0161estruki izbor \u2022 Trenutni feedback","quiz.start":"Po\u010dni Kviz","quiz.next":"Slede\u0107e Pitanje","quiz.finished":"Kviz Zavr\u0161en!","quiz.playagain":"Igraj Ponovo","quiz.review":"Pregled Odgovora","quiz.score":"Poeni","quiz.resulttext":"Ta\u010dno ste odgovorili na {0} od {1} pitanja.","quiz.excellent":"Odli\u010dno! Vi ste foto profesionalac!","quiz.good":"Bravo! Solidno znanje!","quiz.ok":"Nije lo\u0161e! Nastavite da ve\u017ebate!","quiz.needwork":"Ima prostora za napredak! Ponovite osnove.",
"footer.brand":"Va\u0161 besplatni fotografski alat za bolje slike.","footer.tools":"Alati","footer.calcs":"Kalkulatori","footer.comprules":"Pravila Kompozicije","footer.exptriangle":"Trougao Ekspozicije","footer.copy":"\u00a9 2026 PhotoPro Tools \u2014 Napravljeno sa stra\u0161\u0107u za fotografiju.",
"sensor.ff":"Pun Format (36x24mm)","sensor.apsc":"APS-C (23.5x15.6mm)","sensor.apsc_canon":"APS-C Canon (22.3x14.9mm)","sensor.m43":"Micro 4/3 (17.3x13mm)","sensor.1inch":"1 In\u010d (13.2x8.8mm)","sensor.small":"1/2.3 In\u010d (6.17x4.55mm)","sensor.ff_short":"Pun Format","sensor.apsc_short":"APS-C","sensor.m43_short":"Micro 4/3","sensor.ff_1x":"Pun Format (1.0x)","sensor.apsc_nikon":"APS-C Nikon/Sony (1.5x)","sensor.apsc_canon_1_6":"APS-C Canon (1.6x)","sensor.m43_2x":"Micro 4/3 (2.0x)","sensor.1inch_2_7":"1 In\u010d (2.7x)","sensor.small_5_6":"1/2.3 In\u010d (5.6x)",
"lenstype.superwide":"Super \u0160iroki Ugao","lenstype.wide":"\u0160iroki Ugao","lenstype.normal":"Normalni Objektiv","lenstype.tele":"Teleobjektiv","lenstype.supertele":"Super Teleobjektiv",
"macro.true":"Pravi Makro (1:1+)","macro.half":"Polu Makro (~1:2)","macro.close":"Krupni Plan","macro.no":"Nije Makro",
"rule.thirds.name":"Pravilo Tre\u0107ina","rule.thirds.desc":"Podelite sliku na 9 jednakih delova. Postavite klju\u010dne elemente na linije ili preseke.","rule.thirds.tip1":"Horizont na gornju ili donju liniju tre\u0107ine","rule.thirds.tip2":"O\u010di subjekta na gornje preseke","rule.thirds.tip3":"Nikad ne postavljajte glavni motiv ta\u010dno u centar",
"rule.golden.name":"Zlatni Presek","rule.golden.desc":"Odnos 1:1,618 \u2013 savr\u0161ena proporcija prirode.","rule.golden.tip1":"Deli sliku u odnosu pribli\u017eno 62% / 38%","rule.golden.tip2":"Harmoni\u010dniji od pravila tre\u0107ina","rule.golden.tip3":"Prisutan svuda u prirodi (\u0161koljke, cve\u0107e)",
"rule.leading.name":"Vode\u0107e Linije","rule.leading.desc":"Prirodne linije vode pogled posmatra\u010da ka glavnom motivu.","rule.leading.tip1":"Koristite puteve, reke, ograde kao linije","rule.leading.tip2":"Linije treba da vode u sliku","rule.leading.tip3":"Konvergentne linije stvaraju dubinu",
"rule.symmetry.name":"Simetrija & Obrasci","rule.symmetry.desc":"Simetri\u010dne kompozicije zra\u010de mirom i savr\u0161enstvom.","rule.symmetry.tip1":"Odraz u vodi savr\u0161en za simetriju","rule.symmetry.tip2":"Arhitektura nudi prirodnu simetriju","rule.symmetry.tip3":"Svesno kr\u0161enje simetrije kao stilsko sredstvo",
"rule.framing.name":"Prirodni Okvir","rule.framing.desc":"Koristite elemente scene da uokvirite motiv.","rule.framing.tip1":"Lukovi, prozori, grane kao okviri","rule.framing.tip2":"Usmerava pa\u017enju na glavni motiv","rule.framing.tip3":"Stvara dubinu i kontekst",
"rule.negative.name":"Negativan Prostor","rule.negative.desc":"Prazan prostor oko motiva stvara efekat i dramatiku.","rule.negative.tip1":"Manje je vi\u0161e \u2013 koristite minimalizam","rule.negative.tip2":"Dajte motivu prostor da di\u0161e","rule.negative.tip3":"Posebno efikasno kod portreta",
"rule.diagonal.name":"Dijagonale","rule.diagonal.desc":"Dijagonalne linije stvaraju dinamiku i napetost u slici.","rule.diagonal.tip1":"Od ugla do ugla za maksimalnu dinamiku","rule.diagonal.tip2":"Koristite nagnute perspektive","rule.diagonal.tip3":"Smer kretanja du\u017e dijagonale",
"rule.color.name":"Teorija Boja","rule.color.desc":"Komplementarne boje i harmonije za sna\u017ean vizuelni efekat.","rule.color.tip1":"Komplementarne boje za kontrast (plava/narand\u017easta)","rule.color.tip2":"Analogne boje za harmoniju","rule.color.tip3":"Tople boje u prvom planu, hladne u pozadini",
"motif.portrait.title":"Portretna Fotografija","motif.portrait.desc":"Savr\u0161eno prikazati ljude i lica.","motif.landscape.title":"Pejza\u017ena Fotografija","motif.landscape.desc":"Uhvatiti \u0161iroke pejza\u017ee i prirodne scene.","motif.street.title":"Uli\u010dna Fotografija","motif.street.desc":"Autenti\u010dno dokumentovati \u017eivot na ulici.","motif.macro.title":"Makro Fotografija","motif.macro.desc":"Male stvari prikazati veliko.","motif.night.title":"No\u0107na Fotografija","motif.night.desc":"Zvezde, gradska svetla i no\u0107ne scene.","motif.sport.title":"Sportska Fotografija","motif.sport.desc":"Zamrznuti brze pokrete i akciju.","motif.architecture.title":"Arhitektonska Fotografija","motif.architecture.desc":"Zgrade i strukture u savr\u0161enstvu.","motif.wildlife.title":"Fotografija Divljine","motif.wildlife.desc":"\u017divotinje u prirodnom stani\u0161tu.",
"motif.setting.lens":"Objektiv","motif.setting.aperture":"Blenda","motif.setting.iso":"ISO","motif.setting.shutter":"Zatva\u010d",
"nav.simtools":"Simulacija","sim.tag":"Simulacija","sim.title":"Foto Simulacije","sim.desc":"Simulirajte razli\u010dite foto efekte u realnom vremenu.",
"sim.bokeh":"Bokeh","sim.longexp":"Duga Ekspozicija","sim.wb":"Balans Belog","sim.noise":"ISO \u0160um","sim.perspective":"Perspektiva","sim.histogram":"Histogram",
"sim.bokeh.title":"Bokeh Simulator","sim.bokeh.info":"Pogledajte kako se bokeh menja sa razli\u010ditim blendama i \u017ei\u017enim daljinama.","sim.bokeh.blades":"Lamele Blende","sim.bokeh.intensity":"Intenzitet Bokeh-a",
"sim.longexp.title":"Simulator Duge Ekspozicije","sim.longexp.info":"Pogledajte efekat razli\u010ditih vremena ekspozicije na pokret.","sim.longexp.speed":"Brzina Pokreta","sim.longexp.time":"Vreme Ekspozicije (s)",
"sim.wb.title":"Simulator Balansa Belog","sim.wb.info":"Pogledajte kako temperatura boje uti\u010de na va\u0161u sliku.","sim.wb.kelvin":"Temperatura (K)","sim.wb.tint":"Nijansa",
"sim.noise.title":"Simulator ISO \u0160uma","sim.noise.info":"Pogledajte kako digitalni \u0161um raste sa vi\u0161im ISO vrednostima.","sim.noise.level":"Nivo \u0160uma",
"sim.perspective.title":"Simulator Perspektive","sim.perspective.info":"Pogledajte kako \u017ei\u017ena daljina uti\u010de na perspektivu.","sim.perspective.type":"Tip Distorzije",
"sim.histogram.title":"Simulator Histograma","sim.histogram.info":"Pogledajte histogram na osnovu pode\u0161avanja ekspozicije."
},
/* ===== SHQIP (Albanian) ===== */
sq: {
"nav.lens":"Kalkulatori","nav.composition":"Kompozicioni","nav.motif":"Njohja e Motivit","nav.exposure":"Ekspozimi","nav.quiz":"Kuiz",
"hero.welcome":"Mir\u00eb se vini n\u00eb","hero.desc":"Paketa juaj e fundit p\u00ebr fotografi. Llogaritjet e thjerrrave, rregullat e kompozicionit, njohja e motivit dhe kuize interaktive \u2014 t\u00eb gjitha n\u00eb nj\u00eb vend.","hero.calculators":"Kalkulatori","hero.rules":"Rregulla","hero.questions":"Pyetje Kuizi","hero.cta":"Fillo Tani",
"lens.tag":"Mjete","lens.title":"Kalkulatori i Thjerrrave","lens.desc":"Llogaritni thell\u00ebsin\u00eb e fushes, k\u00ebndin e pamjes, faktorin e prerjes dhe m\u00eb shum\u00eb.","lens.tab.dof":"Thell\u00ebsia e Fush\u00ebs","lens.tab.fov":"K\u00ebndi i Pamjes","lens.tab.crop":"Faktori i Prerjes","lens.tab.hyper":"Distanca Hiperfokale","lens.tab.flash":"Rrezja e Blicit","lens.tab.mag":"Zmadhimi",
"calc.focal":"Gjat\u00ebsia Fokale (mm)","calc.aperture":"Hap\u00ebsira (f/)","calc.sensor":"Madh\u00ebsia e Sensorit","calc.calculate":"Llogarit","calc.results":"Rezultatet",
"dof.title":"Llogarit Thell\u00ebsin\u00eb e Fush\u00ebs","dof.info":"Thell\u00ebsia e fush\u00ebs p\u00ebrshkruan zon\u00ebn q\u00eb duket e mpreft\u00eb n\u00eb imazh.","dof.distance":"Distanca deri te Motivi (m)","dof.total":"Thell\u00ebsia e Fush\u00ebs","dof.near":"Pika e Af\u00ebrt","dof.far":"Pika e Larg\u00ebt","dof.coc":"Rrethi i Konfuzionit",
"fov.title":"Llogarit K\u00ebndin e Pamjes","fov.info":"K\u00ebndi i pamjes p\u00ebrcakton sa sken\u00eb kap kamera.","fov.horizontal":"K\u00ebndi Horizontal","fov.vertical":"K\u00ebndi Vertikal","fov.diagonal":"K\u00ebndi Diagonal","fov.lenstype":"Tipi i Thjerr\u00ebs",
"crop.title":"Llogarit Faktorin e Prerjes","crop.info":"Faktori i prerjes tregon ekuivalentin n\u00eb krahasim me formatin e plot\u00eb.","crop.lensfocal":"Gjat\u00ebsia Fokale e Thjerr\u00ebs (mm)","crop.lensaperture":"Hap\u00ebsira e Thjerr\u00ebs (f/)","crop.camerasensor":"Sensori i Kamer\u00ebs","crop.equivfocal":"Gjat\u00ebsia Fokale Ekuivalente","crop.equivaperture":"Hap\u00ebsira Ekuivalente","crop.factor":"Faktori i Prerjes","crop.fullframe":"Formati i Plot\u00eb",
"hyper.title":"Distanca Hiperfokale","hyper.info":"Distanca nga e cila \u00e7do gj\u00eb \u00ebsht\u00eb e mpreft\u00eb deri n\u00eb pafund\u00ebsi.","hyper.distance":"Distanca Hiperfokale","hyper.near":"Pika e Af\u00ebrt (fokus n\u00eb H)","hyper.tiplabel":"K\u00ebshill\u00eb","hyper.tip":"Fokusoni n\u00eb distanc\u00ebn hiperfokale p\u00ebr mpreftimin maksimal.",
"flash.title":"Rrezja e Blicit","flash.info":"Llogaritni rrezen maksimale t\u00eb blicit bazuar n\u00eb numrin udh\u00ebzues.","flash.gn":"Numri Udh\u00ebzues (GN)","flash.maxrange":"Rrezja Maksimale","flash.at100":"N\u00eb ISO 100",
"mag.title":"Zmadhimi","mag.info":"Llogaritni raportin e zmadhimit p\u00ebr makrofotografi.","mag.mindist":"Distanca Min. e Fokusit (cm)","mag.sensorwidth":"Gjer\u00ebsia e Sensorit (mm)","mag.ratio":"Raporti i Zmadhimit","mag.field":"Fusha e Kapur (Gjer\u00ebsia)","mag.macro":"Aft\u00ebsia Makro",
"comp.tag":"Kompozicioni","comp.title":"Rregullat e Fotografis\u00eb","comp.desc":"Z\u00ebt\u00ebroni rregullat m\u00eb t\u00eb r\u00ebd\u00ebsishme t\u00eb kompozicionit p\u00ebr foto mahnitese.","comp.demo":"Shfaq Demo",
"motif.tag":"Njohja","motif.title":"Njohja e Motivit & Zhanret","motif.desc":"M\u00ebsoni zhanre t\u00eb ndryshme t\u00eb fotografis\u00eb dhe cilesimet e tyre optimale.","motif.all":"T\u00eb Gjitha","motif.portrait":"Portret","motif.landscape":"Peizazh","motif.street":"Rrug\u00eb","motif.macro":"Makro","motif.night":"Nat\u00eb","motif.sport":"Sport","motif.architecture":"Arkitektur\u00eb","motif.wildlife":"Kafsh\u00eb",
"exp.tag":"Bazat","exp.title":"Trekend\u00ebshi i Ekspozimit","exp.desc":"Kuptoni nd\u00ebrveprimin midis hap\u00ebsir\u00ebs, shpejt\u00ebsis\u00eb s\u00eb shkrepjes dhe ISO.","exp.aperture":"Hap\u00ebsira","exp.shutter":"Shpejt\u00ebsia e Shkrepjes","exp.aperture.mid":"Hap\u00ebsir\u00eb mesatare \u2013 kompromis i mir\u00eb midis mpreftimit dhe drit\u00ebs.","exp.shutter.mid":"Shpejt\u00ebsi standarde \u2013 ngrijn shumic\u00ebn e l\u00ebvizjeve.","exp.iso.low":"ISO i ul\u00ebt \u2013 zhurm\u00eb minimale, cil\u00ebsia m\u00eb e mir\u00eb.","exp.correct":"Ekspozim i Sakt\u00eb","exp.under":"N\u00ebnekspozuar","exp.over":"Mbiekspozuar","exp.slightunder":"Leht\u00ebsisht n\u00ebnekspozuar","exp.slightover":"Leht\u00ebsisht mbiekspozuar",
"quiz.tag":"Kontroll Dije","quiz.title":"Kuiz Fotografik","quiz.desc":"Testoni njohurit\u00eb tuaja me pyetje interaktive.","quiz.all":"T\u00eb Gjitha Temat","quiz.basics":"Bazat","quiz.composition":"Kompozicioni","quiz.lenses":"Thjerrrat","quiz.exposure":"Ekspozimi","quiz.genres":"Zhanret","quiz.ready":"Gati p\u00ebr Kuizin?","quiz.choose":"Zgjidhni nj\u00eb kategori dhe testoni njohurit\u00eb tuaja!","quiz.info":"10 pyetje p\u00ebr raund \u2022 Zgjedhje e shumefisht\u00eb \u2022 Feedback i menj\u00ehersh\u00ebm","quiz.start":"Fillo Kuizin","quiz.next":"Pyetja Tjet\u00ebr","quiz.finished":"Kuizi P\u00ebrfundoi!","quiz.playagain":"Luaj P\u00ebrs\u00ebri","quiz.review":"Shiko P\u00ebrgjigjet","quiz.score":"Pik\u00eb","quiz.resulttext":"Ju u p\u00ebrgjigj\u00ebt sakt\u00eb {0} nga {1} pyetje.","quiz.excellent":"Shk\u00eblqyesh\u00ebm! Jeni profesionist i fotografis\u00eb!","quiz.good":"Shum\u00eb mir\u00eb! Njohuri solide!","quiz.ok":"Jo keq! Vazhdoni t\u00eb praktikoni!","quiz.needwork":"Ka vend p\u00ebr p\u00ebrmir\u00ebsim! Rishikoni bazat.",
"footer.brand":"Paketa juaj falas e fotografis\u00eb p\u00ebr imazhe m\u00eb t\u00eb mira.","footer.tools":"Mjete","footer.calcs":"Kalkulatori","footer.comprules":"Rregullat e Kompozicionit","footer.exptriangle":"Trekend\u00ebshi i Ekspozimit","footer.copy":"\u00a9 2026 PhotoPro Tools \u2014 Krijuar me pasion p\u00ebr fotografin\u00eb.",
"sensor.ff":"Formati i Plot\u00eb (36x24mm)","sensor.apsc":"APS-C (23.5x15.6mm)","sensor.apsc_canon":"APS-C Canon (22.3x14.9mm)","sensor.m43":"Micro 4/3 (17.3x13mm)","sensor.1inch":"1 In\u00e7 (13.2x8.8mm)","sensor.small":"1/2.3 In\u00e7 (6.17x4.55mm)","sensor.ff_short":"Formati i Plot\u00eb","sensor.apsc_short":"APS-C","sensor.m43_short":"Micro 4/3","sensor.ff_1x":"Formati i Plot\u00eb (1.0x)","sensor.apsc_nikon":"APS-C Nikon/Sony (1.5x)","sensor.apsc_canon_1_6":"APS-C Canon (1.6x)","sensor.m43_2x":"Micro 4/3 (2.0x)","sensor.1inch_2_7":"1 In\u00e7 (2.7x)","sensor.small_5_6":"1/2.3 In\u00e7 (5.6x)",
"lenstype.superwide":"Super K\u00ebnd i Gjer\u00eb","lenstype.wide":"K\u00ebnd i Gjer\u00eb","lenstype.normal":"Thjerr\u00eb Normale","lenstype.tele":"Teleobjektiv","lenstype.supertele":"Super Teleobjektiv",
"macro.true":"Makro e V\u00ebrtet\u00eb (1:1+)","macro.half":"Gjys\u00ebm Makro (~1:2)","macro.close":"Plan i Af\u00ebrt","macro.no":"Jo Makro",
"rule.thirds.name":"Rregulli i t\u00eb Tretave","rule.thirds.desc":"Ndani imazhin n\u00eb 9 pjes\u00eb t\u00eb barabarta. Vendosni elementet kryesore n\u00eb vijat ose kryq\u00ebzimet.","rule.thirds.tip1":"Vendosni horizontin n\u00eb vij\u00ebn e t\u00eb tret\u00ebs s\u00eb sip\u00ebrme ose t\u00eb posht\u00ebme","rule.thirds.tip2":"Syt\u00eb e subjektit n\u00eb kryq\u00ebzimet e sip\u00ebrme","rule.thirds.tip3":"Kurr\u00eb mos vendosni motivin kryesor n\u00eb qend\u00ebr t\u00eb sakt\u00eb",
"rule.golden.name":"Prerja e Art\u00eb","rule.golden.desc":"Raporti 1:1.618 \u2013 proporcioni i p\u00ebrsosur i natyres.","rule.golden.tip1":"Ndan imazhin afersisht 62% me 38%","rule.golden.tip2":"M\u00eb harmonik se rregulli i t\u00eb tretave","rule.golden.tip3":"Gjendet kudo n\u00eb natyr\u00eb (guaska, lule)",
"rule.leading.name":"Vijat Udh\u00ebzuese","rule.leading.desc":"Vijat natyrale udh\u00ebzojn\u00eb shikimin e v\u00ebzhguesit te motivi kryesor.","rule.leading.tip1":"P\u00ebrdorni rruge, lumenj, gardhe si vija","rule.leading.tip2":"Vijat duhet t\u00eb \u00e7ojn\u00eb brenda imazhit","rule.leading.tip3":"Vijat konvergjente krijojn\u00eb thell\u00ebsi",
"rule.symmetry.name":"Simetria & Motivet","rule.symmetry.desc":"Kompozicionet simetrike rrezatojn\u00eb qet\u00ebsi dhe perfekcion.","rule.symmetry.tip1":"Pasqyrimet n\u00eb uj\u00eb perfekte p\u00ebr simetri","rule.symmetry.tip2":"Arkitektura ofron simetri natyrale","rule.symmetry.tip3":"Thyerja e qellimshme e simetrise si element stili",
"rule.framing.name":"Korniza Natyrale","rule.framing.desc":"P\u00ebrdorni elemente t\u00eb sken\u00ebs p\u00ebr t\u00eb kornizuar motivin.","rule.framing.tip1":"Harqe, dritare, deg\u00eb si korniza","rule.framing.tip2":"Drejton v\u00ebmendjen te motivi kryesor","rule.framing.tip3":"Krijon thell\u00ebsi dhe kontekst",
"rule.negative.name":"Hap\u00ebsira Negative","rule.negative.desc":"Hap\u00ebsira bosh rreth motivit krijon ndikim dhe dramatizm.","rule.negative.tip1":"M\u00eb pak \u00ebsht\u00eb m\u00eb shum\u00eb \u2013 p\u00ebrdorni minimalizmin","rule.negative.tip2":"Jepini motivit hap\u00ebsir\u00eb p\u00ebr t\u00eb marr\u00eb frym\u00eb","rule.negative.tip3":"Ve\u00e7an\u00ebrisht efektive n\u00eb portrete",
"rule.diagonal.name":"Diagonalet","rule.diagonal.desc":"Vijat diagonale krijojn\u00eb dinamizm\u00eb dhe tension n\u00eb imazh.","rule.diagonal.tip1":"Nga k\u00ebndi n\u00eb k\u00ebnd p\u00ebr dinamizm\u00eb maksimale","rule.diagonal.tip2":"P\u00ebrdorni perspektiva t\u00eb pjerrta","rule.diagonal.tip3":"Drejtimi i l\u00ebvizjes p\u00ebrgjat\u00eb diagonales",
"rule.color.name":"Teoria e Ngjyrave","rule.color.desc":"Ngjyrat plot\u00ebsuese dhe harmonit\u00eb p\u00ebr ndikim t\u00eb fort\u00eb vizual.","rule.color.tip1":"Ngjyra plot\u00ebsuese p\u00ebr kontrast (blu/portokalli)","rule.color.tip2":"Ngjyra analoge p\u00ebr harmoni","rule.color.tip3":"Ngjyra t\u00eb ngrohta p\u00ebrpara, t\u00eb ftohta n\u00eb sfond",
"motif.portrait.title":"Fotografia e Portretit","motif.portrait.desc":"Paraqitja perfekte e njer\u00ebzve dhe fytyrave.","motif.landscape.title":"Fotografia e Peizazhit","motif.landscape.desc":"Kapja e peizazheve t\u00eb gjera dhe skenave natyrale.","motif.street.title":"Fotografia e Rrug\u00ebs","motif.street.desc":"Dokumentimi autentik i jet\u00ebs n\u00eb rrug\u00eb.","motif.macro.title":"Makrofotografia","motif.macro.desc":"Paraqitja e gjerave t\u00eb vogla n\u00eb madh\u00ebsi.","motif.night.title":"Fotografia e Nat\u00ebs","motif.night.desc":"Yjet, dritat e qytetit dhe skenat e nat\u00ebs.","motif.sport.title":"Fotografia Sportive","motif.sport.desc":"Ngrirja e l\u00ebvizjeve t\u00eb shpejta dhe aksionit.","motif.architecture.title":"Fotografia e Arkitektur\u00ebs","motif.architecture.desc":"Nd\u00ebrtesat dhe strukturat n\u00eb perfekcion.","motif.wildlife.title":"Fotografia e Kafsh\u00ebve","motif.wildlife.desc":"Kafsh\u00ebt n\u00eb habitatin e tyre natyral.",
"motif.setting.lens":"Thjerrza","motif.setting.aperture":"Hap\u00ebsira","motif.setting.iso":"ISO","motif.setting.shutter":"Shkrepja",
"nav.simtools":"Simulimi","sim.tag":"Simulimi","sim.title":"Foto-Simulime","sim.desc":"Simuloni efekte t\u00eb ndryshme fotografike n\u00eb koh\u00eb reale.",
"sim.bokeh":"Bokeh","sim.longexp":"Ekspozim i Gjat\u00eb","sim.wb":"Balanca e Bardh\u00ebs","sim.noise":"Zhurma ISO","sim.perspective":"Perspektiva","sim.histogram":"Histogrami",
"sim.bokeh.title":"Simulatori i Bokeh-ut","sim.bokeh.info":"Shikoni si ndryshon bokeh-u me hap\u00ebsir\u00eb dhe gjat\u00ebsi fokale t\u00eb ndryshme.","sim.bokeh.blades":"Fleta t\u00eb Hap\u00ebsir\u00ebs","sim.bokeh.intensity":"Intensiteti i Bokeh-ut",
"sim.longexp.title":"Simulatori i Ekspozimit t\u00eb Gjat\u00eb","sim.longexp.info":"Shikoni efektin e koheve t\u00eb ndryshme t\u00eb ekspozimit n\u00eb l\u00ebvizje.","sim.longexp.speed":"Shpejt\u00ebsia e L\u00ebvizjes","sim.longexp.time":"Koha e Ekspozimit (s)",
"sim.wb.title":"Simulatori i Balanc\u00ebs s\u00eb Bardh\u00ebs","sim.wb.info":"Shikoni si ndikon temperatura e ngjyr\u00ebs n\u00eb imazhin tuaj.","sim.wb.kelvin":"Temperatura (K)","sim.wb.tint":"Nuanc\u00eb",
"sim.noise.title":"Simulatori i Zhurm\u00ebs ISO","sim.noise.info":"Shikoni si rritet zhurma dixhitale me vlera m\u00eb t\u00eb larta ISO.","sim.noise.level":"Niveli i Zhurm\u00ebs",
"sim.perspective.title":"Simulatori i Perspektiv\u00ebs","sim.perspective.info":"Shikoni si ndikon gjat\u00ebsia fokale n\u00eb perspektiv\u00eb.","sim.perspective.type":"Tipi i Deformimit",
"sim.histogram.title":"Simulatori i Histogramit","sim.histogram.info":"Shikoni histogramin bazuar n\u00eb cil\u00ebsimet e ekspozimit."
},
/* ===== T\u00dcRK\u00c7E ===== */
tr: {
"nav.lens":"Lens Hesaplay\u0131c\u0131","nav.composition":"Kompozisyon","nav.motif":"Konu Tan\u0131ma","nav.exposure":"Pozlama","nav.quiz":"Quiz",
"hero.welcome":"Ho\u015f Geldiniz","hero.desc":"En kapsaml\u0131 foto\u011fraf\u00e7\u0131l\u0131k ara\u00e7 setiniz. Lens hesaplamalar\u0131, kompozisyon kurallar\u0131, konu tan\u0131ma ve interaktif quizler \u2014 hepsi bir arada.","hero.calculators":"Hesaplay\u0131c\u0131lar","hero.rules":"Kurallar","hero.questions":"Quiz Sorular\u0131","hero.cta":"Ba\u015fla",
"lens.tag":"Ara\u00e7lar","lens.title":"Lens Hesaplay\u0131c\u0131","lens.desc":"Alan derinli\u011fi, g\u00f6r\u00fc\u015f a\u00e7\u0131s\u0131, crop fakt\u00f6r\u00fc ve daha fazlas\u0131n\u0131 hesaplay\u0131n.","lens.tab.dof":"Alan Derinli\u011fi (DOF)","lens.tab.fov":"G\u00f6r\u00fc\u015f A\u00e7\u0131s\u0131 (FOV)","lens.tab.crop":"Crop Fakt\u00f6r\u00fc","lens.tab.hyper":"Hiperfokal Mesafe","lens.tab.flash":"Fla\u015f Menzili","lens.tab.mag":"B\u00fcy\u00fctme",
"calc.focal":"Odak Uzakl\u0131\u011f\u0131 (mm)","calc.aperture":"Diyafram (f/)","calc.sensor":"Sens\u00f6r Boyutu","calc.calculate":"Hesapla","calc.results":"Sonu\u00e7lar",
"dof.title":"Alan Derinli\u011fi Hesapla","dof.info":"Alan derinli\u011fi (DOF), g\u00f6r\u00fcnt\u00fcde net g\u00f6r\u00fcnen alan\u0131 tan\u0131mlar.","dof.distance":"Konuya Mesafe (m)","dof.total":"Alan Derinli\u011fi","dof.near":"Yak\u0131n Nokta","dof.far":"Uzak Nokta","dof.coc":"Bulan\u0131kl\u0131k \u00c7emberi",
"fov.title":"G\u00f6r\u00fc\u015f A\u00e7\u0131s\u0131 Hesapla","fov.info":"G\u00f6r\u00fc\u015f a\u00e7\u0131s\u0131 kameran\u0131n ne kadar sahne yakalad\u0131\u011f\u0131n\u0131 belirler.","fov.horizontal":"Yatay G\u00f6r\u00fc\u015f A\u00e7\u0131s\u0131","fov.vertical":"Dikey G\u00f6r\u00fc\u015f A\u00e7\u0131s\u0131","fov.diagonal":"\u00c7apraz G\u00f6r\u00fc\u015f A\u00e7\u0131s\u0131","fov.lenstype":"Lens T\u00fcr\u00fc",
"crop.title":"Crop Fakt\u00f6r\u00fc Hesapla","crop.info":"Crop fakt\u00f6r\u00fc, tam kareye k\u0131yasla e\u015fde\u011fer g\u00f6r\u00fcn\u00fcm\u00fc g\u00f6sterir.","crop.lensfocal":"Lens Odak Uzakl\u0131\u011f\u0131 (mm)","crop.lensaperture":"Lens Diyafram\u0131 (f/)","crop.camerasensor":"Kamera Sens\u00f6r\u00fc","crop.equivfocal":"E\u015fde\u011fer Odak Uzakl\u0131\u011f\u0131 (FF)","crop.equivaperture":"E\u015fde\u011fer Diyafram (FF)","crop.factor":"Crop Fakt\u00f6r\u00fc","crop.fullframe":"Tam Kare",
"hyper.title":"Hiperfokal Mesafe","hyper.info":"Her \u015feyin sonsuza kadar net g\u00f6r\u00fcnd\u00fc\u011f\u00fc mesafe.","hyper.distance":"Hiperfokal Mesafe","hyper.near":"Yak\u0131n Nokta (H'ye odaklanma)","hyper.tiplabel":"\u0130pucu","hyper.tip":"Maksimum netlik i\u00e7in hiperfokal mesafeye odaklan\u0131n.",
"flash.title":"Fla\u015f Menzili","flash.info":"K\u0131lavuz say\u0131s\u0131na g\u00f6re maksimum fla\u015f menzilini hesaplay\u0131n.","flash.gn":"K\u0131lavuz Say\u0131s\u0131 (GN)","flash.maxrange":"Maksimum Menzil","flash.at100":"ISO 100'de",
"mag.title":"B\u00fcy\u00fctme","mag.info":"Makro foto\u011fraf\u00e7\u0131l\u0131k i\u00e7in b\u00fcy\u00fctme oran\u0131n\u0131 hesaplay\u0131n.","mag.mindist":"Min. Odaklama Mesafesi (cm)","mag.sensorwidth":"Sens\u00f6r Geni\u015fli\u011fi (mm)","mag.ratio":"B\u00fcy\u00fctme Oran\u0131","mag.field":"Yakalanan Alan (Geni\u015flik)","mag.macro":"Makro Yetene\u011fi",
"comp.tag":"Kompozisyon","comp.title":"Foto\u011fraf Kurallar\u0131","comp.desc":"Etkileyici foto\u011fraflar i\u00e7in en \u00f6nemli kompozisyon kurallar\u0131n\u0131 \u00f6\u011frenin.","comp.demo":"Demo G\u00f6ster",
"motif.tag":"Konu Tan\u0131ma","motif.title":"Konu Tan\u0131ma & T\u00fcrler","motif.desc":"Farkl\u0131 foto\u011fraf t\u00fcrlerini ve optimal ayarlar\u0131n\u0131 \u00f6\u011frenin.","motif.all":"T\u00fcm\u00fc","motif.portrait":"Portre","motif.landscape":"Manzara","motif.street":"Sokak","motif.macro":"Makro","motif.night":"Gece","motif.sport":"Spor","motif.architecture":"Mimari","motif.wildlife":"Do\u011fa",
"exp.tag":"Temel Bilgiler","exp.title":"Pozlama \u00dc\u00e7geni","exp.desc":"Diyafram, enstantane ve ISO aras\u0131ndaki etkile\u015fimi anlay\u0131n.","exp.aperture":"Diyafram","exp.shutter":"Enstantane","exp.aperture.mid":"Orta diyafram \u2013 netlik ve \u0131\u015f\u0131k aras\u0131nda iyi uzla\u015fma.","exp.shutter.mid":"Standart enstantane \u2013 \u00e7o\u011fu hareketi dondurur.","exp.iso.low":"D\u00fc\u015f\u00fck ISO \u2013 minimum g\u00fcr\u00fclt\u00fc, en iyi kalite.","exp.correct":"Do\u011fru Pozlama","exp.under":"Az Pozlanm\u0131\u015f","exp.over":"Fazla Pozlanm\u0131\u015f","exp.slightunder":"Hafif az pozlanm\u0131\u015f","exp.slightover":"Hafif fazla pozlanm\u0131\u015f",
"quiz.tag":"Bilgi Testi","quiz.title":"Foto\u011fraf Quiz'i","quiz.desc":"T\u00fcm konularda interaktif sorularla bilginizi test edin.","quiz.all":"T\u00fcm Konular","quiz.basics":"Temel","quiz.composition":"Kompozisyon","quiz.lenses":"Lensler","quiz.exposure":"Pozlama","quiz.genres":"T\u00fcrler","quiz.ready":"Quiz'e Haz\u0131r m\u0131s\u0131n\u0131z?","quiz.choose":"Bir kategori se\u00e7in ve bilginizi test edin!","quiz.info":"Tur ba\u015f\u0131na 10 soru \u2022 \u00c7oktan se\u00e7meli \u2022 Anl\u0131k geri bildirim","quiz.start":"Quiz'e Ba\u015fla","quiz.next":"Sonraki Soru","quiz.finished":"Quiz Tamamland\u0131!","quiz.playagain":"Tekrar Oyna","quiz.review":"Cevaplar\u0131 G\u00f6r","quiz.score":"Puan","quiz.resulttext":"{1} sorudan {0} tanesini do\u011fru yan\u0131tlad\u0131n\u0131z.","quiz.excellent":"M\u00fckemmel! Foto\u011fraf\u00e7\u0131l\u0131k ustas\u0131s\u0131n\u0131z!","quiz.good":"Aferin! Sa\u011flam bilgi!","quiz.ok":"Fena de\u011fil! Pratik yapmaya devam edin!","quiz.needwork":"Geli\u015ftirmeye yer var! Temelleri tekrarlay\u0131n.",
"footer.brand":"\u00dccretsiz foto\u011fraf\u00e7\u0131l\u0131k ara\u00e7 setiniz.","footer.tools":"Ara\u00e7lar","footer.calcs":"Hesaplay\u0131c\u0131lar","footer.comprules":"Kompozisyon Kurallar\u0131","footer.exptriangle":"Pozlama \u00dc\u00e7geni","footer.copy":"\u00a9 2026 PhotoPro Tools \u2014 Foto\u011fraf\u00e7\u0131l\u0131k tutkusuyla yap\u0131ld\u0131.",
"sensor.ff":"Tam Kare (36x24mm)","sensor.apsc":"APS-C (23.5x15.6mm)","sensor.apsc_canon":"APS-C Canon (22.3x14.9mm)","sensor.m43":"Micro 4/3 (17.3x13mm)","sensor.1inch":"1 In\u00e7 (13.2x8.8mm)","sensor.small":"1/2.3 In\u00e7 (6.17x4.55mm)","sensor.ff_short":"Tam Kare","sensor.apsc_short":"APS-C","sensor.m43_short":"Micro 4/3","sensor.ff_1x":"Tam Kare (1.0x)","sensor.apsc_nikon":"APS-C Nikon/Sony (1.5x)","sensor.apsc_canon_1_6":"APS-C Canon (1.6x)","sensor.m43_2x":"Micro 4/3 (2.0x)","sensor.1inch_2_7":"1 In\u00e7 (2.7x)","sensor.small_5_6":"1/2.3 In\u00e7 (5.6x)",
"lenstype.superwide":"S\u00fcper Geni\u015f A\u00e7\u0131","lenstype.wide":"Geni\u015f A\u00e7\u0131","lenstype.normal":"Normal Lens","lenstype.tele":"Telefoto","lenstype.supertele":"S\u00fcper Telefoto",
"macro.true":"Ger\u00e7ek Makro (1:1+)","macro.half":"Yar\u0131m Makro (~1:2)","macro.close":"Yak\u0131n \u00c7ekim","macro.no":"Makro De\u011fil",
"rule.thirds.name":"\u00dc\u00e7ler Kural\u0131","rule.thirds.desc":"G\u00f6r\u00fcnt\u00fcy\u00fc 9 e\u015fit par\u00e7aya b\u00f6l\u00fcn. Kilit \u00f6\u011feleri \u00e7izgilere veya kesi\u015fim noktalar\u0131na yerle\u015ftirin.","rule.thirds.tip1":"Ufku \u00fcst veya alt \u00fc\u00e7te bir \u00e7izgisine yerle\u015ftirin","rule.thirds.tip2":"Konunun g\u00f6zlerini \u00fcst kesi\u015fim noktalar\u0131na yerle\u015ftirin","rule.thirds.tip3":"Ana konuyu asla tam ortaya koymay\u0131n",
"rule.golden.name":"Alt\u0131n Oran","rule.golden.desc":"1:1.618 oran\u0131 \u2013 do\u011fan\u0131n m\u00fckemmel oran\u0131.","rule.golden.tip1":"G\u00f6r\u00fcnt\u00fcy\u00fc yakla\u015f\u0131k %62 / %38 oran\u0131nda b\u00f6ler","rule.golden.tip2":"\u00dc\u00e7ler kural\u0131ndan daha uyumlu","rule.golden.tip3":"Do\u011fada her yerde bulunur (kabuklar, \u00e7i\u00e7ekler)",
"rule.leading.name":"Y\u00f6nlendirici \u00c7izgiler","rule.leading.desc":"Do\u011fal \u00e7izgiler izleyicinin g\u00f6z\u00fcn\u00fc ana konuya y\u00f6nlendirir.","rule.leading.tip1":"Yollar\u0131, nehirleri, \u00e7itleri \u00e7izgi olarak kullan\u0131n","rule.leading.tip2":"\u00c7izgiler g\u00f6r\u00fcnt\u00fcn\u00fcn i\u00e7ine y\u00f6nlendirmeli","rule.leading.tip3":"Birle\u015fen \u00e7izgiler derinlik yarat\u0131r",
"rule.symmetry.name":"Simetri & Desenler","rule.symmetry.desc":"Simetrik kompozisyonlar huzur ve m\u00fckemmellik yayar.","rule.symmetry.tip1":"Sudaki yans\u0131malar simetri i\u00e7in m\u00fckemmel","rule.symmetry.tip2":"Mimari do\u011fal simetri sunar","rule.symmetry.tip3":"Simetriyi bilin\u00e7li k\u0131rmak stil unsuru olarak",
"rule.framing.name":"Do\u011fal \u00c7er\u00e7eveleme","rule.framing.desc":"Sahne unsurlar\u0131n\u0131 konuyu \u00e7er\u00e7evelemek i\u00e7in kullan\u0131n.","rule.framing.tip1":"Kemerler, pencereler, dallar \u00e7er\u00e7eve olarak","rule.framing.tip2":"Dikkati ana konuya y\u00f6nlendirir","rule.framing.tip3":"Derinlik ve ba\u011flam yarat\u0131r",
"rule.negative.name":"Negatif Alan","rule.negative.desc":"Konunun etraf\u0131ndaki bo\u015f alan etki ve dramatizm yarat\u0131r.","rule.negative.tip1":"Az \u00e7oktur \u2013 minimalizmi kullan\u0131n","rule.negative.tip2":"Konuya nefes alacak alan verin","rule.negative.tip3":"\u00d6zellikle portrelerde etkili",
"rule.diagonal.name":"Diyagonaller","rule.diagonal.desc":"Diyagonal \u00e7izgiler g\u00f6r\u00fcnt\u00fcde dinamizm ve gerilim yarat\u0131r.","rule.diagonal.tip1":"K\u00f6\u015feden k\u00f6\u015feye maksimum dinamizm","rule.diagonal.tip2":"E\u011fik perspektifler kullan\u0131n","rule.diagonal.tip3":"Hareket y\u00f6n\u00fc diyagonal boyunca",
"rule.color.name":"Renk Teorisi","rule.color.desc":"Tamamlay\u0131c\u0131 renkler ve renk uyumlar\u0131 g\u00fc\u00e7l\u00fc g\u00f6rsel etki i\u00e7in.","rule.color.tip1":"Tamamlay\u0131c\u0131 renkler kontrast i\u00e7in (mavi/turuncu)","rule.color.tip2":"Benzer renkler uyum i\u00e7in","rule.color.tip3":"S\u0131cak renkler \u00f6n planda, so\u011fuk renkler arka planda",
"motif.portrait.title":"Portre Foto\u011fraf\u00e7\u0131l\u0131\u011f\u0131","motif.portrait.desc":"\u0130nsanlar\u0131 ve y\u00fczleri m\u00fckemmel yakala.","motif.landscape.title":"Manzara Foto\u011fraf\u00e7\u0131l\u0131\u011f\u0131","motif.landscape.desc":"Geni\u015f manzaralar\u0131 ve do\u011fa sahnelerini yakala.","motif.street.title":"Sokak Foto\u011fraf\u00e7\u0131l\u0131\u011f\u0131","motif.street.desc":"Sokak ya\u015fam\u0131n\u0131 otantik olarak belgele.","motif.macro.title":"Makro Foto\u011fraf\u00e7\u0131l\u0131k","motif.macro.desc":"K\u00fc\u00e7\u00fck \u015feyleri b\u00fcy\u00fck g\u00f6ster.","motif.night.title":"Gece Foto\u011fraf\u00e7\u0131l\u0131\u011f\u0131","motif.night.desc":"Y\u0131ld\u0131zlar, \u015fehir \u0131\u015f\u0131klar\u0131 ve gece sahneleri.","motif.sport.title":"Spor Foto\u011fraf\u00e7\u0131l\u0131\u011f\u0131","motif.sport.desc":"H\u0131zl\u0131 hareketleri ve aksiyonu dondur.","motif.architecture.title":"Mimari Foto\u011fraf\u00e7\u0131l\u0131k","motif.architecture.desc":"Binalar ve yap\u0131lar m\u00fckemmellikte.","motif.wildlife.title":"Do\u011fa Foto\u011fraf\u00e7\u0131l\u0131\u011f\u0131","motif.wildlife.desc":"Hayvanlar do\u011fal ya\u015fam alanlar\u0131nda.",
"motif.setting.lens":"Lens","motif.setting.aperture":"Diyafram","motif.setting.iso":"ISO","motif.setting.shutter":"Enstantane",
"nav.simtools":"Sim\u00fclasyon","sim.tag":"Sim\u00fclasyon","sim.title":"Foto Sim\u00fclasyonlar\u0131","sim.desc":"Farkl\u0131 foto\u011fraf efektlerini ger\u00e7ek zamanl\u0131 sim\u00fcle edin.",
"sim.bokeh":"Bokeh","sim.longexp":"Uzun Pozlama","sim.wb":"Beyaz Dengesi","sim.noise":"ISO G\u00fcr\u00fclt\u00fc","sim.perspective":"Perspektif","sim.histogram":"Histogram",
"sim.bokeh.title":"Bokeh Sim\u00fclat\u00f6r\u00fc","sim.bokeh.info":"Farkl\u0131 diyafram ve odak uzakl\u0131klar\u0131yla bokeh'in nas\u0131l de\u011fi\u015fti\u011fini g\u00f6r\u00fcn.","sim.bokeh.blades":"Diyafram Kanatlar\u0131","sim.bokeh.intensity":"Bokeh Yo\u011funlu\u011fu",
"sim.longexp.title":"Uzun Pozlama Sim\u00fclat\u00f6r\u00fc","sim.longexp.info":"Farkl\u0131 pozlama s\u00fcrelerinin hareketli nesneler \u00fczerindeki etkisini g\u00f6r\u00fcn.","sim.longexp.speed":"Hareket H\u0131z\u0131","sim.longexp.time":"Pozlama S\u00fcresi (s)",
"sim.wb.title":"Beyaz Dengesi Sim\u00fclat\u00f6r\u00fc","sim.wb.info":"Renk s\u0131cakl\u0131\u011f\u0131n\u0131n g\u00f6r\u00fcnt\u00fcn\u00fcz\u00fc nas\u0131l etkiledi\u011fini g\u00f6r\u00fcn.","sim.wb.kelvin":"S\u0131cakl\u0131k (K)","sim.wb.tint":"Ton",
"sim.noise.title":"ISO G\u00fcr\u00fclt\u00fc Sim\u00fclat\u00f6r\u00fc","sim.noise.info":"Y\u00fcksek ISO de\u011ferlerinde dijital g\u00fcr\u00fclt\u00fcn\u00fcn nas\u0131l artt\u0131\u011f\u0131n\u0131 g\u00f6r\u00fcn.","sim.noise.level":"G\u00fcr\u00fclt\u00fc Seviyesi",
"sim.perspective.title":"Perspektif Sim\u00fclat\u00f6r\u00fc","sim.perspective.info":"Odak uzakl\u0131\u011f\u0131n\u0131n perspektifi nas\u0131l etkiledi\u011fini g\u00f6r\u00fcn.","sim.perspective.type":"Deformasyon Tipi",
"sim.histogram.title":"Histogram Sim\u00fclat\u00f6r\u00fc","sim.histogram.info":"Pozlama ayarlar\u0131na g\u00f6re histograma bak\u0131n."
},
/* ===== SVENSKA ===== */
sv: {
"nav.lens":"Linskalkylator","nav.composition":"Komposition","nav.motif":"Motivigenk\u00e4nning","nav.exposure":"Exponering","nav.quiz":"Quiz",
"hero.welcome":"V\u00e4lkommen till","hero.desc":"Din ultimata fotoverktygsl\u00e5da. Linber\u00e4kningar, kompositionsregler, motivigenk\u00e4nning och interaktiva quiz \u2014 allt p\u00e5 ett st\u00e4lle.","hero.calculators":"Kalkylatorer","hero.rules":"Regler","hero.questions":"Quizfr\u00e5gor","hero.cta":"Kom ig\u00e5ng",
"lens.tag":"Verktyg","lens.title":"Linskalkylator","lens.desc":"Ber\u00e4kna sk\u00e4rpedjup, bildvinkel, cropfaktor och mer.","lens.tab.dof":"Sk\u00e4rpedjup (DOF)","lens.tab.fov":"Bildvinkel (FOV)","lens.tab.crop":"Cropfaktor","lens.tab.hyper":"Hyperfokal Distans","lens.tab.flash":"Blixtens R\u00e4ckvidd","lens.tab.mag":"F\u00f6rstoring",
"calc.focal":"Br\u00e4nnvidd (mm)","calc.aperture":"Bl\u00e4ndare (f/)","calc.sensor":"Sensorstorlek","calc.calculate":"Ber\u00e4kna","calc.results":"Resultat",
"dof.title":"Ber\u00e4kna Sk\u00e4rpedjup","dof.info":"Sk\u00e4rpedjupet beskriver det omr\u00e5de som framst\u00e5r skarpt i bilden.","dof.distance":"Avst\u00e5nd till Motivet (m)","dof.total":"Sk\u00e4rpedjup","dof.near":"N\u00e4rpunkt","dof.far":"Fj\u00e4rrpunkt","dof.coc":"F\u00f6rvirringscirkel",
"fov.title":"Ber\u00e4kna Bildvinkel","fov.info":"Bildvinkeln avg\u00f6r hur mycket av scenen kameran f\u00e5ngar.","fov.horizontal":"Horisontell Bildvinkel","fov.vertical":"Vertikal Bildvinkel","fov.diagonal":"Diagonal Bildvinkel","fov.lenstype":"Linstyp",
"crop.title":"Ber\u00e4kna Cropfaktor","crop.info":"Cropfaktorn visar motsvarande utsnitt j\u00e4mf\u00f6rt med fullformat.","crop.lensfocal":"Objektivets Br\u00e4nnvidd (mm)","crop.lensaperture":"Objektivets Bl\u00e4ndare (f/)","crop.camerasensor":"Kamerasensor","crop.equivfocal":"Motsvarande Br\u00e4nnvidd (FF)","crop.equivaperture":"Motsvarande Bl\u00e4ndare (FF)","crop.factor":"Cropfaktor","crop.fullframe":"Fullformat",
"hyper.title":"Hyperfokal Distans","hyper.info":"Avst\u00e5ndet d\u00e4r allt fr\u00e5n halva avst\u00e5ndet till o\u00e4ndligheten \u00e4r skarpt.","hyper.distance":"Hyperfokal Distans","hyper.near":"N\u00e4rpunkt (fokus p\u00e5 H)","hyper.tiplabel":"Tips","hyper.tip":"Fokusera p\u00e5 hyperfokala distansen f\u00f6r maximal sk\u00e4rpa.",
"flash.title":"Blixtens R\u00e4ckvidd","flash.info":"Ber\u00e4kna maximal blixtr\u00e4ckvidd baserat p\u00e5 ledsiffra.","flash.gn":"Ledsiffra (GN)","flash.maxrange":"Maximal R\u00e4ckvidd","flash.at100":"Vid ISO 100",
"mag.title":"F\u00f6rstoring","mag.info":"Ber\u00e4kna f\u00f6rstoringsf\u00f6rh\u00e5llandet f\u00f6r makrofotografi.","mag.mindist":"Min. Fokusavst\u00e5nd (cm)","mag.sensorwidth":"Sensorbredd (mm)","mag.ratio":"F\u00f6rstoringsf\u00f6rh\u00e5llande","mag.field":"F\u00e5ngat F\u00e4lt (Bredd)","mag.macro":"Makrof\u00f6rm\u00e5ga",
"comp.tag":"Komposition","comp.title":"Fotoregler","comp.desc":"Beh\u00e4rska de viktigaste kompositionsreglerna f\u00f6r fantastiska bilder.","comp.demo":"Visa Demo",
"motif.tag":"Motivigenk\u00e4nning","motif.title":"Motivigenk\u00e4nning & Genrer","motif.desc":"L\u00e4r dig olika fotogenrer och deras optimala inst\u00e4llningar.","motif.all":"Alla","motif.portrait":"Portr\u00e4tt","motif.landscape":"Landskap","motif.street":"Gata","motif.macro":"Makro","motif.night":"Natt","motif.sport":"Sport","motif.architecture":"Arkitektur","motif.wildlife":"Vilt",
"exp.tag":"Grunder","exp.title":"Exponerings\u00adtriangeln","exp.desc":"F\u00f6rst\u00e5 samspelet mellan bl\u00e4ndare, slutartid och ISO.","exp.aperture":"Bl\u00e4ndare","exp.shutter":"Slutartid","exp.aperture.mid":"Mellanbl\u00e4ndare \u2013 bra kompromiss mellan sk\u00e4rpa och ljus.","exp.shutter.mid":"Standardslutar\u00adtid \u2013 fryser de flesta r\u00f6relser.","exp.iso.low":"L\u00e5gt ISO \u2013 minimalt brus, b\u00e4sta kvalitet.","exp.correct":"Korrekt Exponering","exp.under":"Underexponerad","exp.over":"\u00d6verexponerad","exp.slightunder":"Lite underexponerad","exp.slightover":"Lite \u00f6verexponerad",
"quiz.tag":"Kunskapskontroll","quiz.title":"Fotoquiz","quiz.desc":"Testa dina kunskaper med interaktiva fr\u00e5gor.","quiz.all":"Alla \u00c4mnen","quiz.basics":"Grunder","quiz.composition":"Komposition","quiz.lenses":"Objektiv","quiz.exposure":"Exponering","quiz.genres":"Genrer","quiz.ready":"Redo f\u00f6r Quiz?","quiz.choose":"V\u00e4lj en kategori och testa dina kunskaper!","quiz.info":"10 fr\u00e5gor per runda \u2022 Flerval \u2022 Omedelbar feedback","quiz.start":"Starta Quiz","quiz.next":"N\u00e4sta Fr\u00e5ga","quiz.finished":"Quiz Klar!","quiz.playagain":"Spela Igen","quiz.review":"Granska Svar","quiz.score":"Po\u00e4ng","quiz.resulttext":"Du svarade r\u00e4tt p\u00e5 {0} av {1} fr\u00e5gor.","quiz.excellent":"Utm\u00e4rkt! Du \u00e4r ett fotoprofs!","quiz.good":"Bra gjort! Solid kunskap!","quiz.ok":"Inte d\u00e5ligt! Forts\u00e4tt \u00f6va!","quiz.needwork":"Utrymme f\u00f6r f\u00f6rb\u00e4ttring! Repetera grunderna.",
"footer.brand":"Din gratis fotoverktygsl\u00e5da f\u00f6r b\u00e4ttre bilder.","footer.tools":"Verktyg","footer.calcs":"Kalkylatorer","footer.comprules":"Kompositionsregler","footer.exptriangle":"Exponerings\u00adtriangeln","footer.copy":"\u00a9 2026 PhotoPro Tools \u2014 Skapat med passion f\u00f6r fotografi.",
"sensor.ff":"Fullformat (36x24mm)","sensor.apsc":"APS-C (23.5x15.6mm)","sensor.apsc_canon":"APS-C Canon (22.3x14.9mm)","sensor.m43":"Micro 4/3 (17.3x13mm)","sensor.1inch":"1 Tum (13.2x8.8mm)","sensor.small":"1/2.3 Tum (6.17x4.55mm)","sensor.ff_short":"Fullformat","sensor.apsc_short":"APS-C","sensor.m43_short":"Micro 4/3","sensor.ff_1x":"Fullformat (1.0x)","sensor.apsc_nikon":"APS-C Nikon/Sony (1.5x)","sensor.apsc_canon_1_6":"APS-C Canon (1.6x)","sensor.m43_2x":"Micro 4/3 (2.0x)","sensor.1inch_2_7":"1 Tum (2.7x)","sensor.small_5_6":"1/2.3 Tum (5.6x)",
"lenstype.superwide":"Supervidvinkel","lenstype.wide":"Vidvinkel","lenstype.normal":"Normalobjektiv","lenstype.tele":"Teleobjektiv","lenstype.supertele":"Superteleobjektiv",
"macro.true":"\u00c4kta Makro (1:1+)","macro.half":"Halvmakro (~1:2)","macro.close":"N\u00e4rbild","macro.no":"Inte Makro",
"rule.thirds.name":"Tredjedelsregeln","rule.thirds.desc":"Dela bilden i 9 lika delar. Placera viktiga element p\u00e5 linjerna eller sk\u00e4rningspunkterna.","rule.thirds.tip1":"Placera horisonten p\u00e5 \u00f6vre eller nedre tredjedelslinjen","rule.thirds.tip2":"Placera motivets \u00f6gon p\u00e5 \u00f6vre sk\u00e4rningspunkterna","rule.thirds.tip3":"Placera aldrig huvudmotivet exakt i mitten",
"rule.golden.name":"Gyllene Snittet","rule.golden.desc":"F\u00f6rh\u00e5llandet 1:1.618 \u2013 naturens perfekta proportion.","rule.golden.tip1":"Delar bilden i ungef\u00e4r 62% / 38%","rule.golden.tip2":"Mer harmoniskt \u00e4n tredjedelsregeln","rule.golden.tip3":"Finns \u00f6verallt i naturen (snackor, blommor)",
"rule.leading.name":"Ledande Linjer","rule.leading.desc":"Naturliga linjer leder betraktarens blick till huvudmotivet.","rule.leading.tip1":"Anv\u00e4nd v\u00e4gar, floder, staket som linjer","rule.leading.tip2":"Linjerna b\u00f6r leda in i bilden","rule.leading.tip3":"Konvergerande linjer skapar djup",
"rule.symmetry.name":"Symmetri & M\u00f6nster","rule.symmetry.desc":"Symmetriska kompositioner utstrs\u00e5lar lugn och perfektion.","rule.symmetry.tip1":"Reflektioner i vatten perfekt f\u00f6r symmetri","rule.symmetry.tip2":"Arkitektur erbjuder naturlig symmetri","rule.symmetry.tip3":"Medvetet bryta symmetrin som stilgrepp",
"rule.framing.name":"Naturlig Inramning","rule.framing.desc":"Anv\u00e4nd element i scenen f\u00f6r att rama in ditt motiv.","rule.framing.tip1":"B\u00e5gar, f\u00f6nster, grenar som ramar","rule.framing.tip2":"Styr uppm\u00e4rksamheten mot huvudmotivet","rule.framing.tip3":"Skapar djup och sammanhang",
"rule.negative.name":"Negativt Utrymme","rule.negative.desc":"Tomt utrymme runt motivet skapar effekt och dramatik.","rule.negative.tip1":"Mindre \u00e4r mer \u2013 anv\u00e4nd minimalism","rule.negative.tip2":"Ge motivet utrymme att andas","rule.negative.tip3":"S\u00e4rskilt effektivt i portr\u00e4tt",
"rule.diagonal.name":"Diagonaler","rule.diagonal.desc":"Diagonala linjer skapar dynamik och sp\u00e4nning i bilden.","rule.diagonal.tip1":"H\u00f6rn till h\u00f6rn f\u00f6r maximal dynamik","rule.diagonal.tip2":"Anv\u00e4nd lutande perspektiv","rule.diagonal.tip3":"R\u00f6relseriktning l\u00e4ngs diagonalen",
"rule.color.name":"F\u00e4rgteori","rule.color.desc":"Komplement\u00e4rf\u00e4rger och f\u00e4rgharmonier f\u00f6r stark visuell effekt.","rule.color.tip1":"Komplement\u00e4rf\u00e4rger f\u00f6r kontrast (bl\u00e5/orange)","rule.color.tip2":"Analoga f\u00e4rger f\u00f6r harmoni","rule.color.tip3":"Varma f\u00e4rger i f\u00f6rgrunden, kalla i bakgrunden",
"motif.portrait.title":"Portr\u00e4ttfotografi","motif.portrait.desc":"F\u00e5nga m\u00e4nniskor och ansikten perfekt.","motif.landscape.title":"Landskapsfotografi","motif.landscape.desc":"F\u00e5nga vida landskap och naturscener.","motif.street.title":"Gatufotografi","motif.street.desc":"Dokumentera gatulivet autentiskt.","motif.macro.title":"Makrofotografi","motif.macro.desc":"G\u00f6r sm\u00e5 saker stora.","motif.night.title":"Nattfotografi","motif.night.desc":"Stj\u00e4rnor, stadsljus och nattscener.","motif.sport.title":"Sportfotografi","motif.sport.desc":"Frys snabba r\u00f6relser och action.","motif.architecture.title":"Arkitekturfotografi","motif.architecture.desc":"Byggnader och strukturer i perfektion.","motif.wildlife.title":"Viltfotografi","motif.wildlife.desc":"Djur i deras naturliga milj\u00f6.",
"motif.setting.lens":"Objektiv","motif.setting.aperture":"Bl\u00e4ndare","motif.setting.iso":"ISO","motif.setting.shutter":"Slutartid",
"nav.simtools":"Simulering","sim.tag":"Simulering","sim.title":"Fotosimuleringar","sim.desc":"Simulera olika fotoeffekter i realtid.",
"sim.bokeh":"Bokeh","sim.longexp":"L\u00e5ng Exponering","sim.wb":"Vitbalans","sim.noise":"ISO-brus","sim.perspective":"Perspektiv","sim.histogram":"Histogram",
"sim.bokeh.title":"Bokeh-simulator","sim.bokeh.info":"Se hur bokeh f\u00f6r\u00e4ndras med olika bl\u00e4ndare och br\u00e4nnvidder.","sim.bokeh.blades":"Bl\u00e4ndarblad","sim.bokeh.intensity":"Bokeh-intensitet",
"sim.longexp.title":"L\u00e5ngexponerings\u00adsimulator","sim.longexp.info":"Se effekten av olika exponeringstider p\u00e5 r\u00f6relse.","sim.longexp.speed":"R\u00f6relsehastighet","sim.longexp.time":"Exponeringstid (s)",
"sim.wb.title":"Vitbalanssimulator","sim.wb.info":"Se hur f\u00e4rgtemperaturen p\u00e5verkar din bild.","sim.wb.kelvin":"Temperatur (K)","sim.wb.tint":"Nyans",
"sim.noise.title":"ISO-brussimulator","sim.noise.info":"Se hur digitalt brus \u00f6kar med h\u00f6gre ISO-v\u00e4rden.","sim.noise.level":"Brusniv\u00e5",
"sim.perspective.title":"Perspektivsimulator","sim.perspective.info":"Se hur br\u00e4nnvidden p\u00e5verkar perspektivet.","sim.perspective.type":"Distorsionstyp",
"sim.histogram.title":"Histogramsimulator","sim.histogram.info":"Se histogrammet baserat p\u00e5 exponerings\u00adinst\u00e4llningar."
}
};
/* ==================== i18n Functions ==================== */
window.t = function(key) {
var lang = window.currentLang || 'de';
var tr = window.I18N[lang];
if (tr && tr[key] !== undefined) return tr[key];
if (window.I18N.de && window.I18N.de[key] !== undefined) return window.I18N.de[key];
return key;
};
window.setLanguage = function(lang) {
if (!window.I18N[lang]) return;
window.currentLang = lang;
try { localStorage.setItem('photopro-lang', lang); } catch(e) {}
document.documentElement.lang = lang;
// Update data-i18n elements
document.querySelectorAll('[data-i18n]').forEach(function(el) {
var key = el.getAttribute('data-i18n');
var val = t(key);
if (val !== key) el.textContent = val;
});
// Update data-i18n-opt elements (option elements)
document.querySelectorAll('[data-i18n-opt]').forEach(function(el) {
var key = el.getAttribute('data-i18n-opt');
var val = t(key);
if (val !== key) el.textContent = val;
});
// Update lang switcher button
var flag = document.getElementById('langFlag');
if (flag) flag.textContent = lang.toUpperCase();
// Update active state
document.querySelectorAll('.lang-option').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('data-lang') === lang);
});
// Dispatch event for dynamic content
document.dispatchEvent(new CustomEvent('languageChanged', { detail: { lang: lang }}));
};
// Init from localStorage
(function() {
try {
var saved = localStorage.getItem('photopro-lang');
if (saved && window.I18N[saved]) window.currentLang = saved;
} catch(e) {}
})();
window.I18N_READY = true;
+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>
+15
View File
@@ -0,0 +1,15 @@
/vendor/
/storage/uploads/*
/storage/outputs/*
/storage/thumbnails/*
/storage/logs/*
/storage/temp/*
!storage/uploads/.gitkeep
!storage/outputs/.gitkeep
!storage/thumbnails/.gitkeep
!storage/logs/.gitkeep
!storage/temp/.gitkeep
.env
*.swp
*.swo
.DS_Store
+40
View File
@@ -0,0 +1,40 @@
FROM php:8.2-cli
# Install FFmpeg and dependencies
RUN apt-get update && apt-get install -y \
ffmpeg \
libzip-dev \
unzip \
git \
&& docker-php-ext-install pcntl posix sockets \
&& rm -rf /var/lib/apt/lists/*
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /app
# Copy composer files first for caching
COPY composer.json ./
RUN composer install --no-dev --optimize-autoloader 2>/dev/null || true
# Copy application
COPY . .
# Install dependencies
RUN composer install --no-dev --optimize-autoloader
# Create storage directories
RUN mkdir -p storage/uploads storage/outputs storage/thumbnails storage/logs storage/temp \
&& chmod -R 777 storage
# Configure PHP
RUN echo "upload_max_filesize = 5G\n\
post_max_size = 5G\n\
memory_limit = 512M\n\
max_execution_time = 3600\n\
max_input_time = 3600" > /usr/local/etc/php/conf.d/video-converter.ini
EXPOSE 8080 8081
CMD ["php", "-S", "0.0.0.0:8080", "-t", "public", "public/router.php"]
@@ -0,0 +1,68 @@
#!/usr/bin/env php
<?php
/**
* Video Converter Suite - Queue Worker
*
* Processes jobs from the queue sequentially.
* Usage: php bin/queue-worker.php
*/
spl_autoload_register(function ($class) {
$prefix = 'VideoConverter\\';
$baseDir = __DIR__ . '/../src/';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) return;
$relative = substr($class, $len);
$file = $baseDir . str_replace('\\', '/', $relative) . '.php';
if (file_exists($file)) require $file;
});
use VideoConverter\Queue\JobQueue;
use VideoConverter\Format\FormatConverter;
$config = require __DIR__ . '/../config/app.php';
echo "=== Video Converter Suite - Queue Worker ===\n";
echo "Max concurrent: {$config['limits']['max_concurrent_jobs']}\n\n";
$queue = new JobQueue();
$converter = new FormatConverter();
$running = 0;
while (true) {
$queue = new JobQueue(); // Reload state
$converter = new FormatConverter();
$activeJobs = array_filter($converter->getAllJobs(), fn($j) => $j['status'] === 'running');
$running = count($activeJobs);
if ($running < $config['limits']['max_concurrent_jobs']) {
$nextJob = $queue->dequeue();
if ($nextJob) {
echo "[" . date('H:i:s') . "] Processing: {$nextJob['queue_id']}\n";
try {
$result = $converter->convert([
'input_file' => $nextJob['input_file'] ?? '',
'output_format' => $nextJob['output_format'] ?? 'mp4',
'preset' => $nextJob['preset'] ?? 'balanced',
'resolution' => $nextJob['resolution'] ?? null,
]);
if (isset($result['error'])) {
$queue->fail($nextJob['queue_id'], $result['error']);
echo "[" . date('H:i:s') . "] Failed: {$result['error']}\n";
} else {
$queue->complete($nextJob['queue_id'], $result);
echo "[" . date('H:i:s') . "] Started job: {$result['id']}\n";
}
} catch (\Throwable $e) {
$queue->fail($nextJob['queue_id'], $e->getMessage());
echo "[" . date('H:i:s') . "] Error: {$e->getMessage()}\n";
}
}
}
sleep(2);
}
+101
View File
@@ -0,0 +1,101 @@
#!/bin/bash
# Video Converter Suite - Startup Script
# Starts all services: Web Server, WebSocket Server, Queue Worker
echo "================================================"
echo " VIDEO CONVERTER SUITE - Starting Services"
echo "================================================"
echo ""
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$DIR"
# Create storage directories
mkdir -p storage/{uploads,outputs,thumbnails,logs,temp}
# Check FFmpeg
if command -v ffmpeg &> /dev/null; then
echo "[OK] FFmpeg: $(ffmpeg -version 2>&1 | head -1)"
else
echo "[!!] FFmpeg not found. Install with: apt install ffmpeg"
echo " The application will work but conversions will fail."
fi
# Check PHP
if command -v php &> /dev/null; then
echo "[OK] PHP: $(php -v 2>&1 | head -1)"
else
echo "[!!] PHP not found."
exit 1
fi
# Install dependencies if needed
if [ ! -d "vendor" ]; then
echo ""
echo "Installing dependencies..."
if command -v composer &> /dev/null; then
composer install
else
echo "[!!] Composer not found. WebSocket server won't work."
echo " The web interface will still work without it."
fi
fi
echo ""
echo "Starting services..."
echo ""
# Start Web Server
echo "[1/3] Web Server on http://localhost:8080"
php -S 0.0.0.0:8080 -t public public/router.php \
-d upload_max_filesize=5G \
-d post_max_size=5G \
-d memory_limit=512M \
-d max_execution_time=3600 \
> storage/logs/web.log 2>&1 &
WEB_PID=$!
# Start WebSocket Server (optional, requires Ratchet)
if [ -f "vendor/autoload.php" ]; then
echo "[2/3] WebSocket Server on ws://localhost:8081"
php bin/websocket-server.php > storage/logs/websocket.log 2>&1 &
WS_PID=$!
else
echo "[2/3] WebSocket Server: SKIPPED (run composer install first)"
WS_PID=""
fi
# Start Queue Worker
echo "[3/3] Queue Worker"
php bin/queue-worker.php > storage/logs/worker.log 2>&1 &
WORKER_PID=$!
echo ""
echo "================================================"
echo " All services started!"
echo ""
echo " Web UI: http://localhost:8080"
echo " WebSocket: ws://localhost:8081"
echo ""
echo " PIDs: Web=$WEB_PID WS=$WS_PID Worker=$WORKER_PID"
echo " Logs: storage/logs/"
echo ""
echo " Press Ctrl+C to stop all services"
echo "================================================"
# Trap exit to kill all processes
cleanup() {
echo ""
echo "Stopping all services..."
kill $WEB_PID 2>/dev/null
[ -n "$WS_PID" ] && kill $WS_PID 2>/dev/null
kill $WORKER_PID 2>/dev/null
echo "All services stopped."
exit 0
}
trap cleanup EXIT INT TERM
# Wait for any process to exit
wait

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