diff --git a/frontend/src/pages/ProjectDetail.js b/frontend/src/pages/ProjectDetail.js
index 64f2c60..647887f 100644
--- a/frontend/src/pages/ProjectDetail.js
+++ b/frontend/src/pages/ProjectDetail.js
@@ -1,26 +1,16 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
-import { Tabs, Button, Spin, Skeleton, Result, Grid, Select, Layout, Tooltip, message } from 'antd';
-import { ArrowLeftOutlined, LoadingOutlined, LockOutlined, MinusOutlined, PlusOutlined, EllipsisOutlined, ShareAltOutlined, ReloadOutlined } from '@ant-design/icons';
+import { Tabs, Button, Spin, Skeleton, Result, Grid, Select, Layout, App } from 'antd';
+import { ArrowLeftOutlined, LoadingOutlined, LockOutlined, MinusOutlined, PlusOutlined, EllipsisOutlined } from '@ant-design/icons';
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';
const backendUrl = process.env.REACT_APP_BACKEND || '';
const { useBreakpoint } = Grid;
-// Hash-Funktion für Ad-IDs
-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
-}
-
function ProjectDetail({ user, darkMode, onLogout, onToggleDarkMode, overrideParams, ...props }) {
// URL-Parameter holen, mit Override-Support für Smart Resolution
const routeParams = useParams();
@@ -46,70 +36,20 @@ function ProjectDetail({ user, darkMode, onLogout, onToggleDarkMode, overridePar
// Hole Projekt-Logo (und ggf. weitere Metadaten)
const [projectLogo, setProjectLogo] = useState(null);
- // Zoom-Logik (muss im Component-Scope stehen, nicht in useEffect!)
- const ZOOM_LEVELS = [0.15, 0.25, 0.5, 0.75, 1];
- const [zoom, setZoom] = useState(() => {
- const z = parseFloat(localStorage.getItem('adsZoom') || '1');
- return ZOOM_LEVELS.includes(z) ? z : 1;
- });
- function handleZoom(dir) {
- setZoom(z => {
- const idx = ZOOM_LEVELS.indexOf(z);
- let next = z;
- if (dir === 'in') next = ZOOM_LEVELS[Math.min(ZOOM_LEVELS.length - 1, idx + 1)];
- if (dir === 'out') next = ZOOM_LEVELS[Math.max(0, idx - 1)];
- localStorage.setItem('adsZoom', next);
- return next;
- });
- }
+ // Zoom-Logik mit Custom Hook
+ const { zoom, handleZoom, ZOOM_LEVELS } = useZoomState();
+
+ // Message-Hook aus App-Kontext
+ const { message } = App.useApp();
+
+ // Handler für Ad-Aktionen mit Message-Callbacks
+ const handleCopyLink = createCopyLinkHandler((msg) => message.success(msg));
+ const handleRefreshAd = createRefreshAdHandler(
+ (msg) => message.success(msg),
+ (msg) => message.info(msg)
+ );
- // Link kopieren Funktionalität
- const handleCopyLink = 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);
- message.success(`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);
- message.success(`Link zu "${adName}" wurde kopiert!`);
- }
- };
- // iFrame Refresh Funktionalität
- const handleRefreshAd = (adId, adName) => {
- // Finde alle iFrames innerhalb des Ad-Containers
- const adContainer = document.getElementById(adId);
- if (adContainer) {
- const iframes = adContainer.querySelectorAll('iframe');
- let refreshedCount = 0;
-
- 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++;
- }
- });
-
- if (refreshedCount > 0) {
- message.success(`Animation${refreshedCount > 1 ? 'en' : ''} "${adName}" ${refreshedCount > 1 ? 'wurden' : 'wurde'} neu geladen!`);
- } else {
- message.info(`Keine Animationen in "${adName}" gefunden.`);
- }
- }
- };
useEffect(() => {
async function fetchLogo() {
@@ -144,78 +84,10 @@ function ProjectDetail({ user, darkMode, onLogout, onToggleDarkMode, overridePar
// Tabs mit useMemo erstellen, damit sie sich bei Zoom-Änderung aktualisieren ohne API-Call
const tabs = React.useMemo(() => {
if (!tabsData) return [];
- return buildTabsFromCategories(tabsData);
- }, [tabsData, zoom]);
+ return buildTabsFromCategories(tabsData, zoom, darkMode, handleCopyLink, handleRefreshAd);
+ }, [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: (
-
- {Array.isArray(cat.children) && cat.children.filter(sub => sub.type === 'subcategory').length > 0 ? (
- cat.children.filter(sub => sub.type === 'subcategory').map(sub => (
-
-
{sub.name}
- {/* 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 (
-
-
- {ad.name}
-
- }
- onClick={() => handleCopyLink(adId, ad.name)}
- />
-
- {/* Refresh-Button nur bei HTML-Dateien (iFrames) anzeigen */}
- {Array.isArray(ad.files) && ad.files.some(file => file.type === 'html') && (
-
- }
- onClick={() => handleRefreshAd(adId, ad.name)}
- />
-
- )}
-
- {/* Dateien unterhalb des Ads anzeigen */}
- {Array.isArray(ad.files) && ad.files.length > 0 ? (
-
- {ad.files.map(file => (
-
- ))}
-
- ) : (
-
Keine Dateien vorhanden.
- )}
-
- );
- })
- ) : (
-
Keine Ads vorhanden.
- )}
-
- ))
- ) : (
-
Keine Subkategorien vorhanden.
- )}
-
- )
- }));
- }
useEffect(() => {
// Wenn Routing-Parameter fehlen, Tabs zurücksetzen
@@ -320,25 +192,7 @@ function ProjectDetail({ user, darkMode, onLogout, onToggleDarkMode, overridePar
useEffect(() => {
if (!loading && tabs.length > 0) {
const hash = window.location.hash.substring(1);
- 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 - 20;
- window.scrollTo({
- top: elementTop,
- behavior: 'smooth'
- });
- // Kurz hervorheben
- element.style.backgroundColor = '#edff5f';
- setTimeout(() => {
- element.style.backgroundColor = '';
- }, 2000);
- }
- }, 300);
- }
+ scrollToAnchor(hash, 20);
}
}, [loading, tabs, activeKey]);
diff --git a/frontend/src/utils/adUtils.js b/frontend/src/utils/adUtils.js
new file mode 100644
index 0000000..baeafa0
--- /dev/null
+++ b/frontend/src/utils/adUtils.js
@@ -0,0 +1,82 @@
+// 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 Refresh Funktionalität
+export const createRefreshAdHandler = (onSuccess, onInfo) => (adId, adName) => {
+ // Finde alle iFrames innerhalb des Ad-Containers
+ const adContainer = document.getElementById(adId);
+ if (adContainer) {
+ const iframes = adContainer.querySelectorAll('iframe');
+ let refreshedCount = 0;
+
+ 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++;
+ }
+ });
+
+ if (refreshedCount > 0) {
+ onSuccess(`Animation${refreshedCount > 1 ? 'en' : ''} "${adName}" ${refreshedCount > 1 ? 'wurden' : 'wurde'} neu geladen!`);
+ } else {
+ onInfo(`Keine Animationen 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);
+ }
+};
diff --git a/frontend/src/utils/tabUtils.js b/frontend/src/utils/tabUtils.js
new file mode 100644
index 0000000..2ccbac8
--- /dev/null
+++ b/frontend/src/utils/tabUtils.js
@@ -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: (
+
+ {Array.isArray(cat.children) && cat.children.filter(sub => sub.type === 'subcategory').length > 0 ? (
+ cat.children.filter(sub => sub.type === 'subcategory').map(sub => (
+
+
{sub.name}
+ {/* 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 (
+
+
+ {ad.name}
+
+ }
+ onClick={() => handleCopyLink(adId, ad.name)}
+ />
+
+ {/* Refresh-Button nur bei HTML-Dateien (iFrames) anzeigen */}
+ {Array.isArray(ad.files) && ad.files.some(file => file.type === 'html') && (
+
+ }
+ onClick={() => handleRefreshAd(adId, ad.name)}
+ />
+
+ )}
+
+ {/* Dateien unterhalb des Ads anzeigen */}
+ {Array.isArray(ad.files) && ad.files.length > 0 ? (
+
+ {ad.files.map(file => (
+
+ ))}
+
+ ) : (
+
Keine Dateien vorhanden.
+ )}
+
+ );
+ })
+ ) : (
+
Keine Ads vorhanden.
+ )}
+
+ ))
+ ) : (
+
Keine Subkategorien vorhanden.
+ )}
+
+ )
+ }));
+};
diff --git a/frontend/src/utils/zoomUtils.js b/frontend/src/utils/zoomUtils.js
new file mode 100644
index 0000000..52df581
--- /dev/null
+++ b/frontend/src/utils/zoomUtils.js
@@ -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 };
+};