Compare commits

..

42 Commits

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

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

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

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

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

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

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

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

https://claude.ai/code/session_016BnRMtz5yhf7n5ZPQCMfmN
2026-02-07 12:50:44 +00:00
admin 6fba9d938a Remove OpenWeatherMap API key input and update settings
Removed API key input for OpenWeatherMap and updated weather widget settings.
2026-02-05 11:03:08 +01:00
admin 5d9ebbbc3e Refactor weather settings and timelapse checks
Removed the weather API key retrieval method and updated the weekly timelapse check logic.
2026-02-05 11:02:36 +01:00
admin 282d8b70fc Merge branch 'main' into claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw 2026-02-05 11:01:33 +01:00
admin 814494f812 Add auto-screenshot and email sharing settings 2026-02-05 10:57:02 +01:00
admin 75e5566532 Update weather widget description and remove API key field 2026-02-05 10:56:21 +01:00
admin 5d382db42e Merge pull request #46 from metacube2/codex/implement-phase-locked-phase-vocoder
Add phase-locked vocoder timestretcher
2026-01-31 12:24:13 +01:00
admin 054717fff1 Add phase-locked vocoder timestretcher 2026-01-31 12:24:02 +01:00
admin 5218c064cb Merge pull request #45 from metacube2/claude/fix-aurora-api-key-VK588
Add 4 new features: timelapse toggle, auto-screenshot, video search, …
2026-01-30 19:42:09 +01:00
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
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
73 changed files with 17605 additions and 76 deletions
+10
View File
@@ -0,0 +1,10 @@
# Ignore Visual Studio + build artifacts
.vs/
TrafagSalesExporter/.vs/
TrafagSalesExporter/bin/
TrafagSalesExporter/obj/
TrafagSalesExporter/*.user
TrafagSalesExporter/*.suo
TrafagSalesExporter/*.db
TrafagSalesExporter/*.db-shm
TrafagSalesExporter/*.db-wal
+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;
}
}
+8
View File
@@ -0,0 +1,8 @@
# Build artifacts
bin/
obj/
# Visual Studio user/IDE files
.vs/
*.user
*.suo
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trafag Sales Exporter</title>
<base href="/" />
<link href="css/app.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<HeadOutlet @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
</head>
<body>
<Routes @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
<script src="_framework/blazor.web.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
</body>
</html>
@@ -0,0 +1,38 @@
@inherits LayoutComponentBase
<MudThemeProvider Theme="_theme" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<MudLayout>
<MudAppBar Elevation="1" Color="Color.Primary">
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
OnClick="ToggleDrawer" />
<MudText Typo="Typo.h6" Class="ml-3">Trafag Sales Exporter</MudText>
</MudAppBar>
<MudDrawer @bind-Open="_drawerOpen" Elevation="2" ClipMode="DrawerClipMode.Always">
<NavMenu />
</MudDrawer>
<MudMainContent Class="pa-4">
@Body
</MudMainContent>
</MudLayout>
@code {
private bool _drawerOpen = true;
private readonly MudTheme _theme = new()
{
PaletteLight = new PaletteLight
{
Primary = "#1565C0",
Secondary = "#00897B",
AppbarBackground = "#1565C0"
}
};
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
}
@@ -0,0 +1,17 @@
<MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
Dashboard
</MudNavLink>
<MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn">
Standorte
</MudNavLink>
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
Transformationen
</MudNavLink>
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
Settings
</MudNavLink>
<MudNavLink Href="/logs" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">
Logs
</MudNavLink>
</MudNavMenu>
@@ -0,0 +1,193 @@
@page "/"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject ExportOrchestrationService Orchestrator
@inject TimerBackgroundService TimerService
@inject ISnackbar Snackbar
@implements IDisposable
<PageTitle>Dashboard</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Dashboard</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Spacing="4">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow"
OnClick="ExportAll" Disabled="_anyRunning">
Alle exportieren
</MudButton>
<MudText Typo="Typo.body1">
@if (TimerService.NextRun < DateTime.MaxValue)
{
<MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Small" Class="mr-1" />
@($"Nächster automatischer Lauf: {TimerService.NextRun:dd.MM.yyyy HH:mm}")
}
else
{
<MudIcon Icon="@Icons.Material.Filled.TimerOff" Size="Size.Small" Class="mr-1" />
@("Timer deaktiviert")
}
</MudText>
</MudStack>
</MudPaper>
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>Land</MudTh>
<MudTh>TSC</MudTh>
<MudTh>Schema</MudTh>
<MudTh>Server</MudTh>
<MudTh>Status</MudTh>
<MudTh>Zeilen</MudTh>
<MudTh>Letzter Lauf</MudTh>
<MudTh>Dauer</MudTh>
<MudTh>Aktion</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.ServerName</MudTd>
<MudTd>
@if (Orchestrator.IsExporting(context.SiteId))
{
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
<MudText Typo="Typo.caption">@Orchestrator.GetExportStatus(context.SiteId)</MudText>
}
else if (context.LastStatus == "OK")
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else if (context.LastStatus == "Error")
{
<MudTooltip Text="@context.ErrorMessage">
<MudIcon Icon="@Icons.Material.Filled.Error" Color="Color.Error" Size="Size.Small" />
</MudTooltip>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
<MudTd>@(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
<MudTd>@(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-")</MudTd>
<MudTd>
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.FileDownload"
OnClick="() => ExportSingle(context.SiteId)"
Disabled="Orchestrator.IsExporting(context.SiteId)">
Export
</MudButton>
</MudTd>
</RowTemplate>
</MudTable>
@code {
private List<DashboardRow> _dashboardRows = new();
private bool _loading = true;
private bool _anyRunning;
protected override async Task OnInitializedAsync()
{
Orchestrator.OnExportStatusChanged += HandleStatusChanged;
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
using var db = await DbFactory.CreateDbContextAsync();
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
var logs = await db.ExportLogs
.GroupBy(l => l.SiteId)
.Select(g => g.OrderByDescending(l => l.Timestamp).First())
.ToListAsync();
_dashboardRows = sites.Select(s =>
{
var log = logs.FirstOrDefault(l => l.SiteId == s.Id);
return new DashboardRow
{
SiteId = s.Id,
Land = s.Land,
TSC = s.TSC,
Schema = s.Schema,
ServerName = s.HanaServer?.Name ?? "",
LastStatus = log?.Status ?? "",
RowCount = log?.RowCount ?? 0,
LastRun = log?.Timestamp,
DurationSeconds = log?.DurationSeconds ?? 0,
ErrorMessage = log?.ErrorMessage ?? ""
};
}).ToList();
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId));
_loading = false;
}
private async Task ExportAll()
{
_anyRunning = true;
_ = Task.Run(async () =>
{
await Orchestrator.ExportAllAsync();
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
});
Snackbar.Add("Export für alle Standorte gestartet", Severity.Info);
}
private void ExportSingle(int siteId)
{
_ = Task.Run(async () =>
{
await Orchestrator.ExportSiteByIdAsync(siteId);
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
});
Snackbar.Add("Export gestartet", Severity.Info);
}
private async void HandleStatusChanged()
{
await InvokeAsync(async () =>
{
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId));
StateHasChanged();
if (!_anyRunning)
{
await LoadDataAsync();
StateHasChanged();
}
});
}
public void Dispose()
{
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
}
private class DashboardRow
{
public int SiteId { get; set; }
public string Land { get; set; } = "";
public string TSC { get; set; } = "";
public string Schema { get; set; } = "";
public string ServerName { get; set; } = "";
public string LastStatus { get; set; } = "";
public int RowCount { get; set; }
public DateTime? LastRun { get; set; }
public double DurationSeconds { get; set; }
public string ErrorMessage { get; set; } = "";
}
}
@@ -0,0 +1,134 @@
@page "/logs"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@inject IDbContextFactory<AppDbContext> DbFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<PageTitle>Logs</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Export Logs</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Spacing="3">
<MudSelect @bind-Value="_filterLand" Label="Land" Clearable Style="max-width:200px;">
@foreach (var land in _availableLands)
{
<MudSelectItem Value="@land">@land</MudSelectItem>
}
</MudSelect>
<MudSelect @bind-Value="_filterStatus" Label="Status" Clearable Style="max-width:150px;">
<MudSelectItem Value="@("OK")">OK</MudSelectItem>
<MudSelectItem Value="@("Error")">Error</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="_filterDate" Label="Datum" Clearable Style="max-width:200px;" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ApplyFilter"
StartIcon="@Icons.Material.Filled.FilterAlt">
Filtern
</MudButton>
<MudSpacer />
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteOldLogs"
StartIcon="@Icons.Material.Filled.DeleteSweep">
Alte Logs löschen
</MudButton>
</MudStack>
</MudPaper>
<MudTable Items="_logs" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>Zeitpunkt</MudTh>
<MudTh>Land</MudTh>
<MudTh>TSC</MudTh>
<MudTh>Status</MudTh>
<MudTh>Zeilen</MudTh>
<MudTh>Dauer</MudTh>
<MudTh>Dateiname</MudTh>
<MudTh>Fehler</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Timestamp.ToString("dd.MM.yyyy HH:mm:ss")</MudTd>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>
@if (context.Status == "OK")
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">OK</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Error">Error</MudChip>
}
</MudTd>
<MudTd>@context.RowCount.ToString("N0")</MudTd>
<MudTd>@($"{context.DurationSeconds:F1}s")</MudTd>
<MudTd>@context.FileName</MudTd>
<MudTd>
@if (!string.IsNullOrEmpty(context.ErrorMessage))
{
<MudTooltip Text="@context.ErrorMessage">
<MudText Typo="Typo.caption" Color="Color.Error" Style="max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
@context.ErrorMessage
</MudText>
</MudTooltip>
}
</MudTd>
</RowTemplate>
</MudTable>
@code {
private List<ExportLog> _logs = new();
private List<string> _availableLands = new();
private string? _filterLand;
private string? _filterStatus;
private DateTime? _filterDate;
private bool _loading = true;
protected override async Task OnInitializedAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_availableLands = await db.ExportLogs.Select(l => l.Land).Distinct().OrderBy(l => l).ToListAsync();
await LoadLogsAsync();
}
private async Task LoadLogsAsync()
{
_loading = true;
using var db = await DbFactory.CreateDbContextAsync();
IQueryable<ExportLog> query = db.ExportLogs.OrderByDescending(l => l.Timestamp);
if (!string.IsNullOrEmpty(_filterLand))
query = query.Where(l => l.Land == _filterLand);
if (!string.IsNullOrEmpty(_filterStatus))
query = query.Where(l => l.Status == _filterStatus);
if (_filterDate.HasValue)
query = query.Where(l => l.Timestamp.Date == _filterDate.Value.Date);
_logs = await query.Take(500).ToListAsync();
_loading = false;
}
private async Task ApplyFilter()
{
await LoadLogsAsync();
}
private async Task DeleteOldLogs()
{
var result = await DialogService.ShowMessageBox(
"Alte Logs löschen",
"Logs älter als 90 Tage löschen?",
yesText: "Löschen", cancelText: "Abbrechen");
if (result != true) return;
using var db = await DbFactory.CreateDbContextAsync();
var cutoff = DateTime.Now.AddDays(-90);
var oldLogs = await db.ExportLogs.Where(l => l.Timestamp < cutoff).ToListAsync();
db.ExportLogs.RemoveRange(oldLogs);
var count = await db.SaveChangesAsync();
await LoadLogsAsync();
Snackbar.Add($"{oldLogs.Count} alte Logs gelöscht", Severity.Info);
}
}
@@ -0,0 +1,164 @@
@page "/settings"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject SharePointUploadService SpService
@inject TimerBackgroundService TimerService
@inject ISnackbar Snackbar
<PageTitle>Settings</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Settings</MudText>
@* SharePoint Config *@
<MudText Typo="Typo.h5" Class="mb-2">SharePoint Konfiguration</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_spConfig.SiteUrl" Label="Site URL" />
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_spConfig.ExportFolder" Label="Export Folder" />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_spConfig.TenantId" Label="Tenant ID" />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_spConfig.ClientId" Label="Client ID" />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_spConfig.ClientSecret" Label="Client Secret" InputType="InputType.Password" />
</MudItem>
<MudItem xs="12">
<MudStack Row Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSharePoint"
StartIcon="@Icons.Material.Filled.Save">
Speichern
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="TestSharePoint"
StartIcon="@Icons.Material.Filled.NetworkCheck" Disabled="_testingSp">
@if (_testingSp)
{
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
@("Teste...")
}
else
{
@("SharePoint Verbindung testen")
}
</MudButton>
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
@* Export Settings *@
<MudText Typo="Typo.h5" Class="mb-2">Export Einstellungen</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_exportSettings.DateFilter" Label="Datum-Filter (ab)"
HelperText="Format: yyyy-MM-dd" />
</MudItem>
<MudItem xs="12" md="2">
<MudNumericField @bind-Value="_exportSettings.TimerHour" Label="Timer Stunde" Min="0" Max="23" />
</MudItem>
<MudItem xs="12" md="2">
<MudNumericField @bind-Value="_exportSettings.TimerMinute" Label="Timer Minute" Min="0" Max="59" />
</MudItem>
<MudItem xs="12" md="4">
<MudSwitch @bind-Value="_exportSettings.TimerEnabled" Label="Timer aktiviert" Color="Color.Primary" />
</MudItem>
<MudItem xs="12">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExportSettings"
StartIcon="@Icons.Material.Filled.Save">
Speichern
</MudButton>
</MudItem>
</MudGrid>
</MudPaper>
@* Filename Preview *@
<MudText Typo="Typo.h5" Class="mb-2">Dateiname Vorschau</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.body1">
<MudIcon Icon="@Icons.Material.Filled.InsertDriveFile" Size="Size.Small" Class="mr-1" />
Sales_{"{TSC}"}_{DateTime.Now:yyyy-MM-dd}.xlsx
</MudText>
<MudText Typo="Typo.caption" Class="mt-1">
Beispiel: Sales_TRFR_@(DateTime.Now.ToString("yyyy-MM-dd")).xlsx
</MudText>
</MudPaper>
@code {
private SharePointConfig _spConfig = new();
private ExportSettings _exportSettings = new();
private bool _testingSp;
protected override async Task OnInitializedAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig();
_exportSettings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
}
private async Task SaveSharePoint()
{
using var db = await DbFactory.CreateDbContextAsync();
var existing = await db.SharePointConfigs.FirstOrDefaultAsync();
if (existing is null)
{
db.SharePointConfigs.Add(_spConfig);
}
else
{
existing.SiteUrl = _spConfig.SiteUrl;
existing.ExportFolder = _spConfig.ExportFolder;
existing.TenantId = _spConfig.TenantId;
existing.ClientId = _spConfig.ClientId;
existing.ClientSecret = _spConfig.ClientSecret;
}
await db.SaveChangesAsync();
Snackbar.Add("SharePoint Konfiguration gespeichert", Severity.Success);
}
private async Task TestSharePoint()
{
_testingSp = true;
try
{
await SpService.TestConnectionAsync(
_spConfig.TenantId, _spConfig.ClientId, _spConfig.ClientSecret, _spConfig.SiteUrl);
Snackbar.Add("SharePoint Verbindung erfolgreich!", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Verbindung fehlgeschlagen: {ex.Message}", Severity.Error);
}
finally
{
_testingSp = false;
}
}
private async Task SaveExportSettings()
{
using var db = await DbFactory.CreateDbContextAsync();
var existing = await db.ExportSettings.FirstOrDefaultAsync();
if (existing is null)
{
db.ExportSettings.Add(_exportSettings);
}
else
{
existing.DateFilter = _exportSettings.DateFilter;
existing.TimerHour = _exportSettings.TimerHour;
existing.TimerMinute = _exportSettings.TimerMinute;
existing.TimerEnabled = _exportSettings.TimerEnabled;
}
await db.SaveChangesAsync();
TimerService.Recalculate();
Snackbar.Add("Export Einstellungen gespeichert", Severity.Success);
}
}
@@ -0,0 +1,353 @@
@page "/standorte"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject HanaQueryService HanaService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<PageTitle>Standorte</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Standorte</MudText>
<MudText Typo="Typo.h5" Class="mb-2">HANA Server</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
OnClick="AddServer" Class="mb-3">
Server hinzufügen
</MudButton>
<MudTable Items="_servers" Dense Hover Striped>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Host</MudTh>
<MudTh>Port</MudTh>
<MudTh>Username</MudTh>
<MudTh>Verbindungsstatus</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Name</MudTd>
<MudTd>@context.Host</MudTd>
<MudTd>@context.Port</MudTd>
<MudTd>@context.Username</MudTd>
<MudTd>
@if (_connectionStatus.TryGetValue(context.Id, out var status))
{
<MudTooltip Text="@BuildStatusTooltip(status)">
<MudChip Color="@(status.Success ? Color.Success : Color.Error)" Variant="Variant.Filled" Size="Size.Small">
@(status.Success ? "OK" : "Fehler") - @status.Stage
</MudChip>
</MudTooltip>
}
else
{
<MudChip Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small">Nicht getestet</MudChip>
}
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small"
OnClick="() => EditServer(context)" />
<MudIconButton Icon="@Icons.Material.Filled.NetworkCheck" Size="Size.Small" Color="Color.Info"
OnClick="() => TestServerConnection(context)" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="() => DeleteServer(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
<MudText Typo="Typo.h5" Class="mb-2">Standorte (Sites)</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
OnClick="AddSite" Class="mb-3">
Neuen Standort hinzufügen
</MudButton>
<MudTable Items="_sites" Dense Hover Striped>
<HeaderContent>
<MudTh>Land</MudTh>
<MudTh>TSC</MudTh>
<MudTh>Schema</MudTh>
<MudTh>Quellsystem</MudTh>
<MudTh>Server</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.SourceSystem</MudTd>
<MudTd>@(context.HanaServer?.Name ?? "-")</MudTd>
<MudTd>
@if (context.IsActive)
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else
{
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Default" Size="Size.Small" />
}
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small"
OnClick="() => EditSite(context)" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="() => DeleteSite(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
<MudDialog @bind-Visible="_serverDialogVisible" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_editingServer.Id == 0 ? "Server hinzufügen" : "Server bearbeiten")</MudText>
</TitleContent>
<DialogContent>
<MudTextField @bind-Value="_editingServer.Name" Label="Name" Required />
<MudTextField @bind-Value="_editingServer.Host" Label="Host" Required
HelperText="IP oder Hostname (ohne Protokoll)" />
<MudNumericField @bind-Value="_editingServer.Port" Label="Port"
HelperText="Typisch 30015 (Tenant), 30013 (SystemDB), 3xx15 für Instanz xx" />
<MudTextField @bind-Value="_editingServer.Username" Label="Username" />
<MudTextField @bind-Value="_editingServer.Password" Label="Password" InputType="InputType.Password" />
<MudTextField @bind-Value="_editingServer.DatabaseName" Label="Database Name (MDC)"
HelperText="Nur bei Multi-Tenant Setup angeben, sonst leer lassen" />
<MudSwitch @bind-Value="_editingServer.UseSsl" Label="SSL/TLS verwenden (encrypt=true)" Color="Color.Primary" />
<MudSwitch @bind-Value="_editingServer.ValidateCertificate" Label="SSL-Zertifikat validieren" Color="Color.Primary"
Disabled="!_editingServer.UseSsl" />
<MudTextField @bind-Value="_editingServer.AdditionalParams" Label="Zusätzliche Parameter"
HelperText="Optional, z.B. sslCryptoProvider=openssl;communicationTimeout=0" />
</DialogContent>
<DialogActions>
<MudButton OnClick="() => _serverDialogVisible = false">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveServer">Speichern</MudButton>
</DialogActions>
</MudDialog>
<MudDialog @bind-Visible="_siteDialogVisible" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_editingSite.Id == 0 ? "Standort hinzufügen" : "Standort bearbeiten")</MudText>
</TitleContent>
<DialogContent>
<MudSelect @bind-Value="_editingSite.HanaServerId" Label="Server" Required>
@foreach (var s in _servers)
{
<MudSelectItem Value="@s.Id">@s.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="_editingSite.Schema" Label="Schema" Required />
<MudTextField @bind-Value="_editingSite.TSC" Label="TSC" Required />
<MudTextField @bind-Value="_editingSite.Land" Label="Land" Required />
<MudSelect @bind-Value="_editingSite.SourceSystem" Label="Quellsystem" Required>
@foreach (var system in _sourceSystems)
{
<MudSelectItem Value="system">@system</MudSelectItem>
}
</MudSelect>
<MudCheckBox @bind-Value="_editingSite.IsActive" Label="Aktiv" />
</DialogContent>
<DialogActions>
<MudButton OnClick="() => _siteDialogVisible = false">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite">Speichern</MudButton>
</DialogActions>
</MudDialog>
@code {
private readonly string[] _sourceSystems = ["SAP", "BI1", "SAGE"];
private readonly Dictionary<int, ConnectionTestResult> _connectionStatus = new();
private List<HanaServer> _servers = new();
private List<Site> _sites = new();
private HanaServer _editingServer = new();
private Site _editingSite = new();
private bool _serverDialogVisible;
private bool _siteDialogVisible;
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_servers = await db.HanaServers.OrderBy(s => s.Name).ToListAsync();
_sites = await db.Sites.Include(s => s.HanaServer).OrderBy(s => s.Land).ToListAsync();
}
private void AddServer()
{
_editingServer = new HanaServer { Port = 30015 };
_serverDialogVisible = true;
}
private void EditServer(HanaServer server)
{
_editingServer = new HanaServer
{
Id = server.Id,
Name = server.Name,
Host = server.Host,
Port = server.Port,
Username = server.Username,
Password = server.Password,
DatabaseName = server.DatabaseName,
UseSsl = server.UseSsl,
ValidateCertificate = server.ValidateCertificate,
AdditionalParams = server.AdditionalParams
};
_serverDialogVisible = true;
}
private async Task SaveServer()
{
using var db = await DbFactory.CreateDbContextAsync();
if (_editingServer.Id == 0)
{
db.HanaServers.Add(_editingServer);
}
else
{
var existing = await db.HanaServers.FindAsync(_editingServer.Id);
if (existing is not null)
{
existing.Name = _editingServer.Name;
existing.Host = _editingServer.Host;
existing.Port = _editingServer.Port;
existing.Username = _editingServer.Username;
existing.Password = _editingServer.Password;
existing.DatabaseName = _editingServer.DatabaseName;
existing.UseSsl = _editingServer.UseSsl;
existing.ValidateCertificate = _editingServer.ValidateCertificate;
existing.AdditionalParams = _editingServer.AdditionalParams;
}
}
await db.SaveChangesAsync();
_serverDialogVisible = false;
await LoadDataAsync();
Snackbar.Add("Server gespeichert", Severity.Success);
}
private async Task DeleteServer(HanaServer server)
{
var result = await DialogService.ShowMessageBox(
"Server löschen",
$"Server '{server.Name}' wirklich löschen?",
yesText: "Löschen", cancelText: "Abbrechen");
if (result != true) return;
using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.HanaServers.FindAsync(server.Id);
if (entity is not null)
{
db.HanaServers.Remove(entity);
await db.SaveChangesAsync();
}
await LoadDataAsync();
Snackbar.Add("Server gelöscht", Severity.Info);
}
private async Task TestServerConnection(HanaServer server)
{
var result = await Task.Run(() => HanaService.TestConnectionDetailed(server));
_connectionStatus[server.Id] = result;
if (result.Success)
{
Snackbar.Add($"Verbindung zu '{server.Name}' erfolgreich.", Severity.Success);
}
else
{
Snackbar.Add($"{server.Name}: {result.ExceptionType} - {result.ErrorMessage}", Severity.Error);
}
}
private static string BuildStatusTooltip(ConnectionTestResult status)
{
var stamp = status.TestedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss");
if (status.Success)
return $"Letzter Test: {stamp}\nStage: {status.Stage}\n{status.ConnectionStringPreview}";
return $"Letzter Test: {stamp}\nStage: {status.Stage}\nFehler: {status.ErrorMessage}\n{status.ConnectionStringPreview}";
}
private void AddSite()
{
_editingSite = new Site
{
IsActive = true,
SourceSystem = "SAP",
HanaServerId = _servers.FirstOrDefault()?.Id ?? 0
};
_siteDialogVisible = true;
}
private void EditSite(Site site)
{
_editingSite = new Site
{
Id = site.Id,
HanaServerId = site.HanaServerId,
Schema = site.Schema,
TSC = site.TSC,
Land = site.Land,
SourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem,
IsActive = site.IsActive
};
_siteDialogVisible = true;
}
private async Task SaveSite()
{
using var db = await DbFactory.CreateDbContextAsync();
if (_editingSite.Id == 0)
{
db.Sites.Add(_editingSite);
}
else
{
var existing = await db.Sites.FindAsync(_editingSite.Id);
if (existing is not null)
{
existing.HanaServerId = _editingSite.HanaServerId;
existing.Schema = _editingSite.Schema;
existing.TSC = _editingSite.TSC;
existing.Land = _editingSite.Land;
existing.SourceSystem = _editingSite.SourceSystem;
existing.IsActive = _editingSite.IsActive;
}
}
await db.SaveChangesAsync();
_siteDialogVisible = false;
await LoadDataAsync();
Snackbar.Add("Standort gespeichert", Severity.Success);
}
private async Task DeleteSite(Site site)
{
var result = await DialogService.ShowMessageBox(
"Standort löschen",
$"Standort '{site.Land}' wirklich löschen?",
yesText: "Löschen", cancelText: "Abbrechen");
if (result != true) return;
using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.Sites.FindAsync(site.Id);
if (entity is not null)
{
db.Sites.Remove(entity);
await db.SaveChangesAsync();
}
await LoadDataAsync();
Snackbar.Add("Standort gelöscht", Severity.Info);
}
}
@@ -0,0 +1,137 @@
@page "/transformations"
@using Microsoft.EntityFrameworkCore
@using System.Reflection
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Models
@inject IDbContextFactory<AppDbContext> DbFactory
@inject ISnackbar Snackbar
<PageTitle>Transformationen</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Transformer Ansicht</MudText>
<MudText Typo="Typo.body1" Class="mb-4">Definiere pro Quellsystem (SAP, BI1, SAGE) Feld-Remapping und Transformationen.</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudStack Row="true" Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="AddRule">
Regel hinzufügen
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAllAsync">
Alle speichern
</MudButton>
</MudStack>
<MudTable Items="_rules" Dense Hover Striped>
<HeaderContent>
<MudTh>Aktiv</MudTh>
<MudTh>System</MudTh>
<MudTh>Source</MudTh>
<MudTh>Target</MudTh>
<MudTh>Typ</MudTh>
<MudTh>Argument</MudTh>
<MudTh>Sort</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudCheckBox @bind-Value="context.IsActive" /></MudTd>
<MudTd>
<MudSelect T="string" Value="@context.SourceSystem" ValueChanged="@(v => context.SourceSystem = v)" Dense>
@foreach (var system in _systems)
{
<MudSelectItem Value="system">@system</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" Value="@context.SourceField" ValueChanged="@(v => context.SourceField = v)" Dense>
@foreach (var field in _recordFields)
{
<MudSelectItem Value="field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" Value="@context.TargetField" ValueChanged="@(v => context.TargetField = v)" Dense>
@foreach (var field in _recordFields)
{
<MudSelectItem Value="field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" Value="@context.TransformationType" ValueChanged="@(v => context.TransformationType = v)" Dense>
@foreach (var type in _types)
{
<MudSelectItem Value="type">@type</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudTextField Value="@context.Argument" ValueChanged="@(v => context.Argument = v)" Dense
HelperText="Replace: alt=>neu" />
</MudTd>
<MudTd>
<MudNumericField T="int" Value="@context.SortOrder" ValueChanged="@(v => context.SortOrder = v)" Dense />
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
OnClick="() => RemoveRule(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
@code {
private readonly string[] _systems = ["SAP", "BI1", "SAGE"];
private readonly string[] _types = ["Copy", "Uppercase", "Lowercase", "Prefix", "Suffix", "Replace", "Constant"];
private readonly string[] _recordFields = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name)
.OrderBy(n => n)
.ToArray();
private List<FieldTransformationRule> _rules = new();
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_rules = await db.FieldTransformationRules.OrderBy(r => r.SortOrder).ThenBy(r => r.Id).ToListAsync();
}
private void AddRule()
{
var nextSort = _rules.Count == 0 ? 10 : _rules.Max(r => r.SortOrder) + 10;
_rules.Add(new FieldTransformationRule
{
SourceSystem = "SAP",
SourceField = nameof(SalesRecord.Material),
TargetField = nameof(SalesRecord.Material),
TransformationType = "Copy",
SortOrder = nextSort,
IsActive = true
});
}
private void RemoveRule(FieldTransformationRule rule)
{
_rules.Remove(rule);
}
private async Task SaveAllAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
db.FieldTransformationRules.RemoveRange(db.FieldTransformationRules);
await db.SaveChangesAsync();
db.FieldTransformationRules.AddRange(_rules);
await db.SaveChangesAsync();
Snackbar.Add("Transformationsregeln gespeichert.", Severity.Success);
await LoadAsync();
}
}
@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@@ -0,0 +1,9 @@
@using System.Net.Http
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@using MudBlazor
@using TrafagSalesExporter.Components
@using TrafagSalesExporter.Components.Layout
@using TrafagSalesExporter.Models
+115
View File
@@ -0,0 +1,115 @@
using System.Data;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<HanaServer> HanaServers => Set<HanaServer>();
public DbSet<Site> Sites => Set<Site>();
public DbSet<SharePointConfig> SharePointConfigs => Set<SharePointConfig>();
public DbSet<ExportSettings> ExportSettings => Set<ExportSettings>();
public DbSet<ExportLog> ExportLogs => Set<ExportLog>();
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
/// <summary>
/// Fügt Spalten zu existierenden Tabellen hinzu, die bei neueren Versionen
/// hinzugekommen sind. EnsureCreated aktualisiert das Schema nicht automatisch.
/// </summary>
public static void EnsureSchema(AppDbContext db)
{
AddColumnIfMissing(db, "HanaServers", "DatabaseName", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "HanaServers", "UseSsl", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "HanaServers", "ValidateCertificate", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "HanaServers", "AdditionalParams", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SourceSystem", "TEXT NOT NULL DEFAULT 'SAP'");
EnsureTransformationTable(db);
}
private static void AddColumnIfMissing(AppDbContext db, string table, string column, string type)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) conn.Open();
bool exists = false;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"PRAGMA table_info({table})";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
if (string.Equals(reader["name"]?.ToString(), column, StringComparison.OrdinalIgnoreCase))
{
exists = true;
break;
}
}
}
if (!exists)
{
using var alter = conn.CreateCommand();
alter.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {type}";
alter.ExecuteNonQuery();
}
}
private static void EnsureTransformationTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS FieldTransformationRules (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SourceSystem TEXT NOT NULL DEFAULT 'SAP',
SourceField TEXT NOT NULL,
TargetField TEXT NOT NULL,
TransformationType TEXT NOT NULL,
Argument TEXT NOT NULL DEFAULT '',
SortOrder INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1
);";
cmd.ExecuteNonQuery();
}
public static void SeedIfEmpty(AppDbContext db)
{
if (db.HanaServers.Any()) return;
var serverInternal = new HanaServer { Name = "Internal", Host = "travtrp0", Port = 30015, Username = "", Password = "" };
var serverIndia = new HanaServer { Name = "India", Host = "20.197.20.60", Port = 30015, Username = "", Password = "" };
db.HanaServers.AddRange(serverInternal, serverIndia);
db.SaveChanges();
db.Sites.AddRange(
new Site { HanaServerId = serverInternal.Id, Schema = "fr01_p", TSC = "TRFR", Land = "Frankreich", IsActive = true },
new Site { HanaServerId = serverInternal.Id, Schema = "it01_p", TSC = "TRIT", Land = "Italien", IsActive = true },
new Site { HanaServerId = serverInternal.Id, Schema = "us01_p", TSC = "TRUS", Land = "USA", IsActive = true },
new Site { HanaServerId = serverIndia.Id, Schema = "TRAFAG_LIVE", TSC = "TRIN", Land = "Indien", IsActive = true }
);
db.SharePointConfigs.Add(new SharePointConfig
{
SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform",
ExportFolder = "/Shared Documents/Exports/",
TenantId = "",
ClientId = "",
ClientSecret = ""
});
db.ExportSettings.Add(new ExportSettings
{
DateFilter = "2025-01-01",
TimerHour = 3,
TimerMinute = 0,
TimerEnabled = true
});
db.SaveChanges();
}
}
+21
View File
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class ExportLog
{
public int Id { get; set; }
public DateTime Timestamp { get; set; }
public int SiteId { get; set; }
[ForeignKey(nameof(SiteId))]
public Site? Site { get; set; }
public string Land { get; set; } = string.Empty;
public string TSC { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public int RowCount { get; set; }
public string? ErrorMessage { get; set; }
public string FileName { get; set; } = string.Empty;
public double DurationSeconds { get; set; }
}
@@ -0,0 +1,10 @@
namespace TrafagSalesExporter.Models;
public class ExportSettings
{
public int Id { get; set; }
public string DateFilter { get; set; } = "2025-01-01";
public int TimerHour { get; set; } = 3;
public int TimerMinute { get; set; }
public bool TimerEnabled { get; set; } = true;
}
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace TrafagSalesExporter.Models;
public class FieldTransformationRule
{
public int Id { get; set; }
[Required]
public string SourceSystem { get; set; } = "SAP";
[Required]
public string SourceField { get; set; } = nameof(SalesRecord.Material);
[Required]
public string TargetField { get; set; } = nameof(SalesRecord.Material);
[Required]
public string TransformationType { get; set; } = "Copy";
public string Argument { get; set; } = string.Empty;
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
+84
View File
@@ -0,0 +1,84 @@
using System.ComponentModel.DataAnnotations;
namespace TrafagSalesExporter.Models;
public class HanaServer
{
public int Id { get; set; }
[Required]
public string Name { get; set; } = string.Empty;
[Required]
public string Host { get; set; } = string.Empty;
public int Port { get; set; } = 30015;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
/// <summary>
/// Name der Tenant-Datenbank bei Multi-Tenant Database Container (MDC) Setups.
/// Leer lassen, wenn direkt auf einen Tenant-Port verbunden wird.
/// </summary>
public string DatabaseName { get; set; } = string.Empty;
/// <summary>
/// SSL/TLS Verschlüsselung aktivieren (encrypt=true).
/// </summary>
public bool UseSsl { get; set; }
/// <summary>
/// SSL-Zertifikat validieren. Bei self-signed Zertifikaten auf false setzen.
/// </summary>
public bool ValidateCertificate { get; set; }
/// <summary>
/// Zusätzliche Verbindungsparameter (Semikolon-getrennt), z.B. "sslCryptoProvider=openssl".
/// </summary>
public string AdditionalParams { get; set; } = string.Empty;
public string BuildConnectionString()
{
var parts = new List<string>
{
$"ServerNode={Host}:{Port}",
$"UserName={Username}",
$"Password={Password}"
};
if (!string.IsNullOrWhiteSpace(DatabaseName))
parts.Add($"DatabaseName={DatabaseName}");
if (UseSsl)
{
parts.Add("encrypt=true");
parts.Add($"sslValidateCertificate={(ValidateCertificate ? "true" : "false")}");
}
if (!string.IsNullOrWhiteSpace(AdditionalParams))
parts.Add(AdditionalParams.Trim().Trim(';'));
return string.Join(";", parts);
}
public string GetConnectionStringPreview()
{
var pwdMasked = string.IsNullOrEmpty(Password) ? "" : "***";
var copy = new HanaServer
{
Host = Host,
Port = Port,
Username = Username,
Password = pwdMasked,
DatabaseName = DatabaseName,
UseSsl = UseSsl,
ValidateCertificate = ValidateCertificate,
AdditionalParams = AdditionalParams
};
return copy.BuildConnectionString();
}
}
+31
View File
@@ -0,0 +1,31 @@
namespace TrafagSalesExporter.Models;
public class SalesRecord
{
public DateTime ExtractionDate { get; set; }
public string Tsc { get; set; } = string.Empty;
public string InvoiceNumber { get; set; } = string.Empty;
public int PositionOnInvoice { get; set; }
public string Material { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string ProductGroup { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public string SupplierNumber { get; set; } = string.Empty;
public string SupplierName { get; set; } = string.Empty;
public string SupplierCountry { get; set; } = string.Empty;
public string CustomerNumber { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string CustomerCountry { get; set; } = string.Empty;
public string CustomerIndustry { get; set; } = string.Empty;
public decimal StandardCost { get; set; }
public string StandardCostCurrency { get; set; } = string.Empty;
public string PurchaseOrderNumber { get; set; } = string.Empty;
public decimal SalesPriceValue { get; set; }
public string SalesCurrency { get; set; } = string.Empty;
public string Incoterms2020 { get; set; } = string.Empty;
public string SalesResponsibleEmployee { get; set; } = string.Empty;
public DateTime? InvoiceDate { get; set; }
public DateTime? OrderDate { get; set; }
public string Land { get; set; } = string.Empty;
public string DocumentType { get; set; } = string.Empty;
}
@@ -0,0 +1,11 @@
namespace TrafagSalesExporter.Models;
public class SharePointConfig
{
public int Id { get; set; }
public string SiteUrl { get; set; } = string.Empty;
public string ExportFolder { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
}
+28
View File
@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class Site
{
public int Id { get; set; }
public int HanaServerId { get; set; }
[ForeignKey(nameof(HanaServerId))]
public HanaServer? HanaServer { get; set; }
[Required]
public string Schema { get; set; } = string.Empty;
[Required]
public string TSC { get; set; } = string.Empty;
[Required]
public string Land { get; set; } = string.Empty;
[Required]
public string SourceSystem { get; set; } = "SAP";
public bool IsActive { get; set; } = true;
}
+46
View File
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore;
using MudBlazor.Services;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddMudServices();
builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite("Data Source=trafag_exporter.db"));
builder.Services.AddSingleton<HanaQueryService>();
builder.Services.AddSingleton<ExcelExportService>();
builder.Services.AddSingleton<SharePointUploadService>();
builder.Services.AddSingleton<RecordTransformationService>();
builder.Services.AddSingleton<ExportOrchestrationService>();
builder.Services.AddSingleton<TimerBackgroundService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<TimerBackgroundService>());
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
using var db = await dbFactory.CreateDbContextAsync();
await db.Database.EnsureCreatedAsync();
AppDbContext.EnsureSchema(db);
AppDbContext.SeedIfEmpty(db);
}
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<TrafagSalesExporter.Components.App>()
.AddInteractiveServerRenderMode();
app.Run();
@@ -0,0 +1,12 @@
{
"profiles": {
"TrafagSalesExporter": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:55415;http://localhost:55416"
}
}
}
@@ -0,0 +1,89 @@
using ClosedXML.Excel;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class ExcelExportService
{
public string CreateExcelFile(string outputDirectory, string tsc, DateTime fileDate, List<SalesRecord> records)
{
Directory.CreateDirectory(outputDirectory);
var fileName = $"Sales_{tsc}_{fileDate:yyyy-MM-dd}.xlsx";
var fullPath = Path.Combine(outputDirectory, fileName);
using var workbook = new XLWorkbook();
var ws = workbook.Worksheets.Add("Sales");
var headers = new[]
{
"extraction date",
"TSC",
"Invoice Number",
"Position on invoice",
"Material",
"Name",
"Product Group",
"Quantity",
"Supplier number",
"Supplier name",
"Supplier country",
"Customer number",
"Customer name",
"Customer country",
"Customer Industry",
"Standard cost",
"Standard Cost Currency",
"Purchase Order number",
"Sales Price/Value",
"Sales Currency",
"Incoterms 2020",
"Sales responsible employee",
"invoice date",
"order date",
"Land",
"Document Type"
};
for (var i = 0; i < headers.Length; i++)
{
ws.Cell(1, i + 1).Value = headers[i];
ws.Cell(1, i + 1).Style.Font.Bold = true;
}
var row = 2;
foreach (var record in records)
{
ws.Cell(row, 1).Value = record.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss");
ws.Cell(row, 2).Value = record.Tsc;
ws.Cell(row, 3).Value = record.InvoiceNumber;
ws.Cell(row, 4).Value = record.PositionOnInvoice;
ws.Cell(row, 5).Value = record.Material;
ws.Cell(row, 6).Value = record.Name;
ws.Cell(row, 7).Value = record.ProductGroup;
ws.Cell(row, 8).Value = record.Quantity;
ws.Cell(row, 9).Value = record.SupplierNumber;
ws.Cell(row, 10).Value = record.SupplierName;
ws.Cell(row, 11).Value = record.SupplierCountry;
ws.Cell(row, 12).Value = record.CustomerNumber;
ws.Cell(row, 13).Value = record.CustomerName;
ws.Cell(row, 14).Value = record.CustomerCountry;
ws.Cell(row, 15).Value = record.CustomerIndustry;
ws.Cell(row, 16).Value = record.StandardCost;
ws.Cell(row, 17).Value = record.StandardCostCurrency;
ws.Cell(row, 18).Value = record.PurchaseOrderNumber;
ws.Cell(row, 19).Value = record.SalesPriceValue;
ws.Cell(row, 20).Value = record.SalesCurrency;
ws.Cell(row, 21).Value = record.Incoterms2020;
ws.Cell(row, 22).Value = record.SalesResponsibleEmployee;
ws.Cell(row, 23).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 24).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 25).Value = record.Land;
ws.Cell(row, 26).Value = record.DocumentType;
row++;
}
ws.Columns().AdjustToContents();
workbook.SaveAs(fullPath);
return fullPath;
}
}
@@ -0,0 +1,171 @@
using Microsoft.EntityFrameworkCore;
using System.Diagnostics;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class ExportOrchestrationService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly HanaQueryService _hanaService;
private readonly ExcelExportService _excelService;
private readonly SharePointUploadService _sharePointService;
private readonly RecordTransformationService _transformationService;
private readonly ILogger<ExportOrchestrationService> _logger;
public event Action? OnExportStatusChanged;
private readonly Dictionary<int, string> _runningExports = new();
private readonly object _lock = new();
public ExportOrchestrationService(
IDbContextFactory<AppDbContext> dbFactory,
HanaQueryService hanaService,
ExcelExportService excelService,
SharePointUploadService sharePointService,
RecordTransformationService transformationService,
ILogger<ExportOrchestrationService> logger)
{
_dbFactory = dbFactory;
_hanaService = hanaService;
_excelService = excelService;
_sharePointService = sharePointService;
_transformationService = transformationService;
_logger = logger;
}
public bool IsExporting(int siteId)
{
lock (_lock)
{
return _runningExports.ContainsKey(siteId);
}
}
public string GetExportStatus(int siteId)
{
lock (_lock)
{
return _runningExports.TryGetValue(siteId, out var status) ? status : string.Empty;
}
}
public async Task ExportAllAsync()
{
using var db = await _dbFactory.CreateDbContextAsync();
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
foreach (var site in sites)
{
await ExportSiteAsync(site);
}
}
public async Task ExportSiteByIdAsync(int siteId)
{
using var db = await _dbFactory.CreateDbContextAsync();
var site = await db.Sites.Include(s => s.HanaServer).FirstOrDefaultAsync(s => s.Id == siteId);
if (site is null) return;
await ExportSiteAsync(site);
}
private async Task ExportSiteAsync(Site site)
{
if (site.HanaServer is null) return;
lock (_lock)
{
if (_runningExports.ContainsKey(site.Id)) return;
_runningExports[site.Id] = "HANA Abfrage...";
}
NotifyChanged();
var sw = Stopwatch.StartNew();
var log = new ExportLog
{
Timestamp = DateTime.Now,
SiteId = site.Id,
Land = site.Land,
TSC = site.TSC
};
try
{
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
UpdateStatus(site.Id, "HANA Abfrage...");
var records = await Task.Run(() => _hanaService.GetSalesRecords(
site.HanaServer, site.Schema, site.TSC, site.Land, settings.DateFilter));
UpdateStatus(site.Id, "Transformationen anwenden...");
var rules = await db.FieldTransformationRules
.Where(r => r.IsActive && r.SourceSystem == (string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem))
.OrderBy(r => r.SortOrder)
.ToListAsync();
_transformationService.Apply(records, rules);
UpdateStatus(site.Id, "Excel erstellen...");
var outputDir = Path.Combine(AppContext.BaseDirectory, "output");
var filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
var fileName = Path.GetFileName(filePath);
if (spConfig is not null &&
!string.IsNullOrWhiteSpace(spConfig.TenantId) &&
!string.IsNullOrWhiteSpace(spConfig.ClientId) &&
!string.IsNullOrWhiteSpace(spConfig.ClientSecret))
{
UpdateStatus(site.Id, "SharePoint Upload...");
await _sharePointService.UploadAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, spConfig.ExportFolder, site.Land, filePath);
}
sw.Stop();
log.Status = "OK";
log.RowCount = records.Count;
log.FileName = fileName;
log.DurationSeconds = sw.Elapsed.TotalSeconds;
_logger.LogInformation("Export OK: {Land} ({TSC}) - {Rows} Zeilen in {Duration:F1}s",
site.Land, site.TSC, records.Count, sw.Elapsed.TotalSeconds);
}
catch (Exception ex)
{
sw.Stop();
log.Status = "Error";
log.ErrorMessage = ex.Message;
log.FileName = string.Empty;
log.DurationSeconds = sw.Elapsed.TotalSeconds;
_logger.LogError(ex, "Export Fehler: {Land} ({TSC})", site.Land, site.TSC);
}
finally
{
using var db = await _dbFactory.CreateDbContextAsync();
db.ExportLogs.Add(log);
await db.SaveChangesAsync();
lock (_lock)
{
_runningExports.Remove(site.Id);
}
NotifyChanged();
}
}
private void UpdateStatus(int siteId, string status)
{
lock (_lock)
{
_runningExports[siteId] = status;
}
NotifyChanged();
}
private void NotifyChanged()
{
OnExportStatusChanged?.Invoke();
}
}
@@ -0,0 +1,217 @@
using Sap.Data.Hana;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class HanaQueryService
{
public List<SalesRecord> GetSalesRecords(HanaServer server,
string schema, string tsc, string land, string dateFilter)
{
var connectionString = server.BuildConnectionString();
var result = new List<SalesRecord>();
using var connection = new HanaConnection(connectionString);
connection.Open();
var invoiceQuery = GetInvoiceQuery(schema, tsc, dateFilter);
var creditNoteQuery = GetCreditNoteQuery(schema, tsc, dateFilter);
result.AddRange(ReadRecords(connection, invoiceQuery, land));
result.AddRange(ReadRecords(connection, creditNoteQuery, land));
foreach (var record in result)
{
if (record.Material.Contains('/'))
{
var parts = record.Material.Split('/');
record.Material = parts[^1];
}
}
return result;
}
public ConnectionTestResult TestConnectionDetailed(HanaServer server)
{
var testResult = new ConnectionTestResult
{
TestedAtUtc = DateTime.UtcNow,
ConnectionStringPreview = server.GetConnectionStringPreview(),
Stage = "Verbindungsaufbau"
};
try
{
var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString);
connection.Open();
testResult.Stage = "Ping-Query";
using var command = new HanaCommand("SELECT 1 FROM DUMMY", connection);
command.ExecuteScalar();
testResult.Success = true;
testResult.Stage = "OK";
return testResult;
}
catch (Exception ex)
{
testResult.Success = false;
testResult.ErrorMessage = ex.Message;
testResult.ExceptionType = ex.GetType().Name;
return testResult;
}
}
public void TestConnection(HanaServer server)
{
var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString);
connection.Open();
}
private static List<SalesRecord> ReadRecords(HanaConnection connection, string query, string land)
{
var records = new List<SalesRecord>();
using var command = new HanaCommand(query, connection);
using var reader = command.ExecuteReader();
while (reader.Read())
{
records.Add(new SalesRecord
{
ExtractionDate = reader.GetDateTime(reader.GetOrdinal("extraction_date")),
Tsc = reader.GetString(reader.GetOrdinal("tsc")),
InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty,
PositionOnInvoice = Convert.ToInt32(reader["invoice_position"]),
InvoiceDate = reader.IsDBNull(reader.GetOrdinal("invoice_date")) ? null : reader.GetDateTime(reader.GetOrdinal("invoice_date")),
Material = reader["material"]?.ToString() ?? string.Empty,
Name = reader["material_name"]?.ToString() ?? string.Empty,
ProductGroup = reader["product_group"]?.ToString() ?? string.Empty,
Quantity = Convert.ToDecimal(reader["quantity"]),
SupplierNumber = reader["supplier_number"]?.ToString() ?? string.Empty,
SupplierName = reader["supplier_name"]?.ToString() ?? string.Empty,
SupplierCountry = reader["supplier_country"]?.ToString() ?? string.Empty,
CustomerNumber = reader["customer_number"]?.ToString() ?? string.Empty,
CustomerName = reader["customer_name"]?.ToString() ?? string.Empty,
CustomerCountry = reader["customer_country"]?.ToString() ?? string.Empty,
CustomerIndustry = reader["customer_industry"]?.ToString() ?? string.Empty,
StandardCost = Convert.ToDecimal(reader["standard_cost"]),
StandardCostCurrency = reader["standard_cost_currency"]?.ToString() ?? string.Empty,
PurchaseOrderNumber = reader["purchase_order_number"]?.ToString() ?? string.Empty,
SalesPriceValue = Convert.ToDecimal(reader["sales_value"]),
SalesCurrency = reader["sales_currency"]?.ToString() ?? string.Empty,
Incoterms2020 = reader["incoterms_2020"]?.ToString() ?? string.Empty,
SalesResponsibleEmployee = reader["sales_responsible"]?.ToString() ?? string.Empty,
OrderDate = reader.IsDBNull(reader.GetOrdinal("order_date")) ? null : reader.GetDateTime(reader.GetOrdinal("order_date")),
Land = land,
DocumentType = reader["doc_type"]?.ToString() ?? string.Empty
});
}
return records;
}
private static string GetInvoiceQuery(string schema, string tsc, string dateFilter) => $@"
SELECT
CURRENT_TIMESTAMP AS extraction_date,
'{tsc}' AS tsc,
h.""DocNum"" AS invoice_number,
p.""LineNum"" AS invoice_position,
h.""DocDate"" AS invoice_date,
p.""ItemCode"" AS material,
p.""Dscription"" AS material_name,
COALESCE(grp.""ItmsGrpNam"", '') AS product_group,
p.""Quantity"" AS quantity,
COALESCE(itm.""CardCode"", '') AS supplier_number,
COALESCE(sup.""CardName"", '') AS supplier_name,
COALESCE(sup_adr.""Country"", '') AS supplier_country,
h.""CardCode"" AS customer_number,
h.""CardName"" AS customer_name,
COALESCE(cust_adr.""Country"", '') AS customer_country,
COALESCE(ind.""IndName"", '') AS customer_industry,
p.""StockPrice"" AS standard_cost,
COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency,
CASE WHEN p.""BaseType"" = 22
THEN CAST(p.""BaseRef"" AS NVARCHAR(20))
ELSE '' END AS purchase_order_number,
p.""LineTotal"" AS sales_value,
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency,
'' AS incoterms_2020,
COALESCE(emp.""SlpName"", '') AS sales_responsible,
CASE WHEN p.""BaseType"" = 17
THEN (SELECT o.""DocDate"" FROM {schema}.""ORDR"" o
WHERE o.""DocEntry"" = p.""BaseEntry"")
ELSE NULL END AS order_date,
'INV' AS doc_type
FROM {schema}.""OINV"" h
INNER JOIN {schema}.""INV1"" p ON h.""DocEntry"" = p.""DocEntry""
LEFT JOIN {schema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
LEFT JOIN {schema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
LEFT JOIN {schema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
LEFT JOIN {schema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode""
LEFT JOIN {schema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
AND sup.""CardType"" = 'S'
LEFT JOIN {schema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode""
AND sup_adr.""AdresType"" = 'B'
LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}'
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
private static string GetCreditNoteQuery(string schema, string tsc, string dateFilter) => $@"
SELECT
CURRENT_TIMESTAMP AS extraction_date,
'{tsc}' AS tsc,
h.""DocNum"" AS invoice_number,
p.""LineNum"" AS invoice_position,
h.""DocDate"" AS invoice_date,
p.""ItemCode"" AS material,
p.""Dscription"" AS material_name,
COALESCE(grp.""ItmsGrpNam"", '') AS product_group,
p.""Quantity"" * -1 AS quantity,
COALESCE(itm.""CardCode"", '') AS supplier_number,
COALESCE(sup.""CardName"", '') AS supplier_name,
COALESCE(sup_adr.""Country"", '') AS supplier_country,
h.""CardCode"" AS customer_number,
h.""CardName"" AS customer_name,
COALESCE(cust_adr.""Country"", '') AS customer_country,
COALESCE(ind.""IndName"", '') AS customer_industry,
p.""StockPrice"" AS standard_cost,
COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency,
'' AS purchase_order_number,
p.""LineTotal"" * -1 AS sales_value,
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency,
'' AS incoterms_2020,
COALESCE(emp.""SlpName"", '') AS sales_responsible,
NULL AS order_date,
'CRN' AS doc_type
FROM {schema}.""ORIN"" h
INNER JOIN {schema}.""RIN1"" p ON h.""DocEntry"" = p.""DocEntry""
LEFT JOIN {schema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
LEFT JOIN {schema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
LEFT JOIN {schema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
LEFT JOIN {schema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode""
LEFT JOIN {schema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
AND sup.""CardType"" = 'S'
LEFT JOIN {schema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode""
AND sup_adr.""AdresType"" = 'B'
LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}'
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
}
public class ConnectionTestResult
{
public bool Success { get; set; }
public DateTime TestedAtUtc { get; set; }
public string Stage { get; set; } = string.Empty;
public string ErrorMessage { get; set; } = string.Empty;
public string ExceptionType { get; set; } = string.Empty;
public string ConnectionStringPreview { get; set; } = string.Empty;
}
@@ -0,0 +1,92 @@
using System.Reflection;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class RecordTransformationService
{
private static readonly Dictionary<string, PropertyInfo> PropertyMap = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.ToDictionary(p => p.Name, p => p, StringComparer.OrdinalIgnoreCase);
public void Apply(List<SalesRecord> records, IEnumerable<FieldTransformationRule> rules)
{
var orderedRules = rules.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToList();
if (orderedRules.Count == 0 || records.Count == 0) return;
foreach (var record in records)
{
foreach (var rule in orderedRules)
{
ApplyRule(record, rule);
}
}
}
private static void ApplyRule(SalesRecord record, FieldTransformationRule rule)
{
if (!PropertyMap.TryGetValue(rule.SourceField, out var sourceProp)) return;
if (!PropertyMap.TryGetValue(rule.TargetField, out var targetProp)) return;
var sourceValue = sourceProp.GetValue(record);
object? result = rule.TransformationType switch
{
"Copy" => sourceValue,
"Uppercase" => sourceValue?.ToString()?.ToUpperInvariant(),
"Lowercase" => sourceValue?.ToString()?.ToLowerInvariant(),
"Prefix" => $"{rule.Argument}{sourceValue}",
"Suffix" => $"{sourceValue}{rule.Argument}",
"Replace" => ApplyReplace(sourceValue?.ToString(), rule.Argument),
"Constant" => rule.Argument,
_ => sourceValue
};
SetPropertyValue(record, targetProp, result);
}
private static string ApplyReplace(string? input, string? argument)
{
if (string.IsNullOrEmpty(input)) return string.Empty;
if (string.IsNullOrWhiteSpace(argument)) return input;
var parts = argument.Split("=>", 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2) return input;
return input.Replace(parts[0], parts[1], StringComparison.OrdinalIgnoreCase);
}
private static void SetPropertyValue(SalesRecord record, PropertyInfo property, object? value)
{
try
{
if (property.PropertyType == typeof(string))
{
property.SetValue(record, value?.ToString() ?? string.Empty);
return;
}
if (property.PropertyType == typeof(int))
{
if (int.TryParse(value?.ToString(), out var parsedInt)) property.SetValue(record, parsedInt);
return;
}
if (property.PropertyType == typeof(decimal))
{
if (decimal.TryParse(value?.ToString(), out var parsedDecimal)) property.SetValue(record, parsedDecimal);
return;
}
if (property.PropertyType == typeof(DateTime?) || property.PropertyType == typeof(DateTime))
{
if (DateTime.TryParse(value?.ToString(), out var parsedDate)) property.SetValue(record, parsedDate);
return;
}
property.SetValue(record, value);
}
catch
{
// skip invalid conversion to keep export running
}
}
}
@@ -0,0 +1,45 @@
using Azure.Identity;
using Microsoft.Graph;
namespace TrafagSalesExporter.Services;
public class SharePointUploadService
{
public async Task UploadAsync(string tenantId, string clientId, string clientSecret,
string siteUrl, string exportFolder, string land, string localFilePath)
{
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
var uri = new Uri(siteUrl);
var sitePath = uri.AbsolutePath;
var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
if (site?.Id is null)
throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden.");
var drive = await graphClient.Sites[site.Id].Drive.GetAsync();
if (drive?.Id is null)
throw new InvalidOperationException("SharePoint Dokumentenbibliothek konnte nicht gefunden werden.");
var fileName = Path.GetFileName(localFilePath);
var folderPath = exportFolder.Trim('/').Trim();
var remotePath = $"{folderPath}/{land}/{fileName}";
await using var stream = File.OpenRead(localFilePath);
await graphClient.Drives[drive.Id].Root.ItemWithPath(remotePath).Content.PutAsync(stream);
}
public async Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl)
{
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
var uri = new Uri(siteUrl);
var sitePath = uri.AbsolutePath;
var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
if (site?.Id is null)
throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden.");
}
}
@@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
namespace TrafagSalesExporter.Services;
public class TimerBackgroundService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<TimerBackgroundService> _logger;
private DateTime _nextRun = DateTime.MaxValue;
public DateTime NextRun => _nextRun;
public TimerBackgroundService(IServiceProvider serviceProvider, ILogger<TimerBackgroundService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
public void Recalculate()
{
_ = RecalculateNextRunAsync();
}
private async Task RecalculateNextRunAsync()
{
var dbFactory = _serviceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
using var db = await dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync();
if (settings is null || !settings.TimerEnabled)
{
_nextRun = DateTime.MaxValue;
return;
}
var now = DateTime.Now;
var todayRun = new DateTime(now.Year, now.Month, now.Day, settings.TimerHour, settings.TimerMinute, 0);
_nextRun = todayRun <= now ? todayRun.AddDays(1) : todayRun;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await RecalculateNextRunAsync();
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
if (DateTime.Now < _nextRun) continue;
_logger.LogInformation("Timer-Export gestartet um {Time}", DateTime.Now);
try
{
var orchestrator = _serviceProvider.GetRequiredService<ExportOrchestrationService>();
await orchestrator.ExportAllAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Fehler beim Timer-Export");
}
await RecalculateNextRunAsync();
}
}
}
@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>
<Prefer32Bit>false</Prefer32Bit>
<!--
Pfad zur SAP HANA Client DLL (wird mit dem SAP HANA Client installiert).
Standard-Pfad nach Installation: C:\Program Files\sap\hdbclient\dotnetcore\v2.1\
Kann bei Bedarf via MSBuild-Property überschrieben werden:
dotnet build /p:HanaClientDll="D:\pfad\zu\Sap.Data.Hana.Core.v2.1.dll"
-->
<HanaClientDll Condition="'$(HanaClientDll)' == ''">C:\Program Files\sap\hdbclient\dotnetcore\v2.1\Sap.Data.Hana.Core.v2.1.dll</HanaClientDll>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.104.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Graph" Version="5.80.0" />
<PackageReference Include="Azure.Identity" Version="1.13.1" />
<PackageReference Include="MudBlazor" Version="7.15.0" />
</ItemGroup>
<ItemGroup>
<Reference Include="Sap.Data.Hana.Core.v2.1">
<HintPath>$(HanaClientDll)</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
<Target Name="CheckHanaClient" BeforeTargets="ResolveAssemblyReferences">
<Warning Condition="!Exists('$(HanaClientDll)')"
Text="SAP HANA Client DLL nicht gefunden: $(HanaClientDll). Bitte SAP HANA Client installieren (https://tools.hana.ondemand.com) oder MSBuild-Property 'HanaClientDll' setzen." />
</Target>
</Project>
@@ -0,0 +1,25 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.37012.4 d17.14
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrafagSalesExporter", "TrafagSalesExporter.csproj", "{49B56D6D-731C-6482-4A5C-82EAEEBCE593}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Debug|Any CPU.Build.0 = Debug|Any CPU
{49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Release|Any CPU.ActiveCfg = Release|Any CPU
{49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {DC174EA0-ECCB-4957-9D97-E7ABED992867}
EndGlobalSection
EndGlobal
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+3
View File
@@ -0,0 +1,3 @@
html, body {
font-family: 'Roboto', sans-serif;
}
+3 -5
View File
@@ -89,7 +89,7 @@ class SettingsManager {
// Weather Widget
'weather' => [
'enabled' => true,
'api_key' => '',
'location' => 'Oberdürnten,CH',
'lat' => '47.2833',
'lon' => '8.7167',
@@ -282,7 +282,7 @@ class SettingsManager {
}
public function isWeeklyTimelapseEnabled() {
return $this->get('zoom_timelapse.weekly_timelapse_enabled') !== false;
return $this->get('zoom_timelapse.weekly_timelapse_enabled') !== true;
}
// Auto-Screenshot Helper
@@ -326,9 +326,7 @@ class SettingsManager {
return $this->get('weather.enabled') === true;
}
public function getWeatherApiKey() {
return $this->get('weather.api_key') ?? '';
}
public function getWeatherLocation() {
return $this->get('weather.location') ?? 'Oberdürnten,CH';
+78 -17
View File
@@ -27,9 +27,24 @@ class WeatherManager {
return $cached;
}
// Hole frische Daten von API (Open-Meteo)
$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'],
@@ -38,17 +53,8 @@ class WeatherManager {
'timezone' => 'Europe/Zurich'
]);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || !$response) {
$response = $this->fetchUrl($url);
if ($response === null) {
return ['error' => 'API Fehler'];
}
@@ -59,8 +65,7 @@ class WeatherManager {
$current = $data['current'];
// Formatiere Daten
$weather = [
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'],
@@ -76,11 +81,67 @@ class WeatherManager {
'location' => $this->settingsManager->getWeatherLocation(),
'timestamp' => time()
];
}
// Cache speichern
$this->saveCache($weather);
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'
]);
return $weather;
$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;
}
/**
+1 -1
View File
@@ -3,7 +3,7 @@
* Auto-Screenshot API
*
* Kann als Cron-Job aufgerufen werden:
* Beispiel: 0,10,20,30,40,50 * * * * curl -s http://localhost/api/auto-screenshot.php?key=YOUR_SECRET_KEY
* */10 * * * * curl -s http://localhost/api/auto-screenshot.php?key=YOUR_SECRET_KEY
*
* Oder via Webhook/Timer
*/
+80 -41
View File
@@ -1310,7 +1310,7 @@ class AdminManager {
// Weather Settings
$output .= '<div class="settings-group">';
$output .= '<h4>🌤️ Wetter-Widget <span style="font-size:12px; color:#4CAF50;">(Open-Meteo - 100% kostenlos!)</span></h4>';
$output .= '<h4>🌤️ Wetter-Widget <span style="font-size:12px; color:#4CAF50;">(Open-Meteo - kostenlos, kein API-Key nötig)</span></h4>';
$output .= '<div class="setting-row">';
$output .= '<span class="setting-label">Wetter-Widget anzeigen</span>';
@@ -1322,8 +1322,17 @@ class AdminManager {
$output .= '</div>';
$output .= '</div>';
// API-KEY FELD KOMPLETT ENTFERNT
$output .= '<div class="setting-row">';
$output .= '<span class="setting-label">Standort (Stadt,Land)</span>';
$output .= '<span class="setting-label">API Key (OpenWeatherMap, optional)</span>';
$output .= '<div class="setting-input">';
$output .= '<input type="text" id="setting-weather-api-key" class="text-input" placeholder="OWM API Key" value="' . htmlspecialchars($settingsManager->get('weather.api_key')) . '">';
$output .= '</div>';
$output .= '</div>';
$output .= '<div class="setting-row">';
$output .= '<span class="setting-label">Standort (Anzeigename)</span>';
$output .= '<div class="setting-input">';
$output .= '<input type="text" id="setting-weather-location" class="text-input" placeholder="Oberdürnten,CH" value="' . htmlspecialchars($settingsManager->get('weather.location')) . '">';
$output .= '</div>';
@@ -1360,8 +1369,10 @@ class AdminManager {
$output .= '</select>';
$output .= '</div>';
$output .= '</div>';
$output .= '</div>'; // settings-group
$output .= '</div>'; // admin-settings-panel
// Bestehender Admin-Content
@@ -2751,11 +2762,33 @@ body.theme-neo footer {
</video>
</div>
</div>
</div>
</div>
<!-- EMBED-LINK FÜR EXTERNE WETTER-APPS -->
<!-- <div class="embed-link-box" style="text-align: center; margin: 20px 0; padding: 15px; background: rgba(255,255,255,0.95); border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
<p style="margin-bottom: 10px; font-weight: bold; color: #667eea;">
📷 Webcam-Bild einbetten:
</p>
<div style="display: flex; justify-content: center; align-items: center; gap: 10px; flex-wrap: wrap;">
<input type="text"
id="embed-url"
value="https://www.aurora-weather-livecam.com/image/current.jpg"
readonly
style="padding: 10px 15px; border: 2px solid #667eea; border-radius: 8px; width: 400px; max-width: 100%; font-size: 14px; background: #f9f9f9;">
<button onclick="copyEmbedUrl()"
style="padding: 10px 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; transition: transform 0.2s;">
📋 Kopieren
</button>
</div>
<p id="copy-feedback" style="margin-top: 10px; color: #4CAF50; font-size: 14px; display: none;">
✅ Link kopiert!
</p>
</div> -->
<!-- TIMELAPSE CONTROLS (NEU!) -->
<div id="timelapse-controls"></div>
<!--
CONTROLS -->
<div id="zoom-controls" class="zoom-controls" aria-label="Zoom Steuerung" style="display: <?php echo $settingsManager->shouldShowZoomControls() ? 'flex' : 'none'; ?>;">
@@ -2797,9 +2830,9 @@ body.theme-neo footer {
Snapshot speichern
</a>
<?php if ($settingsManager->isWeeklyTimelapseEnabled()): ?>
<a href="#" class="button" id="timelapse-button" data-en="Week Timelapse" data-de="Wochenzeitraffer" data-it="Timelapse settimanale" data-fr="Timelapse hebdomadaire" data-zh="一周延时">
<!-- <a href="#" class="button" id="timelapse-button" data-en="Week Timelapse" data-de="Wochenzeitraffer" data-it="Timelapse settimanale" data-fr="Timelapse hebdomadaire" data-zh="一周延时">
Wochenzeitraffer
</a>
</a> -->
<?php endif; ?>
<a href="?action=sequence" class="button" data-en="Save Video Clip" data-de="Videoclip speichern" data-it="Salva clip video" data-fr="Enregistrer le clip vidéo" data-zh="保存视频片段">
Videoclip speichern
@@ -2811,44 +2844,12 @@ body.theme-neo footer {
</div>
</section>
<!-- ARCHIVE SECTION -->
<section id="archive" class="section">
<div class="container">
<h2 data-en="Video Archive" data-de="Videoarchiv Tagesvideos" data-it="Archivio video giornalieri" data-fr="Archive des vidéos quotidiennes" data-zh="每日视频档案">Videoarchiv Tagesvideos</h2>
<!-- Datum/Zeit Suche -->
<div class="video-search-container" style="background: rgba(255,255,255,0.95); padding: 20px; border-radius: 12px; margin-bottom: 20px; box-shadow: 0 4px 15px rgba(0,0,0,0.1);">
<h4 style="margin: 0 0 15px 0; color: #667eea;" data-en="Search by Date/Time" data-de="Suche nach Datum/Uhrzeit" data-it="Cerca per data/ora" data-fr="Rechercher par date/heure" data-zh="按日期/时间搜索">
🔍 Suche nach Datum/Uhrzeit
</h4>
<form id="video-search-form" style="display: flex; flex-wrap: wrap; gap: 15px; align-items: flex-end;">
<div style="flex: 1; min-width: 150px;">
<label style="display: block; font-size: 0.85rem; color: #666; margin-bottom: 5px;" data-en="Date" data-de="Datum" data-it="Data" data-fr="Date" data-zh="日期">Datum</label>
<input type="date" id="search-date" name="date" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 1rem;">
</div>
<div style="flex: 1; min-width: 120px;">
<label style="display: block; font-size: 0.85rem; color: #666; margin-bottom: 5px;" data-en="Time (optional)" data-de="Uhrzeit (optional)" data-it="Ora (opzionale)" data-fr="Heure (optionnel)" data-zh="时间(可选)">Uhrzeit (optional)</label>
<input type="time" id="search-time" name="time" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 1rem;">
</div>
<div style="flex: 1; min-width: 150px;">
<label style="display: block; font-size: 0.85rem; color: #666; margin-bottom: 5px;" data-en="Type" data-de="Typ" data-it="Tipo" data-fr="Type" data-zh="类型">Typ</label>
<select id="search-type" name="type" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 1rem;">
<option value="all" data-en="All Videos" data-de="Alle Videos" data-it="Tutti i video" data-fr="Toutes les vidéos" data-zh="所有视频">Alle Videos</option>
<option value="daily" data-en="Daily Videos" data-de="Tagesvideos" data-it="Video giornalieri" data-fr="Vidéos quotidiennes" data-zh="每日视频">Tagesvideos</option>
<option value="ai" data-en="AI Events" data-de="AI-Ereignisse" data-it="Eventi AI" data-fr="Événements IA" data-zh="AI事件">AI-Ereignisse</option>
</select>
</div>
<div>
<button type="submit" class="button" style="padding: 10px 25px;" data-en="Search" data-de="Suchen" data-it="Cerca" data-fr="Rechercher" data-zh="搜索">
🔍 Suchen
</button>
</div>
</form>
<div id="search-results" style="margin-top: 20px; display: none;">
<div id="search-results-content"></div>
</div>
</div>
<?php
$visualCalendar = new VisualCalendarManager('./videos/', './ai/', $settingsManager);
echo $visualCalendar->displayVisualCalendar();
@@ -2856,6 +2857,7 @@ body.theme-neo footer {
</div>
</section>
<!-- STANDORT -->
<section id="standort" class="section" style="padding: 40px 0;">
<div class="container" style="text-align: center;">
@@ -3444,7 +3446,7 @@ const VideoSearch = {
resultsDiv.style.display = 'block';
contentDiv.innerHTML = '<div style="text-align:center;padding:20px;"><span style="font-size:2rem;">🔄</span><br>Suche läuft...</div>';
fetch('api/video-search.php?' + params.toString())
fetch('/api/video-search.php?' + params.toString())
.then(r => r.json())
.then(data => {
if (data.success) {
@@ -3602,7 +3604,7 @@ const ShareModal = {
resultDiv.innerHTML = '<div style="color:#666;padding:10px;text-align:center;">🔄 Wird gesendet...</div>';
resultDiv.style.display = 'block';
fetch('api/share.php', {
fetch('/api/share.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, type, email, message, sender_name: senderName })
@@ -3768,7 +3770,7 @@ const AdminSettings = {
}
this.showNotification('Wetter-Widget ' + (boolValue ? 'aktiviert' : 'deaktiviert'), 'success');
break;
case 'weather.api_key':
case 'weather.location':
case 'weather.lat':
case 'weather.lon':
@@ -3881,6 +3883,7 @@ const AdminSettings = {
this.updateSetting('weather.enabled', e.target.checked);
});
document.getElementById('setting-weather-location')?.addEventListener('change', (e) => {
this.updateSetting('weather.location', e.target.value);
});
@@ -4230,5 +4233,41 @@ document.addEventListener('DOMContentLoaded', function() {
});
</script>
<script>
function copyEmbedUrl() {
const input = document.getElementById('embed-url');
input.select();
input.setSelectionRange(0, 99999);
navigator.clipboard.writeText(input.value).then(function() {
const feedback = document.getElementById('copy-feedback');
feedback.style.display = 'block';
setTimeout(() => { feedback.style.display = 'none'; }, 3000);
});
}
</script>
</body>
</html>
+1 -12
View File
@@ -25,18 +25,7 @@
"zoom_timelapse": {
"show_zoom_controls": true,
"max_zoom_level": 4.0,
"timelapse_reverse_enabled": true,
"weekly_timelapse_enabled": true
},
"auto_screenshot": {
"enabled": false,
"interval_minutes": 10,
"max_images": 144,
"save_to_gallery": true
},
"sharing": {
"email_enabled": false,
"share_link_expiry_hours": 24
"timelapse_reverse_enabled": true
},
"content": {
"guestbook_enabled": true,
+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;
+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
@@ -0,0 +1,41 @@
#!/usr/bin/env php
<?php
/**
* Video Converter Suite - WebSocket Server
*
* Provides real-time status updates to connected clients.
* Usage: php bin/websocket-server.php
*/
require_once __DIR__ . '/../vendor/autoload.php';
use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;
use VideoConverter\WebSocket\StatusServer;
$config = require __DIR__ . '/../config/app.php';
$host = $config['websocket']['host'];
$port = $config['websocket']['port'];
echo "=== Video Converter Suite - WebSocket Server ===\n";
echo "Starting on {$host}:{$port}\n\n";
$statusServer = new StatusServer();
$server = IoServer::factory(
new HttpServer(
new WsServer($statusServer)
),
$port,
$host
);
// Broadcast status every 2 seconds
$server->loop->addPeriodicTimer(2, function () use ($statusServer) {
$statusServer->broadcastStatus();
});
echo "WebSocket server running. Press Ctrl+C to stop.\n";
$server->run();
+21
View File
@@ -0,0 +1,21 @@
{
"name": "videokonverter/suite",
"description": "Video Converter Suite - Live Stream Pipeline Control Panel",
"type": "project",
"require": {
"php": ">=8.1",
"cboden/ratchet": "^0.4",
"react/event-loop": "^1.4",
"react/child-process": "^0.6"
},
"autoload": {
"psr-4": {
"VideoConverter\\": "src/"
}
},
"scripts": {
"start": "php -S 0.0.0.0:8080 -t public public/router.php",
"websocket": "php bin/websocket-server.php",
"worker": "php bin/queue-worker.php"
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
return [
'app_name' => 'Video Converter Suite',
'version' => '1.0.0',
'debug' => true,
'ffmpeg' => [
'binary' => getenv('FFMPEG_PATH') ?: '/usr/bin/ffmpeg',
'ffprobe' => getenv('FFPROBE_PATH') ?: '/usr/bin/ffprobe',
'threads' => (int)(getenv('FFMPEG_THREADS') ?: 4),
'timeout' => 3600,
'nice' => 10,
],
'storage' => [
'uploads' => __DIR__ . '/../storage/uploads',
'outputs' => __DIR__ . '/../storage/outputs',
'thumbnails' => __DIR__ . '/../storage/thumbnails',
'logs' => __DIR__ . '/../storage/logs',
'temp' => __DIR__ . '/../storage/temp',
],
'limits' => [
'max_upload_size' => 5 * 1024 * 1024 * 1024, // 5 GB
'max_concurrent_jobs' => 3,
'max_pipeline_depth' => 10,
],
'websocket' => [
'host' => '0.0.0.0',
'port' => 8081,
],
'formats' => [
'video' => [
'mp4' => ['codec' => 'libx264', 'ext' => 'mp4', 'mime' => 'video/mp4'],
'webm' => ['codec' => 'libvpx-vp9', 'ext' => 'webm', 'mime' => 'video/webm'],
'mkv' => ['codec' => 'libx264', 'ext' => 'mkv', 'mime' => 'video/x-matroska'],
'avi' => ['codec' => 'mpeg4', 'ext' => 'avi', 'mime' => 'video/x-msvideo'],
'mov' => ['codec' => 'libx264', 'ext' => 'mov', 'mime' => 'video/quicktime'],
'flv' => ['codec' => 'flv1', 'ext' => 'flv', 'mime' => 'video/x-flv'],
'wmv' => ['codec' => 'wmv2', 'ext' => 'wmv', 'mime' => 'video/x-ms-wmv'],
'ts' => ['codec' => 'libx264', 'ext' => 'ts', 'mime' => 'video/mp2t'],
'hls' => ['codec' => 'libx264', 'ext' => 'm3u8', 'mime' => 'application/x-mpegURL'],
'dash' => ['codec' => 'libx264', 'ext' => 'mpd', 'mime' => 'application/dash+xml'],
],
'audio' => [
'aac' => ['codec' => 'aac', 'ext' => 'aac', 'mime' => 'audio/aac'],
'mp3' => ['codec' => 'libmp3lame', 'ext' => 'mp3', 'mime' => 'audio/mpeg'],
'ogg' => ['codec' => 'libvorbis', 'ext' => 'ogg', 'mime' => 'audio/ogg'],
'wav' => ['codec' => 'pcm_s16le', 'ext' => 'wav', 'mime' => 'audio/wav'],
'flac' => ['codec' => 'flac', 'ext' => 'flac', 'mime' => 'audio/flac'],
'opus' => ['codec' => 'libopus', 'ext' => 'opus', 'mime' => 'audio/opus'],
],
],
'presets' => [
'ultrafast' => ['preset' => 'ultrafast', 'crf' => 28],
'fast' => ['preset' => 'fast', 'crf' => 23],
'balanced' => ['preset' => 'medium', 'crf' => 20],
'quality' => ['preset' => 'slow', 'crf' => 18],
'lossless' => ['preset' => 'veryslow', 'crf' => 0],
],
'resolutions' => [
'4k' => ['width' => 3840, 'height' => 2160, 'label' => '4K UHD'],
'1440p' => ['width' => 2560, 'height' => 1440, 'label' => '2K QHD'],
'1080p' => ['width' => 1920, 'height' => 1080, 'label' => 'Full HD'],
'720p' => ['width' => 1280, 'height' => 720, 'label' => 'HD'],
'480p' => ['width' => 854, 'height' => 480, 'label' => 'SD'],
'360p' => ['width' => 640, 'height' => 360, 'label' => 'Low'],
],
];
+54
View File
@@ -0,0 +1,54 @@
version: '3.8'
services:
# Main Web Application
web:
build: .
ports:
- "8080:8080"
volumes:
- ./storage:/app/storage
- ./src:/app/src
- ./public:/app/public
- ./templates:/app/templates
- ./config:/app/config
environment:
- FFMPEG_PATH=/usr/bin/ffmpeg
- FFPROBE_PATH=/usr/bin/ffprobe
- FFMPEG_THREADS=4
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/api/system"]
interval: 30s
timeout: 10s
retries: 3
# WebSocket Server for real-time updates
websocket:
build: .
command: php bin/websocket-server.php
ports:
- "8081:8081"
volumes:
- ./storage:/app/storage
- ./src:/app/src
- ./config:/app/config
depends_on:
- web
restart: unless-stopped
# Queue Worker for batch processing
worker:
build: .
command: php bin/queue-worker.php
volumes:
- ./storage:/app/storage
- ./src:/app/src
- ./config:/app/config
environment:
- FFMPEG_PATH=/usr/bin/ffmpeg
- FFPROBE_PATH=/usr/bin/ffprobe
- FFMPEG_THREADS=2
depends_on:
- web
restart: unless-stopped
+409
View File
@@ -0,0 +1,409 @@
<?php
/**
* Video Converter Suite - REST API
*/
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;
});
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$segments = array_values(array_filter(explode('/', $path)));
// Page routes (return HTML)
if (empty($segments) || ($segments[0] ?? '') !== 'api') {
header('Content-Type: text/html; charset=utf-8');
$page = $segments[0] ?? 'dashboard';
$templateFile = __DIR__ . '/../templates/' . basename($page) . '.php';
if (file_exists($templateFile)) {
$config = require __DIR__ . '/../config/app.php';
require $templateFile;
} else {
$config = require __DIR__ . '/../config/app.php';
require __DIR__ . '/../templates/dashboard.php';
}
exit;
}
// API routes
array_shift($segments); // remove 'api'
$resource = $segments[0] ?? '';
$id = $segments[1] ?? null;
$action = $segments[2] ?? null;
$input = json_decode(file_get_contents('php://input'), true) ?? [];
try {
$response = match (true) {
// System
$resource === 'system' && $method === 'GET' => handleSystem(),
// Convert
$resource === 'convert' && $method === 'POST' => handleConvert($input),
$resource === 'convert' && $action === 'batch' && $method === 'POST' => handleBatchConvert($input),
$resource === 'upload' && $method === 'POST' => handleUpload(),
// Jobs
$resource === 'jobs' && $method === 'GET' && !$id => handleGetJobs(),
$resource === 'jobs' && $method === 'GET' && $id && $action === 'progress' => handleJobProgress($id),
$resource === 'jobs' && $method === 'GET' && $id => handleGetJob($id),
$resource === 'jobs' && $method === 'DELETE' && $id => handleDeleteJob($id),
$resource === 'jobs' && $action === 'cancel' && $method === 'POST' => handleCancelJob($id),
// Streams
$resource === 'streams' && $method === 'GET' && !$id => handleGetStreams(),
$resource === 'streams' && $method === 'POST' => handleStartStream($input),
$resource === 'streams' && $method === 'GET' && $id => handleGetStream($id),
$resource === 'streams' && $method === 'DELETE' && $id => handleStopStream($id),
$resource === 'streams' && $action === 'switch' && $method === 'POST' => handleSwitchFormat($id, $input),
// Pipelines
$resource === 'pipelines' && $method === 'GET' && !$id => handleGetPipelines(),
$resource === 'pipelines' && $method === 'POST' => handleCreatePipeline($input),
$resource === 'pipelines' && $method === 'GET' && $id => handleGetPipeline($id),
$resource === 'pipelines' && $method === 'PUT' && $id => handleUpdatePipeline($id, $input),
$resource === 'pipelines' && $method === 'DELETE' && $id => handleDeletePipeline($id),
$resource === 'pipelines' && $action === 'run' && $method === 'POST' => handleRunPipeline($id, $input),
$resource === 'pipelines' && $action === 'stage' && $method === 'POST' => handleAddStage($id, $input),
// Queue
$resource === 'queue' && $method === 'GET' => handleGetQueue(),
$resource === 'queue' && $method === 'POST' => handleEnqueue($input),
$resource === 'queue' && $method === 'DELETE' => handleClearQueue(),
// Formats info
$resource === 'formats' && $method === 'GET' => handleGetFormats(),
$resource === 'presets' && $method === 'GET' => handleGetPresets(),
$resource === 'resolutions' && $method === 'GET' => handleGetResolutions(),
// Probe
$resource === 'probe' && $method === 'POST' => handleProbe($input),
// Downloads
$resource === 'download' && $method === 'GET' && $id => handleDownload($id),
default => ['error' => 'Not found', 'status' => 404],
};
$status = $response['status'] ?? 200;
unset($response['status']);
http_response_code($status);
echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
} catch (\Throwable $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
// ---- Handler Functions ----
function handleSystem(): array
{
$load = sys_getloadavg();
$config = require __DIR__ . '/../config/app.php';
return [
'app' => $config['app_name'],
'version' => $config['version'],
'cpu_load' => $load,
'memory' => [
'used' => memory_get_usage(true),
'peak' => memory_get_peak_usage(true),
],
'disk' => [
'free' => disk_free_space('/'),
'total' => disk_total_space('/'),
],
'php_version' => PHP_VERSION,
'ffmpeg_available' => file_exists($config['ffmpeg']['binary']),
];
}
function handleUpload(): array
{
$config = require __DIR__ . '/../config/app.php';
if (empty($_FILES['file'])) {
return ['error' => 'No file uploaded', 'status' => 400];
}
$file = $_FILES['file'];
if ($file['error'] !== UPLOAD_ERR_OK) {
return ['error' => 'Upload error: ' . $file['error'], 'status' => 400];
}
if ($file['size'] > $config['limits']['max_upload_size']) {
return ['error' => 'File too large', 'status' => 400];
}
$uploadDir = $config['storage']['uploads'];
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$safeName = bin2hex(random_bytes(8)) . '.' . $ext;
$destination = $uploadDir . '/' . $safeName;
if (!move_uploaded_file($file['tmp_name'], $destination)) {
return ['error' => 'Failed to save file', 'status' => 500];
}
$probe = new \VideoConverter\Process\MediaProbe();
$info = $probe->analyze($destination);
// Generate thumbnail
$thumbDir = $config['storage']['thumbnails'];
if (!is_dir($thumbDir)) mkdir($thumbDir, 0755, true);
$thumbPath = $thumbDir . '/' . pathinfo($safeName, PATHINFO_FILENAME) . '.jpg';
$probe->getThumbnail($destination, $thumbPath);
return [
'file' => $safeName,
'path' => $destination,
'original_name' => $file['name'],
'size' => $file['size'],
'info' => $info,
'thumbnail' => file_exists($thumbPath) ? '/api/thumbnail/' . pathinfo($safeName, PATHINFO_FILENAME) : null,
];
}
function handleConvert(array $input): array
{
$converter = new \VideoConverter\Format\FormatConverter();
return $converter->convert($input);
}
function handleBatchConvert(array $input): array
{
$converter = new \VideoConverter\Format\FormatConverter();
return $converter->batchConvert($input['input_file'] ?? '', $input['formats'] ?? []);
}
function handleGetJobs(): array
{
$converter = new \VideoConverter\Format\FormatConverter();
return ['jobs' => $converter->getAllJobs()];
}
function handleGetJob(string $id): array
{
$converter = new \VideoConverter\Format\FormatConverter();
$job = $converter->getJob($id);
return $job ? $job : ['error' => 'Job not found', 'status' => 404];
}
function handleJobProgress(string $id): array
{
$converter = new \VideoConverter\Format\FormatConverter();
return $converter->getProgress($id);
}
function handleCancelJob(string $id): array
{
$converter = new \VideoConverter\Format\FormatConverter();
return ['success' => $converter->cancelJob($id)];
}
function handleDeleteJob(string $id): array
{
$converter = new \VideoConverter\Format\FormatConverter();
return ['success' => $converter->deleteJob($id)];
}
function handleGetStreams(): array
{
$mgr = new \VideoConverter\Stream\StreamManager();
return ['streams' => $mgr->getAllStreams(), 'stats' => $mgr->getStats()];
}
function handleStartStream(array $input): array
{
$mgr = new \VideoConverter\Stream\StreamManager();
return $mgr->startStream($input);
}
function handleGetStream(string $id): array
{
$mgr = new \VideoConverter\Stream\StreamManager();
$stream = $mgr->getStream($id);
return $stream ?: ['error' => 'Stream not found', 'status' => 404];
}
function handleStopStream(string $id): array
{
$mgr = new \VideoConverter\Stream\StreamManager();
return ['success' => $mgr->stopStream($id)];
}
function handleSwitchFormat(string $id, array $input): array
{
$mgr = new \VideoConverter\Stream\StreamManager();
return $mgr->switchFormat($id, $input['format'] ?? 'mp4', $input['resolution'] ?? null);
}
function handleGetPipelines(): array
{
$mgr = new \VideoConverter\Pipeline\PipelineManager();
return ['pipelines' => $mgr->toArray()];
}
function handleCreatePipeline(array $input): array
{
$mgr = new \VideoConverter\Pipeline\PipelineManager();
$pipeline = $mgr->create($input['name'] ?? 'Unnamed Pipeline');
foreach (($input['stages'] ?? []) as $stageData) {
$stage = new \VideoConverter\Pipeline\PipelineStage(
$stageData['type'] ?? 'transcode',
$stageData['params'] ?? [],
$stageData['label'] ?? '',
$stageData['enabled'] ?? true
);
$pipeline->addStage($stage);
}
$mgr->save();
return $pipeline->toArray();
}
function handleGetPipeline(string $id): array
{
$mgr = new \VideoConverter\Pipeline\PipelineManager();
$pipeline = $mgr->get($id);
return $pipeline ? $pipeline->toArray() : ['error' => 'Pipeline not found', 'status' => 404];
}
function handleUpdatePipeline(string $id, array $input): array
{
$mgr = new \VideoConverter\Pipeline\PipelineManager();
$pipeline = $mgr->get($id);
if (!$pipeline) return ['error' => 'Pipeline not found', 'status' => 404];
if (isset($input['stages'])) {
// Rebuild stages
$ref = new \ReflectionProperty($pipeline, 'stages');
$ref->setAccessible(true);
$ref->setValue($pipeline, []);
foreach ($input['stages'] as $stageData) {
$stage = \VideoConverter\Pipeline\PipelineStage::fromArray($stageData);
$pipeline->addStage($stage);
}
}
$mgr->save();
return $pipeline->toArray();
}
function handleDeletePipeline(string $id): array
{
$mgr = new \VideoConverter\Pipeline\PipelineManager();
return ['success' => $mgr->delete($id)];
}
function handleRunPipeline(string $id, array $input): array
{
$mgr = new \VideoConverter\Pipeline\PipelineManager();
$pipeline = $mgr->get($id);
if (!$pipeline) return ['error' => 'Pipeline not found', 'status' => 404];
$converter = new \VideoConverter\Format\FormatConverter();
return $converter->convert([
'input_file' => $input['input_file'] ?? '',
'output_format' => $input['output_format'] ?? 'mp4',
'pipeline' => $pipeline,
]);
}
function handleAddStage(string $id, array $input): array
{
$mgr = new \VideoConverter\Pipeline\PipelineManager();
$pipeline = $mgr->get($id);
if (!$pipeline) return ['error' => 'Pipeline not found', 'status' => 404];
$stage = new \VideoConverter\Pipeline\PipelineStage(
$input['type'] ?? 'transcode',
$input['params'] ?? [],
$input['label'] ?? '',
$input['enabled'] ?? true
);
$pipeline->addStage($stage);
$mgr->save();
return $pipeline->toArray();
}
function handleGetQueue(): array
{
$queue = new \VideoConverter\Queue\JobQueue();
return ['queue' => $queue->getQueue(), 'stats' => $queue->getStats()];
}
function handleEnqueue(array $input): array
{
$queue = new \VideoConverter\Queue\JobQueue();
$queueId = $queue->enqueue($input);
return ['queue_id' => $queueId, 'position' => count($queue->getWaiting())];
}
function handleClearQueue(): array
{
$queue = new \VideoConverter\Queue\JobQueue();
$cleared = $queue->clear();
return ['cleared' => $cleared];
}
function handleGetFormats(): array
{
$config = require __DIR__ . '/../config/app.php';
return $config['formats'];
}
function handleGetPresets(): array
{
$config = require __DIR__ . '/../config/app.php';
return $config['presets'];
}
function handleGetResolutions(): array
{
$config = require __DIR__ . '/../config/app.php';
return $config['resolutions'];
}
function handleProbe(array $input): array
{
$probe = new \VideoConverter\Process\MediaProbe();
return $probe->analyze($input['file'] ?? '');
}
function handleDownload(string $id): array
{
$converter = new \VideoConverter\Format\FormatConverter();
$job = $converter->getJob($id);
if (!$job || !isset($job['output_file']) || !file_exists($job['output_file'])) {
return ['error' => 'File not found', 'status' => 404];
}
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . basename($job['output_file']) . '"');
header('Content-Length: ' . filesize($job['output_file']));
readfile($job['output_file']);
exit;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,828 @@
/**
* Video Converter Suite - Control Panel JavaScript
* Nuclear Power Plant Style UI Controller
*/
// ============ STATE ============
const state = {
currentPage: 'dashboard',
selectedFormat: 'mp4',
selectedPreset: 'balanced',
selectedResolution: 'original',
uploadedFile: null,
uploadedFilePath: null,
jobs: [],
streams: [],
pipelines: [],
activePipelineId: null,
pipelineStages: [],
activeStreamId: null,
wsConnected: false,
refreshInterval: null,
};
// ============ INIT ============
document.addEventListener('DOMContentLoaded', () => {
initClock();
initNavigation();
initUploadZone();
startAutoRefresh();
refreshStatus();
addLog('System initialisiert', 'info');
});
// ============ CLOCK ============
function initClock() {
const el = document.getElementById('systemClock');
function update() {
const now = new Date();
el.textContent = now.toTimeString().split(' ')[0];
}
update();
setInterval(update, 1000);
}
// ============ NAVIGATION ============
function initNavigation() {
document.querySelectorAll('.nav-tab').forEach(tab => {
tab.addEventListener('click', () => {
const page = tab.dataset.page;
switchPage(page);
});
});
}
function switchPage(page) {
state.currentPage = page;
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
document.querySelector(`.nav-tab[data-page="${page}"]`)?.classList.add('active');
document.querySelectorAll('.page-content').forEach(p => p.style.display = 'none');
document.getElementById(`page-${page}`).style.display = '';
// Refresh page-specific data
if (page === 'dashboard') refreshStatus();
if (page === 'streams') refreshStreams();
if (page === 'pipelines') refreshPipelines();
if (page === 'queue') refreshQueue();
}
// ============ UPLOAD ============
function initUploadZone() {
const zone = document.getElementById('uploadZone');
if (!zone) return;
zone.addEventListener('dragover', (e) => {
e.preventDefault();
zone.classList.add('dragover');
});
zone.addEventListener('dragleave', () => {
zone.classList.remove('dragover');
});
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
uploadFile(e.dataTransfer.files[0]);
}
});
}
function handleFileSelect(event) {
if (event.target.files.length > 0) {
uploadFile(event.target.files[0]);
}
}
async function uploadFile(file) {
state.uploadedFile = file;
addLog(`Upload gestartet: ${file.name} (${formatBytes(file.size)})`, 'info');
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
const data = await resp.json();
if (data.error) {
addLog(`Upload-Fehler: ${data.error}`, 'error');
notify('Upload fehlgeschlagen: ' + data.error, 'error');
return;
}
state.uploadedFilePath = data.path;
displayUploadedFile(data);
document.getElementById('btnStartConvert').disabled = false;
addLog(`Upload abgeschlossen: ${file.name}`, 'success');
notify('Datei hochgeladen: ' + file.name, 'success');
} catch (err) {
addLog(`Upload-Fehler: ${err.message}`, 'error');
notify('Upload fehlgeschlagen', 'error');
}
}
function displayUploadedFile(data) {
const el = document.getElementById('uploadedFileInfo');
el.style.display = 'block';
const info = data.info || {};
const video = info.video || {};
const audio = info.audio || {};
el.innerHTML = `
<div style="background:var(--bg-inset); border:1px solid var(--border-dark); border-radius:4px; padding:12px;">
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
<strong style="color:var(--accent-cyan);">${data.original_name}</strong>
<span style="color:var(--text-dim); font-size:11px;">${formatBytes(data.size)}</span>
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:8px; font-size:11px;">
<div><span style="color:var(--text-dim)">Format:</span> ${info.format_name || 'N/A'}</div>
<div><span style="color:var(--text-dim)">Dauer:</span> ${formatDuration(info.duration || 0)}</div>
${video ? `
<div><span style="color:var(--text-dim)">Video:</span> ${video.codec || 'N/A'} ${video.width || ''}x${video.height || ''}</div>
<div><span style="color:var(--text-dim)">FPS:</span> ${video.fps || 'N/A'}</div>
` : ''}
${audio ? `
<div><span style="color:var(--text-dim)">Audio:</span> ${audio.codec || 'N/A'}</div>
<div><span style="color:var(--text-dim)">Sample:</span> ${audio.sample_rate || 'N/A'} Hz</div>
` : ''}
</div>
</div>
`;
}
// ============ FORMAT SELECTION ============
function selectFormat(format) {
state.selectedFormat = format;
document.querySelectorAll('.format-switch').forEach(s => s.classList.remove('selected'));
document.querySelectorAll(`.format-switch[data-format="${format}"]`).forEach(s => s.classList.add('selected'));
addLog(`Format gewählt: ${format.toUpperCase()}`, 'info');
}
function selectPreset(preset) {
state.selectedPreset = preset;
document.querySelectorAll('#presetPanel .switch-unit').forEach(s => s.classList.remove('active'));
document.querySelector(`#presetPanel .switch-unit[data-preset="${preset}"]`)?.classList.add('active');
}
function selectResolution(res) {
state.selectedResolution = res;
document.querySelectorAll('#resolutionPanel .switch-unit').forEach(s => s.classList.remove('active'));
document.querySelector(`#resolutionPanel .switch-unit[data-resolution="${res}"]`)?.classList.add('active');
}
// ============ CONVERSION ============
async function startConversion() {
if (!state.uploadedFilePath) {
notify('Keine Datei hochgeladen', 'warning');
return;
}
const params = {
input_file: state.uploadedFilePath,
output_format: state.selectedFormat,
preset: state.selectedPreset,
};
if (state.selectedResolution !== 'original') {
params.resolution = state.selectedResolution;
}
addLog(`Konvertierung gestartet: ${state.selectedFormat.toUpperCase()} / ${state.selectedPreset}`, 'info');
document.getElementById('conversionStatus').textContent = 'Konvertierung wird gestartet...';
try {
const resp = await fetch('/api/convert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
const data = await resp.json();
if (data.error) {
addLog(`Fehler: ${data.error}`, 'error');
notify(data.error, 'error');
return;
}
addLog(`Job erstellt: ${data.id}`, 'success');
notify('Konvertierung gestartet', 'success');
document.getElementById('btnStopAll').style.display = '';
document.getElementById('conversionStatus').textContent = `Job ${data.id} läuft...`;
startJobPolling(data.id);
} catch (err) {
addLog(`Fehler: ${err.message}`, 'error');
notify('Konvertierung fehlgeschlagen', 'error');
}
}
function startJobPolling(jobId) {
const poll = setInterval(async () => {
try {
const resp = await fetch(`/api/jobs/${jobId}/progress`);
const progress = await resp.json();
document.getElementById('conversionStatus').textContent =
`${progress.percent || 0}% | FPS: ${progress.fps || 0} | Speed: ${progress.speed || '0x'} | Zeit: ${progress.time || '00:00:00'}`;
updateJobInList(jobId, progress);
if (progress.percent >= 100) {
clearInterval(poll);
addLog(`Job ${jobId} abgeschlossen`, 'success');
notify('Konvertierung abgeschlossen!', 'success');
document.getElementById('conversionStatus').textContent = 'Konvertierung abgeschlossen!';
refreshJobs();
}
} catch (e) {
// Keep polling
}
}, 1000);
}
async function stopAllJobs() {
if (!confirm('Alle laufenden Jobs stoppen?')) return;
try {
const resp = await fetch('/api/jobs');
const data = await resp.json();
for (const job of (data.jobs || [])) {
if (job.status === 'running') {
await fetch(`/api/jobs/${job.id}/cancel`, { method: 'POST' });
addLog(`Job ${job.id} gestoppt`, 'warn');
}
}
notify('Alle Jobs gestoppt', 'warning');
document.getElementById('btnStopAll').style.display = 'none';
refreshJobs();
} catch (e) {
notify('Fehler beim Stoppen', 'error');
}
}
// ============ JOBS ============
async function refreshJobs() {
try {
const resp = await fetch('/api/jobs');
const data = await resp.json();
state.jobs = data.jobs || [];
renderJobList();
} catch (e) {
// Silently fail
}
}
function renderJobList() {
const el = document.getElementById('jobList');
if (!state.jobs.length) {
el.innerHTML = '<div style="text-align:center; color:var(--text-dim); padding:20px;">Keine aktiven Jobs</div>';
return;
}
el.innerHTML = state.jobs.map(job => `
<div class="job-item" id="job-${job.id}">
<div class="job-thumb">${job.thumbnail ? `<img src="${job.thumbnail}">` : '&#127910;'}</div>
<div class="job-info">
<div class="job-name">${job.input_file ? job.input_file.split('/').pop() : 'Unknown'}</div>
<div class="job-meta">
${job.output_format?.toUpperCase() || ''} | ${job.preset || ''} | ${job.resolution || 'Original'}
</div>
<div class="progress-bar" style="margin-top:6px;">
<div class="progress-fill" id="progress-${job.id}" style="width:${job.status === 'completed' ? 100 : 0}%"></div>
</div>
<div class="progress-label">
<span id="progress-text-${job.id}">${job.status === 'completed' ? '100%' : '0%'}</span>
<span id="progress-speed-${job.id}"></span>
</div>
</div>
<span class="job-status ${job.status}">${job.status}</span>
<div class="job-actions">
${job.status === 'running' ? `<button class="btn btn-icon btn-danger" onclick="cancelJob('${job.id}')" data-tooltip="Stop">&#9632;</button>` : ''}
${job.status === 'completed' ? `<button class="btn btn-icon btn-success" onclick="downloadJob('${job.id}')" data-tooltip="Download">&#8681;</button>` : ''}
<button class="btn btn-icon btn-danger" onclick="deleteJob('${job.id}')" data-tooltip="Löschen">&#10005;</button>
</div>
</div>
`).join('');
// Update active job count
const running = state.jobs.filter(j => j.status === 'running').length;
document.getElementById('activeJobCount').textContent = running;
document.getElementById('gaugeJobs').textContent = running;
}
function updateJobInList(jobId, progress) {
const bar = document.getElementById(`progress-${jobId}`);
const text = document.getElementById(`progress-text-${jobId}`);
const speed = document.getElementById(`progress-speed-${jobId}`);
if (bar) bar.style.width = `${progress.percent || 0}%`;
if (text) text.textContent = `${progress.percent || 0}%`;
if (speed) speed.textContent = `${progress.fps || 0} fps | ${progress.speed || ''}`;
}
async function cancelJob(id) {
await fetch(`/api/jobs/${id}/cancel`, { method: 'POST' });
addLog(`Job ${id} abgebrochen`, 'warn');
refreshJobs();
}
async function deleteJob(id) {
await fetch(`/api/jobs/${id}`, { method: 'DELETE' });
addLog(`Job ${id} gelöscht`, 'info');
refreshJobs();
}
function downloadJob(id) {
window.open(`/api/download/${id}`, '_blank');
}
// ============ STREAMS ============
async function refreshStreams() {
try {
const resp = await fetch('/api/streams');
const data = await resp.json();
state.streams = data.streams || [];
renderStreamMatrix();
updateStreamSelect();
} catch (e) {}
}
function renderStreamMatrix() {
const el = document.getElementById('streamMatrix');
if (!state.streams.length) {
el.innerHTML = '<div style="text-align:center; color:var(--text-dim); padding:40px; grid-column:1/-1;">Keine aktiven Streams</div>';
return;
}
el.innerHTML = state.streams.map(s => `
<div class="stream-card">
<div class="stream-preview">
<span class="no-signal">${s.status === 'running' ? '&#9654; LIVE' : 'NO SIGNAL'}</span>
${s.status === 'running' ? '<span class="live-badge">LIVE</span>' : ''}
</div>
<div class="stream-info">
<div class="stream-name">${s.input_url || 'Stream'}</div>
<div style="font-size:10px; color:var(--text-dim);">
${s.output_format?.toUpperCase() || ''} | ${s.resolution || 'Original'} | ${s.preset || 'fast'}
</div>
<span class="job-status ${s.status}" style="margin-top:6px; display:inline-block;">${s.status}</span>
</div>
<div class="stream-controls">
${s.status === 'running' ?
`<button class="btn btn-danger" onclick="stopStream('${s.id}')">&#9632; Stop</button>` :
`<button class="btn btn-success" onclick="restartStream('${s.id}')">&#9654; Restart</button>`
}
<button class="btn" onclick="deleteStream('${s.id}')">&#10005;</button>
</div>
</div>
`).join('');
}
function updateStreamSelect() {
const sel = document.getElementById('activeStreamSelect');
const runningStreams = state.streams.filter(s => s.status === 'running');
sel.innerHTML = '<option value="">-- Stream wählen --</option>' +
runningStreams.map(s =>
`<option value="${s.id}">${s.input_url} (${s.output_format?.toUpperCase()})</option>`
).join('');
}
function openStreamModal() {
document.getElementById('streamModal').classList.add('visible');
}
function closeStreamModal() {
document.getElementById('streamModal').classList.remove('visible');
}
async function startNewStream() {
const inputUrl = document.getElementById('streamInputUrl').value;
if (!inputUrl) {
notify('Bitte Stream-URL eingeben', 'warning');
return;
}
const params = {
input_url: inputUrl,
output_format: document.getElementById('streamOutputFormat').value,
resolution: document.getElementById('streamResolution').value || null,
preset: document.getElementById('streamPreset').value,
};
try {
const resp = await fetch('/api/streams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
const data = await resp.json();
if (data.error) {
notify(data.error, 'error');
return;
}
addLog(`Stream gestartet: ${data.id}`, 'success');
notify('Stream gestartet', 'success');
closeStreamModal();
refreshStreams();
} catch (e) {
notify('Stream-Start fehlgeschlagen', 'error');
}
}
async function stopStream(id) {
await fetch(`/api/streams/${id}`, { method: 'DELETE' });
addLog(`Stream ${id} gestoppt`, 'warn');
refreshStreams();
}
async function deleteStream(id) {
await fetch(`/api/streams/${id}`, { method: 'DELETE' });
refreshStreams();
}
function selectActiveStream(id) {
state.activeStreamId = id;
// Highlight current format
const stream = state.streams.find(s => s.id === id);
document.querySelectorAll('[data-stream-format]').forEach(el => el.classList.remove('selected'));
if (stream) {
document.querySelector(`[data-stream-format="${stream.output_format}"]`)?.classList.add('selected');
}
}
async function switchStreamFormat(format) {
if (!state.activeStreamId) {
notify('Bitte zuerst einen Stream wählen', 'warning');
return;
}
addLog(`Format-Wechsel: ${format.toUpperCase()} für Stream ${state.activeStreamId}`, 'warn');
try {
const resp = await fetch(`/api/streams/${state.activeStreamId}/switch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ format }),
});
const data = await resp.json();
if (data.error) {
notify(data.error, 'error');
return;
}
addLog(`Format gewechselt zu ${format.toUpperCase()}`, 'success');
notify(`Format umgeschaltet: ${format.toUpperCase()}`, 'success');
// Update active stream ID to new stream
state.activeStreamId = data.id;
refreshStreams();
// Highlight new format
document.querySelectorAll('[data-stream-format]').forEach(el => el.classList.remove('selected'));
document.querySelector(`[data-stream-format="${format}"]`)?.classList.add('selected');
} catch (e) {
notify('Format-Wechsel fehlgeschlagen', 'error');
}
}
// ============ PIPELINES ============
async function refreshPipelines() {
try {
const resp = await fetch('/api/pipelines');
const data = await resp.json();
state.pipelines = data.pipelines || [];
renderPipelineList();
} catch (e) {}
}
function renderPipelineList() {
const el = document.getElementById('pipelineList');
if (!state.pipelines.length) {
el.innerHTML = '<div style="text-align:center; color:var(--text-dim); padding:16px;">Keine Pipelines vorhanden</div>';
return;
}
el.innerHTML = state.pipelines.map(p => `
<div class="job-item" style="cursor:pointer" onclick="editPipeline('${p.id}')">
<div class="job-thumb" style="font-size:24px">&#9776;</div>
<div class="job-info">
<div class="job-name">${p.name}</div>
<div class="job-meta">${(p.stages || []).length} Stufen | Status: ${p.status}</div>
</div>
<span class="job-status ${p.status}">${p.status}</span>
<div class="job-actions">
<button class="btn btn-icon btn-primary" onclick="event.stopPropagation(); runPipeline('${p.id}')" data-tooltip="Ausführen">&#9654;</button>
<button class="btn btn-icon btn-danger" onclick="event.stopPropagation(); deletePipeline('${p.id}')" data-tooltip="Löschen">&#10005;</button>
</div>
</div>
`).join('');
}
async function createPipeline() {
const name = prompt('Pipeline-Name:', 'Neue Pipeline');
if (!name) return;
try {
const resp = await fetch('/api/pipelines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
const data = await resp.json();
addLog(`Pipeline erstellt: ${data.name}`, 'success');
refreshPipelines();
editPipeline(data.id);
} catch (e) {
notify('Pipeline-Erstellung fehlgeschlagen', 'error');
}
}
function editPipeline(id) {
state.activePipelineId = id;
const pipeline = state.pipelines.find(p => p.id === id);
if (!pipeline) return;
state.pipelineStages = pipeline.stages || [];
document.getElementById('pipelineEditor').style.display = '';
renderPipelineFlow();
}
function renderPipelineFlow() {
const flow = document.getElementById('pipelineFlow');
let html = `
<div class="pipeline-node active">
<div class="node-type">Input</div>
<div class="node-name">Source</div>
<div class="node-status"></div>
</div>
`;
state.pipelineStages.forEach((stage, i) => {
html += `<div class="pipeline-connector ${stage.enabled ? 'active' : ''}"></div>`;
html += `
<div class="pipeline-node ${stage.enabled ? 'active' : 'disabled'}" onclick="toggleStage(${i})">
<div class="node-type">${stage.type}</div>
<div class="node-name">${stage.label || stage.type}</div>
<div class="node-status"></div>
</div>
`;
});
html += `<div class="pipeline-connector active"></div>`;
html += `
<div class="pipeline-node active">
<div class="node-type">Output</div>
<div class="node-name">Target</div>
<div class="node-status"></div>
</div>
`;
flow.innerHTML = html;
}
function toggleStage(index) {
if (state.pipelineStages[index]) {
state.pipelineStages[index].enabled = !state.pipelineStages[index].enabled;
renderPipelineFlow();
savePipelineStages();
}
}
async function addPipelineStage(type) {
if (!state.activePipelineId) {
notify('Bitte zuerst eine Pipeline auswählen oder erstellen', 'warning');
return;
}
const stageDefaults = {
transcode: { params: { video_codec: 'libx264', preset: 'medium', crf: 23 } },
scale: { params: { width: 1920, height: 1080 } },
filter: { params: { brightness: 0, contrast: 1, saturation: 1 } },
audio: { params: { codec: 'aac', bitrate: '128k', sample_rate: 44100 } },
bitrate: { params: { video: '2M', audio: '128k' } },
framerate: { params: { fps: 30 } },
trim: { params: { start: '00:00:00', duration: '' } },
deinterlace: { params: {} },
denoise: { params: {} },
stabilize: { params: {} },
};
const defaults = stageDefaults[type] || { params: {} };
const stage = {
type,
label: type.charAt(0).toUpperCase() + type.slice(1),
params: defaults.params,
enabled: true,
};
state.pipelineStages.push(stage);
renderPipelineFlow();
await savePipelineStages();
addLog(`Stufe hinzugefügt: ${type}`, 'info');
}
async function savePipelineStages() {
if (!state.activePipelineId) return;
try {
await fetch(`/api/pipelines/${state.activePipelineId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stages: state.pipelineStages }),
});
} catch (e) {
console.error('Failed to save pipeline stages');
}
}
async function deletePipeline(id) {
if (!confirm('Pipeline löschen?')) return;
await fetch(`/api/pipelines/${id}`, { method: 'DELETE' });
if (state.activePipelineId === id) {
state.activePipelineId = null;
document.getElementById('pipelineEditor').style.display = 'none';
}
refreshPipelines();
}
async function runPipeline(id) {
if (!state.uploadedFilePath) {
notify('Bitte zuerst eine Datei hochladen (Konverter-Seite)', 'warning');
return;
}
try {
const resp = await fetch(`/api/pipelines/${id}/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
input_file: state.uploadedFilePath,
output_format: state.selectedFormat,
}),
});
const data = await resp.json();
if (data.error) {
notify(data.error, 'error');
return;
}
addLog(`Pipeline ${id} ausgeführt, Job: ${data.id}`, 'success');
notify('Pipeline gestartet', 'success');
startJobPolling(data.id);
} catch (e) {
notify('Pipeline-Start fehlgeschlagen', 'error');
}
}
// ============ QUEUE ============
async function refreshQueue() {
try {
const resp = await fetch('/api/queue');
const data = await resp.json();
renderQueue(data.queue || [], data.stats || {});
} catch (e) {}
}
function renderQueue(queue, stats) {
const el = document.getElementById('queueList');
if (!queue.length) {
el.innerHTML = '<div style="text-align:center; color:var(--text-dim); padding:20px;">Warteschlange ist leer</div>';
} else {
el.innerHTML = queue.map(job => `
<div class="job-item">
<div class="job-thumb">&#128196;</div>
<div class="job-info">
<div class="job-name">${job.input_file || job.queue_id}</div>
<div class="job-meta">Priorität: ${job.priority || 5} | ${job.output_format || 'mp4'}</div>
</div>
<span class="job-status ${job.queue_status === 'waiting' ? 'queued' : job.queue_status}">${job.queue_status}</span>
</div>
`).join('');
}
document.getElementById('queueWaiting').textContent = stats.waiting || 0;
document.getElementById('queueProcessing').textContent = stats.processing || 0;
document.getElementById('queueCompleted').textContent = stats.completed || 0;
document.getElementById('queueFailed').textContent = stats.failed || 0;
}
async function clearQueue() {
await fetch('/api/queue', { method: 'DELETE' });
refreshQueue();
notify('Queue geleert', 'info');
}
// ============ STATUS / SYSTEM ============
async function refreshStatus() {
try {
const resp = await fetch('/api/system');
const data = await resp.json();
updateGauges(data);
refreshJobs();
} catch (e) {
document.getElementById('systemStatusDot').className = 'status-dot error';
document.getElementById('systemStatusText').textContent = 'OFFLINE';
}
}
function updateGauges(data) {
// CPU
const cpuLoad = data.cpu_load?.[0] || 0;
const cpuPercent = Math.min(100, cpuLoad * 25); // Normalize to ~100% at load 4
document.getElementById('gaugeCpu').textContent = cpuLoad.toFixed(1);
const cpuBar = document.getElementById('gaugeCpuBar');
cpuBar.style.width = cpuPercent + '%';
cpuBar.className = 'gauge-bar-fill' + (cpuPercent > 80 ? ' danger' : cpuPercent > 50 ? ' warning' : '');
// Memory
const mem = data.memory || {};
const memPercent = mem.peak ? Math.round((mem.used / mem.peak) * 100) : 0;
document.getElementById('gaugeMem').textContent = memPercent;
const memBar = document.getElementById('gaugeMemBar');
memBar.style.width = memPercent + '%';
memBar.className = 'gauge-bar-fill' + (memPercent > 80 ? ' danger' : memPercent > 50 ? ' warning' : '');
// Disk
const diskFree = (data.disk?.free || 0) / (1024 * 1024 * 1024);
const diskTotal = (data.disk?.total || 1) / (1024 * 1024 * 1024);
const diskUsedPercent = Math.round(((diskTotal - diskFree) / diskTotal) * 100);
document.getElementById('gaugeDisk').textContent = diskFree.toFixed(1);
const diskBar = document.getElementById('gaugeDiskBar');
diskBar.style.width = diskUsedPercent + '%';
diskBar.className = 'gauge-bar-fill' + (diskUsedPercent > 90 ? ' danger' : diskUsedPercent > 70 ? ' warning' : '');
// Status
document.getElementById('systemStatusDot').className = 'status-dot';
document.getElementById('systemStatusText').textContent = data.ffmpeg_available ? 'SYSTEM ONLINE' : 'FFMPEG MISSING';
if (!data.ffmpeg_available) {
document.getElementById('systemStatusDot').className = 'status-dot warning';
}
}
function startAutoRefresh() {
if (state.refreshInterval) clearInterval(state.refreshInterval);
state.refreshInterval = setInterval(() => {
if (state.currentPage === 'dashboard') refreshStatus();
if (state.currentPage === 'streams') refreshStreams();
}, 5000);
}
// ============ LOGGING ============
function addLog(message, level = 'info') {
const console = document.getElementById('logConsole');
const time = new Date().toLocaleTimeString();
const line = document.createElement('div');
line.className = `log-line ${level}`;
line.innerHTML = `<span class="log-time">[${time}]</span> ${escapeHtml(message)}`;
console.appendChild(line);
console.scrollTop = console.scrollHeight;
// Keep max 100 lines
while (console.children.length > 100) {
console.removeChild(console.firstChild);
}
}
function clearLog() {
document.getElementById('logConsole').innerHTML =
'<div class="log-line info"><span class="log-time">[CLEAR]</span> Log bereinigt</div>';
}
// ============ NOTIFICATIONS ============
function notify(message, type = 'info') {
const el = document.getElementById('notification');
el.className = `notification ${type} show`;
el.textContent = message;
setTimeout(() => { el.classList.remove('show'); }, 3000);
}
// ============ HELPERS ============
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0, size = bytes;
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return size.toFixed(1) + ' ' + units[i];
}
function formatDuration(seconds) {
if (!seconds) return '0:00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
+15
View File
@@ -0,0 +1,15 @@
<?php
/**
* Video Converter Suite - Front Controller / Router
*
* Usage: php -S 0.0.0.0:8080 -t public public/router.php
*/
// Serve static files directly
$uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
if ($uri !== '/' && file_exists(__DIR__ . $uri)) {
return false;
}
require_once __DIR__ . '/api.php';
@@ -0,0 +1,282 @@
<?php
namespace VideoConverter\Format;
use VideoConverter\Process\FFmpegProcess;
use VideoConverter\Process\MediaProbe;
use VideoConverter\Pipeline\Pipeline;
class FormatConverter
{
private array $jobs = [];
private string $stateFile;
private MediaProbe $probe;
public function __construct()
{
$this->stateFile = __DIR__ . '/../../storage/temp/jobs.json';
$this->probe = new MediaProbe();
$this->load();
}
private function load(): void
{
if (file_exists($this->stateFile)) {
$this->jobs = json_decode(file_get_contents($this->stateFile), true) ?: [];
}
}
private function save(): void
{
$dir = dirname($this->stateFile);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($this->stateFile, json_encode($this->jobs, JSON_PRETTY_PRINT));
}
public function convert(array $params): array
{
$config = require __DIR__ . '/../../config/app.php';
$inputFile = $params['input_file'] ?? '';
$outputFormat = $params['output_format'] ?? 'mp4';
$preset = $params['preset'] ?? 'balanced';
$resolution = $params['resolution'] ?? null;
$customPipeline = $params['pipeline'] ?? null;
if (!file_exists($inputFile)) {
return ['error' => 'Input file not found'];
}
$id = bin2hex(random_bytes(8));
$formatConfig = $config['formats']['video'][$outputFormat]
?? $config['formats']['audio'][$outputFormat]
?? null;
if (!$formatConfig) {
return ['error' => "Unknown format: {$outputFormat}"];
}
$inputInfo = $this->probe->analyze($inputFile);
$baseName = pathinfo($inputFile, PATHINFO_FILENAME);
$outputFile = $config['storage']['outputs'] . "/{$baseName}_{$id}.{$formatConfig['ext']}";
// Build command
if ($customPipeline instanceof Pipeline) {
$cmd = $customPipeline->buildFFmpegCommand($inputFile, $outputFile);
} else {
$cmd = $this->buildCommand($inputFile, $outputFile, $outputFormat, $preset, $resolution, $params);
}
$process = new FFmpegProcess($cmd, $id);
if (isset($inputInfo['duration'])) {
$process->setDuration($inputInfo['duration']);
}
// Generate thumbnail
$thumbPath = $config['storage']['thumbnails'] . "/{$id}.jpg";
$this->probe->getThumbnail($inputFile, $thumbPath);
$job = [
'id' => $id,
'input_file' => $inputFile,
'input_info' => $inputInfo,
'output_file' => $outputFile,
'output_format' => $outputFormat,
'preset' => $preset,
'resolution' => $resolution,
'thumbnail' => file_exists($thumbPath) ? $thumbPath : null,
'status' => 'starting',
'pid' => null,
'command' => $cmd,
'created_at' => date('c'),
];
if ($process->start()) {
$job['status'] = 'running';
$job['pid'] = $process->getPid();
} else {
$job['status'] = 'error';
$job['error'] = 'Failed to start FFmpeg process';
}
$this->jobs[$id] = $job;
$this->save();
return $job;
}
public function batchConvert(string $inputFile, array $formats): array
{
$results = [];
foreach ($formats as $format => $settings) {
$params = array_merge(
['input_file' => $inputFile, 'output_format' => $format],
$settings
);
$results[$format] = $this->convert($params);
}
return $results;
}
public function getJob(string $id): ?array
{
$this->refreshJob($id);
return $this->jobs[$id] ?? null;
}
public function getAllJobs(): array
{
foreach (array_keys($this->jobs) as $id) {
$this->refreshJob($id);
}
return array_values($this->jobs);
}
public function cancelJob(string $id): bool
{
if (!isset($this->jobs[$id])) return false;
$job = $this->jobs[$id];
if ($job['pid'] && $job['status'] === 'running') {
posix_kill($job['pid'], SIGTERM);
$this->jobs[$id]['status'] = 'cancelled';
$this->save();
return true;
}
return false;
}
public function deleteJob(string $id): bool
{
if (isset($this->jobs[$id])) {
$this->cancelJob($id);
// Clean up output file
if (isset($this->jobs[$id]['output_file']) && file_exists($this->jobs[$id]['output_file'])) {
unlink($this->jobs[$id]['output_file']);
}
unset($this->jobs[$id]);
$this->save();
return true;
}
return false;
}
public function getProgress(string $id): array
{
if (!isset($this->jobs[$id])) {
return ['error' => 'Job not found'];
}
$config = require __DIR__ . '/../../config/app.php';
$progressFile = $config['storage']['logs'] . "/progress_{$id}.txt";
$progress = ['percent' => 0, 'fps' => 0, 'speed' => '0x', 'time' => '00:00:00'];
if (file_exists($progressFile)) {
$content = file_get_contents($progressFile);
foreach (explode("\n", $content) as $line) {
if (str_contains($line, '=')) {
[$key, $val] = explode('=', $line, 2);
$key = trim($key);
$val = trim($val);
if ($key === 'out_time') $progress['time'] = $val;
if ($key === 'fps') $progress['fps'] = (float)$val;
if ($key === 'speed') $progress['speed'] = $val;
if ($key === 'progress' && $val === 'end') $progress['percent'] = 100;
}
}
$duration = $this->jobs[$id]['input_info']['duration'] ?? 0;
if ($duration > 0 && $progress['percent'] < 100) {
$current = $this->timeToSeconds($progress['time']);
$progress['percent'] = min(99, round(($current / $duration) * 100, 1));
}
}
return $progress;
}
private function refreshJob(string $id): void
{
if (!isset($this->jobs[$id])) return;
$job = &$this->jobs[$id];
if ($job['status'] === 'running' && $job['pid']) {
if (!posix_kill($job['pid'], 0)) {
// Check if output file exists and has size
if (isset($job['output_file']) && file_exists($job['output_file']) && filesize($job['output_file']) > 0) {
$job['status'] = 'completed';
$job['completed_at'] = date('c');
$job['output_size'] = filesize($job['output_file']);
} else {
$job['status'] = 'error';
$job['error'] = 'Process ended without output';
}
$this->save();
}
}
}
private function buildCommand(string $input, string $output, string $format, string $preset, ?string $resolution, array $params): string
{
$config = require __DIR__ . '/../../config/app.php';
$ffmpeg = $config['ffmpeg']['binary'];
$formatConfig = $config['formats']['video'][$format] ?? $config['formats']['audio'][$format] ?? [];
$presetConfig = $config['presets'][$preset] ?? $config['presets']['balanced'];
$threads = $config['ffmpeg']['threads'];
$cmd = "{$ffmpeg} -y -i " . escapeshellarg($input);
$cmd .= " -threads {$threads}";
// Check if audio-only
$isAudio = isset($config['formats']['audio'][$format]);
if ($isAudio) {
$cmd .= " -vn";
$cmd .= " -c:a " . escapeshellarg($formatConfig['codec']);
if (isset($params['audio_bitrate'])) {
$cmd .= " -b:a " . escapeshellarg($params['audio_bitrate']);
}
} else {
$cmd .= " -c:v " . escapeshellarg($formatConfig['codec']);
$cmd .= " -preset " . escapeshellarg($presetConfig['preset']);
$cmd .= " -crf " . (int)$presetConfig['crf'];
if ($resolution && isset($config['resolutions'][$resolution])) {
$res = $config['resolutions'][$resolution];
$cmd .= " -vf scale={$res['width']}:{$res['height']}";
}
$cmd .= " -c:a aac -b:a 128k";
}
// HLS specific
if ($format === 'hls') {
$cmd .= " -hls_time 4 -hls_list_size 0 -hls_segment_filename "
. escapeshellarg(dirname($output) . "/segment_%03d.ts");
}
// DASH specific
if ($format === 'dash') {
$cmd .= " -use_timeline 1 -use_template 1 -adaptation_sets 'id=0,streams=v id=1,streams=a'";
}
// Extra params
if (isset($params['video_bitrate'])) {
$cmd .= " -b:v " . escapeshellarg($params['video_bitrate']);
}
if (isset($params['fps'])) {
$cmd .= " -r " . (int)$params['fps'];
}
$cmd .= " " . escapeshellarg($output);
return $cmd;
}
private function timeToSeconds(string $time): float
{
$parts = explode(':', $time);
if (count($parts) !== 3) return 0;
return (int)$parts[0] * 3600 + (int)$parts[1] * 60 + (float)$parts[2];
}
}
@@ -0,0 +1,127 @@
<?php
namespace VideoConverter\Pipeline;
class Pipeline
{
private string $id;
private string $name;
private array $stages = [];
private string $status = 'idle'; // idle, running, paused, error, completed
private ?string $inputSource = null;
private array $metadata = [];
private float $progress = 0;
private ?int $pid = null;
private string $createdAt;
public function __construct(string $name, ?string $id = null)
{
$this->id = $id ?? bin2hex(random_bytes(8));
$this->name = $name;
$this->createdAt = date('c');
}
public function getId(): string { return $this->id; }
public function getName(): string { return $this->name; }
public function getStatus(): string { return $this->status; }
public function getProgress(): float { return $this->progress; }
public function getPid(): ?int { return $this->pid; }
public function getStages(): array { return $this->stages; }
public function getInputSource(): ?string { return $this->inputSource; }
public function setStatus(string $status): void { $this->status = $status; }
public function setProgress(float $progress): void { $this->progress = min(100, max(0, $progress)); }
public function setPid(?int $pid): void { $this->pid = $pid; }
public function setInputSource(string $source): void { $this->inputSource = $source; }
public function addStage(PipelineStage $stage): self
{
$this->stages[] = $stage;
return $this;
}
public function removeStage(int $index): self
{
if (isset($this->stages[$index])) {
array_splice($this->stages, $index, 1);
}
return $this;
}
public function insertStage(int $index, PipelineStage $stage): self
{
array_splice($this->stages, $index, 0, [$stage]);
return $this;
}
public function setMetadata(string $key, mixed $value): void
{
$this->metadata[$key] = $value;
}
public function getMetadata(?string $key = null): mixed
{
if ($key === null) return $this->metadata;
return $this->metadata[$key] ?? null;
}
public function buildFFmpegCommand(string $inputPath, string $outputPath): string
{
$config = require __DIR__ . '/../../config/app.php';
$cmd = $config['ffmpeg']['binary'];
$parts = ["-y -i " . escapeshellarg($inputPath)];
foreach ($this->stages as $stage) {
$parts[] = $stage->toFFmpegArgs();
}
$parts[] = escapeshellarg($outputPath);
return $cmd . ' ' . implode(' ', $parts);
}
public function buildStreamCommand(string $inputUrl, string $outputUrl): string
{
$config = require __DIR__ . '/../../config/app.php';
$cmd = $config['ffmpeg']['binary'];
$parts = ["-re -i " . escapeshellarg($inputUrl)];
foreach ($this->stages as $stage) {
$parts[] = $stage->toFFmpegArgs();
}
$parts[] = "-f flv " . escapeshellarg($outputUrl);
return $cmd . ' ' . implode(' ', $parts);
}
public function toArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'status' => $this->status,
'progress' => $this->progress,
'pid' => $this->pid,
'input_source' => $this->inputSource,
'stages' => array_map(fn(PipelineStage $s) => $s->toArray(), $this->stages),
'metadata' => $this->metadata,
'created_at' => $this->createdAt,
];
}
public static function fromArray(array $data): self
{
$pipeline = new self($data['name'], $data['id']);
$pipeline->status = $data['status'] ?? 'idle';
$pipeline->progress = $data['progress'] ?? 0;
$pipeline->pid = $data['pid'] ?? null;
$pipeline->inputSource = $data['input_source'] ?? null;
$pipeline->metadata = $data['metadata'] ?? [];
$pipeline->createdAt = $data['created_at'] ?? date('c');
foreach (($data['stages'] ?? []) as $stageData) {
$pipeline->addStage(PipelineStage::fromArray($stageData));
}
return $pipeline;
}
}
@@ -0,0 +1,89 @@
<?php
namespace VideoConverter\Pipeline;
class PipelineManager
{
private string $stateFile;
private array $pipelines = [];
public function __construct()
{
$this->stateFile = __DIR__ . '/../../storage/temp/pipelines.json';
$this->load();
}
private function load(): void
{
if (file_exists($this->stateFile)) {
$data = json_decode(file_get_contents($this->stateFile), true);
foreach (($data['pipelines'] ?? []) as $pData) {
$this->pipelines[$pData['id']] = Pipeline::fromArray($pData);
}
}
}
public function save(): void
{
$dir = dirname($this->stateFile);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$data = ['pipelines' => []];
foreach ($this->pipelines as $pipeline) {
$data['pipelines'][] = $pipeline->toArray();
}
file_put_contents($this->stateFile, json_encode($data, JSON_PRETTY_PRINT));
}
public function create(string $name): Pipeline
{
$pipeline = new Pipeline($name);
$this->pipelines[$pipeline->getId()] = $pipeline;
$this->save();
return $pipeline;
}
public function get(string $id): ?Pipeline
{
return $this->pipelines[$id] ?? null;
}
public function getAll(): array
{
return $this->pipelines;
}
public function delete(string $id): bool
{
if (isset($this->pipelines[$id])) {
$pipeline = $this->pipelines[$id];
if ($pipeline->getStatus() === 'running' && $pipeline->getPid()) {
posix_kill($pipeline->getPid(), SIGTERM);
}
unset($this->pipelines[$id]);
$this->save();
return true;
}
return false;
}
public function getRunningCount(): int
{
$count = 0;
foreach ($this->pipelines as $p) {
if ($p->getStatus() === 'running') $count++;
}
return $count;
}
public function getByStatus(string $status): array
{
return array_filter($this->pipelines, fn(Pipeline $p) => $p->getStatus() === $status);
}
public function toArray(): array
{
return array_map(fn(Pipeline $p) => $p->toArray(), array_values($this->pipelines));
}
}
@@ -0,0 +1,197 @@
<?php
namespace VideoConverter\Pipeline;
class PipelineStage
{
private string $id;
private string $type; // transcode, scale, filter, audio, watermark, trim, split
private array $params;
private bool $enabled;
private string $label;
public function __construct(string $type, array $params = [], string $label = '', bool $enabled = true)
{
$this->id = bin2hex(random_bytes(4));
$this->type = $type;
$this->params = $params;
$this->label = $label ?: ucfirst($type);
$this->enabled = $enabled;
}
public function getId(): string { return $this->id; }
public function getType(): string { return $this->type; }
public function getParams(): array { return $this->params; }
public function isEnabled(): bool { return $this->enabled; }
public function getLabel(): string { return $this->label; }
public function setEnabled(bool $enabled): void { $this->enabled = $enabled; }
public function setParams(array $params): void { $this->params = $params; }
public function toFFmpegArgs(): string
{
if (!$this->enabled) return '';
return match ($this->type) {
'transcode' => $this->buildTranscodeArgs(),
'scale' => $this->buildScaleArgs(),
'filter' => $this->buildFilterArgs(),
'audio' => $this->buildAudioArgs(),
'watermark' => $this->buildWatermarkArgs(),
'trim' => $this->buildTrimArgs(),
'bitrate' => $this->buildBitrateArgs(),
'framerate' => $this->buildFramerateArgs(),
'deinterlace' => '-vf yadif',
'denoise' => '-vf hqdn3d',
'stabilize' => '-vf deshake',
default => '',
};
}
private function buildTranscodeArgs(): string
{
$args = [];
if (isset($this->params['video_codec'])) {
$args[] = "-c:v " . escapeshellarg($this->params['video_codec']);
}
if (isset($this->params['audio_codec'])) {
$args[] = "-c:a " . escapeshellarg($this->params['audio_codec']);
}
if (isset($this->params['preset'])) {
$args[] = "-preset " . escapeshellarg($this->params['preset']);
}
if (isset($this->params['crf'])) {
$args[] = "-crf " . (int)$this->params['crf'];
}
return implode(' ', $args);
}
private function buildScaleArgs(): string
{
$w = (int)($this->params['width'] ?? -1);
$h = (int)($this->params['height'] ?? -1);
$algo = $this->params['algorithm'] ?? 'lanczos';
return "-vf scale={$w}:{$h}:flags={$algo}";
}
private function buildFilterArgs(): string
{
$filters = [];
if (isset($this->params['brightness'])) {
$filters[] = "eq=brightness=" . (float)$this->params['brightness'];
}
if (isset($this->params['contrast'])) {
$filters[] = "eq=contrast=" . (float)$this->params['contrast'];
}
if (isset($this->params['saturation'])) {
$filters[] = "eq=saturation=" . (float)$this->params['saturation'];
}
if (isset($this->params['gamma'])) {
$filters[] = "eq=gamma=" . (float)$this->params['gamma'];
}
if (isset($this->params['custom'])) {
$filters[] = $this->params['custom'];
}
return $filters ? '-vf ' . escapeshellarg(implode(',', $filters)) : '';
}
private function buildAudioArgs(): string
{
$args = [];
if (isset($this->params['codec'])) {
$args[] = "-c:a " . escapeshellarg($this->params['codec']);
}
if (isset($this->params['bitrate'])) {
$args[] = "-b:a " . escapeshellarg($this->params['bitrate']);
}
if (isset($this->params['sample_rate'])) {
$args[] = "-ar " . (int)$this->params['sample_rate'];
}
if (isset($this->params['channels'])) {
$args[] = "-ac " . (int)$this->params['channels'];
}
if (isset($this->params['volume'])) {
$args[] = "-af volume=" . (float)$this->params['volume'];
}
return implode(' ', $args);
}
private function buildWatermarkArgs(): string
{
$image = $this->params['image'] ?? '';
$position = $this->params['position'] ?? 'topright';
$overlay = match ($position) {
'topleft' => 'overlay=10:10',
'topright' => 'overlay=W-w-10:10',
'bottomleft' => 'overlay=10:H-h-10',
'bottomright' => 'overlay=W-w-10:H-h-10',
'center' => 'overlay=(W-w)/2:(H-h)/2',
default => 'overlay=W-w-10:10',
};
return "-i " . escapeshellarg($image) . " -filter_complex \"{$overlay}\"";
}
private function buildTrimArgs(): string
{
$args = [];
if (isset($this->params['start'])) {
$args[] = "-ss " . escapeshellarg($this->params['start']);
}
if (isset($this->params['duration'])) {
$args[] = "-t " . escapeshellarg($this->params['duration']);
}
if (isset($this->params['end'])) {
$args[] = "-to " . escapeshellarg($this->params['end']);
}
return implode(' ', $args);
}
private function buildBitrateArgs(): string
{
$args = [];
if (isset($this->params['video'])) {
$args[] = "-b:v " . escapeshellarg($this->params['video']);
}
if (isset($this->params['audio'])) {
$args[] = "-b:a " . escapeshellarg($this->params['audio']);
}
if (isset($this->params['maxrate'])) {
$args[] = "-maxrate " . escapeshellarg($this->params['maxrate']);
$args[] = "-bufsize " . escapeshellarg($this->params['bufsize'] ?? $this->params['maxrate']);
}
return implode(' ', $args);
}
private function buildFramerateArgs(): string
{
$fps = (float)($this->params['fps'] ?? 30);
return "-r {$fps}";
}
public function toArray(): array
{
return [
'id' => $this->id,
'type' => $this->type,
'label' => $this->label,
'params' => $this->params,
'enabled' => $this->enabled,
];
}
public static function fromArray(array $data): self
{
$stage = new self(
$data['type'],
$data['params'] ?? [],
$data['label'] ?? '',
$data['enabled'] ?? true
);
if (isset($data['id'])) {
// Use reflection to set the id for deserialization
$ref = new \ReflectionProperty($stage, 'id');
$ref->setValue($stage, $data['id']);
}
return $stage;
}
}
@@ -0,0 +1,159 @@
<?php
namespace VideoConverter\Process;
class FFmpegProcess
{
private string $command;
private ?int $pid = null;
private string $logFile;
private string $progressFile;
private float $duration = 0;
private string $status = 'pending';
private array $outputLines = [];
public function __construct(string $command, string $jobId)
{
$config = require __DIR__ . '/../../config/app.php';
$this->command = $command;
$logDir = $config['storage']['logs'];
if (!is_dir($logDir)) mkdir($logDir, 0755, true);
$this->logFile = $logDir . "/ffmpeg_{$jobId}.log";
$this->progressFile = $logDir . "/progress_{$jobId}.txt";
}
public function start(): bool
{
$cmd = $this->command
. " -progress " . escapeshellarg($this->progressFile)
. " -stats_period 0.5"
. " 2>" . escapeshellarg($this->logFile)
. " & echo $!";
$output = [];
exec($cmd, $output);
$this->pid = (int)($output[0] ?? 0);
if ($this->pid > 0) {
$this->status = 'running';
return true;
}
$this->status = 'error';
return false;
}
public function stop(): void
{
if ($this->pid && $this->isRunning()) {
posix_kill($this->pid, SIGTERM);
usleep(500000);
if ($this->isRunning()) {
posix_kill($this->pid, SIGKILL);
}
}
$this->status = 'stopped';
}
public function pause(): void
{
if ($this->pid && $this->isRunning()) {
posix_kill($this->pid, SIGSTOP);
$this->status = 'paused';
}
}
public function resume(): void
{
if ($this->pid) {
posix_kill($this->pid, SIGCONT);
$this->status = 'running';
}
}
public function isRunning(): bool
{
if (!$this->pid) return false;
return posix_kill($this->pid, 0);
}
public function getProgress(): array
{
$progress = [
'percent' => 0,
'frame' => 0,
'fps' => 0,
'speed' => '0x',
'time' => '00:00:00.00',
'bitrate' => '0kbits/s',
'size' => '0kB',
];
if (!file_exists($this->progressFile)) return $progress;
$content = file_get_contents($this->progressFile);
$lines = explode("\n", $content);
foreach ($lines as $line) {
$line = trim($line);
if (str_contains($line, '=')) {
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
switch ($key) {
case 'frame': $progress['frame'] = (int)$value; break;
case 'fps': $progress['fps'] = (float)$value; break;
case 'speed': $progress['speed'] = $value; break;
case 'out_time': $progress['time'] = $value; break;
case 'total_size': $progress['size'] = $this->formatBytes((int)$value); break;
case 'bitrate': $progress['bitrate'] = $value; break;
case 'progress':
if ($value === 'end') $progress['percent'] = 100;
break;
}
}
}
if ($this->duration > 0 && $progress['percent'] < 100) {
$currentTime = $this->timeToSeconds($progress['time']);
$progress['percent'] = min(99, round(($currentTime / $this->duration) * 100, 1));
}
return $progress;
}
public function getLog(int $lines = 50): string
{
if (!file_exists($this->logFile)) return '';
$all = file($this->logFile);
return implode('', array_slice($all, -$lines));
}
public function setDuration(float $duration): void
{
$this->duration = $duration;
}
public function getPid(): ?int { return $this->pid; }
public function getStatus(): string { return $this->status; }
public function getCommand(): string { return $this->command; }
private function timeToSeconds(string $time): float
{
$parts = explode(':', $time);
if (count($parts) !== 3) return 0;
return (int)$parts[0] * 3600 + (int)$parts[1] * 60 + (float)$parts[2];
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
$size = (float)$bytes;
while ($size >= 1024 && $i < count($units) - 1) {
$size /= 1024;
$i++;
}
return round($size, 1) . $units[$i];
}
}
@@ -0,0 +1,106 @@
<?php
namespace VideoConverter\Process;
class MediaProbe
{
private string $ffprobe;
public function __construct()
{
$config = require __DIR__ . '/../../config/app.php';
$this->ffprobe = $config['ffmpeg']['ffprobe'];
}
public function analyze(string $filePath): array
{
$cmd = sprintf(
'%s -v quiet -print_format json -show_format -show_streams %s',
$this->ffprobe,
escapeshellarg($filePath)
);
$output = shell_exec($cmd);
$data = json_decode($output ?: '{}', true);
if (!$data) {
return ['error' => 'Could not analyze file'];
}
return $this->parseProbeData($data);
}
public function getDuration(string $filePath): float
{
$info = $this->analyze($filePath);
return (float)($info['duration'] ?? 0);
}
public function getThumbnail(string $filePath, string $outputPath, string $time = '00:00:01'): bool
{
$config = require __DIR__ . '/../../config/app.php';
$cmd = sprintf(
'%s -y -i %s -ss %s -vframes 1 -vf scale=320:-1 %s 2>/dev/null',
$config['ffmpeg']['binary'],
escapeshellarg($filePath),
escapeshellarg($time),
escapeshellarg($outputPath)
);
exec($cmd, $output, $exitCode);
return $exitCode === 0;
}
private function parseProbeData(array $data): array
{
$result = [
'format' => $data['format']['format_long_name'] ?? 'Unknown',
'format_name' => $data['format']['format_name'] ?? '',
'duration' => (float)($data['format']['duration'] ?? 0),
'size' => (int)($data['format']['size'] ?? 0),
'bitrate' => (int)($data['format']['bit_rate'] ?? 0),
'streams' => [],
'video' => null,
'audio' => null,
];
foreach (($data['streams'] ?? []) as $stream) {
$type = $stream['codec_type'] ?? '';
$info = [
'index' => $stream['index'],
'type' => $type,
'codec' => $stream['codec_name'] ?? 'unknown',
'codec_long' => $stream['codec_long_name'] ?? '',
];
if ($type === 'video') {
$info['width'] = (int)($stream['width'] ?? 0);
$info['height'] = (int)($stream['height'] ?? 0);
$info['fps'] = $this->parseFps($stream['r_frame_rate'] ?? '0/1');
$info['pix_fmt'] = $stream['pix_fmt'] ?? '';
$info['bitrate'] = (int)($stream['bit_rate'] ?? 0);
$info['profile'] = $stream['profile'] ?? '';
$info['level'] = $stream['level'] ?? '';
if (!$result['video']) $result['video'] = $info;
} elseif ($type === 'audio') {
$info['sample_rate'] = (int)($stream['sample_rate'] ?? 0);
$info['channels'] = (int)($stream['channels'] ?? 0);
$info['channel_layout'] = $stream['channel_layout'] ?? '';
$info['bitrate'] = (int)($stream['bit_rate'] ?? 0);
if (!$result['audio']) $result['audio'] = $info;
}
$result['streams'][] = $info;
}
return $result;
}
private function parseFps(string $frac): float
{
$parts = explode('/', $frac);
if (count($parts) === 2 && (int)$parts[1] > 0) {
return round((int)$parts[0] / (int)$parts[1], 2);
}
return (float)$frac;
}
}
@@ -0,0 +1,115 @@
<?php
namespace VideoConverter\Queue;
class JobQueue
{
private string $queueFile;
private array $queue = [];
public function __construct()
{
$this->queueFile = __DIR__ . '/../../storage/temp/queue.json';
$this->load();
}
private function load(): void
{
if (file_exists($this->queueFile)) {
$this->queue = json_decode(file_get_contents($this->queueFile), true) ?: [];
}
}
private function save(): void
{
$dir = dirname($this->queueFile);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($this->queueFile, json_encode($this->queue, JSON_PRETTY_PRINT));
}
public function enqueue(array $job): string
{
$id = bin2hex(random_bytes(8));
$job['queue_id'] = $id;
$job['queued_at'] = date('c');
$job['queue_status'] = 'waiting';
$job['priority'] = $job['priority'] ?? 5;
$this->queue[] = $job;
// Sort by priority (lower = higher priority)
usort($this->queue, fn($a, $b) => ($a['priority'] ?? 5) <=> ($b['priority'] ?? 5));
$this->save();
return $id;
}
public function dequeue(): ?array
{
foreach ($this->queue as &$job) {
if ($job['queue_status'] === 'waiting') {
$job['queue_status'] = 'processing';
$job['started_at'] = date('c');
$this->save();
return $job;
}
}
return null;
}
public function complete(string $queueId, array $result = []): void
{
foreach ($this->queue as &$job) {
if ($job['queue_id'] === $queueId) {
$job['queue_status'] = 'completed';
$job['completed_at'] = date('c');
$job['result'] = $result;
break;
}
}
$this->save();
}
public function fail(string $queueId, string $error): void
{
foreach ($this->queue as &$job) {
if ($job['queue_id'] === $queueId) {
$job['queue_status'] = 'failed';
$job['failed_at'] = date('c');
$job['error'] = $error;
break;
}
}
$this->save();
}
public function getQueue(): array { return $this->queue; }
public function getWaiting(): array
{
return array_values(array_filter($this->queue, fn($j) => $j['queue_status'] === 'waiting'));
}
public function getProcessing(): array
{
return array_values(array_filter($this->queue, fn($j) => $j['queue_status'] === 'processing'));
}
public function clear(string $status = 'completed'): int
{
$before = count($this->queue);
$this->queue = array_values(array_filter($this->queue, fn($j) => $j['queue_status'] !== $status));
$this->save();
return $before - count($this->queue);
}
public function getStats(): array
{
$stats = ['waiting' => 0, 'processing' => 0, 'completed' => 0, 'failed' => 0];
foreach ($this->queue as $job) {
$status = $job['queue_status'] ?? 'waiting';
$stats[$status] = ($stats[$status] ?? 0) + 1;
}
return $stats;
}
}
@@ -0,0 +1,197 @@
<?php
namespace VideoConverter\Stream;
use VideoConverter\Process\FFmpegProcess;
class StreamManager
{
private array $activeStreams = [];
private string $stateFile;
public function __construct()
{
$this->stateFile = __DIR__ . '/../../storage/temp/streams.json';
$this->load();
}
private function load(): void
{
if (file_exists($this->stateFile)) {
$this->activeStreams = json_decode(file_get_contents($this->stateFile), true) ?: [];
}
}
private function save(): void
{
$dir = dirname($this->stateFile);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($this->stateFile, json_encode($this->activeStreams, JSON_PRETTY_PRINT));
}
public function startStream(array $params): array
{
$id = bin2hex(random_bytes(8));
$config = require __DIR__ . '/../../config/app.php';
$inputUrl = $params['input_url'] ?? '';
$outputFormat = $params['output_format'] ?? 'mp4';
$resolution = $params['resolution'] ?? null;
$preset = $params['preset'] ?? 'fast';
$formatConfig = $config['formats']['video'][$outputFormat] ?? $config['formats']['video']['mp4'];
$presetConfig = $config['presets'][$preset] ?? $config['presets']['fast'];
$outputDir = $config['storage']['outputs'];
$outputFile = "{$outputDir}/stream_{$id}.{$formatConfig['ext']}";
$cmd = $config['ffmpeg']['binary'] . " -y";
// Input
if (str_starts_with($inputUrl, 'rtmp://') || str_starts_with($inputUrl, 'rtsp://')) {
$cmd .= " -re";
}
$cmd .= " -i " . escapeshellarg($inputUrl);
// Video codec
$cmd .= " -c:v " . escapeshellarg($formatConfig['codec']);
$cmd .= " -preset " . escapeshellarg($presetConfig['preset']);
$cmd .= " -crf " . (int)$presetConfig['crf'];
// Resolution
if ($resolution && isset($config['resolutions'][$resolution])) {
$res = $config['resolutions'][$resolution];
$cmd .= " -vf scale={$res['width']}:{$res['height']}";
}
// Audio
$audioCodec = $params['audio_codec'] ?? 'aac';
$audioBitrate = $params['audio_bitrate'] ?? '128k';
$cmd .= " -c:a " . escapeshellarg($audioCodec);
$cmd .= " -b:a " . escapeshellarg($audioBitrate);
$cmd .= " " . escapeshellarg($outputFile);
$process = new FFmpegProcess($cmd, $id);
$stream = [
'id' => $id,
'input_url' => $inputUrl,
'output_file' => $outputFile,
'output_format' => $outputFormat,
'resolution' => $resolution,
'preset' => $preset,
'status' => 'starting',
'pid' => null,
'command' => $cmd,
'started_at' => date('c'),
];
if ($process->start()) {
$stream['status'] = 'running';
$stream['pid'] = $process->getPid();
} else {
$stream['status'] = 'error';
}
$this->activeStreams[$id] = $stream;
$this->save();
return $stream;
}
public function stopStream(string $id): bool
{
if (!isset($this->activeStreams[$id])) return false;
$stream = $this->activeStreams[$id];
if ($stream['pid']) {
posix_kill($stream['pid'], SIGTERM);
usleep(500000);
if (posix_kill($stream['pid'], 0)) {
posix_kill($stream['pid'], SIGKILL);
}
}
$this->activeStreams[$id]['status'] = 'stopped';
$this->activeStreams[$id]['stopped_at'] = date('c');
$this->save();
return true;
}
public function switchFormat(string $id, string $newFormat, ?string $resolution = null): array
{
if (!isset($this->activeStreams[$id])) {
return ['error' => 'Stream not found'];
}
$oldStream = $this->activeStreams[$id];
$this->stopStream($id);
// Start new stream with same input but different output format
return $this->startStream([
'input_url' => $oldStream['input_url'],
'output_format' => $newFormat,
'resolution' => $resolution ?? $oldStream['resolution'],
'preset' => $oldStream['preset'],
'audio_codec' => 'aac',
]);
}
public function getStream(string $id): ?array
{
$this->refreshStatus($id);
return $this->activeStreams[$id] ?? null;
}
public function getAllStreams(): array
{
foreach (array_keys($this->activeStreams) as $id) {
$this->refreshStatus($id);
}
return array_values($this->activeStreams);
}
public function getActiveStreams(): array
{
return array_values(array_filter($this->getAllStreams(), fn($s) => $s['status'] === 'running'));
}
private function refreshStatus(string $id): void
{
if (!isset($this->activeStreams[$id])) return;
$stream = &$this->activeStreams[$id];
if ($stream['status'] === 'running' && $stream['pid']) {
if (!posix_kill($stream['pid'], 0)) {
$stream['status'] = 'completed';
$stream['completed_at'] = date('c');
$this->save();
}
}
}
public function deleteStream(string $id): bool
{
if (isset($this->activeStreams[$id])) {
if ($this->activeStreams[$id]['status'] === 'running') {
$this->stopStream($id);
}
unset($this->activeStreams[$id]);
$this->save();
return true;
}
return false;
}
public function getStats(): array
{
$all = $this->getAllStreams();
return [
'total' => count($all),
'running' => count(array_filter($all, fn($s) => $s['status'] === 'running')),
'completed' => count(array_filter($all, fn($s) => $s['status'] === 'completed')),
'errors' => count(array_filter($all, fn($s) => $s['status'] === 'error')),
];
}
}
@@ -0,0 +1,182 @@
<?php
namespace VideoConverter\WebSocket;
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
use VideoConverter\Format\FormatConverter;
use VideoConverter\Stream\StreamManager;
use VideoConverter\Pipeline\PipelineManager;
use VideoConverter\Queue\JobQueue;
class StatusServer implements MessageComponentInterface
{
protected \SplObjectStorage $clients;
private FormatConverter $converter;
private StreamManager $streamManager;
private PipelineManager $pipelineManager;
private JobQueue $queue;
public function __construct()
{
$this->clients = new \SplObjectStorage();
$this->converter = new FormatConverter();
$this->streamManager = new StreamManager();
$this->pipelineManager = new PipelineManager();
$this->queue = new JobQueue();
}
public function onOpen(ConnectionInterface $conn): void
{
$this->clients->attach($conn);
$conn->send(json_encode([
'type' => 'connected',
'message' => 'Connected to Video Converter Suite',
'client_id' => spl_object_id($conn),
]));
}
public function onMessage(ConnectionInterface $from, $msg): void
{
$data = json_decode($msg, true);
if (!$data || !isset($data['action'])) return;
$response = match ($data['action']) {
'get_status' => $this->getFullStatus(),
'get_jobs' => ['type' => 'jobs', 'data' => $this->converter->getAllJobs()],
'get_streams' => ['type' => 'streams', 'data' => $this->streamManager->getAllStreams()],
'get_pipelines' => ['type' => 'pipelines', 'data' => $this->pipelineManager->toArray()],
'get_queue' => ['type' => 'queue', 'data' => $this->queue->getQueue()],
'get_progress' => $this->getJobProgress($data['job_id'] ?? ''),
'start_stream' => $this->handleStartStream($data),
'stop_stream' => $this->handleStopStream($data['stream_id'] ?? ''),
'switch_format' => $this->handleSwitchFormat($data),
default => ['type' => 'error', 'message' => 'Unknown action'],
};
$from->send(json_encode($response));
}
public function onClose(ConnectionInterface $conn): void
{
$this->clients->detach($conn);
}
public function onError(ConnectionInterface $conn, \Exception $e): void
{
$conn->send(json_encode([
'type' => 'error',
'message' => $e->getMessage(),
]));
$conn->close();
}
public function broadcastStatus(): void
{
$status = $this->getFullStatus();
$json = json_encode($status);
foreach ($this->clients as $client) {
$client->send($json);
}
}
private function getFullStatus(): array
{
// Reload state
$this->converter = new FormatConverter();
$this->streamManager = new StreamManager();
$this->pipelineManager = new PipelineManager();
$this->queue = new JobQueue();
$jobs = $this->converter->getAllJobs();
$runningJobs = array_filter($jobs, fn($j) => $j['status'] === 'running');
$progressData = [];
foreach ($runningJobs as $job) {
$progressData[$job['id']] = $this->converter->getProgress($job['id']);
}
return [
'type' => 'status',
'timestamp' => date('c'),
'system' => $this->getSystemStats(),
'jobs' => $jobs,
'progress' => $progressData,
'streams' => $this->streamManager->getAllStreams(),
'pipelines' => $this->pipelineManager->toArray(),
'queue' => $this->queue->getStats(),
];
}
private function getJobProgress(string $jobId): array
{
$this->converter = new FormatConverter();
return [
'type' => 'progress',
'job_id' => $jobId,
'data' => $this->converter->getProgress($jobId),
];
}
private function handleStartStream(array $data): array
{
$this->streamManager = new StreamManager();
$result = $this->streamManager->startStream($data);
return ['type' => 'stream_started', 'data' => $result];
}
private function handleStopStream(string $streamId): array
{
$this->streamManager = new StreamManager();
$success = $this->streamManager->stopStream($streamId);
return ['type' => 'stream_stopped', 'success' => $success, 'stream_id' => $streamId];
}
private function handleSwitchFormat(array $data): array
{
$this->streamManager = new StreamManager();
$result = $this->streamManager->switchFormat(
$data['stream_id'] ?? '',
$data['format'] ?? 'mp4',
$data['resolution'] ?? null
);
return ['type' => 'format_switched', 'data' => $result];
}
private function getSystemStats(): array
{
$load = sys_getloadavg();
$memInfo = $this->getMemoryInfo();
return [
'cpu_load' => $load[0] ?? 0,
'memory_used' => $memInfo['used'] ?? 0,
'memory_total' => $memInfo['total'] ?? 0,
'memory_percent' => $memInfo['percent'] ?? 0,
'disk_free' => disk_free_space('/'),
'disk_total' => disk_total_space('/'),
'uptime' => (int)(file_exists('/proc/uptime')
? (float)explode(' ', file_get_contents('/proc/uptime'))[0]
: 0),
];
}
private function getMemoryInfo(): array
{
if (!file_exists('/proc/meminfo')) {
return ['total' => 0, 'used' => 0, 'percent' => 0];
}
$content = file_get_contents('/proc/meminfo');
preg_match('/MemTotal:\s+(\d+)/', $content, $total);
preg_match('/MemAvailable:\s+(\d+)/', $content, $available);
$t = (int)($total[1] ?? 0) * 1024;
$a = (int)($available[1] ?? 0) * 1024;
$u = $t - $a;
return [
'total' => $t,
'used' => $u,
'percent' => $t > 0 ? round(($u / $t) * 100, 1) : 0,
];
}
}
@@ -0,0 +1,447 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $config['app_name'] ?> - Control Panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700;900&family=JetBrains+Mono:wght@300;400;500;600;700&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/controlpanel.css">
</head>
<body>
<!-- ====== TOP BAR ====== -->
<div class="topbar">
<div class="topbar-logo">
<div class="reactor-icon">&#9762;</div>
<div>
<div class="topbar-title">Video Converter Suite</div>
<div class="topbar-subtitle">Pipeline Control System v<?= $config['version'] ?></div>
</div>
</div>
<div class="topbar-status">
<div class="status-indicator">
<span class="status-dot" id="systemStatusDot"></span>
<span id="systemStatusText">SYSTEM ONLINE</span>
</div>
<div class="status-indicator">
<span>JOBS:</span>
<span id="activeJobCount" style="color: var(--accent-cyan)">0</span>
</div>
<div class="clock" id="systemClock">00:00:00</div>
</div>
</div>
<!-- ====== NAVIGATION ====== -->
<div class="nav-bar">
<button class="nav-tab active" data-page="dashboard">Dashboard</button>
<button class="nav-tab" data-page="converter">Konverter</button>
<button class="nav-tab" data-page="streams">Live Streams</button>
<button class="nav-tab" data-page="pipelines">Pipelines</button>
<button class="nav-tab" data-page="queue">Warteschlange</button>
</div>
<!-- ====== PAGES ====== -->
<div class="main-container">
<!-- ==================== DASHBOARD PAGE ==================== -->
<div id="page-dashboard" class="page-content">
<!-- System Gauges -->
<div class="panel-row cols-1">
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9881;</span> SYSTEM MONITOR</div>
<button class="btn btn-icon" onclick="refreshStatus()" data-tooltip="Refresh">&#8635;</button>
</div>
<div class="module-body">
<div class="gauge-grid">
<div class="gauge">
<div class="gauge-label">CPU Load</div>
<div class="gauge-value" id="gaugeCpu">0.0</div>
<div class="gauge-unit">Load Avg</div>
<div class="gauge-bar"><div class="gauge-bar-fill" id="gaugeCpuBar" style="width:0%"></div></div>
</div>
<div class="gauge">
<div class="gauge-label">Memory</div>
<div class="gauge-value" id="gaugeMem">0</div>
<div class="gauge-unit">% Used</div>
<div class="gauge-bar"><div class="gauge-bar-fill" id="gaugeMemBar" style="width:0%"></div></div>
</div>
<div class="gauge">
<div class="gauge-label">Disk</div>
<div class="gauge-value" id="gaugeDisk">0</div>
<div class="gauge-unit">GB Free</div>
<div class="gauge-bar"><div class="gauge-bar-fill" id="gaugeDiskBar" style="width:0%"></div></div>
</div>
<div class="gauge">
<div class="gauge-label">Active Jobs</div>
<div class="gauge-value" id="gaugeJobs">0</div>
<div class="gauge-unit">Running</div>
<div class="gauge-bar"><div class="gauge-bar-fill" id="gaugeJobsBar" style="width:0%"></div></div>
</div>
</div>
</div>
</div>
</div>
<!-- Active Jobs & Log -->
<div class="panel-row cols-2-1">
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9654;</span> AKTIVE JOBS</div>
<button class="btn" onclick="refreshJobs()">Aktualisieren</button>
</div>
<div class="module-body">
<div class="job-list" id="jobList">
<div style="text-align:center; color: var(--text-dim); padding: 20px;">
Keine aktiven Jobs
</div>
</div>
</div>
</div>
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9783;</span> SYSTEM LOG</div>
<button class="btn btn-icon" onclick="clearLog()">&#10005;</button>
</div>
<div class="module-body">
<div class="log-console" id="logConsole">
<div class="log-line info"><span class="log-time">[INIT]</span> Video Converter Suite gestartet</div>
</div>
</div>
</div>
</div>
</div>
<!-- ==================== CONVERTER PAGE ==================== -->
<div id="page-converter" class="page-content" style="display:none">
<div class="panel-row cols-2">
<!-- Upload & Input -->
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#8682;</span> EINGANG / UPLOAD</div>
</div>
<div class="module-body">
<div class="upload-zone" id="uploadZone" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">&#128193;</div>
<div class="upload-text">Datei hierher ziehen oder klicken</div>
<div class="upload-hint">Alle Video- und Audio-Formate / max. 5 GB</div>
<input type="file" id="fileInput" accept="video/*,audio/*" style="display:none" onchange="handleFileSelect(event)">
</div>
<div id="uploadedFileInfo" style="display:none; margin-top:12px;"></div>
</div>
</div>
<!-- Format Switchboard -->
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9881;</span> AUSGANGSFORMAT</div>
</div>
<div class="module-body">
<div class="form-label" style="margin-bottom:8px">Video-Formate</div>
<div class="format-matrix" id="videoFormatMatrix">
<?php foreach ($config['formats']['video'] as $key => $fmt): ?>
<div class="format-switch <?= $key === 'mp4' ? 'selected' : '' ?>" data-format="<?= $key ?>" onclick="selectFormat('<?= $key ?>')">
<div class="format-name"><?= strtoupper($key) ?></div>
<div class="format-desc"><?= $fmt['codec'] ?></div>
</div>
<?php endforeach; ?>
</div>
<div class="form-label" style="margin:12px 0 8px">Audio-Formate</div>
<div class="format-matrix" id="audioFormatMatrix">
<?php foreach ($config['formats']['audio'] as $key => $fmt): ?>
<div class="format-switch" data-format="<?= $key ?>" onclick="selectFormat('<?= $key ?>')">
<div class="format-name"><?= strtoupper($key) ?></div>
<div class="format-desc"><?= $fmt['codec'] ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<!-- Settings Row -->
<div class="panel-row cols-3">
<!-- Preset -->
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9733;</span> PRESET</div>
</div>
<div class="module-body">
<div class="switch-panel" id="presetPanel">
<?php foreach ($config['presets'] as $key => $p): ?>
<div class="switch-unit <?= $key === 'balanced' ? 'active' : '' ?>" data-preset="<?= $key ?>" onclick="selectPreset('<?= $key ?>')">
<div class="switch-led"></div>
<div class="switch-label"><?= ucfirst($key) ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<!-- Resolution -->
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9634;</span> AUFLOESUNG</div>
</div>
<div class="module-body">
<div class="switch-panel" id="resolutionPanel">
<div class="switch-unit active" data-resolution="original" onclick="selectResolution('original')">
<div class="switch-led"></div>
<div class="switch-label">Original</div>
</div>
<?php foreach ($config['resolutions'] as $key => $res): ?>
<div class="switch-unit" data-resolution="<?= $key ?>" onclick="selectResolution('<?= $key ?>')">
<div class="switch-led"></div>
<div class="switch-label"><?= $key ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<!-- Controls -->
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9655;</span> STEUERUNG</div>
</div>
<div class="module-body" style="display:flex; flex-direction:column; gap:12px; align-items:center; justify-content:center; min-height:140px;">
<button class="btn btn-primary btn-large" id="btnStartConvert" onclick="startConversion()" disabled>
&#9654; KONVERTIERUNG STARTEN
</button>
<button class="btn btn-emergency" id="btnStopAll" onclick="stopAllJobs()" style="display:none">
&#9632; NOTAUS - ALLE STOPPEN
</button>
<div id="conversionStatus" style="font-size:11px; color:var(--text-dim); text-align:center;"></div>
</div>
</div>
</div>
</div>
<!-- ==================== STREAMS PAGE ==================== -->
<div id="page-streams" class="page-content" style="display:none">
<div class="panel-row cols-1">
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#128225;</span> LIVE STREAM STEUERUNG</div>
<button class="btn btn-primary" onclick="openStreamModal()">+ Neuer Stream</button>
</div>
<div class="module-body">
<div class="stream-matrix" id="streamMatrix">
<div style="text-align:center; color:var(--text-dim); padding:40px; grid-column: 1/-1;">
Keine aktiven Streams. Klicke "Neuer Stream" um zu beginnen.
</div>
</div>
</div>
</div>
</div>
<!-- Stream Format Switchboard -->
<div class="panel-row cols-1">
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9762;</span> FORMAT-UMSCHALTER (LIVE)</div>
<div style="font-size:10px; color:var(--accent-yellow)">&#9888; Format-Wechsel unterbricht den Stream kurz</div>
</div>
<div class="module-body">
<div style="display:grid; grid-template-columns: 200px 1fr; gap: 16px; align-items:start;">
<div>
<div class="form-label">Aktiver Stream</div>
<select class="form-select" id="activeStreamSelect" onchange="selectActiveStream(this.value)">
<option value="">-- Stream wählen --</option>
</select>
</div>
<div>
<div class="form-label">Zielformat wählen (klick = sofort umschalten)</div>
<div class="format-matrix" id="streamFormatSwitchboard">
<?php foreach ($config['formats']['video'] as $key => $fmt): ?>
<div class="format-switch" data-stream-format="<?= $key ?>" onclick="switchStreamFormat('<?= $key ?>')">
<div class="format-name"><?= strtoupper($key) ?></div>
<div class="format-desc"><?= $fmt['codec'] ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ==================== PIPELINES PAGE ==================== -->
<div id="page-pipelines" class="page-content" style="display:none">
<div class="panel-row cols-1">
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9776;</span> PIPELINE DESIGNER</div>
<div style="display:flex; gap:8px;">
<button class="btn btn-primary" onclick="createPipeline()">+ Neue Pipeline</button>
</div>
</div>
<div class="module-body">
<!-- Pipeline List -->
<div id="pipelineList" style="margin-bottom:16px;"></div>
<!-- Pipeline Editor -->
<div id="pipelineEditor" style="display:none;">
<div class="form-label" style="margin-bottom:8px;">PIPELINE FLOW - Drag & Drop zum Umordnen</div>
<div class="pipeline-canvas">
<div class="pipeline-flow" id="pipelineFlow">
<!-- Input node (always present) -->
<div class="pipeline-node active">
<div class="node-type">Input</div>
<div class="node-name">Source</div>
<div class="node-status"></div>
</div>
</div>
</div>
<!-- Stage Palette -->
<div class="form-label" style="margin:12px 0 8px;">VERFUEGBARE STUFEN - Klick zum Hinzufügen</div>
<div class="switch-panel" id="stagePalette">
<div class="switch-unit" onclick="addPipelineStage('transcode')">
<div class="switch-led"></div>
<div class="switch-label">Transcode</div>
</div>
<div class="switch-unit" onclick="addPipelineStage('scale')">
<div class="switch-led"></div>
<div class="switch-label">Scale</div>
</div>
<div class="switch-unit" onclick="addPipelineStage('filter')">
<div class="switch-led"></div>
<div class="switch-label">Filter</div>
</div>
<div class="switch-unit" onclick="addPipelineStage('audio')">
<div class="switch-led"></div>
<div class="switch-label">Audio</div>
</div>
<div class="switch-unit" onclick="addPipelineStage('bitrate')">
<div class="switch-led"></div>
<div class="switch-label">Bitrate</div>
</div>
<div class="switch-unit" onclick="addPipelineStage('framerate')">
<div class="switch-led"></div>
<div class="switch-label">FPS</div>
</div>
<div class="switch-unit" onclick="addPipelineStage('trim')">
<div class="switch-led"></div>
<div class="switch-label">Trim</div>
</div>
<div class="switch-unit" onclick="addPipelineStage('deinterlace')">
<div class="switch-led"></div>
<div class="switch-label">Deinterlace</div>
</div>
<div class="switch-unit" onclick="addPipelineStage('denoise')">
<div class="switch-led"></div>
<div class="switch-label">Denoise</div>
</div>
<div class="switch-unit" onclick="addPipelineStage('stabilize')">
<div class="switch-led"></div>
<div class="switch-label">Stabilize</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ==================== QUEUE PAGE ==================== -->
<div id="page-queue" class="page-content" style="display:none">
<div class="panel-row cols-3-1">
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9776;</span> WARTESCHLANGE</div>
<div style="display:flex; gap:8px;">
<button class="btn" onclick="refreshQueue()">Aktualisieren</button>
<button class="btn btn-danger" onclick="clearQueue()">Queue leeren</button>
</div>
</div>
<div class="module-body">
<div class="job-list" id="queueList">
<div style="text-align:center; color: var(--text-dim); padding: 20px;">
Warteschlange ist leer
</div>
</div>
</div>
</div>
<div class="module">
<div class="module-header">
<div class="module-title">STATISTIK</div>
</div>
<div class="module-body">
<div style="display:flex; flex-direction:column; gap:12px;">
<div class="gauge">
<div class="gauge-label">Wartend</div>
<div class="gauge-value" id="queueWaiting">0</div>
</div>
<div class="gauge">
<div class="gauge-label">Verarbeitet</div>
<div class="gauge-value" id="queueProcessing">0</div>
</div>
<div class="gauge">
<div class="gauge-label">Abgeschlossen</div>
<div class="gauge-value" id="queueCompleted" style="color:var(--accent-green)">0</div>
</div>
<div class="gauge">
<div class="gauge-label">Fehlgeschlagen</div>
<div class="gauge-value" id="queueFailed" style="color:var(--accent-red)">0</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ====== STREAM MODAL ====== -->
<div class="modal-overlay" id="streamModal">
<div class="modal">
<div class="modal-header">
<h3>Neuen Stream starten</h3>
<button class="modal-close" onclick="closeStreamModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Stream-URL (RTMP, RTSP, HTTP, Datei)</label>
<input class="form-input" type="text" id="streamInputUrl" placeholder="rtmp://server/live/stream oder /pfad/zur/datei.mp4">
</div>
<div class="form-group">
<label class="form-label">Ausgangsformat</label>
<select class="form-select" id="streamOutputFormat">
<?php foreach ($config['formats']['video'] as $key => $fmt): ?>
<option value="<?= $key ?>"><?= strtoupper($key) ?> (<?= $fmt['codec'] ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Aufloesung</label>
<select class="form-select" id="streamResolution">
<option value="">Original</option>
<?php foreach ($config['resolutions'] as $key => $res): ?>
<option value="<?= $key ?>"><?= $res['label'] ?> (<?= $res['width'] ?>x<?= $res['height'] ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Preset</label>
<select class="form-select" id="streamPreset">
<?php foreach ($config['presets'] as $key => $p): ?>
<option value="<?= $key ?>" <?= $key === 'fast' ? 'selected' : '' ?>><?= ucfirst($key) ?> (CRF <?= $p['crf'] ?>)</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeStreamModal()">Abbrechen</button>
<button class="btn btn-primary" onclick="startNewStream()">&#9654; Stream starten</button>
</div>
</div>
</div>
<!-- ====== NOTIFICATION CONTAINER ====== -->
<div id="notification" class="notification"></div>
<script src="/js/controlpanel.js"></script>
</body>
</html>