security: clean repository without media files and sensitive data

- Removed area/ directory with 816MB of media files
- Removed sensitive FTP credentials from Git history
- Implemented .env.upload system for secure deployments
- Added comprehensive .gitignore for future protection

This commit represents a clean slate with all sensitive data removed.
This commit is contained in:
Johannes
2025-09-07 11:05:29 +02:00
commit b4758b4f26
61 changed files with 23829 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
REACT_APP_BACKEND=http://localhost:8000
AREA_PATH=/../../../area

10
frontend/.env.production Normal file
View File

@@ -0,0 +1,10 @@
# Production Debug-Konfiguration
REACT_APP_DEBUG_ENABLED=false
# Alle Debug-Optionen deaktiviert für Production
REACT_APP_DEBUG_API=false
REACT_APP_DEBUG_ROUTING=false
REACT_APP_DEBUG_AUTH=false
# Backend URL für Production (falls abweichend)
# REACT_APP_BACKEND=https://your-production-domain.com

18541
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
frontend/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "adspreview-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"@ant-design/icons": "^5.2.5",
"antd": "^5.13.7",
"iconoir-react": "^7.11.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.26.2",
"react-scripts": "^5.0.1",
"sortablejs": "^1.15.6"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"start:all": "concurrently \"npm:start\" \"php -S localhost:8000 -t ../backend/public ../backend/public/index.php\""
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"concurrently": "^9.2.0"
},
"proxy": "http://localhost:8000"
}

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>adspreview</title>
</head>
<body>
<div id="root"></div>
</body>
</html>

132
frontend/src/App.js Normal file
View File

