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} - -
- {/* 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} + +
+ {/* 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 }; +};