Compare commits
18 Commits
7136131fcd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99bcbc9e9a | ||
|
|
bae5afa73f | ||
|
|
c9160136cb | ||
|
|
d36b9de4a6 | ||
|
|
e37bc9e381 | ||
|
|
d465fbeb94 | ||
|
|
71b4d3f845 | ||
|
|
713a1156c4 | ||
|
|
233a952c04 | ||
|
|
8ab35c79af | ||
|
|
1390e35efe | ||
|
|
fd3fc6bcc2 | ||
|
|
754ee5b923 | ||
|
|
501dc92119 | ||
|
|
df746eae6a | ||
|
|
a6ae81f5b5 | ||
|
|
58764765dd | ||
|
|
d4de83ceb1 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,6 +5,7 @@ Thumbs.db
|
|||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
vendor/
|
vendor/
|
||||||
|
ai/
|
||||||
|
|
||||||
# Environment files
|
# Environment files
|
||||||
.env
|
.env
|
||||||
@@ -16,6 +17,7 @@ vendor/
|
|||||||
/backend/public/static/
|
/backend/public/static/
|
||||||
/frontend/build/
|
/frontend/build/
|
||||||
/build_temp/
|
/build_temp/
|
||||||
|
/deployment/build/
|
||||||
|
|
||||||
# Log files
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
@@ -38,3 +40,4 @@ area/
|
|||||||
|
|
||||||
# Deployment
|
# Deployment
|
||||||
.env.upload
|
.env.upload
|
||||||
|
*.code-workspace
|
||||||
@@ -1,236 +1,41 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require_once __DIR__ . '/../../vendor/getid3/getid3.php';
|
require_once __DIR__ . '/../Services/AuthorizationService.php';
|
||||||
|
require_once __DIR__ . '/../Services/FileSystemService.php';
|
||||||
|
require_once __DIR__ . '/../Services/MediaAnalysisService.php';
|
||||||
|
require_once __DIR__ . '/../Services/ProjectService.php';
|
||||||
|
require_once __DIR__ . '/../Services/AdsOverviewService.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProjectsController - Refactored and modularized
|
||||||
|
* Main controller for project-related API endpoints
|
||||||
|
*/
|
||||||
class ProjectsController {
|
class ProjectsController {
|
||||||
|
|
||||||
// Bestimmt den korrekten Pfad zum area-Ordner basierend auf der Umgebung
|
/**
|
||||||
private static function getAreaPath() {
|
* List projects for a specific client
|
||||||
// Prüfe ob wir in der Development-Struktur sind (backend/src/Api)
|
*/
|
||||||
$devPath = __DIR__ . '/../../../area';
|
|
||||||
if (is_dir($devPath)) {
|
|
||||||
return '/../../../area';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prüfe ob wir in der Deployment-Struktur sind (src/Api)
|
|
||||||
$deployPath = __DIR__ . '/../../area';
|
|
||||||
if (is_dir($deployPath)) {
|
|
||||||
return '/../../area';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback auf Development-Pfad
|
|
||||||
return '/../../../area';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zentrale Liste der zu ignorierenden Dateien
|
|
||||||
private static function getIgnoredFiles() {
|
|
||||||
return [
|
|
||||||
'.DS_Store', // macOS System-Datei
|
|
||||||
'Thumbs.db', // Windows Thumbnail-Cache
|
|
||||||
'desktop.ini', // Windows Desktop-Konfiguration
|
|
||||||
'.gitignore', // Git-Konfiguration
|
|
||||||
'.gitkeep', // Git-Platzhalter
|
|
||||||
'config.yaml', // Konfigurationsdateien
|
|
||||||
'config.yml',
|
|
||||||
'setup.yaml',
|
|
||||||
'setup.yml',
|
|
||||||
'.htaccess', // Apache-Konfiguration
|
|
||||||
'index.php', // PHP-Index (außer in HTML-Ordnern)
|
|
||||||
'web.config', // IIS-Konfiguration
|
|
||||||
'.env', // Environment-Variablen
|
|
||||||
'.env.local',
|
|
||||||
'README.md', // Dokumentation
|
|
||||||
'readme.txt',
|
|
||||||
'license.txt',
|
|
||||||
'LICENSE'
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prüft, ob der eingeloggte Client auf den gewünschten Bereich zugreifen darf
|
|
||||||
private static function requireClientAccess($clientDir) {
|
|
||||||
$user = self::requireAuth();
|
|
||||||
// Admins haben grundsätzlich Zugriff, aber prüfe disallowedClients
|
|
||||||
if (isset($user['role']) && $user['role'] === 'admin') {
|
|
||||||
$adminData = self::getAdminData($user['username']);
|
|
||||||
$disallowedClients = $adminData['disallowedClients'] ?? [];
|
|
||||||
|
|
||||||
if (in_array($clientDir, $disallowedClients)) {
|
|
||||||
http_response_code(403);
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode([
|
|
||||||
'success' => false,
|
|
||||||
'error' => [
|
|
||||||
'code' => 'FORBIDDEN',
|
|
||||||
'message' => "Zugriff auf Client '{$clientDir}' ist für diesen Administrator nicht erlaubt."
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
if ($user['role'] === 'client' && $user['dir'] !== $clientDir) {
|
|
||||||
http_response_code(403);
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode([
|
|
||||||
'success' => false,
|
|
||||||
'error' => [
|
|
||||||
'code' => 'FORBIDDEN',
|
|
||||||
'message' => 'Nicht berechtigt für diesen Bereich.'
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
// JWT-basierte Authentifizierungsprüfung für alle API-Methoden
|
|
||||||
private static function requireAuth() {
|
|
||||||
$headers = getallheaders();
|
|
||||||
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? null;
|
|
||||||
if (!$authHeader || !preg_match('/^Bearer\s+(.*)$/i', $authHeader, $matches)) {
|
|
||||||
http_response_code(401);
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode([
|
|
||||||
'success' => false,
|
|
||||||
'error' => [
|
|
||||||
'code' => 'UNAUTHORIZED',
|
|
||||||
'message' => 'Kein gültiges Auth-Token übergeben.'
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
$jwt = $matches[1];
|
|
||||||
require_once __DIR__ . '/../Services/AuthService.php';
|
|
||||||
$user = AuthService::verifyJWT($jwt);
|
|
||||||
if (!$user) {
|
|
||||||
http_response_code(401);
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode([
|
|
||||||
'success' => false,
|
|
||||||
'error' => [
|
|
||||||
'code' => 'UNAUTHORIZED',
|
|
||||||
'message' => 'Token ungültig oder abgelaufen.'
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
// Optional: User-Infos für spätere Nutzung zurückgeben
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prüft ob eine Datei ignoriert werden soll
|
|
||||||
private static function shouldIgnoreFile($filename) {
|
|
||||||
$ignoredFiles = self::getIgnoredFiles();
|
|
||||||
return in_array(strtolower($filename), array_map('strtolower', $ignoredFiles));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function listForClient($clientDir) {
|
public static function listForClient($clientDir) {
|
||||||
self::requireClientAccess($clientDir);
|
AuthorizationService::requireClientAccess($clientDir);
|
||||||
$base = realpath(__DIR__ . self::getAreaPath() . '/' . $clientDir);
|
|
||||||
// Lade clients.json, um den Anzeigenamen des Clients zu bekommen
|
$result = ProjectService::getProjectsForClient($clientDir);
|
||||||
$loginsFile = __DIR__ . '/../../../storage/data/clients.json';
|
|
||||||
$clientDisplay = $clientDir;
|
if (!$result['success']) {
|
||||||
if (file_exists($loginsFile)) {
|
|
||||||
$logins = json_decode(file_get_contents($loginsFile), true);
|
|
||||||
foreach ($logins as $login) {
|
|
||||||
if (isset($login['dir']) && $login['dir'] === $clientDir) {
|
|
||||||
$clientDisplay = $login['dir'];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!$base || !is_dir($base)) {
|
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode([
|
|
||||||
'success' => false,
|
|
||||||
'error' => [
|
|
||||||
'code' => 'NOT_FOUND',
|
|
||||||
'message' => 'Kundenordner nicht gefunden.'
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lade project_order.json falls vorhanden
|
header('Content-Type: application/json');
|
||||||
$projectOrderFile = $base . '/project_order.json';
|
echo json_encode($result);
|
||||||
$projectOrder = [];
|
|
||||||
if (file_exists($projectOrderFile)) {
|
|
||||||
$orderContent = file_get_contents($projectOrderFile);
|
|
||||||
$decoded = json_decode($orderContent, true);
|
|
||||||
if (is_array($decoded)) {
|
|
||||||
$projectOrder = $decoded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$projects = [];
|
|
||||||
$foundProjects = [];
|
|
||||||
|
|
||||||
// Sammle alle verfügbaren Projekte
|
|
||||||
foreach (scandir($base) as $entry) {
|
|
||||||
if ($entry === '.' || $entry === '..') continue;
|
|
||||||
$path = $base . '/' . $entry;
|
|
||||||
if (is_dir($path)) {
|
|
||||||
$setupDir = $path . '/setup';
|
|
||||||
$poster = null;
|
|
||||||
$logo = null;
|
|
||||||
if (is_dir($setupDir)) {
|
|
||||||
foreach (scandir($setupDir) as $file) {
|
|
||||||
if (!$poster && stripos($file, 'poster') === 0 && preg_match('/\.(jpg|jpeg|png|webp)$/i', $file)) {
|
|
||||||
$poster = '/area/' . rawurlencode($clientDir) . '/' . rawurlencode($entry) . '/setup/' . rawurlencode($file);
|
|
||||||
}
|
|
||||||
if (!$logo && stripos($file, 'logo') === 0 && preg_match('/\.(jpg|jpeg|png|webp)$/i', $file)) {
|
|
||||||
$logo = '/area/' . rawurlencode($clientDir) . '/' . rawurlencode($entry) . '/setup/' . rawurlencode($file);
|
|
||||||
}
|
|
||||||
if ($poster && $logo) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$isHE = (stripos($entry, 'HE_') === 0);
|
|
||||||
$foundProjects[$entry] = [
|
|
||||||
'name' => $entry,
|
|
||||||
'path' => $entry,
|
|
||||||
'poster' => $poster,
|
|
||||||
'logo' => $logo,
|
|
||||||
'isHE' => $isHE,
|
|
||||||
'client' => $clientDisplay
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sortiere nach project_order.json falls vorhanden
|
|
||||||
if (!empty($projectOrder)) {
|
|
||||||
// Erst die Projekte in der definierten Reihenfolge
|
|
||||||
foreach ($projectOrder as $orderedProject) {
|
|
||||||
if (isset($foundProjects[$orderedProject])) {
|
|
||||||
$projects[] = $foundProjects[$orderedProject];
|
|
||||||
unset($foundProjects[$orderedProject]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Neue Projekte (nicht in order-Liste) kommen an den ANFANG
|
|
||||||
$remainingProjects = array_values($foundProjects);
|
|
||||||
usort($remainingProjects, function($a, $b) {
|
|
||||||
return strcmp($a['name'], $b['name']);
|
|
||||||
});
|
|
||||||
// Neue Projekte VOR die sortierten einfügen
|
|
||||||
$projects = array_merge($remainingProjects, $projects);
|
|
||||||
} else {
|
|
||||||
// Fallback: Alphabetische Sortierung
|
|
||||||
$projects = array_values($foundProjects);
|
|
||||||
usort($projects, function($a, $b) {
|
|
||||||
return strcmp($a['name'], $b['name']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
echo json_encode([
|
|
||||||
'success' => true,
|
|
||||||
'projects' => $projects
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Speichert die Projekt-Reihenfolge in project_order.json
|
/**
|
||||||
|
* Save project order for a client
|
||||||
|
*/
|
||||||
public static function saveProjectOrder($clientDir) {
|
public static function saveProjectOrder($clientDir) {
|
||||||
self::requireClientAccess($clientDir);
|
AuthorizationService::requireClientAccess($clientDir);
|
||||||
$clientDir = rawurldecode($clientDir);
|
$clientDir = rawurldecode($clientDir);
|
||||||
|
|
||||||
// JSON-Body lesen
|
// Read JSON body
|
||||||
$input = file_get_contents('php://input');
|
$input = file_get_contents('php://input');
|
||||||
$data = json_decode($input, true);
|
$data = json_decode($input, true);
|
||||||
|
|
||||||
@@ -246,71 +51,41 @@ class ProjectsController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$base = realpath(__DIR__ . self::getAreaPath() . '/' . $clientDir);
|
$result = ProjectService::saveProjectOrder($clientDir, $data);
|
||||||
if (!$base || !is_dir($base)) {
|
|
||||||
http_response_code(404);
|
if (!$result['success']) {
|
||||||
echo json_encode([
|
http_response_code($result['error']['code'] === 'NOT_FOUND' ? 404 : 500);
|
||||||
'success' => false,
|
|
||||||
'error' => [
|
|
||||||
'code' => 'NOT_FOUND',
|
|
||||||
'message' => 'Kundenordner nicht gefunden.'
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$projectOrderFile = $base . '/project_order.json';
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($result);
|
||||||
// Validiere dass alle Projekte im order Array auch wirklich existieren
|
|
||||||
$existingProjects = [];
|
|
||||||
foreach (scandir($base) as $entry) {
|
|
||||||
if ($entry === '.' || $entry === '..') continue;
|
|
||||||
if (is_dir($base . '/' . $entry)) {
|
|
||||||
$existingProjects[] = $entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($data['order'] as $projectName) {
|
|
||||||
if (!in_array($projectName, $existingProjects)) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode([
|
|
||||||
'success' => false,
|
|
||||||
'error' => [
|
|
||||||
'code' => 'INVALID_PROJECT',
|
|
||||||
'message' => "Projekt '$projectName' existiert nicht."
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Speichere die neue Reihenfolge
|
|
||||||
$result = file_put_contents($projectOrderFile, json_encode($data['order'], JSON_PRETTY_PRINT));
|
|
||||||
|
|
||||||
if ($result === false) {
|
|
||||||
http_response_code(500);
|
|
||||||
echo json_encode([
|
|
||||||
'success' => false,
|
|
||||||
'error' => [
|
|
||||||
'code' => 'WRITE_ERROR',
|
|
||||||
'message' => 'Fehler beim Speichern der project_order.json.'
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
echo json_encode([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Projekt-Reihenfolge gespeichert.'
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function adsFolders($clientDir, $projectName) {
|
/**
|
||||||
self::requireClientAccess($clientDir);
|
* Get project details (poster, logo, etc.)
|
||||||
|
*/
|
||||||
|
public static function projectDetails($clientDir, $projectName) {
|
||||||
|
AuthorizationService::requireClientAccess($clientDir);
|
||||||
$clientDir = rawurldecode($clientDir);
|
$clientDir = rawurldecode($clientDir);
|
||||||
$projectName = rawurldecode($projectName);
|
$projectName = rawurldecode($projectName);
|
||||||
$adsPath = __DIR__ . self::getAreaPath() . '/' . $clientDir . '/' . $projectName . '/ads';
|
|
||||||
|
$result = ProjectService::getProjectDetails($clientDir, $projectName);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ads folders for a project
|
||||||
|
*/
|
||||||
|
public static function adsFolders($clientDir, $projectName) {
|
||||||
|
AuthorizationService::requireClientAccess($clientDir);
|
||||||
|
$clientDir = rawurldecode($clientDir);
|
||||||
|
$projectName = rawurldecode($projectName);
|
||||||
|
|
||||||
|
$adsPath = __DIR__ . FileSystemService::getAreaPath() . '/' . $clientDir . '/' . $projectName . '/ads';
|
||||||
$folders = [];
|
$folders = [];
|
||||||
|
|
||||||
if (is_dir($adsPath)) {
|
if (is_dir($adsPath)) {
|
||||||
foreach (scandir($adsPath) as $entry) {
|
foreach (scandir($adsPath) as $entry) {
|
||||||
if ($entry === '.' || $entry === '..') continue;
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
@@ -319,20 +94,27 @@ class ProjectsController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode(['folders' => $folders]);
|
echo json_encode(['folders' => $folders]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get subfolders within ads
|
||||||
|
*/
|
||||||
public static function adsSubfolders($clientDir, $projectName, ...$adsFolders) {
|
public static function adsSubfolders($clientDir, $projectName, ...$adsFolders) {
|
||||||
self::requireClientAccess($clientDir);
|
AuthorizationService::requireClientAccess($clientDir);
|
||||||
$adsFolders = array_map('rawurldecode', $adsFolders);
|
$adsFolders = array_map('rawurldecode', $adsFolders);
|
||||||
$base = __DIR__ . self::getAreaPath() . '/' . $clientDir . '/' . $projectName . '/ads';
|
|
||||||
|
$base = __DIR__ . FileSystemService::getAreaPath() . '/' . $clientDir . '/' . $projectName . '/ads';
|
||||||
if (!empty($adsFolders)) {
|
if (!empty($adsFolders)) {
|
||||||
$base .= '/' . implode('/', $adsFolders);
|
$base .= '/' . implode('/', $adsFolders);
|
||||||
}
|
}
|
||||||
|
|
||||||
$real = realpath($base);
|
$real = realpath($base);
|
||||||
$folders = [];
|
$folders = [];
|
||||||
if ($real && is_dir($real) && strpos($real, realpath(__DIR__ . self::getAreaPath() . '/' . $clientDir)) === 0) {
|
|
||||||
|
if (FileSystemService::validatePath($real, $clientDir) && is_dir($real)) {
|
||||||
foreach (scandir($real) as $entry) {
|
foreach (scandir($real) as $entry) {
|
||||||
if ($entry === '.' || $entry === '..') continue;
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
if (is_dir($real . '/' . $entry)) {
|
if (is_dir($real . '/' . $entry)) {
|
||||||
@@ -340,82 +122,70 @@ class ProjectsController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode(['folders' => $folders]);
|
echo json_encode(['folders' => $folders]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liefert die Sub-Subfolder eines Subfolders in ads
|
/**
|
||||||
public static function adsSubSubfolders($clientDir, $projectName, $adsFolder, $subFolder) {
|
* Get files within ads folder (any depth)
|
||||||
self::requireClientAccess($clientDir);
|
*/
|
||||||
$adsFolder = rawurldecode($adsFolder);
|
|
||||||
$subFolder = rawurldecode($subFolder);
|
|
||||||
$base = __DIR__ . self::getAreaPath() . '/' . $clientDir . '/' . $projectName . '/ads/' . $adsFolder . '/' . $subFolder;
|
|
||||||
$real = realpath($base);
|
|
||||||
$folders = [];
|
|
||||||
if ($real && is_dir($real) && strpos($real, realpath(__DIR__ . self::getAreaPath() . '/' . $clientDir)) === 0) {
|
|
||||||
foreach (scandir($real) as $entry) {
|
|
||||||
if ($entry === '.' || $entry === '..') continue;
|
|
||||||
if (is_dir($real . '/' . $entry)) {
|
|
||||||
$folders[] = $entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode(['folders' => $folders]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Liefert die Dateien eines beliebigen Unterordners in ads (beliebige Tiefe)
|
|
||||||
public static function adsFolderFiles($clientDir, $projectName, ...$adsFolders) {
|
public static function adsFolderFiles($clientDir, $projectName, ...$adsFolders) {
|
||||||
self::requireClientAccess($clientDir);
|
AuthorizationService::requireClientAccess($clientDir);
|
||||||
$adsFolders = array_map('rawurldecode', $adsFolders);
|
$adsFolders = array_map('rawurldecode', $adsFolders);
|
||||||
$base = __DIR__ . self::getAreaPath() . '/' . $clientDir . '/' . $projectName . '/ads';
|
|
||||||
|
$base = __DIR__ . FileSystemService::getAreaPath() . '/' . $clientDir . '/' . $projectName . '/ads';
|
||||||
if (!empty($adsFolders)) {
|
if (!empty($adsFolders)) {
|
||||||
$base .= '/' . implode('/', $adsFolders);
|
$base .= '/' . implode('/', $adsFolders);
|
||||||
}
|
}
|
||||||
|
|
||||||
$real = realpath($base);
|
$real = realpath($base);
|
||||||
$files = [];
|
$files = [];
|
||||||
|
|
||||||
if ($real && is_dir($real) && strpos($real, realpath(__DIR__ . self::getAreaPath() . '/' . $clientDir)) === 0) {
|
if (FileSystemService::validatePath($real, $clientDir) && is_dir($real)) {
|
||||||
foreach (scandir($real) as $entry) {
|
foreach (scandir($real) as $entry) {
|
||||||
if ($entry === '.' || $entry === '..') continue;
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
$full = $real . '/' . $entry;
|
$full = $real . '/' . $entry;
|
||||||
|
|
||||||
if (is_file($full)) {
|
if (is_file($full)) {
|
||||||
// Ignoriere bestimmte Dateien
|
// Skip ignored files
|
||||||
if (self::shouldIgnoreFile($entry)) {
|
if (FileSystemService::shouldIgnoreFile($entry)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ext = strtolower(pathinfo($entry, PATHINFO_EXTENSION));
|
$type = FileSystemService::getFileType($entry);
|
||||||
$type = 'other';
|
$url = FileSystemService::createFileUrl($clientDir, $projectName, $adsFolders, $entry);
|
||||||
if (in_array($ext, ['jpg','jpeg','png','gif','webp','svg'])) $type = 'image';
|
|
||||||
elseif (in_array($ext, ['mp4','mov','avi','webm'])) $type = 'video';
|
|
||||||
elseif (in_array($ext, ['html','htm'])) $type = 'html';
|
|
||||||
$urlParts = array_map('rawurlencode', $adsFolders);
|
|
||||||
$files[] = [
|
$files[] = [
|
||||||
'name' => $entry,
|
'name' => $entry,
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'url' => "/area/$clientDir/$projectName/ads/" . implode('/', $urlParts) . (count($urlParts) ? '/' : '') . rawurlencode($entry)
|
'url' => $url
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode(['files' => $files]);
|
echo json_encode(['files' => $files]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liefert sowohl Ordner als auch Dateien eines beliebigen Unterordners in ads (beliebige Tiefe)
|
/**
|
||||||
|
* Get both folders and files within ads folder (any depth)
|
||||||
|
*/
|
||||||
public static function adsFolderContent($clientDir, $projectName, ...$adsFolders) {
|
public static function adsFolderContent($clientDir, $projectName, ...$adsFolders) {
|
||||||
self::requireClientAccess($clientDir);
|
AuthorizationService::requireClientAccess($clientDir);
|
||||||
$adsFolders = array_map('rawurldecode', $adsFolders);
|
$adsFolders = array_map('rawurldecode', $adsFolders);
|
||||||
$base = __DIR__ . self::getAreaPath() . '/' . $clientDir . '/' . $projectName . '/ads';
|
|
||||||
|
$base = __DIR__ . FileSystemService::getAreaPath() . '/' . $clientDir . '/' . $projectName . '/ads';
|
||||||
if (!empty($adsFolders)) {
|
if (!empty($adsFolders)) {
|
||||||
$base .= '/' . implode('/', $adsFolders);
|
$base .= '/' . implode('/', $adsFolders);
|
||||||
}
|
}
|
||||||
|
|
||||||
$real = realpath($base);
|
$real = realpath($base);
|
||||||
$folders = [];
|
$folders = [];
|
||||||
$files = [];
|
$files = [];
|
||||||
|
|
||||||
if ($real && is_dir($real) && strpos($real, realpath(__DIR__ . self::getAreaPath() . '/' . $clientDir)) === 0) {
|
if (FileSystemService::validatePath($real, $clientDir) && is_dir($real)) {
|
||||||
foreach (scandir($real) as $entry) {
|
foreach (scandir($real) as $entry) {
|
||||||
if ($entry === '.' || $entry === '..') continue;
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
$full = $real . '/' . $entry;
|
$full = $real . '/' . $entry;
|
||||||
@@ -423,21 +193,18 @@ class ProjectsController {
|
|||||||
if (is_dir($full)) {
|
if (is_dir($full)) {
|
||||||
$folders[] = $entry;
|
$folders[] = $entry;
|
||||||
} elseif (is_file($full)) {
|
} elseif (is_file($full)) {
|
||||||
// Ignoriere bestimmte Dateien
|
// Skip ignored files
|
||||||
if (self::shouldIgnoreFile($entry)) {
|
if (FileSystemService::shouldIgnoreFile($entry)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ext = strtolower(pathinfo($entry, PATHINFO_EXTENSION));
|
$type = FileSystemService::getFileType($entry);
|
||||||
$type = 'other';
|
$url = FileSystemService::createFileUrl($clientDir, $projectName, $adsFolders, $entry);
|
||||||
if (in_array($ext, ['jpg','jpeg','png','gif','webp','svg'])) $type = 'image';
|
|
||||||
elseif (in_array($ext, ['mp4','mov','avi','webm'])) $type = 'video';
|
|
||||||
elseif (in_array($ext, ['html','htm'])) $type = 'html';
|
|
||||||
$urlParts = array_map('rawurlencode', $adsFolders);
|
|
||||||
$files[] = [
|
$files[] = [
|
||||||
'name' => $entry,
|
'name' => $entry,
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'url' => "/area/$clientDir/$projectName/ads/" . implode('/', $urlParts) . (count($urlParts) ? '/' : '') . rawurlencode($entry)
|
'url' => $url
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,18 +216,22 @@ class ProjectsController {
|
|||||||
'files' => $files
|
'files' => $files
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liefert config.yaml aus einem beliebigen Ordnerpfad
|
/**
|
||||||
|
* Get config.yaml from any folder path
|
||||||
|
*/
|
||||||
public static function getConfig($clientDir, $projectName, ...$adsFolders) {
|
public static function getConfig($clientDir, $projectName, ...$adsFolders) {
|
||||||
self::requireClientAccess($clientDir);
|
AuthorizationService::requireClientAccess($clientDir);
|
||||||
$adsFolders = array_map('rawurldecode', $adsFolders);
|
$adsFolders = array_map('rawurldecode', $adsFolders);
|
||||||
$base = __DIR__ . self::getAreaPath() . '/' . $clientDir . '/' . $projectName . '/ads';
|
|
||||||
|
$base = __DIR__ . FileSystemService::getAreaPath() . '/' . $clientDir . '/' . $projectName . '/ads';
|
||||||
if (!empty($adsFolders)) {
|
if (!empty($adsFolders)) {
|
||||||
$base .= '/' . implode('/', $adsFolders);
|
$base .= '/' . implode('/', $adsFolders);
|
||||||
}
|
}
|
||||||
|
|
||||||
$real = realpath($base);
|
$real = realpath($base);
|
||||||
|
|
||||||
if ($real && is_dir($real) && strpos($real, realpath(__DIR__ . self::getAreaPath() . '/' . $clientDir)) === 0) {
|
if (FileSystemService::validatePath($real, $clientDir) && is_dir($real)) {
|
||||||
$configFile = $real . '/config.yaml';
|
$configFile = $real . '/config.yaml';
|
||||||
if (file_exists($configFile)) {
|
if (file_exists($configFile)) {
|
||||||
header('Content-Type: text/plain; charset=utf-8');
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
@@ -479,272 +250,47 @@ class ProjectsController {
|
|||||||
]
|
]
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liefert die Details eines Projekts (inkl. Logo, Poster etc.)
|
/**
|
||||||
public static function projectDetails($clientDir, $projectName) {
|
* Get recursive ads overview as structured tree with metadata
|
||||||
self::requireClientAccess($clientDir);
|
*/
|
||||||
$clientDir = rawurldecode($clientDir);
|
|
||||||
$projectName = rawurldecode($projectName);
|
|
||||||
|
|
||||||
$setupDir = __DIR__ . self::getAreaPath() . "/$clientDir/$projectName/setup";
|
|
||||||
$poster = null;
|
|
||||||
$logo = null;
|
|
||||||
|
|
||||||
if (is_dir($setupDir)) {
|
|
||||||
foreach (scandir($setupDir) as $file) {
|
|
||||||
if ($file === '.' || $file === '..') continue;
|
|
||||||
if (stripos($file, 'poster') === 0 && preg_match('/\.(jpg|jpeg|png|webp)$/i', $file)) {
|
|
||||||
$poster = '/area/' . rawurlencode($clientDir) . '/' . rawurlencode($projectName) . '/setup/' . rawurlencode($file);
|
|
||||||
}
|
|
||||||
if (!$logo && stripos($file, 'logo') === 0 && preg_match('/\.(jpg|jpeg|png|webp)$/i', $file)) {
|
|
||||||
$logo = '/area/' . rawurlencode($clientDir) . '/' . rawurlencode($projectName) . '/setup/' . rawurlencode($file);
|
|
||||||
}
|
|
||||||
if ($poster && $logo) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode([
|
|
||||||
'success' => true,
|
|
||||||
'data' => [
|
|
||||||
'poster' => $poster,
|
|
||||||
'logo' => $logo,
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rekursive Übersicht aller Ads als strukturierter Baum
|
|
||||||
public static function adsOverview($clientDir, $projectName) {
|
public static function adsOverview($clientDir, $projectName) {
|
||||||
self::requireClientAccess($clientDir);
|
AuthorizationService::requireClientAccess($clientDir);
|
||||||
$clientDir = rawurldecode($clientDir);
|
$clientDir = rawurldecode($clientDir);
|
||||||
$projectName = rawurldecode($projectName);
|
$projectName = rawurldecode($projectName);
|
||||||
$adsRoot = __DIR__ . self::getAreaPath() . "/$clientDir/$projectName/ads";
|
|
||||||
$baseUrl = $_ENV['BACKEND_URL'] ?? (isset($_SERVER['HTTP_HOST']) ? (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] : '');
|
$result = AdsOverviewService::generateOverview($clientDir, $projectName);
|
||||||
|
|
||||||
// Hilfsfunktion für schöne Tab-Titel
|
|
||||||
function beautifyTabName($name) {
|
|
||||||
// Ersetze _ und - durch Leerzeichen, dann jedes Wort groß
|
|
||||||
$name = str_replace(['_', '-'], ' ', $name);
|
|
||||||
$name = mb_strtolower($name, 'UTF-8');
|
|
||||||
$name = preg_replace_callback('/\b\w/u', function($m) { return mb_strtoupper($m[0], 'UTF-8'); }, $name);
|
|
||||||
return $name;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scanAdsFolder($absPath, $relPath, $baseUrl, $clientDir, $projectName) {
|
|
||||||
$result = [];
|
|
||||||
if (!is_dir($absPath)) return $result;
|
|
||||||
$entries = array_diff(scandir($absPath), ['.', '..']);
|
|
||||||
foreach ($entries as $entry) {
|
|
||||||
$entryAbs = "$absPath/$entry";
|
|
||||||
$entryRel = $relPath ? "$relPath/$entry" : $entry;
|
|
||||||
if (is_dir($entryAbs)) {
|
|
||||||
$children = scanAdsFolder($entryAbs, $entryRel, $baseUrl, $clientDir, $projectName);
|
|
||||||
$depth = substr_count($entryRel, '/');
|
|
||||||
$type = match($depth) {
|
|
||||||
0 => 'category',
|
|
||||||
1 => 'subcategory',
|
|
||||||
2 => 'ad',
|
|
||||||
default => 'folder'
|
|
||||||
};
|
|
||||||
$node = [
|
|
||||||
'name' => $entry,
|
|
||||||
'title' => beautifyTabName($entry),
|
|
||||||
'type' => $type,
|
|
||||||
];
|
|
||||||
if ($type === 'ad') {
|
|
||||||
// Prüfe, ob ein Unterordner mit 'html' im Namen existiert
|
|
||||||
$htmlFolder = null;
|
|
||||||
foreach (scandir($entryAbs) as $sub) {
|
|
||||||
if ($sub === '.' || $sub === '..') continue;
|
|
||||||
if (is_dir("$entryAbs/$sub") && stripos($sub, 'html') !== false) {
|
|
||||||
$htmlFolder = $sub;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Dateien sammeln, aber config.yaml (und config.yml) ignorieren
|
|
||||||
$node['files'] = array_values(array_filter($children, function($child) {
|
|
||||||
if (in_array($child['type'], ['category','subcategory','ad','folder'])) return false;
|
|
||||||
if (isset($child['name']) && preg_match('/^config\.ya?ml$/i', $child['name'])) return false;
|
|
||||||
return true;
|
|
||||||
}));
|
|
||||||
// Spezialfall: HTML-Ordner gefunden
|
|
||||||
if ($htmlFolder) {
|
|
||||||
$htmlAbs = "$entryAbs/$htmlFolder";
|
|
||||||
$htmlIndex = "$htmlAbs/index.html";
|
|
||||||
// Suche config.yaml eine Ebene über dem HTML-Ordner (im Ad-Ordner)
|
|
||||||
$configYaml = "$entryAbs/config.yaml";
|
|
||||||
$meta = [];
|
|
||||||
if (is_file($configYaml)) {
|
|
||||||
$yaml = file_get_contents($configYaml);
|
|
||||||
$width = 0;
|
|
||||||
$height = 0;
|
|
||||||
$lines = preg_split('/\r?\n/', $yaml);
|
|
||||||
foreach ($lines as $line) {
|
|
||||||
if ($width === 0 && preg_match('/width\s*:\s*["\']?([\d,.]+)\s*([a-zA-Z%]*)["\']?/i', $line, $wMatch)) {
|
|
||||||
$val = str_replace([',', ' '], '', $wMatch[1]);
|
|
||||||
$width = (int)floatval($val);
|
|
||||||
}
|
|
||||||
if ($height === 0 && preg_match('/height\s*:\s*["\']?([\d,.]+)\s*([a-zA-Z%]*)["\']?/i', $line, $hMatch)) {
|
|
||||||
$val = str_replace([',', ' '], '', $hMatch[1]);
|
|
||||||
$height = (int)floatval($val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$meta['width'] = $width;
|
|
||||||
$meta['height'] = $height;
|
|
||||||
} else {
|
|
||||||
// Fallback: Versuche Dimensionen aus dem Ad-Ordnernamen zu extrahieren (z.B. 800x250)
|
|
||||||
$adFolderName = basename($entryAbs);
|
|
||||||
if (preg_match('/(\d{2,5})[xX](\d{2,5})/', $adFolderName, $matches)) {
|
|
||||||
$meta['width'] = (int)$matches[1];
|
|
||||||
$meta['height'] = (int)$matches[2];
|
|
||||||
// file_put_contents(__DIR__.'/debug.log', "Fallback: Dimensionen aus Ordnername erkannt width={$meta['width']} height={$meta['height']} ($adFolderName)\n", FILE_APPEND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (is_file($htmlIndex)) {
|
|
||||||
$url = "$baseUrl/area/" . rawurlencode($clientDir) . "/" . rawurlencode($projectName) . "/ads/" . ($relPath ? str_replace('%2F', '/', rawurlencode($relPath)) . '/' : '') . rawurlencode($entry) . "/" . rawurlencode($htmlFolder) . "/index.html";
|
|
||||||
$fileMeta = [
|
|
||||||
'name' => 'index.html',
|
|
||||||
'type' => 'html',
|
|
||||||
'url' => $url,
|
|
||||||
'size' => is_file($htmlIndex) ? filesize($htmlIndex) : null,
|
|
||||||
'width' => (isset($meta['width'])) ? $meta['width'] : 123,
|
|
||||||
'height' => (isset($meta['height'])) ? $meta['height'] : 456
|
|
||||||
];
|
|
||||||
// Füge index.html nur hinzu, wenn sie nicht schon in files ist
|
|
||||||
$already = false;
|
|
||||||
foreach ($node['files'] as $f) {
|
|
||||||
if ($f['name'] === 'index.html') { $already = true; break; }
|
|
||||||
}
|
|
||||||
if (!$already) {
|
|
||||||
$node['files'][] = $fileMeta;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$node['children'] = array_filter($children, fn($c) => in_array($c['type'], ['category','subcategory','ad','folder']));
|
|
||||||
}
|
|
||||||
$result[] = $node;
|
|
||||||
} else {
|
|
||||||
// Datei
|
|
||||||
$ext = strtolower(pathinfo($entry, PATHINFO_EXTENSION));
|
|
||||||
$type = match(true) {
|
|
||||||
in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp']) => 'image',
|
|
||||||
in_array($ext, ['mp4', 'mov', 'webm']) => 'video',
|
|
||||||
$ext === 'html' => 'html',
|
|
||||||
default => 'file'
|
|
||||||
};
|
|
||||||
$url = "$baseUrl/area/" . rawurlencode($clientDir) . "/" . rawurlencode($projectName) . "/ads/" . ($relPath ? str_replace('%2F', '/', rawurlencode($relPath)) . '/' : '') . rawurlencode($entry);
|
|
||||||
|
|
||||||
$meta = [];
|
|
||||||
$fileAbs = $entryAbs;
|
|
||||||
// Dateigröße
|
|
||||||
$meta['size'] = is_file($fileAbs) ? filesize($fileAbs) : null;
|
|
||||||
|
|
||||||
// Dimensionen für Bilder
|
|
||||||
if ($type === 'image') {
|
|
||||||
$imgInfo = @getimagesize($fileAbs);
|
|
||||||
if ($imgInfo) {
|
|
||||||
$meta['width'] = $imgInfo[0];
|
|
||||||
$meta['height'] = $imgInfo[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Dimensionen und Dauer für Videos (nur wenn ffprobe verfügbar)
|
|
||||||
if ($type === 'video' && is_file($fileAbs)) {
|
|
||||||
$ffprobePath = shell_exec('which ffprobe');
|
|
||||||
$ffprobeUsed = false;
|
|
||||||
if ($ffprobePath) {
|
|
||||||
$ffprobe = trim($ffprobePath);
|
|
||||||
if ($ffprobe !== '') {
|
|
||||||
$cmd = escapeshellcmd($ffprobe) . ' -v error -select_streams v:0 -show_entries stream=width,height,duration -of default=noprint_wrappers=1:nokey=1 ' . escapeshellarg($fileAbs);
|
|
||||||
$out = shell_exec($cmd);
|
|
||||||
if ($out) {
|
|
||||||
$lines = explode("\n", trim($out));
|
|
||||||
if (count($lines) >= 3) {
|
|
||||||
$meta['width'] = (int)$lines[0];
|
|
||||||
$meta['height'] = (int)$lines[1];
|
|
||||||
$meta['duration'] = (float)$lines[2];
|
|
||||||
$ffprobeUsed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback auf getID3, falls ffprobe nicht verfügbar oder fehlgeschlagen
|
|
||||||
if (!$ffprobeUsed) {
|
|
||||||
try {
|
|
||||||
$getID3 = new \getID3();
|
|
||||||
$info = $getID3->analyze($fileAbs);
|
|
||||||
if (!empty($info['video']['resolution_x']) && !empty($info['video']['resolution_y'])) {
|
|
||||||
$meta['width'] = (int)$info['video']['resolution_x'];
|
|
||||||
$meta['height'] = (int)$info['video']['resolution_y'];
|
|
||||||
}
|
|
||||||
if (!empty($info['playtime_seconds'])) {
|
|
||||||
$meta['duration'] = (float)$info['playtime_seconds'];
|
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
// Fehler ignorieren, Metadaten bleiben leer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$result[] = array_merge([
|
|
||||||
'name' => $entry,
|
|
||||||
'title' => beautifyTabName($entry),
|
|
||||||
'type' => $type,
|
|
||||||
'url' => $url
|
|
||||||
], $meta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
usort($result, function($a, $b) {
|
|
||||||
if (($a['type'] === 'file') !== ($b['type'] === 'file')) {
|
|
||||||
return $a['type'] === 'file' ? 1 : -1;
|
|
||||||
}
|
|
||||||
return strnatcasecmp($a['name'], $b['name']);
|
|
||||||
});
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tree = scanAdsFolder($adsRoot, '', $baseUrl, $clientDir, $projectName);
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode($tree, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
echo json_encode($result['data'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Projekt→Client Mapping für Admin Smart URL Resolution
|
/**
|
||||||
|
* Get project→client mapping for admin smart URL resolution
|
||||||
|
*/
|
||||||
public static function getProjectClientMapping() {
|
public static function getProjectClientMapping() {
|
||||||
// Nur Admins dürfen diese API verwenden
|
$user = AuthorizationService::requireAdminAccess();
|
||||||
$user = self::requireAuth();
|
|
||||||
if (!isset($user['role']) || $user['role'] !== 'admin') {
|
// Load admin data for client permissions
|
||||||
http_response_code(403);
|
$adminData = AuthorizationService::getAdminData($user['username']);
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode([
|
|
||||||
'success' => false,
|
|
||||||
'error' => [
|
|
||||||
'code' => 'FORBIDDEN',
|
|
||||||
'message' => 'Nur Administratoren können das Projekt-Client-Mapping abrufen.'
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lade Admin-Daten für Client-Berechtigungen
|
|
||||||
$adminData = self::getAdminData($user['username']);
|
|
||||||
$disallowedClients = $adminData['disallowedClients'] ?? [];
|
$disallowedClients = $adminData['disallowedClients'] ?? [];
|
||||||
|
|
||||||
$areaPath = __DIR__ . self::getAreaPath();
|
$areaPath = __DIR__ . FileSystemService::getAreaPath();
|
||||||
$mapping = [];
|
$mapping = [];
|
||||||
|
|
||||||
// Durchsuche alle Client-Ordner
|
// Scan all client directories
|
||||||
if (is_dir($areaPath)) {
|
if (is_dir($areaPath)) {
|
||||||
$clientDirs = scandir($areaPath);
|
$clientDirs = scandir($areaPath);
|
||||||
foreach ($clientDirs as $clientDir) {
|
foreach ($clientDirs as $clientDir) {
|
||||||
if ($clientDir === '.' || $clientDir === '..' || !is_dir($areaPath . '/' . $clientDir)) {
|
if ($clientDir === '.' || $clientDir === '..' || !is_dir($areaPath . '/' . $clientDir)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfe ob Admin Zugriff auf diesen Client hat
|
// Check if admin has access to this client
|
||||||
if (in_array($clientDir, $disallowedClients)) {
|
if (in_array($clientDir, $disallowedClients)) {
|
||||||
continue; // Client ist für diesen Admin nicht erlaubt
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$clientPath = $areaPath . '/' . $clientDir;
|
$clientPath = $areaPath . '/' . $clientDir;
|
||||||
if (is_dir($clientPath)) {
|
if (is_dir($clientPath)) {
|
||||||
$projects = scandir($clientPath);
|
$projects = scandir($clientPath);
|
||||||
@@ -752,19 +298,19 @@ class ProjectsController {
|
|||||||
if ($project === '.' || $project === '..' || !is_dir($clientPath . '/' . $project)) {
|
if ($project === '.' || $project === '..' || !is_dir($clientPath . '/' . $project)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignoriere spezielle Dateien
|
// Ignore special files
|
||||||
if (in_array($project, ['project_order.json', 'logins.json'])) {
|
if (in_array($project, ['order_project.json', 'logins.json'])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Füge Projekt→Client Mapping hinzu
|
// Add project→client mapping
|
||||||
$mapping[$project] = $clientDir;
|
$mapping[$project] = $clientDir;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@@ -775,18 +321,4 @@ class ProjectsController {
|
|||||||
]
|
]
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hilfsfunktion: Admin-Daten laden
|
|
||||||
private static function getAdminData($username) {
|
|
||||||
$adminFile = __DIR__ . '/../../storage/data/admins.json';
|
|
||||||
if (file_exists($adminFile)) {
|
|
||||||
$admins = json_decode(file_get_contents($adminFile), true);
|
|
||||||
foreach ($admins as $admin) {
|
|
||||||
if ($admin['username'] === $username) {
|
|
||||||
return $admin;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
185
backend/src/Services/AdsOverviewService.php
Normal file
185
backend/src/Services/AdsOverviewService.php
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/MediaAnalysisService.php';
|
||||||
|
require_once __DIR__ . '/FileSystemService.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AdsOverviewService
|
||||||
|
* Handles the complex ads overview tree generation with metadata
|
||||||
|
*/
|
||||||
|
class AdsOverviewService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate recursive ads overview as structured tree
|
||||||
|
*/
|
||||||
|
public static function generateOverview($clientDir, $projectName) {
|
||||||
|
$adsRoot = __DIR__ . FileSystemService::getAreaPath() . "/$clientDir/$projectName/ads";
|
||||||
|
$baseUrl = $_ENV['BACKEND_URL'] ?? (
|
||||||
|
isset($_SERVER['HTTP_HOST'])
|
||||||
|
? (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https://' : 'http://') . $_SERVER['HTTP_HOST']
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
$tree = self::scanAdsFolder($adsRoot, '', $baseUrl, $clientDir, $projectName);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'data' => $tree
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively scan ads folder structure
|
||||||
|
*/
|
||||||
|
private static function scanAdsFolder($absPath, $relPath, $baseUrl, $clientDir, $projectName) {
|
||||||
|
$result = [];
|
||||||
|
if (!is_dir($absPath)) return $result;
|
||||||
|
|
||||||
|
$entries = array_diff(scandir($absPath), ['.', '..']);
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
$entryAbs = "$absPath/$entry";
|
||||||
|
$entryRel = $relPath ? "$relPath/$entry" : $entry;
|
||||||
|
|
||||||
|
if (is_dir($entryAbs)) {
|
||||||
|
$children = self::scanAdsFolder($entryAbs, $entryRel, $baseUrl, $clientDir, $projectName);
|
||||||
|
$depth = substr_count($entryRel, '/');
|
||||||
|
|
||||||
|
$type = match($depth) {
|
||||||
|
0 => 'category',
|
||||||
|
1 => 'subcategory',
|
||||||
|
2 => 'ad',
|
||||||
|
default => 'folder'
|
||||||
|
};
|
||||||
|
|
||||||
|
$node = [
|
||||||
|
'name' => $entry,
|
||||||
|
'title' => MediaAnalysisService::beautifyTabName($entry),
|
||||||
|
'type' => $type,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($type === 'ad') {
|
||||||
|
$node = self::processAdFolder($node, $entryAbs, $children, $baseUrl, $clientDir, $projectName, $relPath, $entry);
|
||||||
|
} else {
|
||||||
|
$node['children'] = array_filter($children, fn($c) => in_array($c['type'], ['category','subcategory','ad','folder']));
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[] = $node;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// File
|
||||||
|
$fileNode = self::processFile($entryAbs, $entry, $baseUrl, $clientDir, $projectName, $relPath);
|
||||||
|
if ($fileNode) {
|
||||||
|
$result[] = $fileNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: folders first, then files
|
||||||
|
usort($result, function($a, $b) {
|
||||||
|
if (($a['type'] === 'file') !== ($b['type'] === 'file')) {
|
||||||
|
return $a['type'] === 'file' ? 1 : -1;
|
||||||
|
}
|
||||||
|
return strnatcasecmp($a['name'], $b['name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process ad folder with HTML detection and config parsing
|
||||||
|
*/
|
||||||
|
private static function processAdFolder($node, $entryAbs, $children, $baseUrl, $clientDir, $projectName, $relPath, $entry) {
|
||||||
|
// Check if subfolder with 'html' in name exists
|
||||||
|
$htmlFolder = null;
|
||||||
|
foreach (scandir($entryAbs) as $sub) {
|
||||||
|
if ($sub === '.' || $sub === '..') continue;
|
||||||
|
if (is_dir("$entryAbs/$sub") && stripos($sub, 'html') !== false) {
|
||||||
|
$htmlFolder = $sub;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect files, but ignore config.yaml
|
||||||
|
$node['files'] = array_values(array_filter($children, function($child) {
|
||||||
|
if (in_array($child['type'], ['category','subcategory','ad','folder'])) return false;
|
||||||
|
if (isset($child['name']) && preg_match('/^config\.ya?ml$/i', $child['name'])) return false;
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Special case: HTML folder found
|
||||||
|
if ($htmlFolder) {
|
||||||
|
$htmlAbs = "$entryAbs/$htmlFolder";
|
||||||
|
$htmlIndex = "$htmlAbs/index.html";
|
||||||
|
|
||||||
|
// Look for config.yaml one level above HTML folder (in ad folder)
|
||||||
|
$configYaml = "$entryAbs/config.yaml";
|
||||||
|
$meta = [];
|
||||||
|
|
||||||
|
if (is_file($configYaml)) {
|
||||||
|
$meta = MediaAnalysisService::parseAdConfig($configYaml);
|
||||||
|
} else {
|
||||||
|
// Fallback: try to extract dimensions from ad folder name (e.g. 800x250)
|
||||||
|
$adFolderName = basename($entryAbs);
|
||||||
|
$meta = MediaAnalysisService::parseDimensionsFromFolderName($adFolderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_file($htmlIndex)) {
|
||||||
|
$url = "$baseUrl/area/" . rawurlencode($clientDir) . "/" . rawurlencode($projectName) . "/ads/"
|
||||||
|
. ($relPath ? str_replace('%2F', '/', rawurlencode($relPath)) . '/' : '')
|
||||||
|
. rawurlencode($entry) . "/" . rawurlencode($htmlFolder) . "/index.html";
|
||||||
|
|
||||||
|
$fileMeta = [
|
||||||
|
'name' => 'index.html',
|
||||||
|
'type' => 'html',
|
||||||
|
'url' => $url,
|
||||||
|
'size' => filesize($htmlIndex),
|
||||||
|
'width' => $meta['width'] ?? 123,
|
||||||
|
'height' => $meta['height'] ?? 456
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add index.html only if not already in files
|
||||||
|
$already = false;
|
||||||
|
foreach ($node['files'] as $f) {
|
||||||
|
if ($f['name'] === 'index.html') {
|
||||||
|
$already = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$already) {
|
||||||
|
$node['files'][] = $fileMeta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process individual file with metadata
|
||||||
|
*/
|
||||||
|
private static function processFile($fileAbs, $entry, $baseUrl, $clientDir, $projectName, $relPath) {
|
||||||
|
$type = FileSystemService::getFileType($entry);
|
||||||
|
|
||||||
|
$url = "$baseUrl/area/" . rawurlencode($clientDir) . "/" . rawurlencode($projectName) . "/ads/"
|
||||||
|
. ($relPath ? str_replace('%2F', '/', rawurlencode($relPath)) . '/' : '') . rawurlencode($entry);
|
||||||
|
|
||||||
|
$meta = ['size' => filesize($fileAbs)];
|
||||||
|
|
||||||
|
// Get metadata based on file type
|
||||||
|
if ($type === 'image') {
|
||||||
|
$imageMeta = MediaAnalysisService::getImageMetadata($fileAbs);
|
||||||
|
$meta = array_merge($meta, $imageMeta);
|
||||||
|
} elseif ($type === 'video') {
|
||||||
|
$videoMeta = MediaAnalysisService::getVideoMetadata($fileAbs);
|
||||||
|
$meta = array_merge($meta, $videoMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge([
|
||||||
|
'name' => $entry,
|
||||||
|
'title' => MediaAnalysisService::beautifyTabName($entry),
|
||||||
|
'type' => $type === 'other' ? 'file' : $type,
|
||||||
|
'url' => $url
|
||||||
|
], $meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
backend/src/Services/AuthorizationService.php
Normal file
127
backend/src/Services/AuthorizationService.php
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AuthorizationService
|
||||||
|
* Handles authentication and authorization for the AdsPreview system
|
||||||
|
*/
|
||||||
|
class AuthorizationService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT-based authentication check for all API methods
|
||||||
|
*/
|
||||||
|
public static function requireAuth() {
|
||||||
|
$headers = getallheaders();
|
||||||
|
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? null;
|
||||||
|
|
||||||
|
if (!$authHeader || !preg_match('/^Bearer\s+(.*)$/i', $authHeader, $matches)) {
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'UNAUTHORIZED',
|
||||||
|
'message' => 'Kein gültiges Auth-Token übergeben.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$jwt = $matches[1];
|
||||||
|
require_once __DIR__ . '/AuthService.php';
|
||||||
|
$user = AuthService::verifyJWT($jwt);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'UNAUTHORIZED',
|
||||||
|
'message' => 'Token ungültig oder abgelaufen.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the logged-in user can access the requested client directory
|
||||||
|
*/
|
||||||
|
public static function requireClientAccess($clientDir) {
|
||||||
|
$user = self::requireAuth();
|
||||||
|
|
||||||
|
// Admins have general access, but check disallowedClients
|
||||||
|
if (isset($user['role']) && $user['role'] === 'admin') {
|
||||||
|
$adminData = self::getAdminData($user['username']);
|
||||||
|
$disallowedClients = $adminData['disallowedClients'] ?? [];
|
||||||
|
|
||||||
|
if (in_array($clientDir, $disallowedClients)) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'FORBIDDEN',
|
||||||
|
'message' => "Zugriff auf Client '{$clientDir}' ist für diesen Administrator nicht erlaubt."
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client users can only access their own directory
|
||||||
|
if ($user['role'] === 'client' && $user['dir'] !== $clientDir) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'FORBIDDEN',
|
||||||
|
'message' => 'Nicht berechtigt für diesen Bereich.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load admin data from storage
|
||||||
|
*/
|
||||||
|
public static function getAdminData($username) {
|
||||||
|
$adminFile = __DIR__ . '/../../storage/data/admins.json';
|
||||||
|
if (file_exists($adminFile)) {
|
||||||
|
$admins = json_decode(file_get_contents($adminFile), true);
|
||||||
|
foreach ($admins as $admin) {
|
||||||
|
if ($admin['username'] === $username) {
|
||||||
|
return $admin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is admin
|
||||||
|
*/
|
||||||
|
public static function requireAdminAccess() {
|
||||||
|
$user = self::requireAuth();
|
||||||
|
if (!isset($user['role']) || $user['role'] !== 'admin') {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'FORBIDDEN',
|
||||||
|
'message' => 'Nur Administratoren können diese Funktion nutzen.'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
96
backend/src/Services/FileSystemService.php
Normal file
96
backend/src/Services/FileSystemService.php
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FileSystemService
|
||||||
|
* Handles file system operations and path management
|
||||||
|
*/
|
||||||
|
class FileSystemService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the correct path to the area folder based on environment
|
||||||
|
*/
|
||||||
|
public static function getAreaPath() {
|
||||||
|
// Check if we're in development structure (backend/src/Api)
|
||||||
|
$devPath = __DIR__ . '/../../../area';
|
||||||
|
if (is_dir($devPath)) {
|
||||||
|
return '/../../../area';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're in deployment structure (src/Api)
|
||||||
|
$deployPath = __DIR__ . '/../../area';
|
||||||
|
if (is_dir($deployPath)) {
|
||||||
|
return '/../../area';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to development path
|
||||||
|
return '/../../../area';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central list of files to ignore
|
||||||
|
*/
|
||||||
|
public static function getIgnoredFiles() {
|
||||||
|
return [
|
||||||
|
'.DS_Store', // macOS System file
|
||||||
|
'Thumbs.db', // Windows Thumbnail cache
|
||||||
|
'desktop.ini', // Windows Desktop configuration
|
||||||
|
'.gitignore', // Git configuration
|
||||||
|
'.gitkeep', // Git placeholder
|
||||||
|
'config.yaml', // Configuration files
|
||||||
|
'config.yml',
|
||||||
|
'setup.yaml',
|
||||||
|
'setup.yml',
|
||||||
|
'.htaccess', // Apache configuration
|
||||||
|
'index.php', // PHP index (except in HTML folders)
|
||||||
|
'web.config', // IIS configuration
|
||||||
|
'.env', // Environment variables
|
||||||
|
'.env.local',
|
||||||
|
'README.md', // Documentation
|
||||||
|
'readme.txt',
|
||||||
|
'license.txt',
|
||||||
|
'LICENSE'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file should be ignored
|
||||||
|
*/
|
||||||
|
public static function shouldIgnoreFile($filename) {
|
||||||
|
$ignoredFiles = self::getIgnoredFiles();
|
||||||
|
return in_array(strtolower($filename), array_map('strtolower', $ignoredFiles));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file type based on extension
|
||||||
|
*/
|
||||||
|
public static function getFileType($filename) {
|
||||||
|
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'])) {
|
||||||
|
return 'image';
|
||||||
|
} elseif (in_array($ext, ['mp4', 'mov', 'avi', 'webm'])) {
|
||||||
|
return 'video';
|
||||||
|
} elseif (in_array($ext, ['html', 'htm'])) {
|
||||||
|
return 'html';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create safe URL for file access
|
||||||
|
*/
|
||||||
|
public static function createFileUrl($clientDir, $projectName, $adsFolders, $filename) {
|
||||||
|
$urlParts = array_map('rawurlencode', $adsFolders);
|
||||||
|
return "/area/" . rawurlencode($clientDir) . "/" . rawurlencode($projectName) . "/ads/"
|
||||||
|
. implode('/', $urlParts) . (count($urlParts) ? '/' : '') . rawurlencode($filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate path is within allowed client directory
|
||||||
|
*/
|
||||||
|
public static function validatePath($realPath, $clientDir) {
|
||||||
|
$allowedBasePath = realpath(__DIR__ . self::getAreaPath() . '/' . $clientDir);
|
||||||
|
return $realPath && strpos($realPath, $allowedBasePath) === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
131
backend/src/Services/MediaAnalysisService.php
Normal file
131
backend/src/Services/MediaAnalysisService.php
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../vendor/getid3/getid3.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MediaAnalysisService
|
||||||
|
* Handles media file analysis and metadata extraction
|
||||||
|
*/
|
||||||
|
class MediaAnalysisService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get image dimensions
|
||||||
|
*/
|
||||||
|
public static function getImageMetadata($filePath) {
|
||||||
|
$meta = ['size' => filesize($filePath)];
|
||||||
|
|
||||||
|
$imgInfo = @getimagesize($filePath);
|
||||||
|
if ($imgInfo) {
|
||||||
|
$meta['width'] = $imgInfo[0];
|
||||||
|
$meta['height'] = $imgInfo[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get video metadata (dimensions and duration)
|
||||||
|
*/
|
||||||
|
public static function getVideoMetadata($filePath) {
|
||||||
|
$meta = ['size' => filesize($filePath)];
|
||||||
|
|
||||||
|
// Try ffprobe first
|
||||||
|
$ffprobePath = shell_exec('which ffprobe');
|
||||||
|
$ffprobeUsed = false;
|
||||||
|
|
||||||
|
if ($ffprobePath) {
|
||||||
|
$ffprobe = trim($ffprobePath);
|
||||||
|
if ($ffprobe !== '') {
|
||||||
|
$cmd = escapeshellcmd($ffprobe) . ' -v error -select_streams v:0 -show_entries stream=width,height,duration -of default=noprint_wrappers=1:nokey=1 ' . escapeshellarg($filePath);
|
||||||
|
$out = shell_exec($cmd);
|
||||||
|
|
||||||
|
if ($out) {
|
||||||
|
$lines = explode("\n", trim($out));
|
||||||
|
if (count($lines) >= 3) {
|
||||||
|
$meta['width'] = (int)$lines[0];
|
||||||
|
$meta['height'] = (int)$lines[1];
|
||||||
|
$meta['duration'] = (float)$lines[2];
|
||||||
|
$ffprobeUsed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to getID3 if ffprobe not available or failed
|
||||||
|
if (!$ffprobeUsed) {
|
||||||
|
try {
|
||||||
|
$getID3 = new \getID3();
|
||||||
|
$info = $getID3->analyze($filePath);
|
||||||
|
|
||||||
|
if (!empty($info['video']['resolution_x']) && !empty($info['video']['resolution_y'])) {
|
||||||
|
$meta['width'] = (int)$info['video']['resolution_x'];
|
||||||
|
$meta['height'] = (int)$info['video']['resolution_y'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($info['playtime_seconds'])) {
|
||||||
|
$meta['duration'] = (float)$info['playtime_seconds'];
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Ignore errors, metadata remains empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse config.yaml for ad dimensions
|
||||||
|
*/
|
||||||
|
public static function parseAdConfig($configPath) {
|
||||||
|
if (!file_exists($configPath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$yaml = file_get_contents($configPath);
|
||||||
|
$meta = [];
|
||||||
|
$width = 0;
|
||||||
|
$height = 0;
|
||||||
|
|
||||||
|
$lines = preg_split('/\r?\n/', $yaml);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if ($width === 0 && preg_match('/width\s*:\s*["\']?([\d,.]+)\s*([a-zA-Z%]*)["\']?/i', $line, $wMatch)) {
|
||||||
|
$val = str_replace([',', ' '], '', $wMatch[1]);
|
||||||
|
$width = (int)floatval($val);
|
||||||
|
}
|
||||||
|
if ($height === 0 && preg_match('/height\s*:\s*["\']?([\d,.]+)\s*([a-zA-Z%]*)["\']?/i', $line, $hMatch)) {
|
||||||
|
$val = str_replace([',', ' '], '', $hMatch[1]);
|
||||||
|
$height = (int)floatval($val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($width > 0) $meta['width'] = $width;
|
||||||
|
if ($height > 0) $meta['height'] = $height;
|
||||||
|
|
||||||
|
return $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract dimensions from folder name (e.g., "800x250")
|
||||||
|
*/
|
||||||
|
public static function parseDimensionsFromFolderName($folderName) {
|
||||||
|
if (preg_match('/(\d{2,5})[xX](\d{2,5})/', $folderName, $matches)) {
|
||||||
|
return [
|
||||||
|
'width' => (int)$matches[1],
|
||||||
|
'height' => (int)$matches[2]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beautify tab names (replace _ and - with spaces, capitalize)
|
||||||
|
*/
|
||||||
|
public static function beautifyTabName($name) {
|
||||||
|
$name = str_replace(['_', '-'], ' ', $name);
|
||||||
|
$name = mb_strtolower($name, 'UTF-8');
|
||||||
|
$name = preg_replace_callback('/\b\w/u', function($m) {
|
||||||
|
return mb_strtoupper($m[0], 'UTF-8');
|
||||||
|
}, $name);
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
246
backend/src/Services/ProjectService.php
Normal file
246
backend/src/Services/ProjectService.php
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../Services/AuthorizationService.php';
|
||||||
|
require_once __DIR__ . '/../Services/FileSystemService.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProjectService
|
||||||
|
* Handles project-related operations
|
||||||
|
*/
|
||||||
|
class ProjectService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get projects for a specific client with ordering
|
||||||
|
*/
|
||||||
|
public static function getProjectsForClient($clientDir) {
|
||||||
|
$base = realpath(__DIR__ . FileSystemService::getAreaPath() . '/' . $clientDir);
|
||||||
|
|
||||||
|
if (!$base || !is_dir($base)) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'NOT_FOUND',
|
||||||
|
'message' => 'Kundenordner nicht gefunden.'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load client display name
|
||||||
|
$clientDisplay = self::getClientDisplayName($clientDir);
|
||||||
|
|
||||||
|
// Load project order if available
|
||||||
|
$projectOrder = self::loadProjectOrder($base);
|
||||||
|
|
||||||
|
// Collect available projects
|
||||||
|
$foundProjects = self::scanProjectsInDirectory($base, $clientDir, $clientDisplay);
|
||||||
|
|
||||||
|
// Sort projects according to order
|
||||||
|
$projects = self::sortProjectsByOrder($foundProjects, $projectOrder);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'projects' => $projects
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save project order to order_project.json
|
||||||
|
*/
|
||||||
|
public static function saveProjectOrder($clientDir, $orderData) {
|
||||||
|
$base = realpath(__DIR__ . FileSystemService::getAreaPath() . '/' . $clientDir);
|
||||||
|
|
||||||
|
if (!$base || !is_dir($base)) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'NOT_FOUND',
|
||||||
|
'message' => 'Kundenordner nicht gefunden.'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that all projects in order array actually exist
|
||||||
|
$existingProjects = self::getExistingProjectNames($base);
|
||||||
|
|
||||||
|
foreach ($orderData['order'] as $projectName) {
|
||||||
|
if (!in_array($projectName, $existingProjects)) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'INVALID_PROJECT',
|
||||||
|
'message' => "Projekt '$projectName' existiert nicht."
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save new order
|
||||||
|
$projectOrderFile = $base . '/order_project.json';
|
||||||
|
$result = file_put_contents($projectOrderFile, json_encode($orderData['order'], JSON_PRETTY_PRINT));
|
||||||
|
|
||||||
|
if ($result === false) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => [
|
||||||
|
'code' => 'WRITE_ERROR',
|
||||||
|
'message' => 'Fehler beim Speichern der order_project.json.'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Projekt-Reihenfolge gespeichert.'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get project details (logo, poster, etc.)
|
||||||
|
*/
|
||||||
|
public static function getProjectDetails($clientDir, $projectName) {
|
||||||
|
$setupDir = __DIR__ . FileSystemService::getAreaPath() . "/$clientDir/$projectName/setup";
|
||||||
|
$poster = null;
|
||||||
|
$logo = null;
|
||||||
|
|
||||||
|
if (is_dir($setupDir)) {
|
||||||
|
foreach (scandir($setupDir) as $file) {
|
||||||
|
if ($file === '.' || $file === '..') continue;
|
||||||
|
|
||||||
|
if (stripos($file, 'poster') === 0 && preg_match('/\.(jpg|jpeg|png|webp)$/i', $file)) {
|
||||||
|
$poster = '/area/' . rawurlencode($clientDir) . '/' . rawurlencode($projectName) . '/setup/' . rawurlencode($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$logo && stripos($file, 'logo') === 0 && preg_match('/\.(jpg|jpeg|png|webp)$/i', $file)) {
|
||||||
|
$logo = '/area/' . rawurlencode($clientDir) . '/' . rawurlencode($projectName) . '/setup/' . rawurlencode($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($poster && $logo) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'poster' => $poster,
|
||||||
|
'logo' => $logo,
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private helper methods
|
||||||
|
*/
|
||||||
|
|
||||||
|
private static function getClientDisplayName($clientDir) {
|
||||||
|
$loginsFile = __DIR__ . '/../../storage/data/clients.json';
|
||||||
|
$clientDisplay = $clientDir;
|
||||||
|
|
||||||
|
if (file_exists($loginsFile)) {
|
||||||
|
$logins = json_decode(file_get_contents($loginsFile), true);
|
||||||
|
foreach ($logins as $login) {
|
||||||
|
if (isset($login['dir']) && $login['dir'] === $clientDir) {
|
||||||
|
$clientDisplay = $login['dir'];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $clientDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function loadProjectOrder($basePath) {
|
||||||
|
$projectOrderFile = $basePath . '/order_project.json';
|
||||||
|
$projectOrder = [];
|
||||||
|
|
||||||
|
if (file_exists($projectOrderFile)) {
|
||||||
|
$orderContent = file_get_contents($projectOrderFile);
|
||||||
|
$decoded = json_decode($orderContent, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$projectOrder = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $projectOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function scanProjectsInDirectory($base, $clientDir, $clientDisplay) {
|
||||||
|
$foundProjects = [];
|
||||||
|
|
||||||
|
foreach (scandir($base) as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
|
||||||
|
$path = $base . '/' . $entry;
|
||||||
|
if (is_dir($path)) {
|
||||||
|
$setupDir = $path . '/setup';
|
||||||
|
$poster = null;
|
||||||
|
$logo = null;
|
||||||
|
|
||||||
|
if (is_dir($setupDir)) {
|
||||||
|
foreach (scandir($setupDir) as $file) {
|
||||||
|
if (!$poster && stripos($file, 'poster') === 0 && preg_match('/\.(jpg|jpeg|png|webp)$/i', $file)) {
|
||||||
|
$poster = '/area/' . rawurlencode($clientDir) . '/' . rawurlencode($entry) . '/setup/' . rawurlencode($file);
|
||||||
|
}
|
||||||
|
if (!$logo && stripos($file, 'logo') === 0 && preg_match('/\.(jpg|jpeg|png|webp)$/i', $file)) {
|
||||||
|
$logo = '/area/' . rawurlencode($clientDir) . '/' . rawurlencode($entry) . '/setup/' . rawurlencode($file);
|
||||||
|
}
|
||||||
|
if ($poster && $logo) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$isHE = (stripos($entry, 'HE_') === 0);
|
||||||
|
$foundProjects[$entry] = [
|
||||||
|
'name' => $entry,
|
||||||
|
'path' => $entry,
|
||||||
|
'poster' => $poster,
|
||||||
|
'logo' => $logo,
|
||||||
|
'isHE' => $isHE,
|
||||||
|
'client' => $clientDisplay
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $foundProjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sortProjectsByOrder($foundProjects, $projectOrder) {
|
||||||
|
$projects = [];
|
||||||
|
|
||||||
|
if (!empty($projectOrder)) {
|
||||||
|
// First add projects in defined order
|
||||||
|
foreach ($projectOrder as $orderedProject) {
|
||||||
|
if (isset($foundProjects[$orderedProject])) {
|
||||||
|
$projects[] = $foundProjects[$orderedProject];
|
||||||
|
unset($foundProjects[$orderedProject]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New projects (not in order list) come at the BEGINNING
|
||||||
|
$remainingProjects = array_values($foundProjects);
|
||||||
|
usort($remainingProjects, function($a, $b) {
|
||||||
|
return strcmp($a['name'], $b['name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert new projects BEFORE sorted ones
|
||||||
|
$projects = array_merge($remainingProjects, $projects);
|
||||||
|
} else {
|
||||||
|
// Fallback: Alphabetical sorting
|
||||||
|
$projects = array_values($foundProjects);
|
||||||
|
usort($projects, function($a, $b) {
|
||||||
|
return strcmp($a['name'], $b['name']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function getExistingProjectNames($basePath) {
|
||||||
|
$existingProjects = [];
|
||||||
|
foreach (scandir($basePath) as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') continue;
|
||||||
|
if (is_dir($basePath . '/' . $entry)) {
|
||||||
|
$existingProjects[] = $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $existingProjects;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,13 @@ FTP_PASS="dein-passwort"
|
|||||||
# Pfad auf dem Server (meist /htdocs, /public_html, /www oder /)
|
# Pfad auf dem Server (meist /htdocs, /public_html, /www oder /)
|
||||||
FTP_PATH="/htdocs"
|
FTP_PATH="/htdocs"
|
||||||
|
|
||||||
|
# SSH/SFTP Port (Standard: 22)
|
||||||
|
FTP_PORT="22"
|
||||||
|
|
||||||
|
# SSH-Key Authentifizierung (Optional, für SSH-Key-only Server)
|
||||||
|
# Falls gesetzt, wird SSH-Key statt Passwort verwendet
|
||||||
|
# FTP_SSH_KEY="$HOME/.ssh/id_rsa"
|
||||||
|
|
||||||
# Beispiele für verschiedene Provider:
|
# Beispiele für verschiedene Provider:
|
||||||
# Strato: FTP_HOST="ftp.strato.de" FTP_PATH="/htdocs"
|
# Strato: FTP_HOST="ftp.strato.de" FTP_PATH="/htdocs"
|
||||||
# 1&1: FTP_HOST="ftp.1und1.de" FTP_PATH="/htdocs"
|
# 1&1: FTP_HOST="ftp.1und1.de" FTP_PATH="/htdocs"
|
||||||
|
|||||||
@@ -27,10 +27,23 @@ set -a # Export aller Variablen
|
|||||||
source "$ENV_FILE"
|
source "$ENV_FILE"
|
||||||
set +a
|
set +a
|
||||||
|
|
||||||
|
# Standard-Port falls nicht gesetzt
|
||||||
|
FTP_PORT=${FTP_PORT:-22}
|
||||||
|
|
||||||
echo "🔧 KONFIGURATION:"
|
echo "🔧 KONFIGURATION:"
|
||||||
echo " Host: $FTP_HOST"
|
echo " Host: $FTP_HOST"
|
||||||
echo " User: $FTP_USER"
|
echo " User: $FTP_USER"
|
||||||
|
echo " Port: $FTP_PORT"
|
||||||
echo " Path: $FTP_PATH"
|
echo " Path: $FTP_PATH"
|
||||||
|
|
||||||
|
# SSH-Key Authentifizierung prüfen
|
||||||
|
if [ -n "$FTP_SSH_KEY" ] && [ -f "$FTP_SSH_KEY" ]; then
|
||||||
|
echo " Auth: SSH-Key ($FTP_SSH_KEY)"
|
||||||
|
USE_SSH_KEY=1
|
||||||
|
else
|
||||||
|
echo " Auth: Passwort"
|
||||||
|
USE_SSH_KEY=0
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Prüfe ob wir im deployment/scripts/ Ordner sind und wechsle zur Projekt-Root
|
# Prüfe ob wir im deployment/scripts/ Ordner sind und wechsle zur Projekt-Root
|
||||||
@@ -70,17 +83,35 @@ if [[ ! "$response" =~ ^[Yy]$ ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📤 Uploade via SFTP..."
|
echo "📤 Uploade via SFTP..."
|
||||||
lftp -c "
|
|
||||||
set sftp:auto-confirm yes;
|
|
||||||
set ssl:verify-certificate no;
|
|
||||||
open sftp://$FTP_USER:$FTP_PASS@$FTP_HOST;
|
|
||||||
cd $FTP_PATH;
|
|
||||||
|
|
||||||
lcd deployment/build;
|
if [ $USE_SSH_KEY -eq 1 ]; then
|
||||||
mirror --reverse --delete --verbose --exclude-glob=node_modules/ --exclude-glob=.git/ --exclude-glob=.* --exclude area/ ./ ./;
|
# SSH-Key Authentifizierung
|
||||||
|
lftp -c "
|
||||||
|
set sftp:auto-confirm yes;
|
||||||
|
set ssl:verify-certificate no;
|
||||||
|
set sftp:connect-program 'ssh -i $FTP_SSH_KEY -p $FTP_PORT -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null';
|
||||||
|
open sftp://$FTP_USER@$FTP_HOST;
|
||||||
|
cd $FTP_PATH;
|
||||||
|
|
||||||
|
lcd deployment/build;
|
||||||
|
mirror --reverse --delete --verbose --exclude-glob=node_modules/ --exclude-glob=.git/ --exclude-glob=.* --exclude area/ ./ ./;
|
||||||
|
|
||||||
|
bye
|
||||||
|
"
|
||||||
|
else
|
||||||
|
# Passwort Authentifizierung
|
||||||
|
lftp -c "
|
||||||
|
set sftp:auto-confirm yes;
|
||||||
|
set ssl:verify-certificate no;
|
||||||
|
open sftp://$FTP_USER:$FTP_PASS@$FTP_HOST:$FTP_PORT;
|
||||||
|
cd $FTP_PATH;
|
||||||
|
|
||||||
bye
|
lcd deployment/build;
|
||||||
"
|
mirror --reverse --delete --verbose --exclude-glob=node_modules/ --exclude-glob=.git/ --exclude-glob=.* --exclude area/ ./ ./;
|
||||||
|
|
||||||
|
bye
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
echo "✅ Upload erfolgreich!"
|
echo "✅ Upload erfolgreich!"
|
||||||
|
|||||||
149
documentation/AREA_STRUCTURE.md
Normal file
149
documentation/AREA_STRUCTURE.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# Area Directory Structure
|
||||||
|
|
||||||
|
Der `area/` Ordner enthält alle Projektdaten und Media-Dateien für die AdsPreview-Anwendung. Dieser Ordner ist aus Sicherheitsgründen und zur Größenreduzierung nicht im Git-Repository enthalten.
|
||||||
|
|
||||||
|
## 📁 Verzeichnisstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
area/
|
||||||
|
├── logins.json # Login-Konfiguration für Clients
|
||||||
|
├── [Client-Name]/ # Pro Client ein Ordner
|
||||||
|
│ ├── order_project.json # Reihenfolge der Projekte für den Client
|
||||||
|
│ └── [Projekt-Name]/ # Pro Projekt ein Ordner
|
||||||
|
│ ├── index.php # Optional: Custom PHP für das Projekt
|
||||||
|
│ ├── ads/ # Werbemittel-Ordner
|
||||||
|
│ │ ├── [Kampagne]/ # Kampagnen-Ordner
|
||||||
|
│ │ │ ├── [Format]/ # Format-spezifische Dateien
|
||||||
|
│ │ │ │ ├── [Größe]/ # Größen-spezifische Dateien
|
||||||
|
│ │ │ │ │ ├── *.jpg # Bilder
|
||||||
|
│ │ │ │ │ ├── *.png # Bilder
|
||||||
|
│ │ │ │ │ ├── *.mp4 # Videos
|
||||||
|
│ │ │ │ │ └── *.gif # Animierte Bilder
|
||||||
|
│ │ │ │ └── config.yaml # Optional: HTML5-Konfiguration
|
||||||
|
│ │ │ └── html/ # Optional: HTML5-Werbemittel
|
||||||
|
│ │ │ ├── index.html # HTML5-Anzeige
|
||||||
|
│ │ │ └── assets/ # Zusätzliche Assets
|
||||||
|
│ │ └── core/ # Optional: Legacy PHP Core-Dateien
|
||||||
|
│ └── setup/ # Projekt-Konfiguration
|
||||||
|
│ ├── setup.yaml # Projekt-Setup-Konfiguration
|
||||||
|
│ ├── logo.png # Client-Logo
|
||||||
|
│ ├── poster.jpg # Projekt-Poster
|
||||||
|
│ ├── showreel_close.svg # Showreel-Icons
|
||||||
|
│ └── showreel_dkt.png # Showreel-Assets
|
||||||
|
└── AllScreens/ # Spezielle AllScreens-Projekte
|
||||||
|
└── [Projekt-Name]/ # Struktur wie bei Client-Projekten
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Konfigurationsdateien
|
||||||
|
|
||||||
|
### `area/logins.json`
|
||||||
|
Definiert die verfügbaren Clients und deren Zugriffsdaten:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"name": "ClientName",
|
||||||
|
"folder": "ClientFolder",
|
||||||
|
"password": "client_password"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `area/[Client]/order_project.json`
|
||||||
|
Definiert die Reihenfolge der Projekte für einen Client:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"order_project": [
|
||||||
|
"Projekt1",
|
||||||
|
"Projekt2",
|
||||||
|
"Projekt3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `area/[Client]/[Projekt]/setup/setup.yaml`
|
||||||
|
Projekt-spezifische Konfiguration:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
project_name: "Projektname"
|
||||||
|
client: "Client Name"
|
||||||
|
description: "Projektbeschreibung"
|
||||||
|
showreel_enabled: true
|
||||||
|
poster_image: "poster.jpg"
|
||||||
|
logo_image: "logo.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📂 Beispiel-Struktur
|
||||||
|
|
||||||
|
```
|
||||||
|
area/
|
||||||
|
├── logins.json
|
||||||
|
├── Paramount/
|
||||||
|
│ ├── order_project.json
|
||||||
|
│ ├── Mission_Impossible_7/
|
||||||
|
│ │ ├── ads/
|
||||||
|
│ │ │ ├── Kinobanner/
|
||||||
|
│ │ │ │ ├── Cineplex/
|
||||||
|
│ │ │ │ │ └── Powerbanner_1420x420/
|
||||||
|
│ │ │ │ │ ├── PARA_MI7_Cineplex_PB_1420x420_Datum.jpg
|
||||||
|
│ │ │ │ │ └── PARA_MI7_Cineplex_PB_1420x420_Jetzt.jpg
|
||||||
|
│ │ │ │ └── Cinestar/
|
||||||
|
│ │ │ └── Programmatic/
|
||||||
|
│ │ │ ├── Billboard/
|
||||||
|
│ │ │ │ └── 800x250/
|
||||||
|
│ │ │ │ ├── config.yaml
|
||||||
|
│ │ │ │ └── html/
|
||||||
|
│ │ │ │ ├── index.html
|
||||||
|
│ │ │ │ ├── bg.jpg
|
||||||
|
│ │ │ │ └── assets/
|
||||||
|
│ │ │ └── HPA/
|
||||||
|
│ │ └── setup/
|
||||||
|
│ │ ├── setup.yaml
|
||||||
|
│ │ ├── logo.png
|
||||||
|
│ │ └── poster.jpg
|
||||||
|
│ └── HE_Lioness_S2/
|
||||||
|
└── Studiocanal/
|
||||||
|
└── HE_We_Live_in_Time/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Wichtige Hinweise
|
||||||
|
|
||||||
|
### Datei-Namenskonventionen:
|
||||||
|
- **Ordnernamen**: Keine Leerzeichen, verwende `_` oder `-`
|
||||||
|
- **Dateinamen**: Beschreibende Namen mit Client-Präfix
|
||||||
|
- **Bildformate**: JPG, PNG, GIF unterstützt
|
||||||
|
- **Videoformate**: MP4 empfohlen
|
||||||
|
|
||||||
|
### Dateigröße-Optimierung:
|
||||||
|
- **Bilder**: Komprimierte Versionen mit `_sz.jpg` Suffix für kleinere Dateien
|
||||||
|
- **Videos**: Web-optimierte MP4-Dateien
|
||||||
|
- **Thumbnails**: Kleine Preview-Versionen für Listen-Ansichten
|
||||||
|
|
||||||
|
### HTML5-Werbemittel:
|
||||||
|
- **config.yaml**: Definiert Einstellungen für interaktive Anzeigen
|
||||||
|
- **index.html**: Haupt-HTML-Datei
|
||||||
|
- **Assets**: Alle zusätzlichen Dateien (CSS, JS, Bilder)
|
||||||
|
|
||||||
|
## 🔒 Sicherheit
|
||||||
|
|
||||||
|
- Der `area/` Ordner ist in `.gitignore` ausgeschlossen
|
||||||
|
- Keine sensiblen Daten in Dateinamen verwenden
|
||||||
|
- Regelmäßige Backups des `area/` Ordners erstellen
|
||||||
|
- Zugriffskontrolle über `logins.json` verwalten
|
||||||
|
|
||||||
|
## 📋 Setup-Checkliste
|
||||||
|
|
||||||
|
1. ✅ `area/` Ordner im Projekt-Root erstellen
|
||||||
|
2. ✅ `logins.json` mit Client-Daten anlegen
|
||||||
|
3. ✅ Client-Ordner erstellen
|
||||||
|
4. ✅ `order_project.json` für jeden Client
|
||||||
|
5. ✅ Projekt-Ordner mit `setup/` Verzeichnis
|
||||||
|
6. ✅ `ads/` Struktur nach Kampagnen organisieren
|
||||||
|
7. ✅ Media-Dateien hochladen und testen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
💡 **Tipp**: Verwende die Admin-Oberfläche der AdsPreview-Anwendung zur einfachen Verwaltung von Clients und Projekten.
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# 🚀 Deployment Guide
|
# 🚀 Deployment Guide
|
||||||
|
|
||||||
|
## 📂 Wichtige Dokumentation
|
||||||
|
- **[Area Structure Guide](AREA_STRUCTURE.md)**: Aufbau des `area/` Verzeichnisses mit Projektdaten
|
||||||
|
- **[Deployment Scripts](deployment/scripts/README.md)**: Sichere Upload-Skripte mit `.env.upload`
|
||||||
|
|
||||||
## 🏠 Lokale Entwicklungsumgebung
|
## 🏠 Lokale Entwicklungsumgebung
|
||||||
|
|
||||||
### Voraussetzungen
|
### Voraussetzungen
|
||||||
@@ -66,7 +70,7 @@ public_html/ (oder htdocs/)
|
|||||||
├── area/ # Client Project Files
|
├── area/ # Client Project Files
|
||||||
│ ├── Paramount/
|
│ ├── Paramount/
|
||||||
│ ├── Studiocanal/
|
│ ├── Studiocanal/
|
||||||
│ └── project_order.json
|
│ └── order_project.json
|
||||||
├── api/ # PHP Backend
|
├── api/ # PHP Backend
|
||||||
├── storage/ # User Data (needs write permissions!)
|
├── storage/ # User Data (needs write permissions!)
|
||||||
│ └── data/
|
│ └── data/
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
||||||
<title>adspreview</title>
|
<title>AdsPreview</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -19,6 +19,41 @@ function App() {
|
|||||||
return stored === 'true';
|
return stored === 'true';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set initial background colors immediately on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const htmlElement = document.documentElement;
|
||||||
|
const bodyElement = document.body;
|
||||||
|
|
||||||
|
// Set initial colors based on current darkMode state
|
||||||
|
const bgColor = darkMode ? '#181818' : '#f5f5f5';
|
||||||
|
htmlElement.style.backgroundColor = bgColor;
|
||||||
|
bodyElement.style.backgroundColor = bgColor;
|
||||||
|
|
||||||
|
if (darkMode) {
|
||||||
|
htmlElement.classList.add('dark-mode');
|
||||||
|
} else {
|
||||||
|
htmlElement.classList.remove('dark-mode');
|
||||||
|
}
|
||||||
|
}, []); // Run only once on mount
|
||||||
|
|
||||||
|
// Update HTML class and background color when darkMode changes
|
||||||
|
useEffect(() => {
|
||||||
|
const htmlElement = document.documentElement;
|
||||||
|
const bodyElement = document.body;
|
||||||
|
|
||||||
|
if (darkMode) {
|
||||||
|
htmlElement.classList.add('dark-mode');
|
||||||
|
// Set dark mode background colors to prevent white overscroll areas
|
||||||
|
htmlElement.style.backgroundColor = '#181818';
|
||||||
|
bodyElement.style.backgroundColor = '#181818';
|
||||||
|
} else {
|
||||||
|
htmlElement.classList.remove('dark-mode');
|
||||||
|
// Set light mode background colors
|
||||||
|
htmlElement.style.backgroundColor = '#ebebeb';
|
||||||
|
bodyElement.style.backgroundColor = '#f5f5f5';
|
||||||
|
}
|
||||||
|
}, [darkMode]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('jwt');
|
const token = localStorage.getItem('jwt');
|
||||||
@@ -45,7 +80,16 @@ function App() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{error && <Alert type="error" message={error} showIcon style={{ margin: 16 }} />}
|
{error && <Alert type="error" message={error} showIcon style={{ margin: 16 }} />}
|
||||||
<LoginPage onLogin={() => setUser(null)} />
|
<LoginPage onLogin={(userData) => {
|
||||||
|
// Re-fetch user data nach erfolgreichem Login
|
||||||
|
getCurrentUser().then(res => {
|
||||||
|
if (res.success) {
|
||||||
|
setUser(res.data);
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
setUser(null);
|
||||||
|
});
|
||||||
|
}} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AntApp>
|
</AntApp>
|
||||||
@@ -99,8 +143,7 @@ function App() {
|
|||||||
>
|
>
|
||||||
<AntApp>
|
<AntApp>
|
||||||
<Router>
|
<Router>
|
||||||
<Layout style={{ minHeight: '100vh' }}>
|
<Layout style={{ minHeight: '100dvh' }}>
|
||||||
{/* Kein globaler Header mehr, Header wird ggf. in einzelnen Seiten eingebunden */}
|
|
||||||
<Layout.Content style={{ padding: 0 }}>
|
<Layout.Content style={{ padding: 0 }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
{user.role === 'admin' ? (
|
{user.role === 'admin' ? (
|
||||||
@@ -114,7 +157,16 @@ function App() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Route path="/" element={<ClientProjects user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
|
<Route path="/" element={<ClientProjects user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
|
||||||
<Route path="/login" element={<LoginPage onLogin={() => setUser(null)} />} />
|
<Route path="/login" element={<LoginPage onLogin={(userData) => {
|
||||||
|
// Re-fetch user data nach erfolgreichem Login
|
||||||
|
getCurrentUser().then(res => {
|
||||||
|
if (res.success) {
|
||||||
|
setUser(res.data);
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
setUser(null);
|
||||||
|
});
|
||||||
|
}} />} />
|
||||||
<Route path=":project/:tab?" element={<ProjectDetail user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
|
<Route path=":project/:tab?" element={<ProjectDetail user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
|
||||||
{/* Catch-All-Route für nicht gefundene Pfade */}
|
{/* Catch-All-Route für nicht gefundene Pfade */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Spin } from 'antd';
|
import { Spin } from 'antd';
|
||||||
|
import { useVideoAutoPlay, useInViewport } from '../utils/viewportUtils';
|
||||||
|
|
||||||
// Hilfsfunktion für Dateigröße
|
// Hilfsfunktion für Dateigröße
|
||||||
export function formatFileSize(bytes) {
|
export function formatFileSize(bytes) {
|
||||||
@@ -18,12 +19,48 @@ export function formatDuration(seconds) {
|
|||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hilfsfunktion zum Überprüfen ob eine Datei ignoriert werden soll
|
||||||
|
export function shouldIgnoreFile(filename) {
|
||||||
|
const ignoredFiles = [
|
||||||
|
'.DS_Store', // macOS System file
|
||||||
|
'Thumbs.db', // Windows Thumbnail cache
|
||||||
|
'desktop.ini', // Windows Desktop configuration
|
||||||
|
'.gitignore', // Git configuration
|
||||||
|
'.gitkeep', // Git placeholder
|
||||||
|
'config.yaml', // Configuration files
|
||||||
|
'config.yml',
|
||||||
|
'setup.yaml',
|
||||||
|
'setup.yml',
|
||||||
|
'.htaccess', // Apache configuration
|
||||||
|
'web.config', // IIS configuration
|
||||||
|
'.env', // Environment variables
|
||||||
|
'.env.local',
|
||||||
|
'README.md', // Documentation
|
||||||
|
'readme.txt',
|
||||||
|
'license.txt',
|
||||||
|
'LICENSE'
|
||||||
|
];
|
||||||
|
|
||||||
|
return ignoredFiles.some(ignored =>
|
||||||
|
filename.toLowerCase() === ignored.toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Datei-Vorschau-Komponente mit sofortiger Zoom-Skalierung und Loading-State
|
// Datei-Vorschau-Komponente mit sofortiger Zoom-Skalierung und Loading-State
|
||||||
export default function FilePreview({ file, zoom = 1, darkMode = false }) {
|
export default function FilePreview({ file, zoom = 1, darkMode = false }) {
|
||||||
|
// Frühe Rückgabe für ignorierte Dateien
|
||||||
|
if (shouldIgnoreFile(file.name)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const [loaded, setLoaded] = React.useState(false);
|
const [loaded, setLoaded] = React.useState(false);
|
||||||
const width = file.width || 300;
|
const width = file.width || 300;
|
||||||
const height = file.height || 200;
|
const height = file.height || 200;
|
||||||
|
|
||||||
|
// Viewport-Detection für Videos und iFrames
|
||||||
|
const { containerRef: videoContainerRef, videoRef, isInViewport: videoInViewport } = useVideoAutoPlay();
|
||||||
|
const { ref: iframeRef, isInViewport: iframeInViewport } = useInViewport();
|
||||||
|
|
||||||
// Wrapper für saubere Skalierung
|
// Wrapper für saubere Skalierung
|
||||||
const wrapperStyle = {
|
const wrapperStyle = {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
@@ -45,7 +82,7 @@ export default function FilePreview({ file, zoom = 1, darkMode = false }) {
|
|||||||
const renderFileContent = () => {
|
const renderFileContent = () => {
|
||||||
if (file.type === 'html') {
|
if (file.type === 'html') {
|
||||||
return (
|
return (
|
||||||
<div style={wrapperStyle}>
|
<div style={wrapperStyle} ref={iframeRef}>
|
||||||
<div style={innerStyle}>
|
<div style={innerStyle}>
|
||||||
{!loaded && (
|
{!loaded && (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -64,14 +101,34 @@ export default function FilePreview({ file, zoom = 1, darkMode = false }) {
|
|||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<iframe
|
{/* iFrame nur laden wenn im Viewport oder bereits geladen */}
|
||||||
src={file.url}
|
{iframeInViewport && (
|
||||||
title={file.name}
|
<iframe
|
||||||
width={width}
|
src={file.url}
|
||||||
height={height}
|
title={file.name}
|
||||||
style={{ border: 'none', opacity: loaded ? 1 : 0 }}
|
width={width}
|
||||||
onLoad={() => setLoaded(true)}
|
height={height}
|
||||||
/>
|
style={{ border: 'none', opacity: loaded ? 1 : 0 }}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Placeholder wenn nicht im Viewport */}
|
||||||
|
{!iframeInViewport && (
|
||||||
|
<div style={{
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
backgroundColor: darkMode ? '#1f1f1f' : '#f5f5f5',
|
||||||
|
border: `1px solid ${darkMode ? '#404040' : '#d9d9d9'}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: darkMode ? '#888' : '#666',
|
||||||
|
fontSize: 14
|
||||||
|
}}>
|
||||||
|
HTML Animation
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -112,7 +169,7 @@ export default function FilePreview({ file, zoom = 1, darkMode = false }) {
|
|||||||
}
|
}
|
||||||
if (file.type === 'video') {
|
if (file.type === 'video') {
|
||||||
return (
|
return (
|
||||||
<div style={wrapperStyle}>
|
<div style={wrapperStyle} ref={videoContainerRef}>
|
||||||
<div style={innerStyle}>
|
<div style={innerStyle}>
|
||||||
{!loaded && (
|
{!loaded && (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -132,11 +189,11 @@ export default function FilePreview({ file, zoom = 1, darkMode = false }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<video
|
<video
|
||||||
|
ref={videoRef}
|
||||||
src={file.url}
|
src={file.url}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
|
||||||
loop
|
loop
|
||||||
muted
|
muted
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
@@ -148,6 +205,10 @@ export default function FilePreview({ file, zoom = 1, darkMode = false }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Sonstige Datei: Prüfen ob ignoriert werden soll
|
||||||
|
if (shouldIgnoreFile(file.name)) {
|
||||||
|
return null; // Ignorierte Dateien nicht anzeigen
|
||||||
|
}
|
||||||
// Sonstige Datei: Link
|
// Sonstige Datei: Link
|
||||||
return (
|
return (
|
||||||
<a href={file.url} target="_blank" rel="noopener noreferrer">{file.name}</a>
|
<a href={file.url} target="_blank" rel="noopener noreferrer">{file.name}</a>
|
||||||
@@ -155,7 +216,7 @@ export default function FilePreview({ file, zoom = 1, darkMode = false }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', marginBottom: 16, marginTop: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', marginBottom: 16, marginTop: 8 }}>
|
||||||
{renderFileContent()}
|
{renderFileContent()}
|
||||||
{/* Badge für jede einzelne Datei */}
|
{/* Badge für jede einzelne Datei */}
|
||||||
{(file.width || file.height || file.size || file.duration) && (
|
{(file.width || file.height || file.size || file.duration) && (
|
||||||
|
|||||||
@@ -58,13 +58,6 @@ export default function UserMenu({ user, onLogout, darkMode, onToggleDarkMode, s
|
|||||||
onClick: clearAdsCache,
|
onClick: clearAdsCache,
|
||||||
},
|
},
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
{
|
|
||||||
key: 'settings',
|
|
||||||
icon: <SettingOutlined />,
|
|
||||||
label: 'Einstellungen (bald)',
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
{ type: 'divider' },
|
|
||||||
{
|
{
|
||||||
key: 'logout',
|
key: 'logout',
|
||||||
icon: <LogoutOutlined />,
|
icon: <LogoutOutlined />,
|
||||||
|
|||||||
@@ -9,9 +9,75 @@
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
padding: 9px 32px;
|
padding: 9px 32px;
|
||||||
margin-bottom: 0px;
|
margin-bottom: 0px;
|
||||||
|
background: #001f1e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ant-btn-variant-outlined:disabled {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs-nav button.ant-tabs-nav-more {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-layout-header.overview-header {
|
||||||
|
padding: 0 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-tabs-content {
|
.ant-tabs-content {
|
||||||
padding: 0px 32px;
|
padding: 0px 32px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust tab navigation padding on mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ant-tabs > .ant-tabs-nav {
|
||||||
|
padding: 9px 16px; /* Reduced padding on mobile */
|
||||||
|
}
|
||||||
|
.ant-layout-header.overview-header {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
.ant-tabs-content {
|
||||||
|
padding: 0px 16px; /* Reduced padding on mobile */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile project header */
|
||||||
|
.mobile-project-header {
|
||||||
|
padding: 0px 16px !important;
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
z-index: 100 !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: space-between !important;
|
||||||
|
gap: 12px !important;
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 64px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile project content - add top padding to account for fixed header */
|
||||||
|
.mobile-project-content {
|
||||||
|
padding-top: 64px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile select styling */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ant-select .ant-select-selector {
|
||||||
|
background: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-select .ant-select-selection-item {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-select .ant-select-arrow {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -198,7 +198,7 @@ export default function ClientProjects({ user, darkMode, onLogout, onToggleDarkM
|
|||||||
cursor: ${sortMode ? 'grabbing' : 'default'};
|
cursor: ${sortMode ? 'grabbing' : 'default'};
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
<Layout.Header style={{
|
<Layout.Header className='overview-header' style={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
top: 0,
|
top: 0,
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
@@ -206,8 +206,7 @@ export default function ClientProjects({ user, darkMode, onLogout, onToggleDarkM
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
background: darkMode ? '#001f1e' : '#001f1e',
|
background: darkMode ? '#001f1e' : '#001f1e',
|
||||||
color: darkMode ? '#fff' : '#fff',
|
color: darkMode ? '#fff' : '#fff'
|
||||||
padding: '0 32px'
|
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontWeight: 700, fontSize: 20, color: darkMode ? '#fff' : undefined }}>Übersicht</div>
|
<div style={{ fontWeight: 700, fontSize: 20, color: darkMode ? '#fff' : undefined }}>Übersicht</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Form, Input, Button, Typography, Alert, message } from 'antd';
|
import { Form, Input, Button, Typography, Alert, message, Switch } from 'antd';
|
||||||
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
import { LockOutlined, UserOutlined, SettingOutlined, SunOutlined, MoonOutlined } from '@ant-design/icons';
|
||||||
|
import { preserveFullUrl } from '../utils/urlUtils';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|
||||||
@@ -8,7 +9,30 @@ const { Title } = Typography;
|
|||||||
export default function LoginPage({ onLogin }) {
|
export default function LoginPage({ onLogin }) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const darkMode = typeof window !== 'undefined' && localStorage.getItem('darkMode') === 'true';
|
const [isAdminMode, setIsAdminMode] = useState(false);
|
||||||
|
const [darkMode, setDarkMode] = useState(
|
||||||
|
typeof window !== 'undefined' && localStorage.getItem('darkMode') === 'true'
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleDarkMode = () => {
|
||||||
|
const newDarkMode = !darkMode;
|
||||||
|
setDarkMode(newDarkMode);
|
||||||
|
localStorage.setItem('darkMode', newDarkMode.toString());
|
||||||
|
|
||||||
|
// Update HTML class for immediate visual feedback
|
||||||
|
const htmlElement = document.documentElement;
|
||||||
|
const bodyElement = document.body;
|
||||||
|
|
||||||
|
if (newDarkMode) {
|
||||||
|
htmlElement.classList.add('dark-mode');
|
||||||
|
htmlElement.style.backgroundColor = '#181818';
|
||||||
|
bodyElement.style.backgroundColor = '#181818';
|
||||||
|
} else {
|
||||||
|
htmlElement.classList.remove('dark-mode');
|
||||||
|
htmlElement.style.backgroundColor = '#f5f5f5';
|
||||||
|
bodyElement.style.backgroundColor = '#f5f5f5';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onFinish = async (values) => {
|
const onFinish = async (values) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -23,10 +47,15 @@ export default function LoginPage({ onLogin }) {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
localStorage.setItem('jwt', data.token);
|
localStorage.setItem('jwt', data.token);
|
||||||
message.success('Login erfolgreich!');
|
message.success('Login erfolgreich!');
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = window.location.pathname + window.location.search || '/';
|
// Callback für App.js um Re-Auth zu triggern
|
||||||
}, 600);
|
|
||||||
onLogin && onLogin(data);
|
onLogin && onLogin(data);
|
||||||
|
|
||||||
|
// Forciere einen harten Reload mit der vollständigen URL inklusive Hash
|
||||||
|
setTimeout(() => {
|
||||||
|
// Verwende assign() statt replace() für bessere Browser-Kompatibilität
|
||||||
|
window.location.assign(preserveFullUrl());
|
||||||
|
}, 500);
|
||||||
} else {
|
} else {
|
||||||
setError(data.error?.message || 'Login fehlgeschlagen');
|
setError(data.error?.message || 'Login fehlgeschlagen');
|
||||||
}
|
}
|
||||||
@@ -40,15 +69,34 @@ export default function LoginPage({ onLogin }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
minHeight: '100vh',
|
minHeight: '100dvh',
|
||||||
width: '100vw',
|
width: '100vw',
|
||||||
background: darkMode ? '#181818' : '#f5f5f5',
|
background: darkMode ? '#181818' : '#f5f5f5',
|
||||||
transition: 'background 0.2s',
|
transition: 'background 0.2s',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
position: 'relative'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Dark Mode Toggle in der oberen rechten Ecke */}
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
icon={darkMode ? <SunOutlined /> : <MoonOutlined />}
|
||||||
|
onClick={toggleDarkMode}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
color: darkMode ? '#fff' : '#666',
|
||||||
|
backgroundColor: darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
|
||||||
|
border: 'none',
|
||||||
|
width: 40,
|
||||||
|
height: 40
|
||||||
|
}}
|
||||||
|
title={darkMode ? 'Light Mode aktivieren' : 'Dark Mode aktivieren'}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 350,
|
width: 350,
|
||||||
@@ -56,20 +104,61 @@ export default function LoginPage({ onLogin }) {
|
|||||||
background: darkMode ? '#1f1f1f' : '#fff',
|
background: darkMode ? '#1f1f1f' : '#fff',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
boxShadow: darkMode ? '0 2px 8px #222' : '0 2px 8px #eee',
|
boxShadow: darkMode ? '0 2px 8px #222' : '0 2px 8px #eee',
|
||||||
color: darkMode ? '#fff' : undefined
|
color: darkMode ? '#fff' : '#1f1f1f'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Title level={3} style={{ textAlign: 'center', color: darkMode ? '#fff' : undefined }}>Anmeldung</Title>
|
<Title level={3} style={{ textAlign: 'center', color: darkMode ? '#fff' : '#1f1f1f' }}>
|
||||||
|
{isAdminMode ? 'Admin-Anmeldung' : 'Benutzer-Anmeldung'}
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
{/* Admin-Mode Toggle */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 24,
|
||||||
|
gap: 8
|
||||||
|
}}>
|
||||||
|
<SettingOutlined style={{ color: darkMode ? '#888' : '#666' }} />
|
||||||
|
<span style={{ color: darkMode ? '#888' : '#666', fontSize: 14 }}>Admin-Login</span>
|
||||||
|
<Switch
|
||||||
|
checked={isAdminMode}
|
||||||
|
onChange={setIsAdminMode}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <Alert type="error" message={error} showIcon style={{ marginBottom: 16 }} />}
|
{error && <Alert type="error" message={error} showIcon style={{ marginBottom: 16 }} />}
|
||||||
<Form name="login" onFinish={onFinish} layout="vertical">
|
<Form name="login" onFinish={onFinish} layout="vertical">
|
||||||
<Form.Item name="username" label={<span style={{ color: darkMode ? '#fff' : undefined }}>Benutzername (nur Admin)</span>} >
|
{/* Benutzername-Feld nur im Admin-Mode */}
|
||||||
<Input prefix={<UserOutlined />} placeholder="Benutzername" autoComplete="username" style={darkMode ? { background: '#222', color: '#fff' } : {}} />
|
{isAdminMode && (
|
||||||
</Form.Item>
|
<Form.Item
|
||||||
<Form.Item name="password" label={<span style={{ color: darkMode ? '#fff' : undefined }}>Passwort</span>} rules={[{ required: true, message: 'Bitte Passwort eingeben!' }]}>
|
name="username"
|
||||||
<Input.Password prefix={<LockOutlined />} placeholder="Passwort" autoComplete="current-password" style={darkMode ? { background: '#222', color: '#fff' } : {}} />
|
label={<span style={{ color: darkMode ? '#fff' : '#1f1f1f' }}>Benutzername</span>}
|
||||||
|
rules={[{ required: isAdminMode, message: 'Bitte Benutzername eingeben!' }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<UserOutlined />}
|
||||||
|
placeholder="Admin-Benutzername"
|
||||||
|
autoComplete="username"
|
||||||
|
style={darkMode ? { background: '#222', color: '#fff' } : {}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
label={<span style={{ color: darkMode ? '#fff' : '#1f1f1f' }}>Passwort</span>}
|
||||||
|
rules={[{ required: true, message: 'Bitte Passwort eingeben!' }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
placeholder={isAdminMode ? "Admin-Passwort" : "Passwort"}
|
||||||
|
autoComplete="current-password"
|
||||||
|
style={darkMode ? { background: '#222', color: '#fff' } : {}}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||||
Einloggen
|
{isAdminMode ? 'Als Admin einloggen' : 'Einloggen'}
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Tabs, Button, Spin, Skeleton, Result } from 'antd';
|
import { Tabs, Button, Spin, Skeleton, Result, Grid, Select, Layout, App } from 'antd';
|
||||||
import { ArrowLeftOutlined, LoadingOutlined, LockOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined, LoadingOutlined, LockOutlined, MinusOutlined, PlusOutlined, EllipsisOutlined } from '@ant-design/icons';
|
||||||
import UserMenu from '../components/UserMenu';
|
import UserMenu from '../components/UserMenu';
|
||||||
import FilePreview, { formatFileSize, formatDuration } from '../components/FilePreview';
|
import { useZoomState } from '../utils/zoomUtils';
|
||||||
|
import { scrollToAnchor, createCopyLinkHandler, createRefreshAdHandler } from '../utils/adUtils';
|
||||||
|
import { buildTabsFromCategories } from '../utils/tabUtils';
|
||||||
import debugLogger from '../utils/debugLogger';
|
import debugLogger from '../utils/debugLogger';
|
||||||
|
|
||||||
const backendUrl = process.env.REACT_APP_BACKEND || '';
|
const backendUrl = process.env.REACT_APP_BACKEND || '';
|
||||||
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
function ProjectDetail({ user, darkMode, onLogout, onToggleDarkMode, overrideParams, ...props }) {
|
function ProjectDetail({ user, darkMode, onLogout, onToggleDarkMode, overrideParams, ...props }) {
|
||||||
// URL-Parameter holen, mit Override-Support für Smart Resolution
|
// URL-Parameter holen, mit Override-Support für Smart Resolution
|
||||||
@@ -16,6 +19,10 @@ function ProjectDetail({ user, darkMode, onLogout, onToggleDarkMode, overridePar
|
|||||||
const client = user.role === 'admin' ? routeClient : user.client;
|
const client = user.role === 'admin' ? routeClient : user.client;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Responsive breakpoints
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
const isMobile = !screens.md; // md breakpoint ist 768px
|
||||||
|
|
||||||
debugLogger.routing('ProjectDetail - Received params:', {
|
debugLogger.routing('ProjectDetail - Received params:', {
|
||||||
routeParams,
|
routeParams,
|
||||||
overrideParams,
|
overrideParams,
|
||||||
@@ -29,22 +36,20 @@ function ProjectDetail({ user, darkMode, onLogout, onToggleDarkMode, overridePar
|
|||||||
// Hole Projekt-Logo (und ggf. weitere Metadaten)
|
// Hole Projekt-Logo (und ggf. weitere Metadaten)
|
||||||
const [projectLogo, setProjectLogo] = useState(null);
|
const [projectLogo, setProjectLogo] = useState(null);
|
||||||
|
|
||||||
// Zoom-Logik (muss im Component-Scope stehen, nicht in useEffect!)
|
// Zoom-Logik mit Custom Hook
|
||||||
const ZOOM_LEVELS = [0.15, 0.25, 0.5, 0.75, 1];
|
const { zoom, handleZoom, ZOOM_LEVELS } = useZoomState();
|
||||||
const [zoom, setZoom] = useState(() => {
|
|
||||||
const z = parseFloat(localStorage.getItem('adsZoom') || '1');
|
// Message-Hook aus App-Kontext
|
||||||
return ZOOM_LEVELS.includes(z) ? z : 1;
|
const { message } = App.useApp();
|
||||||
});
|
|
||||||
function handleZoom(dir) {
|
// Handler für Ad-Aktionen mit Message-Callbacks
|
||||||
setZoom(z => {
|
const handleCopyLink = createCopyLinkHandler((msg) => message.success(msg));
|
||||||
const idx = ZOOM_LEVELS.indexOf(z);
|
const handleRefreshAd = createRefreshAdHandler(
|
||||||
let next = z;
|
(msg) => message.success(msg),
|
||||||
if (dir === 'in') next = ZOOM_LEVELS[Math.min(ZOOM_LEVELS.length - 1, idx + 1)];
|
(msg) => message.info(msg)
|
||||||
if (dir === 'out') next = ZOOM_LEVELS[Math.max(0, idx - 1)];
|
);
|
||||||
localStorage.setItem('adsZoom', next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchLogo() {
|
async function fetchLogo() {
|
||||||
@@ -79,54 +84,10 @@ function ProjectDetail({ user, darkMode, onLogout, onToggleDarkMode, overridePar
|
|||||||
// Tabs mit useMemo erstellen, damit sie sich bei Zoom-Änderung aktualisieren ohne API-Call
|
// Tabs mit useMemo erstellen, damit sie sich bei Zoom-Änderung aktualisieren ohne API-Call
|
||||||
const tabs = React.useMemo(() => {
|
const tabs = React.useMemo(() => {
|
||||||
if (!tabsData) return [];
|
if (!tabsData) return [];
|
||||||
return buildTabsFromCategories(tabsData);
|
return buildTabsFromCategories(tabsData, zoom, darkMode, handleCopyLink, handleRefreshAd);
|
||||||
}, [tabsData, zoom]);
|
}, [tabsData, zoom, darkMode, handleCopyLink, handleRefreshAd]);
|
||||||
|
|
||||||
|
|
||||||
function buildTabsFromCategories(data) {
|
|
||||||
let categories = [];
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
categories = data.filter(child => child.type === 'category');
|
|
||||||
} else if (data && Array.isArray(data.children)) {
|
|
||||||
categories = data.children.filter(child => child.type === 'category');
|
|
||||||
}
|
|
||||||
return categories.map(cat => ({
|
|
||||||
key: cat.title,
|
|
||||||
label: cat.title,
|
|
||||||
children: (
|
|
||||||
<div>
|
|
||||||
{Array.isArray(cat.children) && cat.children.filter(sub => sub.type === 'subcategory').length > 0 ? (
|
|
||||||
cat.children.filter(sub => sub.type === 'subcategory').map(sub => (
|
|
||||||
<div key={sub.name} style={{ marginTop: 24 }}>
|
|
||||||
<h2 style={{ marginBottom: 4 }}>{sub.name}</h2>
|
|
||||||
{/* Ads unterhalb der Subcategory anzeigen */}
|
|
||||||
{Array.isArray(sub.children) && sub.children.filter(ad => ad.type === 'ad').length > 0 ? (
|
|
||||||
sub.children.filter(ad => ad.type === 'ad').map(ad => (
|
|
||||||
<div key={ad.name} style={{ marginLeft: 0, marginBottom: 16 }}>
|
|
||||||
<div style={{ color: '#555', fontSize: 15, display: 'flex', alignItems: 'center', gap: 8 }}>{ad.name}</div>
|
|
||||||
{/* Dateien unterhalb des Ads anzeigen */}
|
|
||||||
{Array.isArray(ad.files) && ad.files.length > 0 ? (
|
|
||||||
<div style={{ marginLeft: 0, display: 'flex', flexWrap: 'wrap', gap: 32 }}>
|
|
||||||
{ad.files.map(file => (
|
|
||||||
<FilePreview key={file.name + '__' + zoom} file={file} zoom={zoom} darkMode={darkMode} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ color: '#bbb', marginLeft: 16, fontSize: 13 }}>Keine Dateien vorhanden.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div style={{ color: '#aaa', marginLeft: 16, fontSize: 14 }}>Keine Ads vorhanden.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div style={{ color: '#888', marginTop: 16 }}>Keine Subkategorien vorhanden.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Wenn Routing-Parameter fehlen, Tabs zurücksetzen
|
// Wenn Routing-Parameter fehlen, Tabs zurücksetzen
|
||||||
@@ -227,6 +188,14 @@ function ProjectDetail({ user, darkMode, onLogout, onToggleDarkMode, overridePar
|
|||||||
fetchTabs();
|
fetchTabs();
|
||||||
}, [user, client, project, tab]);
|
}, [user, client, project, tab]);
|
||||||
|
|
||||||
|
// Scroll zu Sprungmarke nach dem Laden
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && tabs.length > 0) {
|
||||||
|
const hash = window.location.hash.substring(1);
|
||||||
|
scrollToAnchor(hash, 20);
|
||||||
|
}
|
||||||
|
}, [loading, tabs, activeKey]);
|
||||||
|
|
||||||
// Tab-Wechsel: Schreibe Tab in die URL
|
// Tab-Wechsel: Schreibe Tab in die URL
|
||||||
const handleTabChange = (key) => {
|
const handleTabChange = (key) => {
|
||||||
setActiveKey(key);
|
setActiveKey(key);
|
||||||
@@ -238,53 +207,93 @@ function ProjectDetail({ user, darkMode, onLogout, onToggleDarkMode, overridePar
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Finde den aktuellen Tab-Content
|
||||||
|
const activeTabContent = tabs.find(tab => tab.key === activeKey)?.children;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<style>{`
|
|
||||||
.ads-details-tabs .ant-tabs-nav {
|
|
||||||
background: ${darkMode ? '#1f1f1f' : '#001f1e'};
|
|
||||||
color: ${darkMode ? '#fff' : '#fff'};
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
{/* Zoom-Buttons jetzt in tabBarExtraContent.right */}
|
|
||||||
<Spin spinning={loading} indicator={<LoadingOutlined spin />} size="large" tip="Lade Projektdaten..." fullscreen />
|
<Spin spinning={loading} indicator={<LoadingOutlined spin />} size="large" tip="Lade Projektdaten..." fullscreen />
|
||||||
{!loading && (
|
{!loading && (
|
||||||
tabs.length > 0 ? (
|
tabs.length > 0 ? (
|
||||||
<Tabs
|
isMobile ? (
|
||||||
className="ads-details-tabs"
|
// Mobile Darstellung mit Select und separatem Content
|
||||||
items={tabs}
|
<Layout>
|
||||||
activeKey={activeKey}
|
<Layout.Header className="mobile-project-header" style={{
|
||||||
onChange={handleTabChange}
|
background: darkMode ? '#001f1e' : '#001f1e', // Dynamisch, falls später unterschiedliche Themes gewünscht
|
||||||
tabBarExtraContent={{
|
color: darkMode ? '#fff' : '#fff'
|
||||||
left: (
|
}}>
|
||||||
<span style={{ display: 'flex', alignItems: 'center' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1 }}>
|
||||||
<Button
|
<Button
|
||||||
icon={<ArrowLeftOutlined />}
|
icon={<ArrowLeftOutlined style={{ color: '#001f1e' }} />}
|
||||||
onClick={() => navigate('/')}
|
onClick={() => navigate('/')}
|
||||||
style={{ marginRight: projectLogo ? 12 : 0 }}
|
size="small"
|
||||||
/>
|
/>
|
||||||
{projectLogo && (
|
<Select
|
||||||
<img
|
value={activeKey}
|
||||||
src={projectLogo}
|
onChange={handleTabChange}
|
||||||
alt="Logo"
|
style={{
|
||||||
style={{ height: 38, marginRight: 32, objectFit: 'contain' }}
|
flex: 1,
|
||||||
/>
|
minWidth: 120,
|
||||||
)}
|
height: 26
|
||||||
</span>
|
}}
|
||||||
),
|
size="middle"
|
||||||
right: (
|
suffixIcon={<EllipsisOutlined style={{ color: '#fff' }} />}
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
options={tabs.map(tab => ({
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
value: tab.key,
|
||||||
<Button size="small" onClick={() => handleZoom('out')} disabled={zoom <= ZOOM_LEVELS[0]}>-</Button>
|
label: tab.label
|
||||||
<span style={{ minWidth: 48, textAlign: 'center' }}>{Math.round(zoom * 100)}%</span>
|
}))}
|
||||||
<Button size="small" onClick={() => handleZoom('in')} disabled={zoom >= ZOOM_LEVELS[ZOOM_LEVELS.length - 1]}>+</Button>
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Button size="small" shape="circle" icon={<MinusOutlined style={{ color: '#001f1e' }} />} onClick={() => handleZoom('out')} disabled={zoom <= ZOOM_LEVELS[0]}></Button>
|
||||||
|
<span style={{ minWidth: 36, textAlign: 'center', fontSize: 12 }}>{Math.round(zoom * 100)}%</span>
|
||||||
|
<Button size="small" shape="circle" icon={<PlusOutlined style={{ color: '#001f1e' }} />} onClick={() => handleZoom('in')} disabled={zoom >= ZOOM_LEVELS[ZOOM_LEVELS.length - 1]}></Button>
|
||||||
</div>
|
</div>
|
||||||
<UserMenu user={user} onLogout={onLogout} darkMode={darkMode} onToggleDarkMode={onToggleDarkMode} />
|
<UserMenu user={user} onLogout={onLogout} darkMode={darkMode} onToggleDarkMode={onToggleDarkMode} />
|
||||||
</span>
|
</div>
|
||||||
)
|
</Layout.Header>
|
||||||
}}
|
<Layout.Content className="mobile-project-content" style={{ padding: '0px 16px' }}>
|
||||||
/>
|
{activeTabContent}
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
) : (
|
||||||
|
// Desktop Darstellung mit Tabs
|
||||||
|
<Tabs
|
||||||
|
items={tabs}
|
||||||
|
more={{ icon: <EllipsisOutlined />, trigger: 'click' }}
|
||||||
|
activeKey={activeKey}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
tabBarExtraContent={{
|
||||||
|
left: (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined style={{ color: '#001f1e' }} />}
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
style={{ marginRight: projectLogo ? 12 : 0 }}
|
||||||
|
/>
|
||||||
|
{projectLogo && (
|
||||||
|
<img
|
||||||
|
src={projectLogo}
|
||||||
|
alt="Logo"
|
||||||
|
style={{ height: 38, marginRight: 32, objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
right: (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Button size="small" shape="circle" icon={<MinusOutlined style={{ color: '#001f1e' }} />} onClick={() => handleZoom('out')} disabled={zoom <= ZOOM_LEVELS[0]}></Button>
|
||||||
|
<span style={{ minWidth: 48, textAlign: 'center' }}>{Math.round(zoom * 100)}%</span>
|
||||||
|
<Button size="small" shape="circle" icon={<PlusOutlined style={{ color: '#001f1e' }} />} onClick={() => handleZoom('in')} disabled={zoom >= ZOOM_LEVELS[ZOOM_LEVELS.length - 1]}></Button>
|
||||||
|
</div>
|
||||||
|
<UserMenu user={user} onLogout={onLogout} darkMode={darkMode} onToggleDarkMode={onToggleDarkMode} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
) : error ? (
|
) : error ? (
|
||||||
// Spezifische Fehlerbehandlung
|
// Spezifische Fehlerbehandlung
|
||||||
<Result
|
<Result
|
||||||
|
|||||||
106
frontend/src/utils/adUtils.js
Normal file
106
frontend/src/utils/adUtils.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Hash-Funktion für Ad-IDs
|
||||||
|
export function generateAdId(adName, subcategoryName, categoryName) {
|
||||||
|
const combined = `${categoryName}-${subcategoryName}-${adName}`;
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < combined.length; i++) {
|
||||||
|
const char = combined.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + char;
|
||||||
|
hash = hash & hash; // 32-bit integer
|
||||||
|
}
|
||||||
|
return Math.abs(hash).toString(36); // Base36 für kürzere IDs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link kopieren Funktionalität
|
||||||
|
export const createCopyLinkHandler = (onSuccess) => async (adId, adName) => {
|
||||||
|
try {
|
||||||
|
// Entferne bestehende Sprungmarke aus der URL
|
||||||
|
const currentUrl = window.location.href;
|
||||||
|
const baseUrl = currentUrl.split('#')[0]; // Alles vor dem ersten #
|
||||||
|
const linkWithAnchor = `${baseUrl}#${adId}`;
|
||||||
|
await navigator.clipboard.writeText(linkWithAnchor);
|
||||||
|
onSuccess(`Link zu "${adName}" wurde kopiert!`);
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback für ältere Browser
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
const baseUrl = window.location.href.split('#')[0];
|
||||||
|
textArea.value = `${baseUrl}#${adId}`;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
onSuccess(`Link zu "${adName}" wurde kopiert!`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// iFrame und Video Refresh Funktionalität
|
||||||
|
export const createRefreshAdHandler = (onSuccess, onInfo) => (adId, adName) => {
|
||||||
|
// Finde alle iFrames und Videos innerhalb des Ad-Containers
|
||||||
|
const adContainer = document.getElementById(adId);
|
||||||
|
if (adContainer) {
|
||||||
|
const iframes = adContainer.querySelectorAll('iframe');
|
||||||
|
const videos = adContainer.querySelectorAll('video');
|
||||||
|
let refreshedCount = 0;
|
||||||
|
let restartedCount = 0;
|
||||||
|
|
||||||
|
// iFrames refreshen (mit Cache-Busting)
|
||||||
|
iframes.forEach((iframe) => {
|
||||||
|
if (iframe.src) {
|
||||||
|
// Füge einen Timestamp als URL-Parameter hinzu, um den Cache zu umgehen
|
||||||
|
const url = new URL(iframe.src);
|
||||||
|
url.searchParams.set('refresh', Date.now().toString());
|
||||||
|
iframe.src = url.toString();
|
||||||
|
refreshedCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Videos neu starten
|
||||||
|
videos.forEach((video) => {
|
||||||
|
if (video.src || video.currentSrc) {
|
||||||
|
video.currentTime = 0; // Zurück zum Start
|
||||||
|
video.play().catch(err => {
|
||||||
|
// Autoplay kann durch Browser-Richtlinien blockiert werden
|
||||||
|
console.log('Video restart prevented:', err);
|
||||||
|
});
|
||||||
|
restartedCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCount = refreshedCount + restartedCount;
|
||||||
|
if (totalCount > 0) {
|
||||||
|
let message = '';
|
||||||
|
if (refreshedCount > 0 && restartedCount > 0) {
|
||||||
|
message = `${refreshedCount} Animation${refreshedCount > 1 ? 'en' : ''} und ${restartedCount} Video${restartedCount > 1 ? 's' : ''} in "${adName}" ${totalCount > 1 ? 'wurden' : 'wurde'} neu gestartet!`;
|
||||||
|
} else if (refreshedCount > 0) {
|
||||||
|
message = `${refreshedCount} Animation${refreshedCount > 1 ? 'en' : ''} in "${adName}" ${refreshedCount > 1 ? 'wurden' : 'wurde'} neu geladen!`;
|
||||||
|
} else if (restartedCount > 0) {
|
||||||
|
message = `${restartedCount} Video${restartedCount > 1 ? 's' : ''} in "${adName}" ${restartedCount > 1 ? 'wurden' : 'wurde'} neu gestartet!`;
|
||||||
|
}
|
||||||
|
onSuccess(message);
|
||||||
|
} else {
|
||||||
|
onInfo(`Keine Animationen oder Videos in "${adName}" gefunden.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scroll zu Sprungmarke mit Offset
|
||||||
|
export const scrollToAnchor = (hash, offset = 20) => {
|
||||||
|
if (hash) {
|
||||||
|
// Kurz warten bis die Inhalte gerendert sind
|
||||||
|
setTimeout(() => {
|
||||||
|
const element = document.getElementById(hash);
|
||||||
|
if (element) {
|
||||||
|
// Scroll mit Offset, um Navigation zu berücksichtigen
|
||||||
|
const elementTop = element.offsetTop - offset;
|
||||||
|
window.scrollTo({
|
||||||
|
top: elementTop,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
// Kurz hervorheben
|
||||||
|
element.style.backgroundColor = '#edff5f';
|
||||||
|
setTimeout(() => {
|
||||||
|
element.style.backgroundColor = '';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
77
frontend/src/utils/tabUtils.js
Normal file
77
frontend/src/utils/tabUtils.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, Tooltip } from 'antd';
|
||||||
|
import { ShareAltOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import FilePreview from '../components/FilePreview';
|
||||||
|
import { generateAdId } from './adUtils';
|
||||||
|
|
||||||
|
// Tabs aus Kategorien-Daten erstellen
|
||||||
|
export const buildTabsFromCategories = (data, zoom, darkMode, handleCopyLink, handleRefreshAd) => {
|
||||||
|
let categories = [];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
categories = data.filter(child => child.type === 'category');
|
||||||
|
} else if (data && Array.isArray(data.children)) {
|
||||||
|
categories = data.children.filter(child => child.type === 'category');
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories.map(cat => ({
|
||||||
|
key: cat.title,
|
||||||
|
label: cat.title,
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
{Array.isArray(cat.children) && cat.children.filter(sub => sub.type === 'subcategory').length > 0 ? (
|
||||||
|
cat.children.filter(sub => sub.type === 'subcategory').map(sub => (
|
||||||
|
<div key={sub.name} style={{ marginTop: 24 }}>
|
||||||
|
<h2 style={{ marginBottom: 4 }}>{sub.name}</h2>
|
||||||
|
{/* Ads unterhalb der Subcategory anzeigen */}
|
||||||
|
{Array.isArray(sub.children) && sub.children.filter(ad => ad.type === 'ad').length > 0 ? (
|
||||||
|
sub.children.filter(ad => ad.type === 'ad').map(ad => {
|
||||||
|
const adId = generateAdId(ad.name, sub.name, cat.title);
|
||||||
|
return (
|
||||||
|
<div key={ad.name} id={adId} style={{ marginLeft: 0, marginBottom: 16 }}>
|
||||||
|
<div style={{ color: '#555', fontSize: 15, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{ad.name}
|
||||||
|
<Tooltip title="Link kopieren">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
icon={<ShareAltOutlined />}
|
||||||
|
onClick={() => handleCopyLink(adId, ad.name)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
{/* Refresh-Button bei HTML-Dateien (iFrames) und Videos anzeigen */}
|
||||||
|
{Array.isArray(ad.files) && ad.files.some(file => file.type === 'html' || file.type === 'video') && (
|
||||||
|
<Tooltip title="Animationen und Videos neu starten">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => handleRefreshAd(adId, ad.name)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Dateien unterhalb des Ads anzeigen */}
|
||||||
|
{Array.isArray(ad.files) && ad.files.length > 0 ? (
|
||||||
|
<div style={{ marginLeft: 0, display: 'flex', flexWrap: 'wrap', gap: 32 }}>
|
||||||
|
{ad.files.map(file => (
|
||||||
|
<FilePreview key={file.name + '__' + zoom} file={file} zoom={zoom} darkMode={darkMode} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: '#bbb', marginLeft: 16, fontSize: 13 }}>Keine Dateien vorhanden.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div style={{ color: '#aaa', marginLeft: 16, fontSize: 14 }}>Keine Ads vorhanden.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div style={{ color: '#888', marginTop: 16 }}>Keine Subkategorien vorhanden.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
};
|
||||||
46
frontend/src/utils/urlUtils.js
Normal file
46
frontend/src/utils/urlUtils.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// URL-Hash-Preservation Utilities für Login-Flow
|
||||||
|
|
||||||
|
// Vollständige URL mit Hash speichern (für Login-Redirects)
|
||||||
|
export const preserveFullUrl = () => {
|
||||||
|
const fullUrl = window.location.pathname + window.location.search + window.location.hash;
|
||||||
|
return fullUrl || '/';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hash aus URL extrahieren (ohne #)
|
||||||
|
export const extractHash = () => {
|
||||||
|
return window.location.hash.substring(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hash in localStorage speichern (für komplexe Login-Flows)
|
||||||
|
export const saveHashForRedirect = () => {
|
||||||
|
const hash = extractHash();
|
||||||
|
if (hash) {
|
||||||
|
localStorage.setItem('pendingHash', hash);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gespeichtes Hash wiederherstellen und löschen
|
||||||
|
export const restoreAndClearHash = () => {
|
||||||
|
const hash = localStorage.getItem('pendingHash');
|
||||||
|
if (hash) {
|
||||||
|
localStorage.removeItem('pendingHash');
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nach Login zum gespeicherten Hash navigieren
|
||||||
|
export const redirectToSavedHash = (navigate) => {
|
||||||
|
const hash = restoreAndClearHash();
|
||||||
|
if (hash) {
|
||||||
|
// Hash in URL setzen und scrollen
|
||||||
|
window.location.hash = hash;
|
||||||
|
// Scroll-Event für den Fall dass scrollToAnchor nicht automatisch ausgelöst wird
|
||||||
|
setTimeout(() => {
|
||||||
|
const element = document.getElementById(hash);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
62
frontend/src/utils/viewportUtils.js
Normal file
62
frontend/src/utils/viewportUtils.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
// Custom Hook für Intersection Observer
|
||||||
|
export const useInViewport = (options = {}) => {
|
||||||
|
const [isInViewport, setIsInViewport] = useState(false);
|
||||||
|
const [hasBeenInViewport, setHasBeenInViewport] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
const inViewport = entry.isIntersecting;
|
||||||
|
setIsInViewport(inViewport);
|
||||||
|
|
||||||
|
// Einmal im Viewport gewesen = für immer markiert (für Videos die einmal geladen werden sollen)
|
||||||
|
if (inViewport && !hasBeenInViewport) {
|
||||||
|
setHasBeenInViewport(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
threshold: 0.5, // 50% des Elements müssen sichtbar sein
|
||||||
|
rootMargin: '50px', // 50px Vorlaufbereich
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(element);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.unobserve(element);
|
||||||
|
};
|
||||||
|
}, [hasBeenInViewport, options]);
|
||||||
|
|
||||||
|
return { ref, isInViewport, hasBeenInViewport };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook speziell für Videos mit Auto-Play Kontrolle
|
||||||
|
export const useVideoAutoPlay = (options = {}) => {
|
||||||
|
const { ref, isInViewport, hasBeenInViewport } = useInViewport(options);
|
||||||
|
const videoRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
if (isInViewport) {
|
||||||
|
// Video abspielen wenn im Viewport
|
||||||
|
video.play().catch(err => {
|
||||||
|
// Autoplay kann durch Browser-Richtlinien blockiert werden
|
||||||
|
console.log('Autoplay prevented:', err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Video pausieren wenn aus dem Viewport
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
}, [isInViewport]);
|
||||||
|
|
||||||
|
return { containerRef: ref, videoRef, isInViewport, hasBeenInViewport };
|
||||||
|
};
|
||||||
29
frontend/src/utils/zoomUtils.js
Normal file
29
frontend/src/utils/zoomUtils.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
// Zoom-Levels Konstante
|
||||||
|
export const ZOOM_LEVELS = [0.15, 0.25, 0.5, 0.75, 1];
|
||||||
|
|
||||||
|
// Zoom-State Hook
|
||||||
|
export const useZoomState = () => {
|
||||||
|
const [zoom, setZoom] = useState(() => {
|
||||||
|
const z = parseFloat(localStorage.getItem('adsZoom') || '1');
|
||||||
|
return ZOOM_LEVELS.includes(z) ? z : 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleZoom = (direction) => {
|
||||||
|
setZoom(currentZoom => {
|
||||||
|
const idx = ZOOM_LEVELS.indexOf(currentZoom);
|
||||||
|
let nextZoom = currentZoom;
|
||||||
|
if (direction === 'in') {
|
||||||
|
nextZoom = ZOOM_LEVELS[Math.min(ZOOM_LEVELS.length - 1, idx + 1)];
|
||||||
|
}
|
||||||
|
if (direction === 'out') {
|
||||||
|
nextZoom = ZOOM_LEVELS[Math.max(0, idx - 1)];
|
||||||
|
}
|
||||||
|
localStorage.setItem('adsZoom', nextZoom);
|
||||||
|
return nextZoom;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { zoom, handleZoom, ZOOM_LEVELS };
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user