@@ -0,0 +1,132 @@
import React, { useState, useEffect } from 'react';
import LoginPage from './pages/LoginPage';
import { ConfigProvider, theme, Spin, Alert, Layout, App as AntApp, Tabs } from 'antd';
import { LoadingOutlined } from '@ant-design/icons';
import { getCurrentUser } from './services/api';
import UserMenu from './components/UserMenu';
import AdminDashboard from './pages/AdminDashboard';
import ClientProjects from './pages/ClientProjects';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import ProjectDetail from './pages/ProjectDetail';
import SmartProjectRoute from './components/SmartProjectRoute';
function App() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [darkMode, setDarkMode] = useState(() => {
const stored = localStorage.getItem('darkMode');
return stored === 'true';
});
useEffect(() => {
const token = localStorage.getItem('jwt');
if (token && !user) {
getCurrentUser().then(res => {
if (res.success) {
setUser(res.user);
} else {
setError(res.error?.message || 'Fehler beim Laden der Userdaten');
localStorage.removeItem('jwt');
}
}).catch(() => setError('Serverfehler')).finally(() => setLoading(false));
} else {
setLoading(false);
}
}, [user]);
if (!user) {
return (
<ConfigProvider theme={{ algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm }}>
<AntApp>
{loading ? (
<Spin indicator={<LoadingOutlined spin />} size="large" tip="Lade Preview..." fullscreen />
) : (
<>
{error && <Alert type="error" message={error} showIcon style={{ margin: 16 }} />}
<LoginPage onLogin={() => setUser(null)} />
</>
)}
</AntApp>
</ConfigProvider>
);
}
const handleLogout = () => {
// Entferne alle ads-overview-Caches beim Logout
Object.keys(localStorage).forEach(key => {
if (key.startsWith('ads-overview-')) {
localStorage.removeItem(key);
}
});
localStorage.removeItem('jwt');
setUser(null);
};
const handleToggleDarkMode = () => {
setDarkMode((prev) => {
localStorage.setItem('darkMode', !prev);
return !prev;
});
};
return (
<ConfigProvider
theme={{
token: {
colorPrimary: '#c792ff',
colorText: darkMode ? '#fff' : '#000',
colorBgBase: darkMode ? '#181818' : '#f5f5f5',
colorBgContainer: darkMode ? '#1f1f1f' : '#fff',
colorBorder: darkMode ? '#404040' : '#d9d9d9',
borderRadius: 4,
lineWidth: 0
},
components: {
Button: {
defaultBg: darkMode ? '#c792ff' : '#c792ff',
defaultHoverBg: darkMode ? '#edff5f' : '#edff5f',
defaultHoverColor: darkMode ? '#001f1e' : '#001f1e',
},
Tabs: {
itemColor: darkMode ? '#fff' : '#fff',
background: darkMode ? '#1f1f1f' : '#001f1e',
},
},
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm
}}
>
<AntApp>
<Router>
<Layout style={{ minHeight: '100vh' }}>
{/* Kein globaler Header mehr, Header wird ggf. in einzelnen Seiten eingebunden */}
<Layout.Content style={{ padding: 0 }}>
<Routes>
{user.role === 'admin' ? (
<>
<Route path="/" element={<AdminDashboard user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
{/* Smart URL Resolution für Admin-Zugriff auf Client-URLs - MUSS VOR der 3-Parameter Route stehen */}
<Route path=":project/:tab?" element={<SmartProjectRoute user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
<Route path=":client/:project/:tab?" element={<ProjectDetail user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</>
) : (
<>
<Route path="/" element={<ClientProjects user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
<Route path="/login" element={<LoginPage onLogin={() => setUser(null)} />} />
<Route path=":project/:tab?" element={<ProjectDetail user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
{/* Catch-All-Route für nicht gefundene Pfade */}
<Route path="*" element={<Navigate to="/" replace />} />
</>
)}
</Routes>
</Layout.Content>
</Layout>
</Router>
</AntApp>
</ConfigProvider>
);
}
export default App;

View File

@@ -0,0 +1,29 @@
import React, { useState, useEffect } from 'react';
import { Table, Spin } from 'antd';
import { getAll } from '../services/entityService';
export default function ClientsManagement() {
const [clients, setClients] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getAll('clients')
.then(data => {
const items = data.clients || {};
const list = Object.entries(items).map(([name, obj]) => ({ key: name, ...obj }));
setClients(list);
})
.finally(() => setLoading(false));
}, []);
const columns = [
{ title: 'Name', dataIndex: 'key', key: 'key' },
{ title: 'Ordner', dataIndex: 'dir', key: 'dir' }
];
if (loading) {
return <Spin />;
}
return <Table dataSource={clients} columns={columns} pagination={false} />;
}

View File

@@ -0,0 +1,184 @@
import React from 'react';
import { Spin } from 'antd';
// Hilfsfunktion für Dateigröße
export function formatFileSize(bytes) {
if (typeof bytes !== 'number' || isNaN(bytes)) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
// Hilfsfunktion für Video-Duration
export function formatDuration(seconds) {
if (typeof seconds !== 'number' || isNaN(seconds)) return '';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
// Datei-Vorschau-Komponente mit sofortiger Zoom-Skalierung und Loading-State
export default function FilePreview({ file, zoom = 1, darkMode = false }) {
const [loaded, setLoaded] = React.useState(false);
const width = file.width || 300;
const height = file.height || 200;
// Wrapper für saubere Skalierung
const wrapperStyle = {
display: 'inline-block',
width: width * zoom,
height: height * zoom,
overflow: 'hidden',
margin: 0,
padding: 0,
position: 'relative'
};
const innerStyle = {
width,
height,
transform: `scale(${zoom})`,
transformOrigin: 'top left',
display: 'block'
};
const renderFileContent = () => {
if (file.type === 'html') {
return (
<div style={wrapperStyle}>
<div style={innerStyle}>
{!loaded && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: width,
height: height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: darkMode ? '#2c2c2c' : '#ffffff',
border: `1px solid ${darkMode ? '#404040' : '#d9d9d9'}`,
borderRadius: 4
}}>
<Spin size="large" />
</div>
)}
<iframe
src={file.url}
title={file.name}
width={width}
height={height}
style={{ border: 'none', opacity: loaded ? 1 : 0 }}
onLoad={() => setLoaded(true)}
/>
</div>
</div>
);
}
if (file.type === 'image') {
return (
<div style={wrapperStyle}>
<div style={innerStyle}>
{!loaded && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: width,
height: height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: darkMode ? '#2c2c2c' : '#ffffff',
border: `1px solid ${darkMode ? '#404040' : '#d9d9d9'}`,
borderRadius: 6
}}>
<Spin size="large" />
</div>
)}
<img
src={file.url}
alt={file.name}
width={width}
height={height}
style={{ border: 'none', opacity: loaded ? 1 : 0 }}
onLoad={() => setLoaded(true)}
onError={() => setLoaded(true)}
/>
</div>
</div>
);
}
if (file.type === 'video') {
return (
<div style={wrapperStyle}>
<div style={innerStyle}>
{!loaded && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: width,
height: height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: darkMode ? '#2c2c2c' : '#ffffff',
border: `1px solid ${darkMode ? '#404040' : '#d9d9d9'}`,
borderRadius: 6
}}>
<Spin size="large" />
</div>
)}
<video
src={file.url}
width={width}
height={height}
controls
autoPlay
loop
muted
preload="metadata"
style={{ border: 'none', opacity: loaded ? 1 : 0 }}
onLoadedData={() => setLoaded(true)}
onError={() => setLoaded(true)}
/>
</div>
</div>
);
}
// Sonstige Datei: Link
return (
<a href={file.url} target="_blank" rel="noopener noreferrer">{file.name}</a>
);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', marginBottom: 16, marginTop: 16 }}>
{renderFileContent()}
{/* Badge für jede einzelne Datei */}
{(file.width || file.height || file.size || file.duration) && (
<div style={{
marginTop: 0,
background: darkMode ? 'rgba(31, 31, 31, 0.95)' : 'rgba(31, 31, 31, 0.95)',
color: darkMode ? '#f9f9f9' : '#f9f9f9',
borderBottomLeftRadius: 4,
borderBottomRightRadius: 4,
fontSize: 11,
padding: '2px 6px',
display: 'inline-block',
lineHeight: 1.4,
pointerEvents: 'none',
zIndex: 10
}}>
{file.width && file.height ? `${file.width}×${file.height}px` : ''}
{(file.width && file.height) && (file.size || file.duration) ? ' · ' : ''}
{file.size ? formatFileSize(file.size) : ''}
{file.size && file.duration ? ' · ' : ''}
{file.type === 'video' && file.duration ? `${formatDuration(file.duration)} s` : ''}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,106 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Spin, Result, Button } from 'antd';
import { LoadingOutlined, LockOutlined } from '@ant-design/icons';
import ProjectDetail from '../pages/ProjectDetail';
import urlResolutionService from '../services/urlResolutionService';
import debugLogger from '../utils/debugLogger';
// Smart Route Component für Admin URL Resolution
export default function SmartProjectRoute({ user, darkMode, onLogout, onToggleDarkMode }) {
const { project, tab } = useParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [resolvedClient, setResolvedClient] = useState(null);
useEffect(() => {
const resolveUrl = async () => {
debugLogger.routing('SmartProjectRoute - Resolving URL for:', { project, tab });
// Nur für Admins, die Client-URLs verwenden
if (user.role !== 'admin') {
debugLogger.routing('User ist kein Admin, keine URL Resolution');
setLoading(false);
return;
}
try {
debugLogger.routing('Resolving client for project:', project);
const clientName = await urlResolutionService.resolveClientForProject(project);
if (clientName) {
debugLogger.success('Client resolved:', { project, clientName, tab });
setResolvedClient(clientName);
} else {
debugLogger.warn('No client found for project:', project);
setError(`Projekt "${project}" wurde keinem Client zugeordnet.`);
}
} catch (err) {
debugLogger.error('Error during URL resolution:', err);
setError(`Fehler bei der URL-Resolution: ${err.message}`);
} finally {
setLoading(false);
}
};
resolveUrl();
}, [project, tab, user.role]);
if (loading) {
return (
<Spin
indicator={<LoadingOutlined spin />}
size="large"
tip="Löse URL auf..."
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh'
}}
/>
);
}
if (error) {
return (
<Result
icon={<LockOutlined />}
status="403"
title="Keine Zugriffsberechtigung"
subTitle={error}
extra={
<Button type="primary" onClick={() => navigate('/')}>
Zurück zum Dashboard
</Button>
}
/>
);
}
// Für Clients oder wenn keine Resolution nötig: Normale ProjectDetail
if (user.role !== 'admin' || !resolvedClient) {
return (
<ProjectDetail
user={user}
darkMode={darkMode}
onLogout={onLogout}
onToggleDarkMode={onToggleDarkMode}
/>
);
}
// Für Admins mit aufgelöster URL: ProjectDetail mit injiziertem Client-Parameter
debugLogger.routing('Passing parameters to ProjectDetail:', { client: resolvedClient, project, tab });
return (
<ProjectDetail
user={user}
darkMode={darkMode}
onLogout={onLogout}
onToggleDarkMode={onToggleDarkMode}
// Inject resolved client parameter
overrideParams={{ client: resolvedClient, project, tab }}
/>
);
}

View File

