mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
Merge branch 'main' into narl.io
This commit is contained in:
commit
7416f9a924
8 files changed
+672
-127
No files matched your search
@@ -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<BannerSettingsDialogProps> = ({ 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 (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
Edit Settings: {banner?.name}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||
Modify the JSON configuration for this banner model. Changes take effect immediately for all users.
|
||||
</Typography>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
<TextField
|
||||
multiline
|
||||
rows={12}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={settingsStr}
|
||||
onChange={(e) => setSettingsStr(e.target.value)}
|
||||
placeholder='{ "key": "value" }'
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} color="inherit">{t('common.cancel')}</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="contained"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default BannerSettingsDialog;
|
||||
@@ -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<DynamicBannerProps & { settings: any }> = ({ username, settings }) => {
|
||||
// Placeholder for a second model
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, width: '100%', height: '100%',
|
||||
background: 'rgba(0,0,0,0.8)', color: '#0f0', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace',
|
||||
zIndex: 1, overflow: 'hidden', opacity: settings.opacity || 0.5
|
||||
}}>
|
||||
<div>
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<div key={i} style={{ animation: `matrixFall ${2 + Math.random() * 3}s linear infinite` }}>
|
||||
{username.split('').map((char, j) => (
|
||||
<span key={j} style={{ display: 'block' }}>{char}</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<style>
|
||||
{`
|
||||
@keyframes matrixFall {
|
||||
0% { transform: translateY(-100%); }
|
||||
100% { transform: translateY(100%); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
|
||||
const [banner, setBanner] = useState<Banner | null>(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 <GenerativeBanner {...props} settings={banner?.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'matrix') {
|
||||
return <MatrixBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'nebula') {
|
||||
return <NebulaBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return <GenerativeBanner {...props} />;
|
||||
};
|
||||
|
||||
export default DynamicBanner;
|
||||
@@ -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<GenerativeBannerProps> = ({ username, palette, ratingsCount, followersCount }) => {
|
||||
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(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<GenerativeBannerProps> = ({ 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<GenerativeBannerProps> = ({ 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<GenerativeBannerProps> = ({ 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<GenerativeBannerProps> = ({ 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<GenerativeBannerProps> = ({ username, palette,
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [username, palette, ratingsCount, followersCount, theme]);
|
||||
}, [username, palette, ratingsCount, followersCount, theme, nodeCountBase, connectionDist, speed, globalOpacity]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
|
||||
interface NebulaBannerProps {
|
||||
username: string;
|
||||
palette: string[];
|
||||
ratingsCount: number;
|
||||
followersCount: number;
|
||||
settings?: {
|
||||
particleCount?: number;
|
||||
speed?: number;
|
||||
opacity?: number;
|
||||
attractionForce?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(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 (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
mixBlendMode: 'screen'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NebulaBanner;
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Stats | null>(null);
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [banners, setBanners] = useState<Banner[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [currentTab, setCurrentTab] = useState(0);
|
||||
const [selectedBanner, setSelectedBanner] = useState<Banner | null>(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 = () => {
|
||||
</Button>
|
||||
<Typography variant={isMobile ? "h4" : "h3"} sx={{ fontWeight: 'bold', mb: 4 }}>{t('admin.title')}</Typography>
|
||||
|
||||
<Grid container spacing={isMobile ? 2 : 3} sx={{ mb: 6 }}>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.primary.main, 0.1) }}>
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>{t('admin.totalUsers')}</Typography>
|
||||
<Typography variant={isMobile ? "h5" : "h4"} sx={{ fontWeight: 'bold' }}>{stats?.total_users}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.error.main, 0.1) }}>
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>{t('admin.openTickets')}</Typography>
|
||||
<Typography variant={isMobile ? "h5" : "h4"} sx={{ fontWeight: 'bold', color: 'error.main' }}>{stats?.open_tickets}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Stack direction={isMobile ? "column" : "row"} justifyContent="space-between" alignItems={isMobile ? "flex-start" : "center"} spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{t('admin.config')}: SMTP</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{stats?.email_config.host}:{stats?.email_config.port} •
|
||||
TLS: {String(stats?.email_config.use_tls)} •
|
||||
Insecure: {String(stats?.email_config.skip_verify)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="outlined" fullWidth={isMobile} startIcon={<EmailIcon />} onClick={handleSendTestEmail} size="small">
|
||||
{t('admin.sendTestEmail')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onChange={(_, v) => setCurrentTab(v)}
|
||||
sx={{ mb: 4, borderBottom: 1, borderColor: 'divider' }}
|
||||
variant={isMobile ? "scrollable" : "standard"}
|
||||
>
|
||||
<Tab icon={<SettingsIcon />} label={t('admin.overview')} iconPosition="start" />
|
||||
<Tab icon={<PeopleIcon />} label={t('admin.users')} iconPosition="start" />
|
||||
<Tab icon={<WallpaperIcon />} label={t('admin.banners')} iconPosition="start" />
|
||||
</Tabs>
|
||||
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Stack direction={isMobile ? "column" : "row"} justifyContent="space-between" alignItems={isMobile ? "flex-start" : "center"} sx={{ mb: 2 }} spacing={2}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{t('admin.users')}</Typography>
|
||||
<TextField
|
||||
size="small" placeholder="Search..."
|
||||
fullWidth={isMobile}
|
||||
value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ width: isMobile ? '100%' : 300 }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{isMobile ? (
|
||||
<Stack spacing={2}>
|
||||
{filteredUsers.map(user => (
|
||||
<Card key={user.id} variant="outlined" sx={{ borderRadius: 3 }}>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{user.username}</Typography>
|
||||
<IconButton size="small" onClick={() => navigate(`/admin-panel/user/${user.id}`)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{currentTab === 0 && (
|
||||
<Grid container spacing={isMobile ? 2 : 3} sx={{ mb: 6 }}>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.primary.main, 0.1) }}>
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>{t('admin.totalUsers')}</Typography>
|
||||
<Typography variant={isMobile ? "h5" : "h4"} sx={{ fontWeight: 'bold' }}>{stats?.total_users}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.error.main, 0.1) }}>
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>{t('admin.openTickets')}</Typography>
|
||||
<Typography variant={isMobile ? "h5" : "h4"} sx={{ fontWeight: 'bold', color: 'error.main' }}>{stats?.open_tickets}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Stack direction={isMobile ? "column" : "row"} justifyContent="space-between" alignItems={isMobile ? "flex-start" : "center"} spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{t('admin.config')}: SMTP</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{stats?.email_config.host}:{stats?.email_config.port} •
|
||||
TLS: {String(stats?.email_config.use_tls)} •
|
||||
Insecure: {String(stats?.email_config.skip_verify)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" display="block">{user.email}</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||
<Chip label={user.is_active ? 'Active' : 'Inactive'} color={user.is_active ? 'success' : 'default'} size="small" sx={{ height: 20, fontSize: '0.65rem' }} />
|
||||
{user.is_superuser && <Chip label="Admin" color="secondary" size="small" sx={{ height: 20, fontSize: '0.65rem' }} />}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<Button variant="outlined" fullWidth={isMobile} startIcon={<EmailIcon />} onClick={handleSendTestEmail} size="small">
|
||||
{t('admin.sendTestEmail')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{currentTab === 1 && (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Stack direction={isMobile ? "column" : "row"} justifyContent="space-between" alignItems={isMobile ? "flex-start" : "center"} sx={{ mb: 2 }} spacing={2}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{t('admin.users')}</Typography>
|
||||
<TextField
|
||||
size="small" placeholder="Search..."
|
||||
fullWidth={isMobile}
|
||||
value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ width: isMobile ? '100%' : 300 }}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<TableContainer component={Paper} sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Table>
|
||||
<TableHead sx={{ bgcolor: 'action.hover' }}>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>ID</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>User</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Email</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Status</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Joined</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>IP History</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }} align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredUsers.map((user) => (
|
||||
<TableRow key={user.id} hover>
|
||||
<TableCell>{user.id}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{user.username}</Typography>
|
||||
{user.is_superuser && <Chip label="Superuser" size="small" color="secondary" sx={{ height: 16, fontSize: '0.6rem' }} />}
|
||||
</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={user.is_active ? 'Active' : 'Inactive'}
|
||||
color={user.is_active ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(user.date_joined)}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
||||
{user.ips.slice(0, 3).map(ip => (
|
||||
<Chip key={ip} label={ip} size="small" variant="outlined" sx={{ fontSize: '0.7rem' }} />
|
||||
))}
|
||||
{user.ips.length > 3 && <Typography variant="caption">+{user.ips.length - 3}</Typography>}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
|
||||
{isMobile ? (
|
||||
<Stack spacing={2}>
|
||||
{filteredUsers.map(user => (
|
||||
<Card key={user.id} variant="outlined" sx={{ borderRadius: 3 }}>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{user.username}</Typography>
|
||||
<IconButton size="small" onClick={() => navigate(`/admin-panel/user/${user.id}`)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" display="block">{user.email}</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||
<Chip label={user.is_active ? 'Active' : 'Inactive'} color={user.is_active ? 'success' : 'default'} size="small" sx={{ height: 20, fontSize: '0.65rem' }} />
|
||||
{user.is_superuser && <Chip label="Admin" color="secondary" size="small" sx={{ height: 20, fontSize: '0.65rem' }} />}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<TableContainer component={Paper} sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Table>
|
||||
<TableHead sx={{ bgcolor: 'action.hover' }}>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>ID</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>User</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Email</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Status</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Joined</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>IP History</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }} align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredUsers.map((user) => (
|
||||
<TableRow key={user.id} hover>
|
||||
<TableCell>{user.id}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{user.username}</Typography>
|
||||
{user.is_superuser && <Chip label="Superuser" size="small" color="secondary" sx={{ height: 16, fontSize: '0.6rem' }} />}
|
||||
</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={user.is_active ? 'Active' : 'Inactive'}
|
||||
color={user.is_active ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(user.date_joined)}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
||||
{user.ips.slice(0, 3).map(ip => (
|
||||
<Chip key={ip} label={ip} size="small" variant="outlined" sx={{ fontSize: '0.7rem' }} />
|
||||
))}
|
||||
{user.ips.length > 3 && <Typography variant="caption">+{user.ips.length - 3}</Typography>}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => navigate(`/admin-panel/user/${user.id}`)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{currentTab === 2 && (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold', mb: 3 }}>{t('admin.banners')}</Typography>
|
||||
<Grid container spacing={3}>
|
||||
{banners.map((banner) => (
|
||||
<Grid size={{ xs: 12, md: 4 }} key={banner.id}>
|
||||
<Card sx={{ borderRadius: 3, position: 'relative', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{banner.is_active && (
|
||||
<Chip
|
||||
icon={<CheckCircleIcon />}
|
||||
label="Active"
|
||||
color="success"
|
||||
size="small"
|
||||
sx={{ position: 'absolute', top: 12, right: 12, zIndex: 2 }}
|
||||
/>
|
||||
)}
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 1 }}>{banner.name}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{banner.description}</Typography>
|
||||
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: 'action.hover', borderRadius: 2 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block', mb: 1 }}>Settings:</Typography>
|
||||
<pre style={{ margin: 0, fontSize: '0.7rem', overflow: 'auto' }}>
|
||||
{JSON.stringify(banner.settings, null, 2)}
|
||||
</pre>
|
||||
</Box>
|
||||
</CardContent>
|
||||
<Box sx={{ p: 2, pt: 0 }}>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<EditIcon />}
|
||||
onClick={() => {
|
||||
setSelectedBanner(banner);
|
||||
setIsSettingsDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Edit Settings
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant={banner.is_active ? "outlined" : "contained"}
|
||||
disabled={banner.is_active}
|
||||
onClick={() => handleActivateBanner(banner.id)}
|
||||
>
|
||||
{banner.is_active ? "Currently Active" : "Set as Active"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<BannerSettingsDialog
|
||||
open={isSettingsDialogOpen}
|
||||
onClose={() => {
|
||||
setIsSettingsDialogOpen(false);
|
||||
setSelectedBanner(null);
|
||||
}}
|
||||
banner={selectedBanner}
|
||||
onSave={fetchData}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 */}
|
||||
<GenerativeBanner
|
||||
<DynamicBanner
|
||||
username={data.user.username}
|
||||
palette={palette}
|
||||
ratingsCount={data.rated_count}
|
||||
|
||||
@@ -34,7 +34,7 @@ import { useTitle } from '../hooks/useTitle';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import RatingBadge from '../components/RatingBadge';
|
||||
import RichText from '../components/RichText';
|
||||
import GenerativeBanner from '../components/GenerativeBanner';
|
||||
import DynamicBanner from '../components/DynamicBanner';
|
||||
import { formatDate } from '../utils/date';
|
||||
|
||||
interface Rating {
|
||||
@@ -283,7 +283,7 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
|
||||
}} />
|
||||
|
||||
{/* Layer 2: Interactive Generative Art */}
|
||||
<GenerativeBanner
|
||||
<DynamicBanner
|
||||
username={data.username}
|
||||
palette={palette}
|
||||
ratingsCount={data.ratings.length}
|
||||
|
||||
Reference in new issue
Block a user