From 0a345283fc9230c24c5a1e7c79bf0c20593d803f Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 18 Feb 2026 13:29:59 +0100 Subject: [PATCH] added banner tab to admin dashboard --- .../src/components/BannerSettingsDialog.tsx | 98 +++++ frontend/src/components/DynamicBanner.tsx | 91 +++++ frontend/src/components/GenerativeBanner.tsx | 24 +- frontend/src/components/NebulaBanner.tsx | 231 ++++++++++++ frontend/src/i18n.ts | 4 + frontend/src/pages/AdminDashboard.tsx | 343 ++++++++++++------ frontend/src/pages/Dashboard.tsx | 4 +- frontend/src/pages/PublicProfile.tsx | 4 +- 8 files changed, 672 insertions(+), 127 deletions(-) create mode 100644 frontend/src/components/BannerSettingsDialog.tsx create mode 100644 frontend/src/components/DynamicBanner.tsx create mode 100644 frontend/src/components/NebulaBanner.tsx diff --git a/frontend/src/components/BannerSettingsDialog.tsx b/frontend/src/components/BannerSettingsDialog.tsx new file mode 100644 index 0000000..684e507 --- /dev/null +++ b/frontend/src/components/BannerSettingsDialog.tsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react'; +import { + Dialog, DialogTitle, DialogContent, DialogActions, Button, + TextField, Box, Typography, Alert +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import api from '../api'; + +interface Banner { + id: number; + name: string; + slug: string; + description: string; + is_active: boolean; + settings: any; +} + +interface BannerSettingsDialogProps { + open: boolean; + onClose: () => void; + banner: Banner | null; + onSave: () => void; +} + +const BannerSettingsDialog: React.FC = ({ open, onClose, banner, onSave }) => { + const { t } = useTranslation(); + const [settingsStr, setSettingsStr] = useState(''); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + + React.useEffect(() => { + if (banner) { + setSettingsStr(JSON.stringify(banner.settings, null, 2)); + setError(''); + } + }, [banner]); + + const handleSave = async () => { + if (!banner) return; + + try { + const parsedSettings = JSON.parse(settingsStr); + setSaving(true); + await api.patch(`banners/${banner.id}/`, { settings: parsedSettings }); + onSave(); + onClose(); + } catch (err: any) { + setError(err instanceof SyntaxError ? 'Invalid JSON format' : (err.response?.data?.error || 'Failed to save settings')); + } finally { + setSaving(false); + } + }; + + return ( + + + Edit Settings: {banner?.name} + + + + + Modify the JSON configuration for this banner model. Changes take effect immediately for all users. + + + {error && {error}} + + setSettingsStr(e.target.value)} + placeholder='{ "key": "value" }' + sx={{ + '& .MuiInputBase-root': { + fontFamily: 'monospace', + fontSize: '0.85rem' + } + }} + /> + + + + + + + + ); +}; + +export default BannerSettingsDialog; diff --git a/frontend/src/components/DynamicBanner.tsx b/frontend/src/components/DynamicBanner.tsx new file mode 100644 index 0000000..da2a2fa --- /dev/null +++ b/frontend/src/components/DynamicBanner.tsx @@ -0,0 +1,91 @@ +import React, { useState, useEffect } from 'react'; +import api from '../api'; +import GenerativeBanner from './GenerativeBanner'; +import NebulaBanner from './NebulaBanner'; + +interface Banner { + id: number; + name: string; + slug: string; + description: string; + is_active: boolean; + settings: any; +} + +interface DynamicBannerProps { + username: string; + palette: string[]; + ratingsCount: number; + followersCount: number; +} + +// Future expansion: Add more banner components here +const MatrixBanner: React.FC = ({ username, settings }) => { + // Placeholder for a second model + return ( +
+
+ {Array.from({ length: 20 }).map((_, i) => ( +
+ {username.split('').map((char, j) => ( + {char} + ))} +
+ ))} +
+ +
+ ); +}; + +const DynamicBanner: React.FC = (props) => { + const [banner, setBanner] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchActiveBanner = async () => { + try { + const res = await api.get('banners/active/'); + setBanner(res.data); + } catch (err) { + console.error('Failed to fetch active banner:', err); + } finally { + setLoading(false); + } + }; + + fetchActiveBanner(); + }, []); + + if (loading) return null; + + // Default to GenerativeBanner if no active banner or unrecognized slug + if (!banner || banner.slug === 'generative-web') { + return ; + } + + if (banner.slug === 'matrix') { + return ; + } + + if (banner.slug === 'nebula') { + return ; + } + + // Fallback + return ; +}; + +export default DynamicBanner; diff --git a/frontend/src/components/GenerativeBanner.tsx b/frontend/src/components/GenerativeBanner.tsx index 0ad891c..debad58 100644 --- a/frontend/src/components/GenerativeBanner.tsx +++ b/frontend/src/components/GenerativeBanner.tsx @@ -6,12 +6,23 @@ interface GenerativeBannerProps { palette: string[]; ratingsCount: number; followersCount: number; + settings?: { + nodeCountBase?: number; + connectionDist?: number; + speed?: number; + opacity?: number; + }; } -const GenerativeBanner: React.FC = ({ username, palette, ratingsCount, followersCount }) => { +const GenerativeBanner: React.FC = ({ username, palette, ratingsCount, followersCount, settings }) => { const canvasRef = useRef(null); const theme = useTheme(); + const nodeCountBase = settings?.nodeCountBase ?? 60; + const connectionDist = settings?.connectionDist ?? 140; + const speed = settings?.speed ?? 0.005; + const globalOpacity = settings?.opacity ?? 0.35; + const getSeed = (str: string) => { let hash = 0; for (let i = 0; i < str.length; i++) { @@ -31,7 +42,7 @@ const GenerativeBanner: React.FC = ({ username, palette, const seed = getSeed(username); // Configuration - High density for "complex web" feel - const nodeCount = Math.min(60 + Math.floor(ratingsCount * 1.5), 120); + const nodeCount = Math.min(nodeCountBase + Math.floor(ratingsCount * 1.5), 120); const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main]; interface Node { @@ -132,8 +143,8 @@ const GenerativeBanner: React.FC = ({ username, palette, // Return force (Elasticity) - slightly slower return const dxBase = node.baseX - node.x; const dyBase = node.baseY - node.y; - node.vx += dxBase * 0.005; - node.vy += dyBase * 0.005; + node.vx += dxBase * speed; + node.vy += dyBase * speed; // Mouse interaction const dxMouse = mouse.x - node.x; @@ -168,7 +179,6 @@ const GenerativeBanner: React.FC = ({ username, palette, }); // Draw Mesh (Connections) - const connectionDist = 140; ctx.lineWidth = 1; for (let i = 0; i < nodes.length; i++) { @@ -181,7 +191,7 @@ const GenerativeBanner: React.FC = ({ username, palette, if (dist < connectionDist) { currentConnections++; - const opacity = (1 - dist / connectionDist) * 0.35; + const opacity = (1 - dist / connectionDist) * globalOpacity; ctx.beginPath(); ctx.moveTo(nodes[i].x, nodes[i].y); ctx.lineTo(nodes[j].x, nodes[j].y); @@ -246,7 +256,7 @@ const GenerativeBanner: React.FC = ({ username, palette, } cancelAnimationFrame(animationFrameId); }; - }, [username, palette, ratingsCount, followersCount, theme]); + }, [username, palette, ratingsCount, followersCount, theme, nodeCountBase, connectionDist, speed, globalOpacity]); return ( = ({ username, palette, ratingsCount, followersCount, settings }) => { + const canvasRef = useRef(null); + const theme = useTheme(); + + const particleCount = settings?.particleCount ?? Math.min(150 + Math.floor(ratingsCount * 2), 300); + const speed = settings?.speed ?? 0.002; + const globalOpacity = settings?.opacity ?? 0.8; + const attractionForce = settings?.attractionForce ?? 0.15; + + const getSeed = (str: string) => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + return hash; + }; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d', { alpha: true }); + if (!ctx) return; + + let animationFrameId: number; + const seed = getSeed(username); + const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main]; + + interface Particle { + x: number; + y: number; + vx: number; + vy: number; + size: number; + color: string; + alpha: number; + targetAlpha: number; + phase: number; + offset: number; + } + + let particles: Particle[] = []; + + const pseudoRandom = (offset: number) => { + const x = Math.sin(seed + offset) * 10000; + return x - Math.floor(x); + }; + + const initParticles = (width: number, height: number) => { + const newParticles = []; + for (let i = 0; i < particleCount; i++) { + newParticles.push({ + x: pseudoRandom(i) * width, + y: pseudoRandom(i + 500) * height, + vx: (pseudoRandom(i + 1000) - 0.5) * 0.5, + vy: (pseudoRandom(i + 1500) - 0.5) * 0.5, + size: 2 + pseudoRandom(i + 2000) * 8, + color: colors[i % colors.length], + alpha: 0, + targetAlpha: 0.1 + pseudoRandom(i + 2500) * 0.4, + phase: pseudoRandom(i + 3000) * Math.PI * 2, + offset: pseudoRandom(i + 3500) * 100 + }); + } + return newParticles; + }; + + const resize = () => { + const parent = canvas.parentElement; + if (parent) { + const rect = parent.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + canvas.style.width = `${rect.width}px`; + canvas.style.height = `${rect.height}px`; + particles = initParticles(rect.width, rect.height); + } + }; + + const resizeObserver = new ResizeObserver(() => resize()); + if (canvas.parentElement) resizeObserver.observe(canvas.parentElement); + resize(); + + let mouse = { x: -1000, y: -1000, down: false }; + + const handleMouseMove = (e: MouseEvent) => { + const rect = canvas.getBoundingClientRect(); + mouse.x = e.clientX - rect.left; + mouse.y = e.clientY - rect.top; + }; + + const handleMouseDown = () => { mouse.down = true; }; + const handleMouseUp = () => { mouse.down = false; }; + + const parent = canvas.parentElement; + if (parent) { + parent.addEventListener('mousemove', handleMouseMove); + parent.addEventListener('mousedown', handleMouseDown); + parent.addEventListener('mouseup', handleMouseUp); + parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; }); + } + + let time = 0; + const draw = () => { + const width = canvas.width / (window.devicePixelRatio || 1); + const height = canvas.height / (window.devicePixelRatio || 1); + + if (!width || !height) { + animationFrameId = requestAnimationFrame(draw); + return; + } + + ctx.clearRect(0, 0, width, height); + + // Nebula Background Gradient (Darker tone of the primary color) + const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width/1.2); + bgGrad.addColorStop(0, alpha(theme.palette.background.paper, 0.4)); + bgGrad.addColorStop(1, alpha('#000', 0.8)); + ctx.fillStyle = bgGrad; + ctx.fillRect(0, 0, width, height); + + time += speed; + + particles.forEach((p) => { + // Organic Movement (Cloud-like) + const noiseX = Math.sin(time + p.phase) * 0.5; + const noiseY = Math.cos(time * 0.8 + p.phase) * 0.5; + + p.vx += noiseX * 0.01; + p.vy += noiseY * 0.01; + + // Mouse Interaction + const dx = mouse.x - p.x; + const dy = mouse.y - p.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < 250) { + if (mouse.down) { + // Pull in effect + p.vx += (dx / dist) * attractionForce; + p.vy += (dy / dist) * attractionForce; + } else { + // Disturbance / Swirl effect + const angle = Math.atan2(dy, dx); + p.vx += Math.cos(angle + Math.PI/2) * 0.05; + p.vy += Math.sin(angle + Math.PI/2) * 0.05; + } + } + + // Friction + p.vx *= 0.96; + p.vy *= 0.96; + + p.x += p.vx; + p.y += p.vy; + + // Screen Wrap + if (p.x < -50) p.x = width + 50; + if (p.x > width + 50) p.x = -50; + if (p.y < -50) p.y = height + 50; + if (p.y > height + 50) p.y = -50; + + // Fade in + if (p.alpha < p.targetAlpha) p.alpha += 0.005; + + // Draw Particle (Nebula blob) + const pOpacity = p.alpha * globalOpacity * (0.6 + Math.sin(time * 2 + p.offset) * 0.4); + + const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 3); + grad.addColorStop(0, alpha(p.color, pOpacity)); + grad.addColorStop(0.5, alpha(p.color, pOpacity * 0.3)); + grad.addColorStop(1, alpha(p.color, 0)); + + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size * 4, 0, Math.PI * 2); + ctx.fill(); + }); + + animationFrameId = requestAnimationFrame(draw); + }; + + draw(); + + return () => { + resizeObserver.disconnect(); + if (parent) { + parent.removeEventListener('mousemove', handleMouseMove); + parent.removeEventListener('mousedown', handleMouseDown); + parent.removeEventListener('mouseup', handleMouseUp); + } + cancelAnimationFrame(animationFrameId); + }; + }, [username, palette, ratingsCount, followersCount, theme, particleCount, speed, globalOpacity, attractionForce]); + + return ( + + ); +}; + +export default NebulaBanner; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 4856b6f..afff887 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -153,6 +153,8 @@ const resources = { "title": "Admin Control Panel", "users": "User Management", "stats": "System Statistics", + "overview": "Overview", + "banners": "Banner Models", "config": "Server Configuration", "totalUsers": "Total Users", "openTickets": "Open Tickets", @@ -347,6 +349,8 @@ const resources = { "title": "Admin-Kontrollzentrum", "users": "Benutzerverwaltung", "stats": "System-Statistiken", + "overview": "Übersicht", + "banners": "Banner-Modelle", "config": "Server-Konfiguration", "totalUsers": "Benutzer gesamt", "openTickets": "Offene Tickets", diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index 0846138..e1ee2ea 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -3,16 +3,21 @@ import { Container, Typography, Box, Grid, Card, CardContent, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Stack, alpha, CircularProgress, Chip, TextField, IconButton, - useMediaQuery + useMediaQuery, Tabs, Tab } from '@mui/material'; import EditIcon from '@mui/icons-material/Edit'; import EmailIcon from '@mui/icons-material/Email'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import SettingsIcon from '@mui/icons-material/Settings'; +import PeopleIcon from '@mui/icons-material/People'; +import WallpaperIcon from '@mui/icons-material/Wallpaper'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import api from '../api'; import { useTitle } from '../hooks/useTitle'; import { formatDate } from '../utils/date'; +import BannerSettingsDialog from '../components/BannerSettingsDialog'; interface Stats { total_users: number; @@ -39,6 +44,15 @@ interface AdminUser { ips: string[]; } +interface Banner { + id: number; + name: string; + slug: string; + description: string; + is_active: boolean; + settings: any; +} + const AdminDashboard: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -46,17 +60,23 @@ const AdminDashboard: React.FC = () => { useTitle(t('admin.title')); const [stats, setStats] = useState(null); const [users, setUsers] = useState([]); + const [banners, setBanners] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); + const [currentTab, setCurrentTab] = useState(0); + const [selectedBanner, setSelectedBanner] = useState(null); + const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false); const fetchData = async () => { try { - const [statsRes, usersRes] = await Promise.all([ + const [statsRes, usersRes, bannersRes] = await Promise.all([ api.get('admin-custom/stats/'), - api.get('admin-custom/users/') + api.get('admin-custom/users/'), + api.get('banners/') ]); setStats(statsRes.data); setUsers(usersRes.data); + setBanners(bannersRes.data); } catch (err) { console.error(err); navigate('/'); @@ -65,6 +85,15 @@ const AdminDashboard: React.FC = () => { } }; + const handleActivateBanner = async (id: number) => { + try { + await api.post(`banners/${id}/activate/`); + fetchData(); + } catch (err) { + console.error(err); + } + }; + useEffect(() => { fetchData(); }, []); @@ -97,126 +126,208 @@ const AdminDashboard: React.FC = () => { {t('admin.title')} - - - alpha(theme.palette.primary.main, 0.1) }}> - - {t('admin.totalUsers')} - {stats?.total_users} - - - - - alpha(theme.palette.error.main, 0.1) }}> - - {t('admin.openTickets')} - {stats?.open_tickets} - - - - - - - - - {t('admin.config')}: SMTP - - {stats?.email_config.host}:{stats?.email_config.port} • - TLS: {String(stats?.email_config.use_tls)} • - Insecure: {String(stats?.email_config.skip_verify)} - - - - - - - - + setCurrentTab(v)} + sx={{ mb: 4, borderBottom: 1, borderColor: 'divider' }} + variant={isMobile ? "scrollable" : "standard"} + > + } label={t('admin.overview')} iconPosition="start" /> + } label={t('admin.users')} iconPosition="start" /> + } label={t('admin.banners')} iconPosition="start" /> + - - - {t('admin.users')} - setSearch(e.target.value)} - sx={{ width: isMobile ? '100%' : 300 }} - /> - - - {isMobile ? ( - - {filteredUsers.map(user => ( - - - - {user.username} - navigate(`/admin-panel/user/${user.id}`)}> - - + {currentTab === 0 && ( + + + alpha(theme.palette.primary.main, 0.1) }}> + + {t('admin.totalUsers')} + {stats?.total_users} + + + + + alpha(theme.palette.error.main, 0.1) }}> + + {t('admin.openTickets')} + {stats?.open_tickets} + + + + + + + + + {t('admin.config')}: SMTP + + {stats?.email_config.host}:{stats?.email_config.port} • + TLS: {String(stats?.email_config.use_tls)} • + Insecure: {String(stats?.email_config.skip_verify)} + - {user.email} - - - {user.is_superuser && } - - - - ))} + + + + + + + )} + + {currentTab === 1 && ( + + + {t('admin.users')} + setSearch(e.target.value)} + sx={{ width: isMobile ? '100%' : 300 }} + /> - ) : ( - - - - - ID - User - Email - Status - Joined - IP History - Actions - - - - {filteredUsers.map((user) => ( - - {user.id} - - {user.username} - {user.is_superuser && } - - {user.email} - - - - {formatDate(user.date_joined)} - - - {user.ips.slice(0, 3).map(ip => ( - - ))} - {user.ips.length > 3 && +{user.ips.length - 3}} - - - + + {isMobile ? ( + + {filteredUsers.map(user => ( + + + + {user.username} navigate(`/admin-panel/user/${user.id}`)}> - + + {user.email} + + + {user.is_superuser && } + + + + ))} + + ) : ( + +
+ + + ID + User + Email + Status + Joined + IP History + Actions - ))} - -
-
- )} -
+ + + {filteredUsers.map((user) => ( + + {user.id} + + {user.username} + {user.is_superuser && } + + {user.email} + + + + {formatDate(user.date_joined)} + + + {user.ips.slice(0, 3).map(ip => ( + + ))} + {user.ips.length > 3 && +{user.ips.length - 3}} + + + + navigate(`/admin-panel/user/${user.id}`)}> + + + + + ))} + + + + )} +
+ )} + + {currentTab === 2 && ( + + {t('admin.banners')} + + {banners.map((banner) => ( + + + {banner.is_active && ( + } + label="Active" + color="success" + size="small" + sx={{ position: 'absolute', top: 12, right: 12, zIndex: 2 }} + /> + )} + + {banner.name} + {banner.description} + + + Settings: +
+                                                {JSON.stringify(banner.settings, null, 2)}
+                                            
+
+
+ + + + + + +
+
+ ))} +
+
+ )} + + { + setIsSettingsDialogOpen(false); + setSelectedBanner(null); + }} + banner={selectedBanner} + onSave={fetchData} + /> ); }; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 28d4e5d..4a29c14 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -38,7 +38,7 @@ import { useTitle } from '../hooks/useTitle'; import RatingBadge from '../components/RatingBadge'; import MentionTextField from '../components/MentionTextField'; import RichText from '../components/RichText'; -import GenerativeBanner from '../components/GenerativeBanner'; +import DynamicBanner from '../components/DynamicBanner'; import { formatDate } from '../utils/date'; interface DashboardData { @@ -281,7 +281,7 @@ const Dashboard: React.FC = () => { }} /> {/* Layer 2: Interactive Generative Art */} - = ({ adminMode }) => { }} /> {/* Layer 2: Interactive Generative Art */} -