@@ -0,0 +1,405 @@
import React, { useEffect, useState } from 'react';
import { Table, Button, Spin, Alert, Modal, Form, Input, Select, App, Tooltip } from 'antd';
import { EditPencil, Trash } from 'iconoir-react';
import { getAll, create, update, remove } from '../services/entityService';
const { Option } = Select;
export default function UserManagement() {
const [users, setUsers] = useState([]);
const [clients, setClients] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [modal, modalContextHolder] = Modal.useModal();
const { message } = App.useApp();
useEffect(() => {
fetchUsers();
fetchClients();
}, []);
function fetchUsers() {
setLoading(true);
getAll('users').then(res => {
if (res.success && Array.isArray(res.users)) {
setUsers(res.users);
} else {
setError(res.error?.message || 'Fehler beim Laden der Benutzer');
}
}).catch(() => setError('Serverfehler')).finally(() => setLoading(false));
}
function fetchClients() {
getAll('clients').then(res => {
if (res.success && res.clients) {
// Extrahiere Client-Verzeichnisnamen (dir) aus der Response
const clientDirs = Object.values(res.clients).map(client => client.dir);
// Entferne Duplikate falls vorhanden
const uniqueClientDirs = [...new Set(clientDirs)];
setClients(uniqueClientDirs);
}
}).catch(err => {
console.warn('Fehler beim Laden der Clients:', err);
setClients([]);
});
}
async function handleCreateUser(values) {
setSaving(true);
try {
// Stelle sicher, dass disallowedClients als Array gespeichert wird
if (values.disallowedClients && !Array.isArray(values.disallowedClients)) {
values.disallowedClients = [];
}
const data = await create('users', values);
if (data.success) {
message.success('Benutzer angelegt');
setModalOpen(false);
form.resetFields();
fetchUsers();
} else {
message.error(data.error?.message || 'Fehler beim Anlegen');
}
} catch (e) {
message.error('Netzwerkfehler');
} finally {
setSaving(false);
}
}
function handleDeleteUser(user) {
modal.confirm({
title: `Benutzer wirklich löschen?`,
content: `Soll der Benutzer "${user.username}" wirklich gelöscht werden?`,
okText: 'Löschen',
okType: 'danger',
cancelText: 'Abbrechen',
onOk: async () => {
const data = await remove('users', user.id);
if (data.success) {
message.success('Benutzer gelöscht');
fetchUsers();
} else {
message.error(data.error?.message || 'Fehler beim Löschen');
}
}
});
}
function openEditModal(user) {
setEditingUser(user);
setEditModalOpen(true);
}
async function handleEditUser(values) {
setSaving(true);
try {
// Separater API-Call für Passwort-Update falls angegeben
if (values.password) {
const token = localStorage.getItem('jwt');
const passwordResponse = await fetch(`/api/admin/users/${editingUser.id}/password`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ password: values.password })
});
const passwordResult = await passwordResponse.json();
if (!passwordResult.success) {
message.error(passwordResult.error?.message || 'Fehler beim Ändern des Passworts');
setSaving(false);
return;
}
}
// Standard User-Daten aktualisieren (ohne Passwort)
const { password, confirmPassword, ...userData } = values;
// Stelle sicher, dass disallowedClients als Array gespeichert wird
if (userData.disallowedClients && !Array.isArray(userData.disallowedClients)) {
userData.disallowedClients = [];
}
const data = await update('users', editingUser.id, userData);
if (data.success) {
message.success('Benutzer erfolgreich aktualisiert' + (values.password ? ' (inklusive Passwort)' : ''));
setEditModalOpen(false);
fetchUsers();
} else {
message.error(data.error?.message || 'Fehler beim Bearbeiten');
}
} catch (e) {
message.error('Netzwerkfehler');
} finally {
setSaving(false);
}
}
const columns = [
{ title: 'Benutzername', dataIndex: 'username', key: 'username' },
{ title: 'Rolle', dataIndex: 'role', key: 'role' },
{ title: 'E-Mail', dataIndex: 'email', key: 'email' },
{
title: 'Gesperrte Clients',
key: 'disallowedClients',
render: (_, record) => {
if (record.role !== 'admin' || !record.disallowedClients || record.disallowedClients.length === 0) {
return <span style={{ color: '#888', fontStyle: 'italic' }}>Keine</span>;
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{record.disallowedClients.map(client => (
<span
key={client}
style={{
background: '#ff4d4f',
color: 'white',
padding: '2px 8px',
borderRadius: 12,
fontSize: 12,
fontWeight: 500
}}
>
{client}
</span>
))}
</div>
);
}
},
{
title: 'Aktionen',
key: 'actions',
render: (_, record) => (
<span style={{ display: 'flex', gap: '8px' }}>
<Tooltip title="Benutzer bearbeiten">
<Button
type="text"
size="small"
icon={<EditPencil />}
onClick={() => openEditModal(record)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px'
}}
/>
</Tooltip>
<Tooltip title="Benutzer löschen">
<Button
type="text"
size="small"
icon={<Trash />}
onClick={() => handleDeleteUser(record)}
danger
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px'
}}
/>
</Tooltip>
</span>
)
}
];
if (loading) {
return <Spin tip="Lade Benutzer..." fullscreen />;
}
if (error) {
return <Alert type="error" message={error} showIcon />;
}
return (
<div>
{modalContextHolder}
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => setModalOpen(true)}>
Neuen Benutzer anlegen
</Button>
<Table columns={columns} dataSource={users} rowKey="id" />
<Modal
title="Neuen Benutzer anlegen"
open={modalOpen}
onCancel={() => {
setModalOpen(false);
form.resetFields();
}}
footer={null}
destroyOnHidden={true}
>
<Form form={form} layout="vertical" onFinish={handleCreateUser}>
<Form.Item name="username" label="Benutzername" rules={[{ required: true, message: 'Bitte Benutzernamen angeben' }]}>
<Input autoFocus />
</Form.Item>
<Form.Item name="email" label="E-Mail" rules={[{ type: 'email', message: 'Ungültige E-Mail' }]}>
<Input />
</Form.Item>
<Form.Item name="role" label="Rolle" rules={[{ required: true, message: 'Bitte Rolle wählen' }]}>
<Select placeholder="Rolle wählen">
<Option value="admin">Admin</Option>
<Option value="client">Client</Option>
</Select>
</Form.Item>
{/* Client-Berechtigungen nur für Admins anzeigen */}
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.role !== currentValues.role}
>
{({ getFieldValue }) =>
getFieldValue('role') === 'admin' ? (
<Form.Item
name="disallowedClients"
label="Gesperrte Clients"
tooltip="Wählen Sie die Clients aus, auf die dieser Administrator keinen Zugriff haben soll"
>
<Select
mode="multiple"
allowClear
style={{ width: '100%' }}
placeholder="Clients auswählen, die gesperrt werden sollen"
options={clients.map(client => ({
label: client,
value: client
}))}
/>
</Form.Item>
) : null
}
</Form.Item>
<Form.Item
name="password"
label="Passwort"
rules={[
{ min: 6, message: 'Passwort muss mindestens 6 Zeichen lang sein' }
]}
>
<Input.Password placeholder="Passwort eingeben (optional)" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={saving} block>
Anlegen
</Button>
</Form.Item>
</Form>
</Modal>
<Modal
title="Benutzer bearbeiten"
open={editModalOpen}
onCancel={() => setEditModalOpen(false)}
footer={null}
destroyOnHidden={true}
>
<Form
key={editingUser?.id}
initialValues={{
username: editingUser?.username,
email: editingUser?.email,
role: editingUser?.role,
disallowedClients: editingUser?.disallowedClients || []
// password und confirmPassword werden bewusst nicht gesetzt, damit sie leer beginnen
}}
layout="vertical"
onFinish={handleEditUser}
>
<Form.Item name="username" label="Benutzername" rules={[{ required: true, message: 'Bitte Benutzernamen angeben' }]}>
<Input />
</Form.Item>
<Form.Item name="email" label="E-Mail" rules={[{ type: 'email', message: 'Ungültige E-Mail' }]}>
<Input />
</Form.Item>
<Form.Item name="role" label="Rolle" rules={[{ required: true, message: 'Bitte Rolle wählen' }]}>
<Select placeholder="Rolle wählen">
<Option value="admin">Admin</Option>
<Option value="client">Client</Option>
</Select>
</Form.Item>
{/* Client-Berechtigungen nur für Admins anzeigen */}
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.role !== currentValues.role}
>
{({ getFieldValue }) =>
getFieldValue('role') === 'admin' ? (
<Form.Item
name="disallowedClients"
label="Gesperrte Clients"
tooltip="Wählen Sie die Clients aus, auf die dieser Administrator keinen Zugriff haben soll"
>
<Select
mode="multiple"
allowClear
style={{ width: '100%' }}
placeholder="Clients auswählen, die gesperrt werden sollen"
options={clients.map(client => ({
label: client,
value: client
}))}
/>
</Form.Item>
) : null
}
</Form.Item>
<div style={{ marginTop: 24, marginBottom: 16, borderTop: '1px solid #f0f0f0', paddingTop: 16 }}>
<h4>Passwort ändern (optional)</h4>
</div>
<Form.Item
name="password"
label="Neues Passwort"
rules={[
{ min: 6, message: 'Passwort muss mindestens 6 Zeichen lang sein' }
]}
>
<Input.Password placeholder="Neues Passwort eingeben (leer lassen für keine Änderung)" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="Passwort bestätigen"
dependencies={['password']}
rules={[
({ getFieldValue }) => ({
validator(_, value) {
const password = getFieldValue('password');
if (!password && !value) {
return Promise.resolve();
}
if (password && !value) {
return Promise.reject(new Error('Bitte Passwort bestätigen'));
}
if (password && value && password !== value) {
return Promise.reject(new Error('Passwörter stimmen nicht überein'));
}
return Promise.resolve();
}
})
]}
>
<Input.Password placeholder="Passwort wiederholen" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={saving} block>
Benutzer speichern
</Button>
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import React from 'react';
import { Dropdown, Menu, Avatar, Button, Switch, message } from 'antd';
import { UserOutlined, SettingOutlined, LogoutOutlined, ReloadOutlined, MoonOutlined, RetweetOutlined } from '@ant-design/icons';
export default function UserMenu({ user, onLogout, darkMode, onToggleDarkMode, sortMode, setSortMode, saving }) {
const [messageApi, contextHolder] = message.useMessage();
// Funktion zum Leeren aller ads-overview-Caches
function clearAdsCache() {
let removed = 0;
Object.keys(localStorage).forEach(key => {
if (key.startsWith('ads-overview-')) {
localStorage.removeItem(key);
removed++;
}
});
if (removed > 0) {
messageApi.success({ content: 'Cache erfolgreich geleert!', duration: 2 });
} else {
messageApi.info({ content: 'Kein Cache gefunden.', duration: 2 });
}
}
const menu = {
items: [
{
key: 'darkmode',
icon: <MoonOutlined />,
label: (
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span>Darkmode</span>
<Switch checked={darkMode} onChange={onToggleDarkMode} size="small" />
</span>
),
},
{ type: 'divider' },
// Sortierfunktion als Menüpunkt
{
key: 'sortmode',
icon: <RetweetOutlined />,
label: (
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span>Sortieren</span>
<Switch
checked={!!sortMode}
onChange={setSortMode}
loading={!!saving}
size="small"
disabled={typeof sortMode === 'undefined' || typeof setSortMode !== 'function'}
/>
</span>
),
disabled: typeof sortMode === 'undefined' || typeof setSortMode !== 'function'
},
{ type: 'divider' },
{
key: 'clearcache',
icon: <ReloadOutlined />,
label: 'Cache leeren',
onClick: clearAdsCache,
},
{ type: 'divider' },
{
key: 'settings',
icon: <SettingOutlined />,
label: 'Einstellungen (bald)',
disabled: true,
},
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Ausloggen',
onClick: onLogout,
},
].filter(Boolean),
};
return (
<>
{contextHolder}
<Dropdown menu={menu} placement="bottomRight">
{/* <Button type="text" style={{ padding: 0, height: 40 }}> */}
{/* {user.username || user.client} */}
<Avatar style={{ backgroundColor: '#c792ff', color: '#001f1e', marginRight: 8 }} icon={<UserOutlined />} />
{/* </Button> */}
</Dropdown>
</>
);
}

17
frontend/src/global.css Normal file
View File

@@ -0,0 +1,17 @@
.ant-tabs {
border-radius: 0px;
padding: 0px;
}
.ant-tabs > .ant-tabs-nav {
position: sticky;
top: 0px;
z-index: 100;
padding: 9px 32px;
margin-bottom: 0px;
}
.ant-tabs-content {
padding: 0px 32px;
overflow: auto;
}

12
frontend/src/index.js Normal file
View File

@@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import 'antd/dist/reset.css';
import './global.css';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { Card, Row, Col, Layout } from 'antd';
import UserManagement from '../components/UserManagement';
import UserMenu from '../components/UserMenu';
import ClientsManagement from '../components/ClientsManagement';
export default function AdminDashboard({ user, darkMode, onLogout, onToggleDarkMode }) {
return (
<Layout>
<Layout.Header style={{
position: 'sticky',
top: 0,
zIndex: 100,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: darkMode ? '#1f1f1f' : '#fff',
padding: '0 32px'
}}>
<div style={{ fontWeight: 700, fontSize: 20, color: darkMode ? '#fff' : undefined }}>Admin Dashboard</div>
<UserMenu user={user} onLogout={onLogout} darkMode={darkMode} onToggleDarkMode={onToggleDarkMode} />
</Layout.Header>
<Layout.Content style={{ padding: 32 }}>
<Row gutter={[16, 16]}>
<Col span={16}>
<Card title="Benutzerverwaltung" variant="outlined">
<UserManagement />
</Card>
<Card title="Clients" variant="outlined" style={{ marginTop: 16 }}>
<ClientsManagement />
</Card>
</Col>
<Col span={8}>
<Card title="Systeminfos" variant="outlined" disabled>
Kommt bald
</Card>
</Col>
<Col span={8}>
<Card title="Einstellungen" variant="outlined" disabled>
Kommt bald
</Card>
</Col>
</Row>
</Layout.Content>
</Layout>
);
}

View File

@@ -0,0 +1,303 @@
import React, { useEffect, useState, useRef } from 'react';
import { Card, Row, Col, Spin, Alert, Image, Layout, Switch, App } from 'antd';
import { LoadingOutlined } from '@ant-design/icons';
import { Link } from 'react-router-dom';
import { getClientProjects } from '../services/projectService';
import UserMenu from '../components/UserMenu';
import Sortable from 'sortablejs';
const backendUrl = process.env.REACT_APP_BACKEND || '';
export default function ClientProjects({ user, darkMode, onLogout, onToggleDarkMode }) {
const { message } = App.useApp();
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [sortMode, setSortMode] = useState(false);
const [saving, setSaving] = useState(false);
const normalProjectsRef = useRef(null);
const heProjectsRef = useRef(null);
const normalSortableRef = useRef(null);
const heSortableRef = useRef(null);
useEffect(() => {
const token = localStorage.getItem('jwt');
getClientProjects(token).then(res => {
if (res.success) {
setProjects(res.projects);
} else {
setError(res.error?.message || 'Fehler beim Laden der Projekte');
}
}).catch(() => setError('Serverfehler')).finally(() => setLoading(false));
}, []);
// SortableJS initialisieren/zerstören
useEffect(() => {
if (sortMode && normalProjectsRef.current && heProjectsRef.current) {
// Zerstöre bestehende Instanzen falls vorhanden
if (normalSortableRef.current) {
try {
normalSortableRef.current.destroy();
} catch (e) {
// Ignore cleanup errors
}
normalSortableRef.current = null;
}
if (heSortableRef.current) {
try {
heSortableRef.current.destroy();
} catch (e) {
// Ignore cleanup errors
}
heSortableRef.current = null;
}
// Initialisiere SortableJS für beide Bereiche
normalSortableRef.current = Sortable.create(normalProjectsRef.current, {
animation: 150,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
dragClass: 'sortable-drag',
onEnd: handleSortEnd
});
heSortableRef.current = Sortable.create(heProjectsRef.current, {
animation: 150,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
dragClass: 'sortable-drag',
onEnd: handleSortEnd
});
} else {
// Zerstöre SortableJS Instanzen sicher
if (normalSortableRef.current) {
try {
normalSortableRef.current.destroy();
} catch (e) {
// Ignore cleanup errors
}
normalSortableRef.current = null;
}
if (heSortableRef.current) {
try {
heSortableRef.current.destroy();
} catch (e) {
// Ignore cleanup errors
}
heSortableRef.current = null;
}
}
return () => {
// Cleanup beim Unmount
if (normalSortableRef.current) {
try {
normalSortableRef.current.destroy();
} catch (e) {
// Ignore cleanup errors
}
normalSortableRef.current = null;
}
if (heSortableRef.current) {
try {
heSortableRef.current.destroy();
} catch (e) {
// Ignore cleanup errors
}
heSortableRef.current = null;
}
};
}, [sortMode]);
const handleSortEnd = async (evt) => {
// Neue Reihenfolge aus DOM extrahieren
const newOrder = [];
// Normale Projekte
if (normalProjectsRef.current) {
const normalElements = normalProjectsRef.current.children;
for (let el of normalElements) {
const projectName = el.getAttribute('data-project-name');
if (projectName) newOrder.push(projectName);
}
}
// HE Projekte
if (heProjectsRef.current) {
const heElements = heProjectsRef.current.children;
for (let el of heElements) {
const projectName = el.getAttribute('data-project-name');
if (projectName) newOrder.push(projectName);
}
}
// Speichere neue Reihenfolge
await saveProjectOrder(newOrder);
};
const saveProjectOrder = async (order) => {
setSaving(true);
try {
const token = localStorage.getItem('jwt');
const response = await fetch(`${backendUrl}/api/projects/${encodeURIComponent(user?.dir)}/project-order`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ order })
});
const result = await response.json();
if (result.success) {
message.success('Reihenfolge gespeichert');
// Projekte neu laden um die neue Reihenfolge zu übernehmen
const token = localStorage.getItem('jwt');
const res = await getClientProjects(token);
if (res.success) {
setProjects(res.projects);
}
} else {
message.error('Fehler beim Speichern: ' + (result.error?.message || 'Unbekannter Fehler'));
}
} catch (err) {
message.error('Netzwerkfehler beim Speichern');
} finally {
setSaving(false);
}
};
if (loading) {
return <Spin indicator={<LoadingOutlined spin />} size="large" tip="Lade Projekte..." fullscreen />;
}
if (error) {
return <Alert type="error" message={error} showIcon />;
}
// Projekte trennen
const normalProjects = projects.filter(p => !p.isHE);
const heProjects = projects.filter(p => p.isHE);
return (
<Layout>
<style>{`
.sortable-ghost {
opacity: 0.4;
}
.sortable-chosen {
transform: scale(1.05);
}
.sortable-drag {
transform: rotate(5deg);
}
.sortable-container .ant-col {
cursor: ${sortMode ? 'grab' : 'default'};
}
.sortable-container .ant-col:active {
cursor: ${sortMode ? 'grabbing' : 'default'};
}
`}</style>
<Layout.Header style={{
position: 'sticky',
top: 0,
zIndex: 100,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: darkMode ? '#001f1e' : '#001f1e',
color: darkMode ? '#fff' : '#fff',
padding: '0 32px'
}}>
<div style={{ fontWeight: 700, fontSize: 20, color: darkMode ? '#fff' : undefined }}>Übersicht</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<UserMenu
user={user}
onLogout={onLogout}
darkMode={darkMode}
onToggleDarkMode={onToggleDarkMode}
sortMode={sortMode}
setSortMode={setSortMode}
saving={saving}
/>
</div>
</Layout.Header>
<Layout.Content style={{ padding: 32 }}>
<Row gutter={32}>
<Col xs={24} md={12} style={{ marginBottom: 32 }}>
<h3>Cinema</h3>
<Row
ref={normalProjectsRef}
gutter={[16, 4]}
style={{ columnGap: 8, marginLeft: 0 }}
className={sortMode ? 'sortable-container' : ''}
>
{normalProjects.length === 0 && <Col><Alert type="info" message="Keine Cinema Projekte gefunden." showIcon /></Col>}
{normalProjects.map((p, i) => {
return (
<Col
key={p.name}
data-project-name={p.name}
style={{ padding: 0, margin: 0, width: 120, maxWidth: 120 }}
>
{p.poster && (
<Link
to={`/${encodeURIComponent(p.name)}`}
style={{ display: 'block', pointerEvents: sortMode ? 'none' : 'auto' }}
>
<Image
src={backendUrl + p.poster}
alt="Poster"
width={120}
height={178}
style={{ objectFit: 'cover', display: 'block', width: 120, height: 178, borderRadius: 4, cursor: sortMode ? 'grab' : 'pointer' }}
preview={false}
/>
</Link>
)}
</Col>
);
})}
</Row>
</Col>
<Col xs={24} md={12} style={{ marginBottom: 32 }}>
<h3>Home Entertainment</h3>
<Row
ref={heProjectsRef}
gutter={[16, 4]}
style={{ columnGap: 8, marginLeft: 0 }}
className={sortMode ? 'sortable-container' : ''}
>
{heProjects.length === 0 && <Col><Alert type="info" message="Keine Home Entertainment Projekte gefunden." showIcon /></Col>}
{heProjects.map((p, i) => {
return (
<Col
key={p.name}
data-project-name={p.name}
style={{ padding: 0, margin: 0, width: 120, maxWidth: 120 }}
>
{p.poster && (
<Link
to={`/${encodeURIComponent(p.name)}`}
style={{ display: 'block', pointerEvents: sortMode ? 'none' : 'auto' }}
>
<Image
src={backendUrl + p.poster}
alt="Poster"
width={120}
height={178}
style={{ objectFit: 'cover', display: 'block', width: 120, height: 178, borderRadius: 4, cursor: sortMode ? 'grab' : 'pointer' }}
preview={false}
/>
</Link>
)}
</Col>
);
})}
</Row>
</Col>
</Row>
</Layout.Content>
</Layout>
);
}

View File

@@ -0,0 +1,78 @@
import React, { useState } from 'react';
import { Form, Input, Button, Typography, Alert, message } from 'antd';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
const { Title } = Typography;
export default function LoginPage({ onLogin }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const darkMode = typeof window !== 'undefined' && localStorage.getItem('darkMode') === 'true';
const onFinish = async (values) => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
const data = await res.json();
if (data.success) {
localStorage.setItem('jwt', data.token);
message.success('Login erfolgreich!');
setTimeout(() => {
window.location.href = window.location.pathname + window.location.search || '/';
}, 600);
onLogin && onLogin(data);
} else {
setError(data.error?.message || 'Login fehlgeschlagen');
}
} catch (e) {
setError('Serverfehler');
} finally {
setLoading(false);
}
};
return (
<div
style={{
minHeight: '100vh',
width: '100vw',
background: darkMode ? '#181818' : '#f5f5f5',
transition: 'background 0.2s',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
width: 350,
padding: 24,
background: darkMode ? '#1f1f1f' : '#fff',
borderRadius: 8,
boxShadow: darkMode ? '0 2px 8px #222' : '0 2px 8px #eee',
color: darkMode ? '#fff' : undefined
}}
>
<Title level={3} style={{ textAlign: 'center', color: darkMode ? '#fff' : undefined }}>Anmeldung</Title>
{error && <Alert type="error" message={error} showIcon style={{ marginBottom: 16 }} />}
<Form name="login" onFinish={onFinish} layout="vertical">
<Form.Item name="username" label={<span style={{ color: darkMode ? '#fff' : undefined }}>Benutzername (nur Admin)</span>} >
<Input prefix={<UserOutlined />} placeholder="Benutzername" autoComplete="username" style={darkMode ? { background: '#222', color: '#fff' } : {}} />
</Form.Item>
<Form.Item name="password" label={<span style={{ color: darkMode ? '#fff' : undefined }}>Passwort</span>} rules={[{ required: true, message: 'Bitte Passwort eingeben!' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="Passwort" autoComplete="current-password" style={darkMode ? { background: '#222', color: '#fff' } : {}} />
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
Einloggen
</Button>
</Form>
</div>
</div>
);
}

View File

@@ -0,0 +1,320 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Tabs, Button, Spin, Skeleton, Result } from 'antd';
import { ArrowLeftOutlined, LoadingOutlined, LockOutlined } from '@ant-design/icons';
import UserMenu from '../components/UserMenu';
import FilePreview, { formatFileSize, formatDuration } from '../components/FilePreview';
import debugLogger from '../utils/debugLogger';
const backendUrl = process.env.REACT_APP_BACKEND || '';
function ProjectDetail({ user, darkMode, onLogout, onToggleDarkMode, overrideParams, ...props }) {
// URL-Parameter holen, mit Override-Support für Smart Resolution
const routeParams = useParams();
const params = overrideParams || routeParams;
const { client: routeClient, project, tab } = params;
const client = user.role === 'admin' ? routeClient : user.client;
const navigate = useNavigate();
debugLogger.routing('ProjectDetail - Received params:', {
routeParams,
overrideParams,
finalParams: params,
extractedValues: { client, project, tab }
});
// State für Fehlerstatus
const [error, setError] = useState(null);
// 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;
});
}
useEffect(() => {
async function fetchLogo() {
if (!client || !project) {
setProjectLogo(null);
return;
}
try {
const token = localStorage.getItem('jwt');
const res = await fetch(`${backendUrl}/api/projects/${encodeURIComponent(client)}/${encodeURIComponent(project)}`, {
headers: {
'Authorization': 'Bearer ' + token
}
});
const data = await res.json();
if (data && data.success) {
const { logo } = data.data;
if (logo) setProjectLogo(logo);
}
} catch (e) {
setProjectLogo(null);
}
}
fetchLogo();
}, [client, project]);
// Tabs-Logik: Kategorien aus API laden
const [tabsData, setTabsData] = useState(null);
const [activeKey, setActiveKey] = useState(tab);
const [loading, setLoading] = useState(true);
// 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]);
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(() => {
// Wenn Routing-Parameter fehlen, Tabs zurücksetzen
if (!client || !project) {
setTabsData(null);
setActiveKey(undefined);
setError(null);
setLoading(false);
return;
}
const cacheKey = `ads-overview-${client}-${project}`;
const cacheTTL = 10 * 60 * 1000; // 10 Minuten
async function fetchTabs(forceApi = false) {
setLoading(true);
// Prüfe Cache
if (!forceApi) {
const cached = localStorage.getItem(cacheKey);
if (cached) {
try {
const parsed = JSON.parse(cached);
if (parsed && parsed.data && parsed.timestamp && Date.now() - parsed.timestamp < cacheTTL) {
// Gültiger Cache
const data = parsed.data;
setTabsData(data);
const tabsArr = buildTabsFromCategories(data);
if (tabsArr.length > 0) {
if (tab && tabsArr.some(t => t.key === tab)) {
setActiveKey(tab);
} else {
setActiveKey(tabsArr[0].key);
// Wenn Tab in URL fehlt oder ungültig, URL anpassen
if (!tab || !tabsArr.some(t => t.key === tab)) {
if (user.role === 'admin') {
navigate(`/${encodeURIComponent(client)}/${encodeURIComponent(project)}/${encodeURIComponent(tabsArr[0].key)}`, { replace: true });
} else {
navigate(`/${encodeURIComponent(project)}/${encodeURIComponent(tabsArr[0].key)}`, { replace: true });
}
}
}
}
setLoading(false);
return;
}
} catch (e) {
// Fehler beim Parsen, ignoriere Cache und hole neu
}
}
}
// Kein Cache, abgelaufen oder Fehler: Hole neu
try {
setError(null); // Reset error state
const token = localStorage.getItem('jwt');
const res = await fetch(`${backendUrl}/api/projects/${encodeURIComponent(client)}/${encodeURIComponent(project)}/ads-overview`, {
headers: {
'Authorization': 'Bearer ' + token
}
});
if (!res.ok) {
// HTTP-Fehler behandeln
if (res.status === 403) {
setError({ type: 'forbidden', message: 'Keine Zugriffsberechtigung auf dieses Projekt' });
} else if (res.status === 404) {
setError({ type: 'notFound', message: 'Projekt wurde nicht gefunden' });
} else {
setError({ type: 'general', message: `Fehler beim Laden der Daten (${res.status})` });
}
setTabsData(null);
setLoading(false);
return;
}
const data = await res.json();
// Speichere im Cache
localStorage.setItem(cacheKey, JSON.stringify({ data, timestamp: Date.now() }));
setTabsData(data);
const tabsArr = buildTabsFromCategories(data);
if (tabsArr.length > 0) {
if (tab && tabsArr.some(t => t.key === tab)) {
setActiveKey(tab);
} else {
setActiveKey(tabsArr[0].key);
if (!tab || !tabsArr.some(t => t.key === tab)) {
if (user.role === 'admin') {
navigate(`/${encodeURIComponent(client)}/${encodeURIComponent(project)}/${encodeURIComponent(tabsArr[0].key)}`, { replace: true });
} else {
navigate(`/${encodeURIComponent(project)}/${encodeURIComponent(tabsArr[0].key)}`, { replace: true });
}
}
}
}
} catch (e) {
setError({ type: 'network', message: 'Netzwerkfehler beim Laden der Daten' });
setTabsData(null);
}
setLoading(false);
}
fetchTabs();
}, [user, client, project, tab]);
// Tab-Wechsel: Schreibe Tab in die URL
const handleTabChange = (key) => {
setActiveKey(key);
// Clients verwenden /:project/:tab, Admins /:client/:project/:tab
if (user.role === 'admin') {
navigate(`/${encodeURIComponent(client)}/${encodeURIComponent(project)}/${encodeURIComponent(key)}`);
} else {
navigate(`/${encodeURIComponent(project)}/${encodeURIComponent(key)}`);
}
};
return (
<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 />
{!loading && (
tabs.length > 0 ? (
<Tabs
className="ads-details-tabs"
items={tabs}
activeKey={activeKey}
onChange={handleTabChange}
tabBarExtraContent={{
left: (
<span style={{ display: 'flex', alignItems: 'center' }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
style={{ marginRight: projectLogo ? 12 : 0 }}
/>
{projectLogo && (
<img
src={projectLogo}
alt="Logo"
style={{ height: 38, marginRight: 32, objectFit: 'contain' }}
/>
)}
</span>
),
right: (
<span style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Button size="small" onClick={() => handleZoom('out')} disabled={zoom <= ZOOM_LEVELS[0]}>-</Button>
<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>
<UserMenu user={user} onLogout={onLogout} darkMode={darkMode} onToggleDarkMode={onToggleDarkMode} />
</span>
)
}}
/>
) : error ? (
// Spezifische Fehlerbehandlung
<Result
icon={error.type === 'forbidden' ? <LockOutlined /> : undefined}
status={error.type === 'forbidden' ? '403' : error.type === 'notFound' ? '404' : 'error'}
title={
error.type === 'forbidden' ? 'Keine Zugriffsberechtigung' :
error.type === 'notFound' ? 'Projekt nicht gefunden' :
'Fehler beim Laden'
}
subTitle={error.message}
extra={
<Button type="primary" onClick={() => navigate('/')}>
{user.role === 'admin' ? 'Zurück zum Dashboard' : 'Zurück zur Übersicht'}
</Button>
}
/>
) : (
// Fallback für andere Fälle ohne spezifischen Fehler
<div style={{ padding: 32, textAlign: 'center', color: '#888' }}>
<div>Keine Daten gefunden.</div>
<Button type="primary" style={{ marginTop: 24 }} onClick={() => navigate('/')}>
{user.role === 'admin' ? 'Zurück zum Dashboard' : 'Zurück zur Übersicht'}
</Button>
</div>
)
)}
</div>
);
}
export default ProjectDetail;

View File

@@ -0,0 +1,7 @@
export async function getCurrentUser() {
const token = localStorage.getItem('jwt');
const res = await fetch('/api/auth/user', {
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
}

View File

@@ -0,0 +1,42 @@
// Service für generische CRUD-Operationen auf Admin-Ressourcen (users, clients)
const backendUrl = process.env.REACT_APP_BACKEND || '';
function authHeaders(contentType = false) {
const token = localStorage.getItem('jwt');
const headers = { 'Authorization': 'Bearer ' + token };
if (contentType) headers['Content-Type'] = 'application/json';
return headers;
}
export async function getAll(type) {
const res = await fetch(`${backendUrl}/api/admin/${type}`, {
headers: authHeaders()
});
return res.json();
}
export async function create(type, data) {
const res = await fetch(`${backendUrl}/api/admin/${type}`, {
method: 'POST',
headers: authHeaders(true),
body: JSON.stringify(data)
});
return res.json();
}
export async function update(type, id, data) {
const res = await fetch(`${backendUrl}/api/admin/${type}/${id}`, {
method: 'PUT',
headers: authHeaders(true),
body: JSON.stringify(data)
});
return res.json();
}
export async function remove(type, id) {
const res = await fetch(`${backendUrl}/api/admin/${type}/${id}`, {
method: 'DELETE',
headers: authHeaders()
});
return res.json();
}

View File

@@ -0,0 +1,6 @@
export async function getClientProjects(token) {
const res = await fetch('/api/projects', {
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
}

View File

@@ -0,0 +1,147 @@
// Smart URL Resolution Service für Admin-Zugriff auf Client-URLs
import debugLogger from '../utils/debugLogger';
class URLResolutionService {
constructor() {
// Backend-URL mit Fallback auf localhost:8000
this.baseUrl = process.env.REACT_APP_BACKEND || 'http://localhost:8000';
this.cache = new Map();
this.cacheExpiry = 5 * 60 * 1000; // 5 Minuten
}
// Projekt-Client-Mapping vom Backend laden
async fetchProjectClientMapping() {
debugLogger.api('Fetching project-client mapping from backend...');
try {
const token = localStorage.getItem('jwt');
if (!token) {
debugLogger.error('Kein JWT Token verfügbar');
throw new Error('Kein Token verfügbar');
}
debugLogger.api('API Request to:', `${this.baseUrl}/api/admin/project-client-mapping`);
const response = await fetch(`${this.baseUrl}/api/admin/project-client-mapping`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
debugLogger.api('API Response Status:', response.status);
if (!response.ok) {
const errorText = await response.text();
debugLogger.error('Backend Error Response:', errorText);
throw new Error(`Backend Fehler: ${response.status} - ${errorText}`);
}
const data = await response.json();
debugLogger.api('Backend Response Data:', data);
if (!data.success) {
debugLogger.error('Backend returned success=false:', data.message);
throw new Error(data.message || 'Unbekannter Backend-Fehler');
}
debugLogger.success('Mapping successfully loaded:', data.mapping);
return data.mapping || {};
} catch (error) {
debugLogger.error('Fehler beim Laden des Projekt-Client-Mappings:', error);
throw error;
}
}
// Gecachtes Mapping abrufen oder neu laden
async getProjectClientMapping() {
const cached = this.cache.get('projectClientMapping');
if (cached && (Date.now() - cached.timestamp) < this.cacheExpiry) {
return cached.data;
}
// Frische Daten laden und cachen
const freshData = await this.fetchProjectClientMapping();
this.cache.set('projectClientMapping', {
data: freshData,
timestamp: Date.now()
});
return freshData;
}
// Smart URL Resolution: Finde Client für ein Projekt
async resolveClientForProject(projectName) {
debugLogger.routing('Smart URL Resolution für Projekt:', projectName);
if (!projectName) {
debugLogger.routing('Kein Projektname angegeben');
return null;
}
// Prüfe ob Admin (nur Admins dürfen URL Resolution verwenden)
if (!this.isAdmin()) {
debugLogger.routing('User ist kein Admin');
return null;
}
try {
debugLogger.api('Lade Projekt-Client-Mapping...');
const mapping = await this.getProjectClientMapping();
debugLogger.api('Erhaltenes Mapping:', mapping);
const clientName = mapping[projectName] || null;
debugLogger.success(`Projekt "${projectName}" → Client "${clientName}"`);
return clientName;
} catch (error) {
debugLogger.error('Fehler bei URL Resolution:', error);
return null;
}
}
// Prüfe ob User Admin ist
isAdmin() {
const token = localStorage.getItem('jwt');
debugLogger.auth('JWT Token check:', token ? 'Token vorhanden' : 'Kein Token');
if (!token) return false;
try {
// JWT-Token parsen (vereinfacht)
const payload = JSON.parse(atob(token.split('.')[1]));
debugLogger.auth('Token Payload:', payload);
const isAdminUser = payload.role === 'admin';
debugLogger.auth('Is Admin:', isAdminUser);
return isAdminUser;
} catch (error) {
debugLogger.error('Token parsing error:', error);
return false;
}
}
// Smart URL Resolution für Admin-Zugriff
async resolveAdminUrl(projectName, tab = null) {
if (!this.isAdmin()) {
return null; // Nur für Admins
}
const clientName = await this.resolveClientForProject(projectName);
if (!clientName) {
return null; // Projekt nicht gefunden
}
// Konstruiere Admin-URL
if (tab) {
return `/${encodeURIComponent(clientName)}/${encodeURIComponent(projectName)}/${encodeURIComponent(tab)}`;
} else {
return `/${encodeURIComponent(clientName)}/${encodeURIComponent(projectName)}`;
}
}
// Cache leeren (z.B. bei Logout)
clearCache() {
this.cache.clear();
}
}
// Singleton-Instanz exportieren
const urlResolutionService = new URLResolutionService();
export default urlResolutionService;

View File

@@ -0,0 +1,14 @@
// Service für Benutzer-API
const backendUrl = process.env.REACT_APP_BACKEND || '';
export async function getAllUsers() {
const token = localStorage.getItem('jwt');
const res = await fetch(`${backendUrl}/api/admin/users`, {
headers: {
'Authorization': 'Bearer ' + token
}
});
return await res.json();
}
// Weitere Funktionen wie createUser, updateUser, deleteUser können später ergänzt werden.

View File

@@ -0,0 +1,77 @@
/**
* Beispiel für die Verwendung des globalen Debug-Loggers
* Kann als Referenz für andere Komponenten dienen
*/
import debugLogger from '../utils/debugLogger';
// Beispiel: Verwendung in einer API-Service
class ExampleService {
async fetchData() {
debugLogger.api('Fetching data from API...');
try {
debugLogger.time('API Request');
const response = await fetch('/api/data');
debugLogger.timeEnd('API Request');
if (!response.ok) {
debugLogger.error('API request failed:', response.status);
throw new Error('API Error');
}
const data = await response.json();
debugLogger.success('Data fetched successfully:', data);
return data;
} catch (error) {
debugLogger.error('Error fetching data:', error);
throw error;
}
}
}
// Beispiel: Verwendung in einer React-Komponente
function ExampleComponent() {
const [data, setData] = useState(null);
useEffect(() => {
debugLogger.group('ExampleComponent Mount');
debugLogger.routing('Component mounted with props:', { data });
const loadData = async () => {
try {
const result = await new ExampleService().fetchData();
setData(result);
debugLogger.success('Component state updated');
} catch (error) {
debugLogger.error('Failed to load component data:', error);
}
};
loadData();
debugLogger.groupEnd();
}, []);
// Authentifizierung prüfen
const checkAuth = () => {
const token = localStorage.getItem('jwt');
debugLogger.auth('Checking authentication status');
if (!token) {
debugLogger.warn('No authentication token found');
return false;
}
debugLogger.auth('Authentication token present');
return true;
};
return (
<div>
{/* Component JSX */}
</div>
);
}
export { ExampleService, ExampleComponent };

View File

@@ -0,0 +1,121 @@
/**
* Globale Debug-Utility für die gesamte Anwendung
* Konfigurierbar über .env Variablen
*/
class DebugLogger {
constructor() {
// Debug-Konfiguration aus .env Datei
this.isEnabled = process.env.REACT_APP_DEBUG_ENABLED === 'true';
this.apiDebug = process.env.REACT_APP_DEBUG_API === 'true';
this.routingDebug = process.env.REACT_APP_DEBUG_ROUTING === 'true';
this.authDebug = process.env.REACT_APP_DEBUG_AUTH === 'true';
// Fallback: In Development-Umgebung standardmäßig aktiviert
if (process.env.NODE_ENV === 'development' && this.isEnabled === undefined) {
this.isEnabled = true;
this.apiDebug = true;
this.routingDebug = true;
this.authDebug = true;
}
}
// Allgemeine Debug-Logs
log(message, ...args) {
if (this.isEnabled) {
console.log(`🔧 [DEBUG]`, message, ...args);
}
}
// API-bezogene Debug-Logs
api(message, ...args) {
if (this.isEnabled && this.apiDebug) {
console.log(`📡 [API]`, message, ...args);
}
}
// Routing-bezogene Debug-Logs
routing(message, ...args) {
if (this.isEnabled && this.routingDebug) {
console.log(`🚀 [ROUTING]`, message, ...args);
}
}
// Authentifizierungs-bezogene Debug-Logs
auth(message, ...args) {
if (this.isEnabled && this.authDebug) {
console.log(`🔐 [AUTH]`, message, ...args);
}
}
// Error-Logs (immer aktiviert, auch in Production für kritische Fehler)
error(message, ...args) {
console.error(`❌ [ERROR]`, message, ...args);
}
// Warning-Logs (immer aktiviert)
warn(message, ...args) {
console.warn(`⚠️ [WARN]`, message, ...args);
}
// Success-Logs (nur wenn Debug aktiviert)
success(message, ...args) {
if (this.isEnabled) {
console.log(`✅ [SUCCESS]`, message, ...args);
}
}
// Info-Logs (nur wenn Debug aktiviert)
info(message, ...args) {
if (this.isEnabled) {
console.info(` [INFO]`, message, ...args);
}
}
// Gruppen für zusammenhängende Logs
group(title) {
if (this.isEnabled) {
console.group(`🔧 [DEBUG GROUP] ${title}`);
}
}
groupEnd() {
if (this.isEnabled) {
console.groupEnd();
}
}
// Zeitmessung für Performance-Debugging
time(label) {
if (this.isEnabled) {
console.time(`⏱️ [TIMER] ${label}`);
}
}
timeEnd(label) {
if (this.isEnabled) {
console.timeEnd(`⏱️ [TIMER] ${label}`);
}
}
// Debug-Status anzeigen
getStatus() {
return {
enabled: this.isEnabled,
api: this.apiDebug,
routing: this.routingDebug,
auth: this.authDebug,
environment: process.env.NODE_ENV
};
}
}
// Singleton-Instanz exportieren
const debugLogger = new DebugLogger();
// Zeige Debug-Status beim Import
if (debugLogger.isEnabled) {
debugLogger.info('Debug Logger initialized:', debugLogger.getStatus());
}
export default debugLogger;