drawer fix for desktop

This commit is contained in:
nvrl committed 2026-05-08 23:11:39 +02:00
1 parent 5e3474dd59
commit aa702ad823
38 files changed
+12797 -7992

No files matched your search

+889 -550
View File
File diff suppressed because it is too large. Load diff
+24 -20
View File
@@ -1,39 +1,43 @@
import axios from 'axios'; import axios from "axios";
function getCookie(name: string): string | null { function getCookie(name: string): string | null {
let cookieValue = null; let cookieValue = null;
if (document.cookie && document.cookie !== '') { if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(';'); const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) { for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim(); const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want? // Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === (name + '=')) { if (cookie.substring(0, name.length + 1) === name + "=") {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break; break;
} }
}
} }
return cookieValue; }
return cookieValue;
} }
const api = axios.create({ const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/', baseURL: import.meta.env.VITE_API_URL || "http://localhost:8000/api/",
}); });
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
// Set DRF Auth Token if available from localStorage // Set DRF Auth Token if available from localStorage
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (token) { if (token) {
config.headers.Authorization = `Token ${token}`; config.headers.Authorization = `Token ${token}`;
} }
// Set CSRF token for state-changing methods // Set CSRF token for state-changing methods
const csrfToken = getCookie('csrftoken'); const csrfToken = getCookie("csrftoken");
const method = config.method?.toUpperCase(); const method = config.method?.toUpperCase();
if (csrfToken && method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { if (
if (config.headers) { csrfToken &&
config.headers['X-CSRFToken'] = csrfToken; method &&
} ["POST", "PUT", "PATCH", "DELETE"].includes(method)
) {
if (config.headers) {
config.headers["X-CSRFToken"] = csrfToken;
}
} }
return config; return config;
+168 -139
View File
@@ -1,162 +1,191 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { import {
Dialog, DialogTitle, DialogContent, DialogActions, Button, Dialog,
TextField, Box, Typography, Alert, Slider, Stack, DialogTitle,
Tooltip, IconButton DialogContent,
} from '@mui/material'; DialogActions,
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; Button,
import { useTranslation } from 'react-i18next'; TextField,
import api from '../api'; Box,
Typography,
Alert,
Slider,
Stack,
Tooltip,
IconButton,
} from "@mui/material";
import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
import { useTranslation } from "react-i18next";
import api from "../api";
interface BannerSettingSchema { interface BannerSettingSchema {
key: string; key: string;
label: string; label: string;
type: 'number' | 'slider' | 'text' | 'boolean'; type: "number" | "slider" | "text" | "boolean";
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
description?: string; description?: string;
} }
interface Banner { interface Banner {
id: number; id: number;
name: string; name: string;
slug: string; slug: string;
description: string; description: string;
is_active: boolean; is_active: boolean;
settings: any; settings: any;
schema: BannerSettingSchema[]; schema: BannerSettingSchema[];
} }
interface BannerSettingsDialogProps { interface BannerSettingsDialogProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
banner: Banner | null; banner: Banner | null;
onSave: () => void; onSave: () => void;
} }
const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClose, banner, onSave }) => { const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
const { t } = useTranslation(); open,
const [localSettings, setLocalSettings] = useState<any>({}); onClose,
const [error, setError] = useState(''); banner,
const [saving, setSaving] = useState(false); onSave,
}) => {
const { t } = useTranslation();
const [localSettings, setLocalSettings] = useState<any>({});
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
if (banner) { if (banner) {
setLocalSettings(banner.settings || {}); setLocalSettings(banner.settings || {});
setError(''); setError("");
} }
}, [banner]); }, [banner]);
const handleSave = async () => { const handleSave = async () => {
if (!banner) return; if (!banner) return;
try { try {
setSaving(true); setSaving(true);
await api.patch(`banners/${banner.id}/`, { settings: localSettings }); await api.patch(`banners/${banner.id}/`, { settings: localSettings });
onSave(); onSave();
onClose(); onClose();
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.error || 'Failed to save settings'); setError(err.response?.data?.error || "Failed to save settings");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const updateSetting = (key: string, value: any) => { const updateSetting = (key: string, value: any) => {
setLocalSettings((prev: any) => ({ setLocalSettings((prev: any) => ({
...prev, ...prev,
[key]: value [key]: value,
})); }));
}; };
const renderField = (schema: BannerSettingSchema) => { const renderField = (schema: BannerSettingSchema) => {
const value = localSettings[schema.key] ?? ''; const value = localSettings[schema.key] ?? "";
return (
<Box key={schema.key} sx={{ mb: 3 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
{schema.label}
</Typography>
{schema.description && (
<Tooltip title={schema.description} arrow>
<IconButton size="small" sx={{ p: 0.5 }}>
<HelpOutlineIcon sx={{ fontSize: '1rem', opacity: 0.6 }} />
</IconButton>
</Tooltip>
)}
</Stack>
{schema.type === 'slider' ? (
<Box sx={{ px: 1 }}>
<Slider
value={Number(value)}
min={schema.min ?? 0}
max={schema.max ?? 100}
step={schema.step ?? 1}
onChange={(_, val) => updateSetting(schema.key, val)}
valueLabelDisplay="auto"
/>
</Box>
) : schema.type === 'number' ? (
<TextField
type="number"
fullWidth
size="small"
value={value}
inputProps={{ min: schema.min, max: schema.max, step: schema.step }}
onChange={(e) => updateSetting(schema.key, Number(e.target.value))}
/>
) : (
<TextField
fullWidth
size="small"
value={value}
onChange={(e) => updateSetting(schema.key, e.target.value)}
/>
)}
{schema.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{schema.description}
</Typography>
)}
</Box>
);
};
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> <Box key={schema.key} sx={{ mb: 3 }}>
<DialogTitle sx={{ fontWeight: 'bold' }}> <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
Banner Settings: {banner?.name} <Typography variant="subtitle2" sx={{ fontWeight: "bold" }}>
</DialogTitle> {schema.label}
<DialogContent dividers> </Typography>
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>} {schema.description && (
<Tooltip title={schema.description} arrow>
<IconButton size="small" sx={{ p: 0.5 }}>
<HelpOutlineIcon sx={{ fontSize: "1rem", opacity: 0.6 }} />
</IconButton>
</Tooltip>
)}
</Stack>
{banner?.schema && banner.schema.length > 0 ? ( {schema.type === "slider" ? (
<Box sx={{ mt: 1 }}> <Box sx={{ px: 1 }}>
{banner.schema.map(field => renderField(field))} <Slider
</Box> value={Number(value)}
) : ( min={schema.min ?? 0}
<Typography color="text.secondary" sx={{ fontStyle: 'italic', py: 2 }}> max={schema.max ?? 100}
No dynamic schema defined for this banner. Please use the JSON editor or check the banner configuration files. step={schema.step ?? 1}
</Typography> onChange={(_, val) => updateSetting(schema.key, val)}
)} valueLabelDisplay="auto"
</DialogContent> />
<DialogActions sx={{ px: 3, py: 2 }}> </Box>
<Button onClick={onClose} color="inherit">{t('common.cancel')}</Button> ) : schema.type === "number" ? (
<Button <TextField
onClick={handleSave} type="number"
variant="contained" fullWidth
disabled={saving} size="small"
sx={{ px: 4 }} value={value}
> inputProps={{ min: schema.min, max: schema.max, step: schema.step }}
{saving ? t('common.loading') : t('common.save')} onChange={(e) => updateSetting(schema.key, Number(e.target.value))}
</Button> />
</DialogActions> ) : (
</Dialog> <TextField
fullWidth
size="small"
value={value}
onChange={(e) => updateSetting(schema.key, e.target.value)}
/>
)}
{schema.description && (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mt: 0.5 }}
>
{schema.description}
</Typography>
)}
</Box>
); );
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: "bold" }}>
Banner Settings: {banner?.name}
</DialogTitle>
<DialogContent dividers>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{banner?.schema && banner.schema.length > 0 ? (
<Box sx={{ mt: 1 }}>
{banner.schema.map((field) => renderField(field))}
</Box>
) : (
<Typography
color="text.secondary"
sx={{ fontStyle: "italic", py: 2 }}
>
No dynamic schema defined for this banner. Please use the JSON
editor or check the banner configuration files.
</Typography>
)}
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} color="inherit">
{t("common.cancel")}
</Button>
<Button
onClick={handleSave}
variant="contained"
disabled={saving}
sx={{ px: 4 }}
>
{saving ? t("common.loading") : t("common.save")}
</Button>
</DialogActions>
</Dialog>
);
}; };
export default BannerSettingsDialog; export default BannerSettingsDialog;
+126 -95
View File
@@ -1,108 +1,139 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { import {
Typography, Typography,
Button, Button,
alpha, alpha,
Dialog, Dialog,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
DialogActions, DialogActions,
Box Box,
} from '@mui/material'; } from "@mui/material";
import { useTranslation } from 'react-i18next'; import { useTranslation } from "react-i18next";
import type { CatppuccinTheme } from '../theme'; import type { CatppuccinTheme } from "../theme";
interface CookieBannerProps { interface CookieBannerProps {
onThemeChange: (newTheme: CatppuccinTheme) => void; onThemeChange: (newTheme: CatppuccinTheme) => void;
currentTheme: CatppuccinTheme; currentTheme: CatppuccinTheme;
} }
const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme }) => { const CookieBanner: React.FC<CookieBannerProps> = ({
const { t } = useTranslation(); onThemeChange,
const [open, setOpen] = useState(false); currentTheme,
}) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
const consent = localStorage.getItem('cookie-consent'); const consent = localStorage.getItem("cookie-consent");
if (!consent) { if (!consent) {
setOpen(true); setOpen(true);
} }
}, []); }, []);
const handleAccept = () => { const handleAccept = () => {
localStorage.setItem('cookie-consent', 'true'); localStorage.setItem("cookie-consent", "true");
setOpen(false); setOpen(false);
}; };
const handleToggleTheme = () => { const handleToggleTheme = () => {
const newTheme = currentTheme === 'holy_light' ? 'holy_dark' : 'holy_light'; const newTheme = currentTheme === "holy_light" ? "holy_dark" : "holy_light";
onThemeChange(newTheme); onThemeChange(newTheme);
}; };
return ( return (
<Dialog <Dialog
open={open} open={open}
onClose={() => {}} // Disable closing by clicking backdrop onClose={() => {}} // Disable closing by clicking backdrop
disableEscapeKeyDown disableEscapeKeyDown
maxWidth="sm" maxWidth="sm"
fullWidth fullWidth
PaperProps={{ PaperProps={{
sx: { sx: {
borderRadius: 4, borderRadius: 4,
border: '1px solid', border: "1px solid",
borderColor: 'primary.main', borderColor: "primary.main",
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.98), bgcolor: (theme) => alpha(theme.palette.background.paper, 0.98),
backdropFilter: 'blur(10px)', backdropFilter: "blur(10px)",
boxShadow: (theme) => `0 20px 50px ${alpha(theme.palette.primary.main, 0.2)}`, boxShadow: (theme) =>
} `0 20px 50px ${alpha(theme.palette.primary.main, 0.2)}`,
}} },
}}
>
<DialogTitle sx={{ fontWeight: "bold", fontSize: "1.5rem", pt: 3 }}>
{t("cookieBanner.title")} 🍪
</DialogTitle>
<DialogContent>
<Typography variant="body1" sx={{ mb: 2, lineHeight: 1.7 }}>
{t("cookieBanner.description")}
</Typography>
<Box
sx={{
p: 2,
borderRadius: 2,
bgcolor: "action.hover",
border: "1px solid",
borderColor: "divider",
mt: 2,
}}
> >
<DialogTitle sx={{ fontWeight: 'bold', fontSize: '1.5rem', pt: 3 }}> <Typography
{t('cookieBanner.title')} 🍪 variant="caption"
</DialogTitle> color="text.secondary"
<DialogContent> sx={{
<Typography variant="body1" sx={{ mb: 2, lineHeight: 1.7 }}> fontWeight: "bold",
{t('cookieBanner.description')} display: "block",
</Typography> mb: 1,
<Box sx={{ textTransform: "uppercase",
p: 2, }}
borderRadius: 2, >
bgcolor: 'action.hover', Technical Details:
border: '1px solid', </Typography>
borderColor: 'divider', <Typography variant="caption" color="text.secondary">
mt: 2 • Session ID: Required for login & authentication.
}}> <br />
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', display: 'block', mb: 1, textTransform: 'uppercase' }}> • CSRF Token: Protects your account from malicious attacks.
Technical Details: <br />• Preference Store: Remembers your theme and language choices.
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> </Box>
• Session ID: Required for login & authentication.<br /> </DialogContent>
• CSRF Token: Protects your account from malicious attacks.<br /> <DialogActions
• Preference Store: Remembers your theme and language choices. sx={{ p: 3, gap: 2, flexDirection: { xs: "column", sm: "row" } }}
</Typography> >
</Box> <Button
</DialogContent> fullWidth
<DialogActions sx={{ p: 3, gap: 2, flexDirection: { xs: 'column', sm: 'row' } }}> variant="outlined"
<Button color="primary"
fullWidth onClick={handleToggleTheme}
variant="outlined" sx={{
color="primary" borderRadius: 2,
onClick={handleToggleTheme} textTransform: "none",
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', m: '0 !important' }} fontWeight: "bold",
> m: "0 !important",
{currentTheme === 'holy_light' ? t('cookieBanner.switchToDark') : t('cookieBanner.switchToLight')} }}
</Button> >
<Button {currentTheme === "holy_light"
fullWidth ? t("cookieBanner.switchToDark")
variant="contained" : t("cookieBanner.switchToLight")}
color="primary" </Button>
onClick={handleAccept} <Button
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', px: 4, m: '0 !important' }} fullWidth
> variant="contained"
{t('cookieBanner.accept')} color="primary"
</Button> onClick={handleAccept}
</DialogActions> sx={{
</Dialog> borderRadius: 2,
); textTransform: "none",
fontWeight: "bold",
px: 4,
m: "0 !important",
}}
>
{t("cookieBanner.accept")}
</Button>
</DialogActions>
</Dialog>
);
}; };
export default CookieBanner; export default CookieBanner;
+105 -83
View File
@@ -1,113 +1,135 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import api from '../api'; import api from "../api";
import GenerativeBanner from './GenerativeBanner'; import GenerativeBanner from "./GenerativeBanner";
import NebulaBanner from './NebulaBanner'; import NebulaBanner from "./NebulaBanner";
import FirefliesBanner from './FirefliesBanner'; import FirefliesBanner from "./FirefliesBanner";
import HextechBanner from './HextechBanner'; import HextechBanner from "./HextechBanner";
import HextechCorruptionBanner from './HextechCorruptionBanner'; import HextechCorruptionBanner from "./HextechCorruptionBanner";
import GenerativeFlowBanner from './GenerativeFlowBanner'; import GenerativeFlowBanner from "./GenerativeFlowBanner";
interface Banner { interface Banner {
id: number; id: number;
name: string; name: string;
slug: string; slug: string;
description: string; description: string;
is_active: boolean; is_active: boolean;
settings: any; settings: any;
} }
interface DynamicBannerProps { interface DynamicBannerProps {
username: string; username: string;
palette: string[]; palette: string[];
ratingsCount: number; ratingsCount: number;
followersCount: number; followersCount: number;
} }
// Future expansion: Add more banner components here // Future expansion: Add more banner components here
const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({ username, settings }) => { const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({
// Placeholder for a second model username,
return ( settings,
<div style={{ }) => {
position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', // Placeholder for a second model
background: 'rgba(0,0,0,0.8)', color: '#0f0', display: 'flex', return (
alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace', <div
zIndex: 1, overflow: 'hidden', opacity: settings.opacity || 0.5 style={{
}}> position: "absolute",
<div> top: 0,
{Array.from({ length: 20 }).map((_, i) => ( left: 0,
<div key={i} style={{ animation: `matrixFall ${2 + Math.random() * 3}s linear infinite` }}> width: "100%",
{username.split('').map((char, j) => ( height: "100%",
<span key={j} style={{ display: 'block' }}>{char}</span> background: "rgba(0,0,0,0.8)",
))} color: "#0f0",
</div> display: "flex",
))} alignItems: "center",
</div> justifyContent: "center",
<style> 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 { @keyframes matrixFall {
0% { transform: translateY(-100%); } 0% { transform: translateY(-100%); }
100% { transform: translateY(100%); } 100% { transform: translateY(100%); }
} }
`} `}
</style> </style>
</div> </div>
); );
}; };
const DynamicBanner: React.FC<DynamicBannerProps> = (props) => { const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
const [banner, setBanner] = useState<Banner | null>(null); const [banner, setBanner] = useState<Banner | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const fetchActiveBanner = async () => { const fetchActiveBanner = async () => {
try { try {
const res = await api.get('banners/active/', { const res = await api.get("banners/active/", {
params: { username: props.username } params: { username: props.username },
}); });
setBanner(res.data); setBanner(res.data);
} catch (err) { } catch (err) {
console.error('Failed to fetch active banner:', err); console.error("Failed to fetch active banner:", err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchActiveBanner(); fetchActiveBanner();
}, [props.username]); }, [props.username]);
if (loading) return null; if (loading) return null;
// Default to GenerativeBanner if no active banner or unrecognized slug // Default to GenerativeBanner if no active banner or unrecognized slug
if (!banner || banner.slug === 'generative-web') { if (!banner || banner.slug === "generative-web") {
return <GenerativeBanner {...props} settings={banner?.settings} />; return <GenerativeBanner {...props} settings={banner?.settings} />;
} }
if (banner.slug === 'matrix') { if (banner.slug === "matrix") {
return <MatrixBanner {...props} settings={banner.settings} />; return <MatrixBanner {...props} settings={banner.settings} />;
} }
if (banner.slug === 'nebula') { if (banner.slug === "nebula") {
return <NebulaBanner {...props} settings={banner.settings} />; return <NebulaBanner {...props} settings={banner.settings} />;
} }
if (banner.slug === 'fireflies') { if (banner.slug === "fireflies") {
return <FirefliesBanner {...props} settings={banner.settings} />; return <FirefliesBanner {...props} settings={banner.settings} />;
} }
if (banner.slug === 'hextech') { if (banner.slug === "hextech") {
return <HextechBanner {...props} settings={banner.settings} />; return <HextechBanner {...props} settings={banner.settings} />;
} }
if (banner.slug === 'hextech-corruption') { if (banner.slug === "hextech-corruption") {
return <HextechCorruptionBanner {...props} settings={banner.settings} />; return <HextechCorruptionBanner {...props} settings={banner.settings} />;
} }
if (banner.slug === 'generative-flow') { if (banner.slug === "generative-flow") {
return <GenerativeFlowBanner {...props} settings={banner.settings} />; return <GenerativeFlowBanner {...props} settings={banner.settings} />;
} }
// Fallback // Fallback
return <GenerativeBanner {...props} />; return <GenerativeBanner {...props} />;
}; };
export default DynamicBanner; export default DynamicBanner;
+266 -226
View File
@@ -1,257 +1,297 @@
import React, { useRef, useEffect, useMemo } from 'react'; import React, { useRef, useEffect, useMemo } from "react";
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from "@mui/material";
interface FirefliesBannerProps { interface FirefliesBannerProps {
username: string; username: string;
palette: string[]; palette: string[];
ratingsCount: number; ratingsCount: number;
followersCount: number; followersCount: number;
settings?: { settings?: {
count?: number; count?: number;
speed?: number; speed?: number;
evasionRadius?: number; evasionRadius?: number;
evasionStrength?: number; evasionStrength?: number;
glowSize?: number; glowSize?: number;
flickerSpeed?: number; flickerSpeed?: number;
}; };
} }
const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, settings }) => { const FirefliesBanner: React.FC<FirefliesBannerProps> = ({
const canvasRef = useRef<HTMLCanvasElement>(null); username,
const theme = useTheme(); palette,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const count = settings?.count ?? 150; const count = settings?.count ?? 150;
const baseSpeed = settings?.speed ?? 0.8; // Slower default cruise const baseSpeed = settings?.speed ?? 0.8; // Slower default cruise
const evasionRadius = settings?.evasionRadius ?? 180; const evasionRadius = settings?.evasionRadius ?? 180;
const evasionStrength = settings?.evasionStrength ?? 0.6; // High evasion const evasionStrength = settings?.evasionStrength ?? 0.6; // High evasion
const glowSizeMultiplier = settings?.glowSize ?? 4; const glowSizeMultiplier = settings?.glowSize ?? 4;
const flickerSpeed = settings?.flickerSpeed ?? 0.05; const flickerSpeed = settings?.flickerSpeed ?? 0.05;
const getSeed = (str: string) => { const getSeed = (str: string) => {
let hash = 0; let hash = 0;
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash); hash = str.charCodeAt(i) + ((hash << 5) - hash);
} }
return hash; return hash;
};
const seed = useMemo(() => getSeed(username), [username]);
const brightColors = useMemo(() => {
const colors =
palette.length >= 2
? palette
: [
theme.palette.primary.main,
theme.palette.secondary.main,
theme.palette.info.main,
];
const getBrightness = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) || 0;
const g = parseInt(hex.slice(3, 5), 16) || 0;
const b = parseInt(hex.slice(5, 7), 16) || 0;
return (r * 299 + g * 587 + b * 114) / 1000;
};
const sorted = [...colors].sort(
(a, b) => getBrightness(b) - getBrightness(a),
);
return sorted.slice(0, 3);
}, [palette, theme]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return;
let animationFrameId: number;
interface Firefly {
x: number;
y: number;
vx: number;
vy: number;
size: number;
color: string;
phase: number;
noiseOffset: number;
isEvading: boolean;
}
let swarm: Firefly[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
}; };
const seed = useMemo(() => getSeed(username), [username]); const initSwarm = (width: number, height: number) => {
const newSwarm = [];
for (let i = 0; i < count; i++) {
newSwarm.push({
x: pseudoRandom(i) * width,
y: pseudoRandom(i + 500) * height,
vx: (pseudoRandom(i + 1000) - 0.5) * baseSpeed,
vy: (pseudoRandom(i + 1500) - 0.5) * baseSpeed,
size: 1 + pseudoRandom(i + 2000) * 2,
color: brightColors[i % brightColors.length],
phase: pseudoRandom(i + 2500) * Math.PI * 2,
noiseOffset: pseudoRandom(i + 3000) * 100,
isEvading: false,
});
}
return newSwarm;
};
const brightColors = useMemo(() => { const resize = () => {
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main]; const parent = canvas.parentElement;
const getBrightness = (hex: string) => { if (parent) {
const r = parseInt(hex.slice(1, 3), 16) || 0; const rect = parent.getBoundingClientRect();
const g = parseInt(hex.slice(3, 5), 16) || 0; const dpr = window.devicePixelRatio || 1;
const b = parseInt(hex.slice(5, 7), 16) || 0; canvas.width = rect.width * dpr;
return (r * 299 + g * 587 + b * 114) / 1000; canvas.height = rect.height * dpr;
}; ctx.scale(dpr, dpr);
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a)); canvas.style.width = `${rect.width}px`;
return sorted.slice(0, 3); canvas.style.height = `${rect.height}px`;
}, [palette, theme]); swarm = initSwarm(rect.width, rect.height);
}
};
useEffect(() => { const resizeObserver = new ResizeObserver(() => resize());
const canvas = canvasRef.current; if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
if (!canvas) return; resize();
const ctx = canvas.getContext('2d', { alpha: true }); let mouse = { x: -1000, y: -1000 };
if (!ctx) return; const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
};
let animationFrameId: number; const parent = canvas.parentElement;
if (parent) {
parent.addEventListener("mousemove", handleMouseMove);
parent.addEventListener("mouseleave", () => {
mouse.x = -1000;
mouse.y = -1000;
});
}
interface Firefly { let time = 0;
x: number; const draw = () => {
y: number; const width = canvas.width / (window.devicePixelRatio || 1);
vx: number; const height = canvas.height / (window.devicePixelRatio || 1);
vy: number; if (!width || !height) {
size: number; animationFrameId = requestAnimationFrame(draw);
color: string; return;
phase: number; }
noiseOffset: number;
isEvading: boolean; ctx.clearRect(0, 0, width, height);
// Vibrant theme-aware night sky background
const isLight = theme.palette.mode === "light";
const bgGrad = ctx.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
width,
);
if (isLight) {
// For light themes, we still need a "dusk/night" feel for fireflies to make sense.
// We use a deeper, more saturated version of the theme's colors.
bgGrad.addColorStop(0, alpha(brightColors[2] || "#1a1a2e", 0.85));
bgGrad.addColorStop(1, alpha(brightColors[2] || "#16213e", 1.0));
} else {
bgGrad.addColorStop(0, "#141a2a"); // Noticeable blue-grey tint
bgGrad.addColorStop(1, "#05080f");
}
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
time += flickerSpeed;
swarm.forEach((f, i) => {
// 1. Autonomous Chaotic Movement (Perlin + Jitter)
const driftX = Math.sin(time * 0.5 + f.noiseOffset) * 0.05;
const driftY = Math.cos(time * 0.4 + f.noiseOffset) * 0.05;
// High-frequency jitter for insect feel
const jitterX = (pseudoRandom(i + time * 10) - 0.5) * 0.2;
const jitterY = (pseudoRandom(i + 500 + time * 10) - 0.5) * 0.2;
f.vx += driftX + jitterX;
f.vy += driftY + jitterY;
// 2. Mouse Evasion (Fast Dash)
const dx = mouse.x - f.x;
const dy = mouse.y - f.y;
const dist = Math.sqrt(dx * dx + dy * dy);
f.isEvading = dist < evasionRadius;
if (f.isEvading) {
const force = (evasionRadius - dist) / evasionRadius;
const angle = Math.atan2(dy, dx);
// Sharp acceleration away
f.vx -= Math.cos(angle) * force * evasionStrength * 1.5;
f.vy -= Math.sin(angle) * force * evasionStrength * 1.5;
} }
let swarm: Firefly[] = []; // 3. Dynamic Speed Limits
const currentSpeed = Math.sqrt(f.vx * f.vx + f.vy * f.vy);
const maxSpeed = f.isEvading ? baseSpeed * 10 : baseSpeed * 1.5;
const pseudoRandom = (offset: number) => { if (currentSpeed > maxSpeed) {
const x = Math.sin(seed + offset) * 10000; f.vx = (f.vx / currentSpeed) * maxSpeed;
return x - Math.floor(x); f.vy = (f.vy / currentSpeed) * maxSpeed;
};
const initSwarm = (width: number, height: number) => {
const newSwarm = [];
for (let i = 0; i < count; i++) {
newSwarm.push({
x: pseudoRandom(i) * width,
y: pseudoRandom(i + 500) * height,
vx: (pseudoRandom(i + 1000) - 0.5) * baseSpeed,
vy: (pseudoRandom(i + 1500) - 0.5) * baseSpeed,
size: 1 + pseudoRandom(i + 2000) * 2,
color: brightColors[i % brightColors.length],
phase: pseudoRandom(i + 2500) * Math.PI * 2,
noiseOffset: pseudoRandom(i + 3000) * 100,
isEvading: false
});
}
return newSwarm;
};
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`;
swarm = initSwarm(rect.width, rect.height);
}
};
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
let mouse = { x: -1000, y: -1000 };
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
};
const parent = canvas.parentElement;
if (parent) {
parent.addEventListener('mousemove', handleMouseMove);
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; });
} }
let time = 0; // 4. Smooth Friction
const draw = () => { f.vx *= 0.97;
const width = canvas.width / (window.devicePixelRatio || 1); f.vy *= 0.97;
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, width, height); f.x += f.vx;
f.y += f.vy;
// Vibrant theme-aware night sky background // Screen Wrap
const isLight = theme.palette.mode === 'light'; const margin = 20;
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width); if (f.x < -margin) f.x = width + margin;
if (isLight) { if (f.x > width + margin) f.x = -margin;
// For light themes, we still need a "dusk/night" feel for fireflies to make sense. if (f.y < -margin) f.y = height + margin;
// We use a deeper, more saturated version of the theme's colors. if (f.y > height + margin) f.y = -margin;
bgGrad.addColorStop(0, alpha(brightColors[2] || '#1a1a2e', 0.85));
bgGrad.addColorStop(1, alpha(brightColors[2] || '#16213e', 1.0));
} else {
bgGrad.addColorStop(0, '#141a2a'); // Noticeable blue-grey tint
bgGrad.addColorStop(1, '#05080f');
}
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
time += flickerSpeed; // Flicker
const flicker = 0.4 + Math.sin(time + f.phase) * 0.6;
const opacity = flicker > 0 ? flicker : 0;
swarm.forEach((f, i) => { // Render
// 1. Autonomous Chaotic Movement (Perlin + Jitter) const grad = ctx.createRadialGradient(
const driftX = Math.sin(time * 0.5 + f.noiseOffset) * 0.05; f.x,
const driftY = Math.cos(time * 0.4 + f.noiseOffset) * 0.05; f.y,
0,
f.x,
f.y,
f.size * glowSizeMultiplier,
);
grad.addColorStop(0, alpha(f.color, opacity));
grad.addColorStop(0.4, alpha(f.color, opacity * 0.3));
grad.addColorStop(1, alpha(f.color, 0));
// High-frequency jitter for insect feel ctx.fillStyle = grad;
const jitterX = (pseudoRandom(i + time * 10) - 0.5) * 0.2; ctx.beginPath();
const jitterY = (pseudoRandom(i + 500 + time * 10) - 0.5) * 0.2; ctx.arc(f.x, f.y, f.size * glowSizeMultiplier, 0, Math.PI * 2);
ctx.fill();
f.vx += driftX + jitterX; ctx.fillStyle = alpha("#fff", opacity * 0.8);
f.vy += driftY + jitterY; ctx.beginPath();
ctx.arc(f.x, f.y, f.size * 0.5, 0, Math.PI * 2);
ctx.fill();
});
// 2. Mouse Evasion (Fast Dash) animationFrameId = requestAnimationFrame(draw);
const dx = mouse.x - f.x; };
const dy = mouse.y - f.y;
const dist = Math.sqrt(dx * dx + dy * dy);
f.isEvading = dist < evasionRadius; draw();
if (f.isEvading) {
const force = (evasionRadius - dist) / evasionRadius;
const angle = Math.atan2(dy, dx);
// Sharp acceleration away
f.vx -= Math.cos(angle) * force * evasionStrength * 1.5;
f.vy -= Math.sin(angle) * force * evasionStrength * 1.5;
}
// 3. Dynamic Speed Limits return () => {
const currentSpeed = Math.sqrt(f.vx * f.vx + f.vy * f.vy); resizeObserver.disconnect();
const maxSpeed = f.isEvading ? baseSpeed * 10 : baseSpeed * 1.5; if (parent) {
parent.removeEventListener("mousemove", handleMouseMove);
}
cancelAnimationFrame(animationFrameId);
};
}, [
seed,
brightColors,
count,
baseSpeed,
evasionRadius,
evasionStrength,
glowSizeMultiplier,
flickerSpeed,
theme,
]);
if (currentSpeed > maxSpeed) { return (
f.vx = (f.vx / currentSpeed) * maxSpeed; <canvas
f.vy = (f.vy / currentSpeed) * maxSpeed; ref={canvasRef}
} style={{
position: "absolute",
// 4. Smooth Friction top: 0,
f.vx *= 0.97; left: 0,
f.vy *= 0.97; width: "100%",
height: "100%",
f.x += f.vx; pointerEvents: "none",
f.y += f.vy; zIndex: 1,
}}
// Screen Wrap />
const margin = 20; );
if (f.x < -margin) f.x = width + margin;
if (f.x > width + margin) f.x = -margin;
if (f.y < -margin) f.y = height + margin;
if (f.y > height + margin) f.y = -margin;
// Flicker
const flicker = 0.4 + Math.sin(time + f.phase) * 0.6;
const opacity = flicker > 0 ? flicker : 0;
// Render
const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.size * glowSizeMultiplier);
grad.addColorStop(0, alpha(f.color, opacity));
grad.addColorStop(0.4, alpha(f.color, opacity * 0.3));
grad.addColorStop(1, alpha(f.color, 0));
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(f.x, f.y, f.size * glowSizeMultiplier, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = alpha('#fff', opacity * 0.8);
ctx.beginPath();
ctx.arc(f.x, f.y, f.size * 0.5, 0, Math.PI * 2);
ctx.fill();
});
animationFrameId = requestAnimationFrame(draw);
};
draw();
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
}
cancelAnimationFrame(animationFrameId);
};
}, [seed, brightColors, count, baseSpeed, evasionRadius, evasionStrength, glowSizeMultiplier, flickerSpeed, theme]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1
}}
/>
);
}; };
export default FirefliesBanner; export default FirefliesBanner;
+71 -19
View File
@@ -1,32 +1,84 @@
import React from 'react'; import React from "react";
import { Box, Container, Typography, Link as MuiLink, Divider } from '@mui/material'; import {
import { Link } from 'react-router-dom'; Box,
import { useTranslation } from 'react-i18next'; Container,
Typography,
Link as MuiLink,
Divider,
} from "@mui/material";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
const Footer: React.FC = () => { const Footer: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const year = new Date().getFullYear(); const year = new Date().getFullYear();
return ( return (
<Box component="footer" sx={{ bgcolor: 'background.paper', pt: 6, pb: 4, mt: 'auto', borderTop: '1px solid', borderColor: 'divider' }}> <Box
component="footer"
sx={{
bgcolor: "background.paper",
pt: 6,
pb: 4,
mt: "auto",
borderTop: "1px solid",
borderColor: "divider",
}}
>
<Container maxWidth="lg"> <Container maxWidth="lg">
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, justifyContent: 'space-between', alignItems: 'center', gap: 2, mb: 2 }}> <Box
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}> sx={{
© {year} Holy Flavors Archive display: "flex",
</Typography> flexDirection: { xs: "column", sm: "row" },
justifyContent: "space-between",
alignItems: "center",
gap: 2,
mb: 2,
}}
>
<Typography
variant="body2"
color="text.secondary"
sx={{ fontWeight: "bold" }}
>
© {year} Holy Flavors Archive
</Typography>
<Box sx={{ display: 'flex', gap: 3 }}> <Box sx={{ display: "flex", gap: 3 }}>
<MuiLink component={Link} to="/about" color="inherit" sx={{ fontSize: '0.8rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}> <MuiLink
Impressum component={Link}
</MuiLink> to="/about"
<MuiLink component={Link} to="/privacy" color="inherit" sx={{ fontSize: '0.8rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}> color="inherit"
Privacy Policy sx={{
</MuiLink> fontSize: "0.8rem",
</Box> textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
Impressum
</MuiLink>
<MuiLink
component={Link}
to="/privacy"
color="inherit"
sx={{
fontSize: "0.8rem",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
Privacy Policy
</MuiLink>
</Box>
</Box> </Box>
<Divider sx={{ my: 2, opacity: 0.5 }} /> <Divider sx={{ my: 2, opacity: 0.5 }} />
<Typography variant="caption" color="text.secondary" align="center" display="block"> <Typography
{t('nav.about')} • Fan Project variant="caption"
color="text.secondary"
align="center"
display="block"
>
{t("nav.about")} • Fan Project
</Typography> </Typography>
</Container> </Container>
</Box> </Box>
+296 -251
View File
@@ -1,278 +1,323 @@
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect } from "react";
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from "@mui/material";
interface GenerativeBannerProps { interface GenerativeBannerProps {
username: string; username: string;
palette: string[]; palette: string[];
ratingsCount: number; ratingsCount: number;
followersCount: number; followersCount: number;
settings?: { settings?: {
nodeCountBase?: number; nodeCountBase?: number;
connectionDist?: number; connectionDist?: number;
speed?: number; speed?: number;
opacity?: number; opacity?: number;
}; };
} }
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => { const GenerativeBanner: React.FC<GenerativeBannerProps> = ({
const canvasRef = useRef<HTMLCanvasElement>(null); username,
const theme = useTheme(); palette,
ratingsCount,
followersCount,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const nodeCountBase = settings?.nodeCountBase ?? 60; const nodeCountBase = settings?.nodeCountBase ?? 60;
const connectionDist = settings?.connectionDist ?? 140; const connectionDist = settings?.connectionDist ?? 140;
const speed = settings?.speed ?? 0.005; const speed = settings?.speed ?? 0.005;
const globalOpacity = settings?.opacity ?? 0.35; const globalOpacity = settings?.opacity ?? 0.35;
const getSeed = (str: string) => { const getSeed = (str: string) => {
let hash = 0; let hash = 0;
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash); hash = str.charCodeAt(i) + ((hash << 5) - hash);
} }
return 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);
// Configuration - High density for "complex web" feel
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 {
x: number;
y: number;
vx: number;
vy: number;
baseX: number;
baseY: number;
color: string;
size: number;
}
let nodes: Node[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
}; };
useEffect(() => { const initNodes = (width: number, height: number) => {
const canvas = canvasRef.current; const newNodes = [];
if (!canvas) return; for (let i = 0; i < nodeCount; i++) {
const bx = pseudoRandom(i) * width;
const by = pseudoRandom(i + 100) * height;
newNodes.push({
x: bx,
y: by,
baseX: bx,
baseY: by,
vx: 0,
vy: 0,
color: colors[i % colors.length],
size: 1 + pseudoRandom(i + 200) * 3,
});
}
return newNodes;
};
const ctx = canvas.getContext('2d', { alpha: true }); const resize = () => {
if (!ctx) return; 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`;
nodes = initNodes(rect.width, rect.height);
}
};
let animationFrameId: number; const resizeObserver = new ResizeObserver(() => resize());
const seed = getSeed(username); if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
// Configuration - High density for "complex web" feel let mouse = { x: -1000, y: -1000, down: false };
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 { const handleMouseMove = (e: MouseEvent) => {
x: number; const rect = canvas.getBoundingClientRect();
y: number; mouse.x = e.clientX - rect.left;
vx: number; mouse.y = e.clientY - rect.top;
vy: number; };
baseX: number;
baseY: number; const handleTouchMove = (e: TouchEvent) => {
color: string; if (e.touches.length > 0) {
size: number; const rect = canvas.getBoundingClientRect();
mouse.x = e.touches[0].clientX - rect.left;
mouse.y = e.touches[0].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("touchmove", handleTouchMove, { passive: false });
parent.addEventListener("mousedown", handleMouseDown);
parent.addEventListener("mouseup", handleMouseUp);
parent.addEventListener("touchstart", (e) => {
mouse.down = true;
handleTouchMove(e);
});
parent.addEventListener("touchend", handleMouseUp);
parent.addEventListener("mouseleave", () => {
mouse.x = -1000;
mouse.y = -1000;
mouse.down = false;
});
}
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);
// Physics Update
nodes.forEach((node) => {
// Return force (Elasticity) - slightly slower return
const dxBase = node.baseX - node.x;
const dyBase = node.baseY - node.y;
node.vx += dxBase * speed;
node.vy += dyBase * speed;
// Mouse interaction
const dxMouse = mouse.x - node.x;
const dyMouse = mouse.y - node.y;
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
if (mouse.down) {
// Grab and drag effect - less strong
const dragDist = 300;
if (distMouse < dragDist) {
const power = (dragDist - distMouse) / dragDist;
node.vx += dxMouse * power * 0.08;
node.vy += dyMouse * power * 0.08;
}
} else {
// Hover disturbance - less strong pushing effect
const hoverDist = 150;
if (distMouse < hoverDist) {
const power = (hoverDist - distMouse) / hoverDist;
node.vx -= dxMouse * power * 0.02;
node.vy -= dyMouse * power * 0.02;
}
} }
let nodes: Node[] = []; // Damping (Friction) - stronger damping for less "fast" feel
node.vx *= 0.88;
node.vy *= 0.88;
const pseudoRandom = (offset: number) => { // Update Position
const x = Math.sin(seed + offset) * 10000; node.x += node.vx;
return x - Math.floor(x); node.y += node.vy;
}; });
const initNodes = (width: number, height: number) => { // Draw Mesh (Connections)
const newNodes = []; ctx.lineWidth = 1;
for (let i = 0; i < nodeCount; i++) {
const bx = pseudoRandom(i) * width; for (let i = 0; i < nodes.length; i++) {
const by = pseudoRandom(i + 100) * height; let currentConnections = 0;
newNodes.push({ // We only connect a few to keep it a "web" and not a solid block
x: bx, y: by, for (let j = i + 1; j < nodes.length && currentConnections < 6; j++) {
baseX: bx, baseY: by, const dx = nodes[i].x - nodes[j].x;
vx: 0, vy: 0, const dy = nodes[i].y - nodes[j].y;
color: colors[i % colors.length], const dist = Math.sqrt(dx * dx + dy * dy);
size: 1 + pseudoRandom(i + 200) * 3
}); if (dist < connectionDist) {
currentConnections++;
const opacity = (1 - dist / connectionDist) * globalOpacity;
ctx.beginPath();
ctx.moveTo(nodes[i].x, nodes[i].y);
ctx.lineTo(nodes[j].x, nodes[j].y);
// Dynamic gradient for the line
const lineGrad = ctx.createLinearGradient(
nodes[i].x,
nodes[i].y,
nodes[j].x,
nodes[j].y,
);
lineGrad.addColorStop(0, alpha(nodes[i].color, opacity));
lineGrad.addColorStop(1, alpha(nodes[j].color, opacity));
ctx.strokeStyle = lineGrad;
ctx.stroke();
// Fill some triangles for complexity
if (dist < connectionDist * 0.6 && pseudoRandom(i + j) > 0.92) {
for (let k = j + 1; k < nodes.length; k++) {
const d3x = nodes[i].x - nodes[k].x;
const d3y = nodes[i].y - nodes[k].y;
if (Math.sqrt(d3x * d3x + d3y * d3y) < connectionDist * 0.6) {
ctx.beginPath();
ctx.moveTo(nodes[i].x, nodes[i].y);
ctx.lineTo(nodes[j].x, nodes[j].y);
ctx.lineTo(nodes[k].x, nodes[k].y);
ctx.closePath();
ctx.fillStyle = alpha(nodes[i].color, 0.04);
ctx.fill();
break;
}
}
} }
return newNodes; }
};
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`;
nodes = initNodes(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 handleTouchMove = (e: TouchEvent) => {
if (e.touches.length > 0) {
const rect = canvas.getBoundingClientRect();
mouse.x = e.touches[0].clientX - rect.left;
mouse.y = e.touches[0].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('touchmove', handleTouchMove, { passive: false });
parent.addEventListener('mousedown', handleMouseDown);
parent.addEventListener('mouseup', handleMouseUp);
parent.addEventListener('touchstart', (e) => { mouse.down = true; handleTouchMove(e); });
parent.addEventListener('touchend', handleMouseUp);
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
} }
}
const draw = () => { // Draw Nodes
const width = canvas.width / (window.devicePixelRatio || 1); nodes.forEach((node) => {
const height = canvas.height / (window.devicePixelRatio || 1); ctx.beginPath();
ctx.arc(node.x, node.y, node.size, 0, Math.PI * 2);
ctx.fillStyle = node.color;
ctx.fill();
if (!width || !height) { // Core glow
animationFrameId = requestAnimationFrame(draw); ctx.beginPath();
return; ctx.arc(node.x, node.y, node.size * 2.5, 0, Math.PI * 2);
} ctx.fillStyle = alpha(node.color, 0.15);
ctx.fill();
});
ctx.clearRect(0, 0, width, height); animationFrameId = requestAnimationFrame(draw);
};
// Physics Update draw();
nodes.forEach((node) => {
// Return force (Elasticity) - slightly slower return
const dxBase = node.baseX - node.x;
const dyBase = node.baseY - node.y;
node.vx += dxBase * speed;
node.vy += dyBase * speed;
// Mouse interaction return () => {
const dxMouse = mouse.x - node.x; resizeObserver.disconnect();
const dyMouse = mouse.y - node.y; if (parent) {
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse); parent.removeEventListener("mousemove", handleMouseMove);
parent.removeEventListener("touchmove", handleTouchMove);
parent.removeEventListener("mousedown", handleMouseDown);
parent.removeEventListener("mouseup", handleMouseUp);
parent.removeEventListener("touchstart", handleMouseDown);
parent.removeEventListener("touchend", handleMouseUp);
}
cancelAnimationFrame(animationFrameId);
};
}, [
username,
palette,
ratingsCount,
followersCount,
theme,
nodeCountBase,
connectionDist,
speed,
globalOpacity,
]);
if (mouse.down) { return (
// Grab and drag effect - less strong <canvas
const dragDist = 300; ref={canvasRef}
if (distMouse < dragDist) { style={{
const power = (dragDist - distMouse) / dragDist; position: "absolute",
node.vx += dxMouse * power * 0.08; top: 0,
node.vy += dyMouse * power * 0.08; left: 0,
} width: "100%",
} else { height: "100%",
// Hover disturbance - less strong pushing effect pointerEvents: "none",
const hoverDist = 150; zIndex: 1,
if (distMouse < hoverDist) { mixBlendMode: theme.palette.mode === "light" ? "multiply" : "screen",
const power = (hoverDist - distMouse) / hoverDist; }}
node.vx -= dxMouse * power * 0.02; />
node.vy -= dyMouse * power * 0.02; );
}
}
// Damping (Friction) - stronger damping for less "fast" feel
node.vx *= 0.88;
node.vy *= 0.88;
// Update Position
node.x += node.vx;
node.y += node.vy;
});
// Draw Mesh (Connections)
ctx.lineWidth = 1;
for (let i = 0; i < nodes.length; i++) {
let currentConnections = 0;
// We only connect a few to keep it a "web" and not a solid block
for (let j = i + 1; j < nodes.length && currentConnections < 6; j++) {
const dx = nodes[i].x - nodes[j].x;
const dy = nodes[i].y - nodes[j].y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < connectionDist) {
currentConnections++;
const opacity = (1 - dist / connectionDist) * globalOpacity;
ctx.beginPath();
ctx.moveTo(nodes[i].x, nodes[i].y);
ctx.lineTo(nodes[j].x, nodes[j].y);
// Dynamic gradient for the line
const lineGrad = ctx.createLinearGradient(nodes[i].x, nodes[i].y, nodes[j].x, nodes[j].y);
lineGrad.addColorStop(0, alpha(nodes[i].color, opacity));
lineGrad.addColorStop(1, alpha(nodes[j].color, opacity));
ctx.strokeStyle = lineGrad;
ctx.stroke();
// Fill some triangles for complexity
if (dist < connectionDist * 0.6 && pseudoRandom(i + j) > 0.92) {
for (let k = j + 1; k < nodes.length; k++) {
const d3x = nodes[i].x - nodes[k].x;
const d3y = nodes[i].y - nodes[k].y;
if (Math.sqrt(d3x*d3x + d3y*d3y) < connectionDist * 0.6) {
ctx.beginPath();
ctx.moveTo(nodes[i].x, nodes[i].y);
ctx.lineTo(nodes[j].x, nodes[j].y);
ctx.lineTo(nodes[k].x, nodes[k].y);
ctx.closePath();
ctx.fillStyle = alpha(nodes[i].color, 0.04);
ctx.fill();
break;
}
}
}
}
}
}
// Draw Nodes
nodes.forEach(node => {
ctx.beginPath();
ctx.arc(node.x, node.y, node.size, 0, Math.PI * 2);
ctx.fillStyle = node.color;
ctx.fill();
// Core glow
ctx.beginPath();
ctx.arc(node.x, node.y, node.size * 2.5, 0, Math.PI * 2);
ctx.fillStyle = alpha(node.color, 0.15);
ctx.fill();
});
animationFrameId = requestAnimationFrame(draw);
};
draw();
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
parent.removeEventListener('touchmove', handleTouchMove);
parent.removeEventListener('mousedown', handleMouseDown);
parent.removeEventListener('mouseup', handleMouseUp);
parent.removeEventListener('touchstart', handleMouseDown);
parent.removeEventListener('touchend', handleMouseUp);
}
cancelAnimationFrame(animationFrameId);
};
}, [username, palette, ratingsCount, followersCount, theme, nodeCountBase, connectionDist, speed, globalOpacity]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1,
mixBlendMode: theme.palette.mode === 'light' ? 'multiply' : 'screen'
}}
/>
);
}; };
export default GenerativeBanner; export default GenerativeBanner;
+240 -191
View File
@@ -1,223 +1,272 @@
import React, { useRef, useEffect, useMemo } from 'react'; import React, { useRef, useEffect, useMemo } from "react";
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from "@mui/material";
interface GenerativeFlowProps { interface GenerativeFlowProps {
username: string; username: string;
palette: string[]; palette: string[];
ratingsCount: number; ratingsCount: number;
followersCount: number; followersCount: number;
settings?: { settings?: {
blobCount?: number; blobCount?: number;
flowSpeed?: number; flowSpeed?: number;
vibrancy?: number; vibrancy?: number;
blur?: number; blur?: number;
distortion?: number; distortion?: number;
}; };
} }
const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette, settings }) => { const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({
const canvasRef = useRef<HTMLCanvasElement>(null); username,
const theme = useTheme(); palette,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const blobCount = settings?.blobCount ?? 8; const blobCount = settings?.blobCount ?? 8;
const flowSpeed = settings?.flowSpeed ?? 0.008; const flowSpeed = settings?.flowSpeed ?? 0.008;
const vibrancy = settings?.vibrancy ?? 0.8; const vibrancy = settings?.vibrancy ?? 0.8;
const blurAmount = settings?.blur ?? 60; const blurAmount = settings?.blur ?? 60;
const distortionFactor = settings?.distortion ?? 1.2; const distortionFactor = settings?.distortion ?? 1.2;
const getSeed = (str: string) => { const getSeed = (str: string) => {
let hash = 0; let hash = 0;
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash); hash = str.charCodeAt(i) + ((hash << 5) - hash);
} }
return hash; return hash;
};
const seed = useMemo(() => getSeed(username), [username]);
const brightColors = useMemo(() => {
const colors =
palette.length >= 2
? palette
: [
theme.palette.primary.main,
theme.palette.secondary.main,
theme.palette.info.main,
];
const getBrightness = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) || 0;
const g = parseInt(hex.slice(3, 5), 16) || 0;
const b = parseInt(hex.slice(5, 7), 16) || 0;
return (r * 299 + g * 587 + b * 114) / 1000;
};
const sorted = [...colors].sort(
(a, b) => getBrightness(b) - getBrightness(a),
);
return sorted.slice(0, 4);
}, [palette, theme]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return;
let animationFrameId: number;
let time = 0;
interface Blob {
x: number;
y: number;
ox: number;
oy: number;
vx: number;
vy: number;
size: number;
color: string;
phase: number;
speedFactor: number;
}
let blobs: Blob[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
}; };
const seed = useMemo(() => getSeed(username), [username]); const initBlobs = (width: number, height: number) => {
const newBlobs = [];
for (let i = 0; i < blobCount; i++) {
const rx = pseudoRandom(i) * width;
const ry = pseudoRandom(i + 500) * height;
newBlobs.push({
x: rx,
y: ry,
ox: rx,
oy: ry,
vx: 0,
vy: 0,
size: width / 4 + pseudoRandom(i + 1000) * (width / 3),
color: brightColors[i % brightColors.length],
phase: pseudoRandom(i + 1500) * Math.PI * 2,
speedFactor: 0.5 + pseudoRandom(i + 2000),
});
}
return newBlobs;
};
const brightColors = useMemo(() => { const resize = () => {
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main]; const parent = canvas.parentElement;
const getBrightness = (hex: string) => { if (parent) {
const r = parseInt(hex.slice(1, 3), 16) || 0; const rect = parent.getBoundingClientRect();
const g = parseInt(hex.slice(3, 5), 16) || 0; const dpr = window.devicePixelRatio || 1;
const b = parseInt(hex.slice(5, 7), 16) || 0; canvas.width = rect.width * dpr;
return (r * 299 + g * 587 + b * 114) / 1000; canvas.height = rect.height * dpr;
}; ctx.scale(dpr, dpr);
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a)); canvas.style.width = `${rect.width}px`;
return sorted.slice(0, 4); canvas.style.height = `${rect.height}px`;
}, [palette, theme]); blobs = initBlobs(rect.width, rect.height);
}
};
useEffect(() => { const resizeObserver = new ResizeObserver(() => resize());
const canvas = canvasRef.current; if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
if (!canvas) return; resize();
const ctx = canvas.getContext('2d', { alpha: true }); let mouse = { x: -1000, y: -1000 };
if (!ctx) return; const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
};
let animationFrameId: number; const parent = canvas.parentElement;
let time = 0; if (parent) {
parent.addEventListener("mousemove", handleMouseMove);
parent.addEventListener("mouseleave", () => {
mouse.x = -1000;
mouse.y = -1000;
});
}
interface Blob { const draw = () => {
x: number; const width = canvas.width / (window.devicePixelRatio || 1);
y: number; const height = canvas.height / (window.devicePixelRatio || 1);
ox: number; if (!width || !height) {
oy: number; animationFrameId = requestAnimationFrame(draw);
vx: number; return;
vy: number; }
size: number;
color: string;
phase: number;
speedFactor: number;
}
let blobs: Blob[] = []; ctx.clearRect(0, 0, width, height);
const pseudoRandom = (offset: number) => { // Background Base
const x = Math.sin(seed + offset) * 10000; const isLight = theme.palette.mode === "light";
return x - Math.floor(x); const bgGrad = ctx.createRadialGradient(
}; width / 2,
height / 2,
0,
width / 2,
height / 2,
width,
);
if (isLight) {
// For light themes, use a deeper, more saturated version of the theme color
bgGrad.addColorStop(
0,
alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.6),
);
bgGrad.addColorStop(
1,
alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.9),
);
} else {
bgGrad.addColorStop(0, "#0a0a1a");
bgGrad.addColorStop(1, "#020205");
}
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
const initBlobs = (width: number, height: number) => { time += flowSpeed;
const newBlobs = [];
for (let i = 0; i < blobCount; i++) {
const rx = pseudoRandom(i) * width;
const ry = pseudoRandom(i + 500) * height;
newBlobs.push({
x: rx, y: ry,
ox: rx, oy: ry,
vx: 0, vy: 0,
size: (width / 4) + pseudoRandom(i + 1000) * (width / 3),
color: brightColors[i % brightColors.length],
phase: pseudoRandom(i + 1500) * Math.PI * 2,
speedFactor: 0.5 + pseudoRandom(i + 2000)
});
}
return newBlobs;
};
const resize = () => { // Apply filter for "Liquid" look
const parent = canvas.parentElement; ctx.filter = `blur(${blurAmount}px)`;
if (parent) { ctx.globalCompositeOperation = "screen";
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`;
blobs = initBlobs(rect.width, rect.height);
}
};
const resizeObserver = new ResizeObserver(() => resize()); blobs.forEach((b) => {
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement); // Harmonic autonomous drift
resize(); const driftX = Math.sin(time * b.speedFactor + b.phase) * (width / 5);
const driftY =
Math.cos(time * 0.8 * b.speedFactor + b.phase) * (height / 5);
let mouse = { x: -1000, y: -1000 }; // Mouse distortion
const handleMouseMove = (e: MouseEvent) => { const dxMouse = mouse.x - b.ox;
const rect = canvas.getBoundingClientRect(); const dyMouse = mouse.y - b.oy;
mouse.x = e.clientX - rect.left; const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
mouse.y = e.clientY - rect.top; const mouseForce = Math.max(0, (500 - distMouse) / 500);
};
const parent = canvas.parentElement; const mX = dxMouse * mouseForce * distortionFactor;
if (parent) { const mY = dyMouse * mouseForce * distortionFactor;
parent.addEventListener('mousemove', handleMouseMove);
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; });
}
const draw = () => { b.x = b.ox + driftX + mX;
const width = canvas.width / (window.devicePixelRatio || 1); b.y = b.oy + driftY + mY;
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, width, height); // Pulsing size
const pulse = 1 + Math.sin(time + b.phase) * 0.1;
const currentSize = b.size * pulse;
// Background Base const grad = ctx.createRadialGradient(
const isLight = theme.palette.mode === 'light'; b.x,
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width); b.y,
if (isLight) { 0,
// For light themes, use a deeper, more saturated version of the theme color b.x,
bgGrad.addColorStop(0, alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.6)); b.y,
bgGrad.addColorStop(1, alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.9)); currentSize,
} else { );
bgGrad.addColorStop(0, '#0a0a1a'); grad.addColorStop(0, alpha(b.color, vibrancy));
bgGrad.addColorStop(1, '#020205'); grad.addColorStop(1, alpha(b.color, 0));
}
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
time += flowSpeed; ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(b.x, b.y, currentSize, 0, Math.PI * 2);
ctx.fill();
});
// Apply filter for "Liquid" look ctx.filter = "none";
ctx.filter = `blur(${blurAmount}px)`; ctx.globalCompositeOperation = "source-over";
ctx.globalCompositeOperation = 'screen';
blobs.forEach((b) => { animationFrameId = requestAnimationFrame(draw);
// Harmonic autonomous drift };
const driftX = Math.sin(time * b.speedFactor + b.phase) * (width / 5);
const driftY = Math.cos(time * 0.8 * b.speedFactor + b.phase) * (height / 5);
// Mouse distortion draw();
const dxMouse = mouse.x - b.ox;
const dyMouse = mouse.y - b.oy;
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
const mouseForce = Math.max(0, (500 - distMouse) / 500);
const mX = dxMouse * mouseForce * distortionFactor; return () => {
const mY = dyMouse * mouseForce * distortionFactor; resizeObserver.disconnect();
if (parent) {
parent.removeEventListener("mousemove", handleMouseMove);
}
cancelAnimationFrame(animationFrameId);
};
}, [
seed,
brightColors,
blobCount,
flowSpeed,
vibrancy,
blurAmount,
distortionFactor,
theme,
]);
b.x = b.ox + driftX + mX; return (
b.y = b.oy + driftY + mY; <canvas
ref={canvasRef}
// Pulsing size style={{
const pulse = 1 + Math.sin(time + b.phase) * 0.1; position: "absolute",
const currentSize = b.size * pulse; top: 0,
left: 0,
const grad = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, currentSize); width: "100%",
grad.addColorStop(0, alpha(b.color, vibrancy)); height: "100%",
grad.addColorStop(1, alpha(b.color, 0)); pointerEvents: "none",
zIndex: 1,
ctx.fillStyle = grad; }}
ctx.beginPath(); />
ctx.arc(b.x, b.y, currentSize, 0, Math.PI * 2); );
ctx.fill();
});
ctx.filter = 'none';
ctx.globalCompositeOperation = 'source-over';
animationFrameId = requestAnimationFrame(draw);
};
draw();
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
}
cancelAnimationFrame(animationFrameId);
};
}, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1
}}
/>
);
}; };
export default GenerativeFlowBanner; export default GenerativeFlowBanner;
+297 -240
View File
@@ -1,269 +1,326 @@
import React, { useRef, useEffect, useMemo } from 'react'; import React, { useRef, useEffect, useMemo } from "react";
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from "@mui/material";
interface HextechBannerProps { interface HextechBannerProps {
username: string; username: string;
palette: string[]; palette: string[];
ratingsCount: number; ratingsCount: number;
followersCount: number; followersCount: number;
settings?: { settings?: {
gridSize?: number; gridSize?: number;
pulseSpeed?: number; pulseSpeed?: number;
glowIntensity?: number; glowIntensity?: number;
streamCount?: number; streamCount?: number;
distortion?: number; distortion?: number;
}; };
} }
const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settings }) => { const HextechBanner: React.FC<HextechBannerProps> = ({
const canvasRef = useRef<HTMLCanvasElement>(null); username,
const theme = useTheme(); palette,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const gridSize = settings?.gridSize ?? 40; const gridSize = settings?.gridSize ?? 40;
const pulseSpeed = settings?.pulseSpeed ?? 0.02; const pulseSpeed = settings?.pulseSpeed ?? 0.02;
const glowIntensity = settings?.glowIntensity ?? 0.8; const glowIntensity = settings?.glowIntensity ?? 0.8;
const streamCount = settings?.streamCount ?? 12; const streamCount = settings?.streamCount ?? 12;
const distortionFactor = settings?.distortion ?? 0.5; const distortionFactor = settings?.distortion ?? 0.5;
const getSeed = (str: string) => { const getSeed = (str: string) => {
let hash = 0; let hash = 0;
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash); hash = str.charCodeAt(i) + ((hash << 5) - hash);
} }
return hash; return hash;
};
const seed = useMemo(() => getSeed(username), [username]);
const brightColors = useMemo(() => {
const colors =
palette.length >= 2
? palette
: [
theme.palette.primary.main,
theme.palette.secondary.main,
theme.palette.info.main,
];
const getBrightness = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) || 0;
const g = parseInt(hex.slice(3, 5), 16) || 0;
const b = parseInt(hex.slice(5, 7), 16) || 0;
return (r * 299 + g * 587 + b * 114) / 1000;
};
const sorted = [...colors].sort(
(a, b) => getBrightness(b) - getBrightness(a),
);
return sorted.slice(0, 3);
}, [palette, theme]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return;
let animationFrameId: number;
let time = 0;
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
}; };
const seed = useMemo(() => getSeed(username), [username]); // Mana Stream Logic
interface Stream {
segments: { x: number; y: number }[];
color: string;
life: number;
speed: number;
}
const brightColors = useMemo(() => { let streams: Stream[] = [];
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
const getBrightness = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) || 0;
const g = parseInt(hex.slice(3, 5), 16) || 0;
const b = parseInt(hex.slice(5, 7), 16) || 0;
return (r * 299 + g * 587 + b * 114) / 1000;
};
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
return sorted.slice(0, 3);
}, [palette, theme]);
useEffect(() => { const createStream = (width: number, height: number) => {
const canvas = canvasRef.current; const color =
if (!canvas) return; brightColors[Math.floor(pseudoRandom(time) * brightColors.length)];
let x = pseudoRandom(time + 1) * width;
let y = pseudoRandom(time + 2) * height;
const ctx = canvas.getContext('2d', { alpha: true }); return {
if (!ctx) return; segments: [{ x, y }],
color,
life: 1.0,
speed: 2 + pseudoRandom(time + 3) * 4,
};
};
let animationFrameId: number; const drawHexagon = (x: number, y: number, r: number, tilt: number) => {
let time = 0; ctx.beginPath();
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i + tilt;
const px = x + r * Math.cos(angle);
const py = y + r * Math.sin(angle);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
};
const pseudoRandom = (offset: number) => { const resize = () => {
const x = Math.sin(seed + offset) * 10000; const parent = canvas.parentElement;
return x - Math.floor(x); 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`;
}
};
// Mana Stream Logic const resizeObserver = new ResizeObserver(() => resize());
interface Stream { if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
segments: { x: number, y: number }[]; resize();
color: string;
life: number; let mouse = { x: -1000, y: -1000, down: false };
speed: number; 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;
});
}
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);
// Background
const isLight = theme.palette.mode === "light";
const bgGrad = ctx.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
width,
);
if (isLight) {
// For light themes, use a deeper, more saturated version of the theme color
bgGrad.addColorStop(0, alpha(brightColors[2] || brightColors[0], 0.65));
bgGrad.addColorStop(1, alpha(brightColors[2] || brightColors[0], 0.95));
} else {
bgGrad.addColorStop(0, "#0a051a"); // Arcane Purple tint
bgGrad.addColorStop(1, "#020105");
}
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
time += pulseSpeed;
// 1. Draw Hex Grid
ctx.lineWidth = 1;
const h = gridSize * Math.sqrt(3);
const w = gridSize * 2;
for (let y = -gridSize; y < height + gridSize; y += h / 2) {
for (let x = -gridSize; x < width + gridSize; x += w * 0.75) {
const xOffset = Math.floor(y / (h / 2)) % 2 === 0 ? 0 : w * 0.375;
const finalX = x + xOffset;
const finalY = y;
// Apply Trippiness (Distortion)
const dist = Math.sqrt(
Math.pow(finalX - mouse.x, 2) + Math.pow(finalY - mouse.y, 2),
);
const mouseImpact =
Math.max(0, (200 - dist) / 200) * (mouse.down ? 2 : 1);
const dx =
Math.sin(time + finalX * 0.01) *
gridSize *
distortionFactor *
(1 + mouseImpact);
const dy =
Math.cos(time + finalY * 0.01) *
gridSize *
distortionFactor *
(1 + mouseImpact);
const drawX = finalX + dx;
const drawY = finalY + dy;
// Pulse glow
const pulseVal =
0.1 + Math.sin(time * 2 + (finalX + finalY) * 0.005) * 0.1;
const opacity = (0.1 + pulseVal + mouseImpact * 0.3) * glowIntensity;
ctx.strokeStyle = alpha(brightColors[0], opacity);
drawHexagon(drawX, drawY, gridSize * 0.9, time * 0.1);
ctx.stroke();
// Occasional Runes
if (pseudoRandom(Math.floor(finalX) + Math.floor(finalY)) > 0.92) {
ctx.fillStyle = alpha(brightColors[1], opacity * 0.5);
ctx.font = `${gridSize * 0.5}px serif`;
ctx.fillText("⚡", drawX - 5, drawY + 5);
}
} }
}
let streams: Stream[] = []; // 2. Process Mana Streams
if (streams.length < streamCount && Math.random() > 0.9) {
streams.push(createStream(width, height));
}
const createStream = (width: number, height: number) => { ctx.globalCompositeOperation = "screen";
const color = brightColors[Math.floor(pseudoRandom(time) * brightColors.length)]; streams.forEach((s, idx) => {
let x = pseudoRandom(time + 1) * width; s.life -= 0.005;
let y = pseudoRandom(time + 2) * height; const last = s.segments[s.segments.length - 1];
return { const ang = pseudoRandom(time + idx) * Math.PI * 2;
segments: [{ x, y }], const nextX = last.x + Math.cos(ang) * s.speed;
color, const nextY = last.y + Math.sin(ang) * s.speed;
life: 1.0,
speed: 2 + pseudoRandom(time + 3) * 4
};
};
const drawHexagon = (x: number, y: number, r: number, tilt: number) => { s.segments.push({ x: nextX, y: nextY });
ctx.beginPath(); if (s.segments.length > 20) s.segments.shift();
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i + tilt;
const px = x + r * Math.cos(angle);
const py = y + r * Math.sin(angle);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
};
const resize = () => { ctx.beginPath();
const parent = canvas.parentElement; ctx.strokeStyle = alpha(s.color, s.life * glowIntensity);
if (parent) { ctx.lineWidth = 2;
const rect = parent.getBoundingClientRect(); s.segments.forEach((seg, i) => {
const dpr = window.devicePixelRatio || 1; if (i === 0) ctx.moveTo(seg.x, seg.y);
canvas.width = rect.width * dpr; else ctx.lineTo(seg.x, seg.y);
canvas.height = rect.height * dpr; });
ctx.scale(dpr, dpr); ctx.stroke();
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
}
};
const resizeObserver = new ResizeObserver(() => resize()); const head = s.segments[s.segments.length - 1];
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement); const g = ctx.createRadialGradient(
resize(); head.x,
head.y,
0,
head.x,
head.y,
15,
);
g.addColorStop(0, alpha(s.color, s.life));
g.addColorStop(1, alpha(s.color, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(head.x, head.y, 15, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalCompositeOperation = "source-over";
let mouse = { x: -1000, y: -1000, down: false }; streams = streams.filter((s) => s.life > 0);
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; animationFrameId = requestAnimationFrame(draw);
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; });
}
const draw = () => { 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); return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener("mousemove", handleMouseMove);
parent.removeEventListener("mousedown", handleMouseDown);
parent.removeEventListener("mouseup", handleMouseUp);
}
cancelAnimationFrame(animationFrameId);
};
}, [
seed,
brightColors,
gridSize,
pulseSpeed,
glowIntensity,
streamCount,
distortionFactor,
theme,
]);
// Background return (
const isLight = theme.palette.mode === 'light'; <canvas
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width); ref={canvasRef}
if (isLight) { style={{
// For light themes, use a deeper, more saturated version of the theme color position: "absolute",
bgGrad.addColorStop(0, alpha(brightColors[2] || brightColors[0], 0.65)); top: 0,
bgGrad.addColorStop(1, alpha(brightColors[2] || brightColors[0], 0.95)); left: 0,
} else { width: "100%",
bgGrad.addColorStop(0, '#0a051a'); // Arcane Purple tint height: "100%",
bgGrad.addColorStop(1, '#020105'); pointerEvents: "none",
} zIndex: 1,
ctx.fillStyle = bgGrad; }}
ctx.fillRect(0, 0, width, height); />
);
time += pulseSpeed;
// 1. Draw Hex Grid
ctx.lineWidth = 1;
const h = gridSize * Math.sqrt(3);
const w = gridSize * 2;
for (let y = -gridSize; y < height + gridSize; y += h/2) {
for (let x = -gridSize; x < width + gridSize; x += w * 0.75) {
const xOffset = (Math.floor(y / (h/2)) % 2 === 0) ? 0 : w * 0.375;
const finalX = x + xOffset;
const finalY = y;
// Apply Trippiness (Distortion)
const dist = Math.sqrt(Math.pow(finalX - mouse.x, 2) + Math.pow(finalY - mouse.y, 2));
const mouseImpact = Math.max(0, (200 - dist) / 200) * (mouse.down ? 2 : 1);
const dx = Math.sin(time + (finalX * 0.01)) * gridSize * distortionFactor * (1 + mouseImpact);
const dy = Math.cos(time + (finalY * 0.01)) * gridSize * distortionFactor * (1 + mouseImpact);
const drawX = finalX + dx;
const drawY = finalY + dy;
// Pulse glow
const pulseVal = 0.1 + Math.sin(time * 2 + (finalX + finalY) * 0.005) * 0.1;
const opacity = (0.1 + pulseVal + mouseImpact * 0.3) * glowIntensity;
ctx.strokeStyle = alpha(brightColors[0], opacity);
drawHexagon(drawX, drawY, gridSize * 0.9, time * 0.1);
ctx.stroke();
// Occasional Runes
if (pseudoRandom(Math.floor(finalX) + Math.floor(finalY)) > 0.92) {
ctx.fillStyle = alpha(brightColors[1], opacity * 0.5);
ctx.font = `${gridSize * 0.5}px serif`;
ctx.fillText("⚡", drawX - 5, drawY + 5);
}
}
}
// 2. Process Mana Streams
if (streams.length < streamCount && Math.random() > 0.9) {
streams.push(createStream(width, height));
}
ctx.globalCompositeOperation = 'screen';
streams.forEach((s, idx) => {
s.life -= 0.005;
const last = s.segments[s.segments.length - 1];
const ang = pseudoRandom(time + idx) * Math.PI * 2;
const nextX = last.x + Math.cos(ang) * s.speed;
const nextY = last.y + Math.sin(ang) * s.speed;
s.segments.push({ x: nextX, y: nextY });
if (s.segments.length > 20) s.segments.shift();
ctx.beginPath();
ctx.strokeStyle = alpha(s.color, s.life * glowIntensity);
ctx.lineWidth = 2;
s.segments.forEach((seg, i) => {
if (i === 0) ctx.moveTo(seg.x, seg.y);
else ctx.lineTo(seg.x, seg.y);
});
ctx.stroke();
const head = s.segments[s.segments.length - 1];
const g = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 15);
g.addColorStop(0, alpha(s.color, s.life));
g.addColorStop(1, alpha(s.color, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(head.x, head.y, 15, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalCompositeOperation = 'source-over';
streams = streams.filter(s => s.life > 0);
animationFrameId = requestAnimationFrame(draw);
};
draw();
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
parent.removeEventListener('mousedown', handleMouseDown);
parent.removeEventListener('mouseup', handleMouseUp);
}
cancelAnimationFrame(animationFrameId);
};
}, [seed, brightColors, gridSize, pulseSpeed, glowIntensity, streamCount, distortionFactor, theme]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1
}}
/>
);
}; };
export default HextechBanner; export default HextechBanner;
@@ -1,258 +1,294 @@
import React, { useRef, useEffect, useMemo } from 'react'; import React, { useRef, useEffect, useMemo } from "react";
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from "@mui/material";
interface HextechCorruptionBannerProps { interface HextechCorruptionBannerProps {
username: string; username: string;
palette: string[]; palette: string[];
ratingsCount: number; ratingsCount: number;
followersCount: number; followersCount: number;
settings?: { settings?: {
cellCount?: number; cellCount?: number;
shimmerSpeed?: number; shimmerSpeed?: number;
glowIntensity?: number; glowIntensity?: number;
instability?: number; instability?: number;
shellDarkness?: number; shellDarkness?: number;
}; };
} }
const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ username, palette, settings }) => { const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({
const canvasRef = useRef<HTMLCanvasElement>(null); username,
const theme = useTheme(); palette,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const cellCount = settings?.cellCount ?? 40; const cellCount = settings?.cellCount ?? 40;
const shimmerSpeed = settings?.shimmerSpeed ?? 0.015; const shimmerSpeed = settings?.shimmerSpeed ?? 0.015;
const glowIntensity = settings?.glowIntensity ?? 0.9; const glowIntensity = settings?.glowIntensity ?? 0.9;
const instability = settings?.instability ?? 0.6; const instability = settings?.instability ?? 0.6;
const shellDarkness = settings?.shellDarkness ?? 0.85; const shellDarkness = settings?.shellDarkness ?? 0.85;
const getSeed = (str: string) => { const getSeed = (str: string) => {
let hash = 0; let hash = 0;
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash); hash = str.charCodeAt(i) + ((hash << 5) - hash);
} }
return hash; return hash;
};
const seed = useMemo(() => getSeed(username), [username]);
const brightColors = useMemo(() => {
const colors =
palette.length >= 2
? palette
: [
theme.palette.primary.main,
theme.palette.secondary.main,
theme.palette.info.main,
];
const getBrightness = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) || 0;
const g = parseInt(hex.slice(3, 5), 16) || 0;
const b = parseInt(hex.slice(5, 7), 16) || 0;
return (r * 299 + g * 587 + b * 114) / 1000;
};
const sorted = [...colors].sort(
(a, b) => getBrightness(b) - getBrightness(a),
);
return sorted.slice(0, 3);
}, [palette, theme]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return;
let animationFrameId: number;
let time = 0;
interface ControlPoint {
x: number;
y: number;
ox: number;
oy: number;
vx: number;
vy: number;
phase: number;
color: string;
}
let points: ControlPoint[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
}; };
const seed = useMemo(() => getSeed(username), [username]); const initPoints = (width: number, height: number) => {
const newPoints = [];
for (let i = 0; i < cellCount; i++) {
const rx = pseudoRandom(i) * width;
const ry = pseudoRandom(i + 500) * height;
newPoints.push({
x: rx,
y: ry,
ox: rx,
oy: ry,
vx: 0,
vy: 0,
phase: pseudoRandom(i + 1000) * Math.PI * 2,
color: brightColors[i % brightColors.length],
});
}
return newPoints;
};
const brightColors = useMemo(() => { const resize = () => {
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main]; const parent = canvas.parentElement;
const getBrightness = (hex: string) => { if (parent) {
const r = parseInt(hex.slice(1, 3), 16) || 0; const rect = parent.getBoundingClientRect();
const g = parseInt(hex.slice(3, 5), 16) || 0; const dpr = window.devicePixelRatio || 1;
const b = parseInt(hex.slice(5, 7), 16) || 0; canvas.width = rect.width * dpr;
return (r * 299 + g * 587 + b * 114) / 1000; canvas.height = rect.height * dpr;
}; ctx.scale(dpr, dpr);
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a)); canvas.style.width = `${rect.width}px`;
return sorted.slice(0, 3); canvas.style.height = `${rect.height}px`;
}, [palette, theme]); points = initPoints(rect.width, rect.height);
}
};
useEffect(() => { const resizeObserver = new ResizeObserver(() => resize());
const canvas = canvasRef.current; if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
if (!canvas) return; resize();
const ctx = canvas.getContext('2d', { alpha: true }); let mouse = { x: -1000, y: -1000, down: false };
if (!ctx) return; 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;
};
let animationFrameId: number; const parent = canvas.parentElement;
let time = 0; 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;
});
}
interface ControlPoint { const draw = () => {
x: number; const width = canvas.width / (window.devicePixelRatio || 1);
y: number; const height = canvas.height / (window.devicePixelRatio || 1);
ox: number; if (!width || !height) {
oy: number; animationFrameId = requestAnimationFrame(draw);
vx: number; return;
vy: number; }
phase: number;
color: string;
}
let points: ControlPoint[] = []; ctx.clearRect(0, 0, width, height);
time += shimmerSpeed;
const pseudoRandom = (offset: number) => { // 1. Draw Under-Glow (Inner Cores)
const x = Math.sin(seed + offset) * 10000; ctx.globalCompositeOperation = "source-over";
return x - Math.floor(x); points.forEach((p) => {
}; // Harmonic motion + mouse interaction
const dxMouse = mouse.x - p.x;
const dyMouse = mouse.y - p.y;
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
const mouseForce = Math.max(0, (300 - distMouse) / 300);
const initPoints = (width: number, height: number) => { const driftX = Math.sin(time + p.phase) * 20 * instability;
const newPoints = []; const driftY = Math.cos(time * 0.8 + p.phase) * 20 * instability;
for (let i = 0; i < cellCount; i++) {
const rx = pseudoRandom(i) * width;
const ry = pseudoRandom(i + 500) * height;
newPoints.push({
x: rx, y: ry,
ox: rx, oy: ry,
vx: 0, vy: 0,
phase: pseudoRandom(i + 1000) * Math.PI * 2,
color: brightColors[i % brightColors.length]
});
}
return newPoints;
};
const resize = () => { p.x = p.ox + driftX + (mouse.down ? -dxMouse * mouseForce * 0.5 : 0);
const parent = canvas.parentElement; p.y = p.oy + driftY + (mouse.down ? -dyMouse * mouseForce * 0.5 : 0);
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`;
points = initPoints(rect.width, rect.height);
}
};
const resizeObserver = new ResizeObserver(() => resize()); const pulse = 0.7 + Math.sin(time * 3 + p.phase) * 0.3;
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement); const glowSize = 60 + mouseForce * 40;
resize();
let mouse = { x: -1000, y: -1000, down: false }; const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize);
const handleMouseMove = (e: MouseEvent) => { grad.addColorStop(0, alpha(p.color, 0.8 * glowIntensity * pulse));
const rect = canvas.getBoundingClientRect(); grad.addColorStop(0.5, alpha(p.color, 0.3 * glowIntensity));
mouse.x = e.clientX - rect.left; grad.addColorStop(1, alpha(p.color, 0));
mouse.y = e.clientY - rect.top;
};
const handleMouseDown = () => { mouse.down = true; };
const handleMouseUp = () => { mouse.down = false; };
const parent = canvas.parentElement; ctx.fillStyle = grad;
if (parent) { ctx.beginPath();
parent.addEventListener('mousemove', handleMouseMove); ctx.arc(p.x, p.y, glowSize, 0, Math.PI * 2);
parent.addEventListener('mousedown', handleMouseDown); ctx.fill();
parent.addEventListener('mouseup', handleMouseUp); });
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
}
const draw = () => { // 2. Draw Dark "Void" Shell (Voronoi-based cutout)
const width = canvas.width / (window.devicePixelRatio || 1); // We use a temporary canvas to draw the shell then mask it, or just use a heavy overlay
const height = canvas.height / (window.devicePixelRatio || 1); ctx.globalCompositeOperation = "source-over";
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, width, height); // Create the "holes" effect by using a pattern of circles that are actually transparent
time += shimmerSpeed; // Actually, to get the specific look from the image, we can use a simpler approach:
// Layer a dark color over everything, but with 'destination-out' circles at the point locations.
// 1. Draw Under-Glow (Inner Cores) const offCanvas = document.createElement("canvas");
ctx.globalCompositeOperation = 'source-over'; offCanvas.width = canvas.width;
points.forEach((p) => { offCanvas.height = canvas.height;
// Harmonic motion + mouse interaction const offCtx = offCanvas.getContext("2d");
const dxMouse = mouse.x - p.x; if (offCtx) {
const dyMouse = mouse.y - p.y; const dpr = window.devicePixelRatio || 1;
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse); offCtx.scale(dpr, dpr);
const mouseForce = Math.max(0, (300 - distMouse) / 300);
const driftX = Math.sin(time + p.phase) * 20 * instability; // Draw full dark shell
const driftY = Math.cos(time * 0.8 + p.phase) * 20 * instability; const shellColor =
theme.palette.mode === "light"
? alpha("#1a0a2a", shellDarkness)
: alpha("#05020a", shellDarkness);
offCtx.fillStyle = shellColor;
offCtx.fillRect(0, 0, width, height);
p.x = p.ox + driftX + (mouse.down ? -dxMouse * mouseForce * 0.5 : 0); // Cut holes
p.y = p.oy + driftY + (mouse.down ? -dyMouse * mouseForce * 0.5 : 0); offCtx.globalCompositeOperation = "destination-out";
points.forEach((p) => {
const holeSize = 15 + Math.sin(time + p.phase) * 5 + instability * 10;
const pulse = 0.7 + Math.sin(time * 3 + p.phase) * 0.3; // Main hole
const glowSize = 60 + (mouseForce * 40); offCtx.beginPath();
offCtx.arc(p.x, p.y, holeSize, 0, Math.PI * 2);
offCtx.fill();
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize); // Add secondary smaller holes for that "punctured" look
grad.addColorStop(0, alpha(p.color, 0.8 * glowIntensity * pulse)); for (let j = 0; j < 3; j++) {
grad.addColorStop(0.5, alpha(p.color, 0.3 * glowIntensity)); const angle = p.phase + j * Math.PI * 0.6;
grad.addColorStop(1, alpha(p.color, 0)); const hx = p.x + Math.cos(angle) * (holeSize * 1.5);
const hy = p.y + Math.sin(angle) * (holeSize * 1.5);
offCtx.beginPath();
offCtx.arc(hx, hy, holeSize * 0.4, 0, Math.PI * 2);
offCtx.fill();
}
});
ctx.fillStyle = grad; ctx.drawImage(offCanvas, 0, 0, width, height);
ctx.beginPath(); }
ctx.arc(p.x, p.y, glowSize, 0, Math.PI * 2);
ctx.fill();
});
// 2. Draw Dark "Void" Shell (Voronoi-based cutout) // 3. Add Iridescent Edge Highlights
// We use a temporary canvas to draw the shell then mask it, or just use a heavy overlay ctx.globalCompositeOperation = "screen";
ctx.globalCompositeOperation = 'source-over'; ctx.lineWidth = 2;
points.forEach((p) => {
const pulse = 0.5 + Math.sin(time * 2 + p.phase) * 0.5;
ctx.strokeStyle = alpha(p.color, pulse * 0.4);
ctx.beginPath();
ctx.arc(p.x, p.y, 20 + pulse * 5, 0, Math.PI * 2);
ctx.stroke();
});
// Create the "holes" effect by using a pattern of circles that are actually transparent ctx.globalCompositeOperation = "source-over";
// Actually, to get the specific look from the image, we can use a simpler approach: animationFrameId = requestAnimationFrame(draw);
// Layer a dark color over everything, but with 'destination-out' circles at the point locations. };
const offCanvas = document.createElement('canvas'); draw();
offCanvas.width = canvas.width;
offCanvas.height = canvas.height;
const offCtx = offCanvas.getContext('2d');
if (offCtx) {
const dpr = window.devicePixelRatio || 1;
offCtx.scale(dpr, dpr);
// Draw full dark shell return () => {
const shellColor = theme.palette.mode === 'light' ? alpha('#1a0a2a', shellDarkness) : alpha('#05020a', shellDarkness); resizeObserver.disconnect();
offCtx.fillStyle = shellColor; if (parent) {
offCtx.fillRect(0, 0, width, height); parent.removeEventListener("mousemove", handleMouseMove);
parent.removeEventListener("mousedown", handleMouseDown);
parent.removeEventListener("mouseup", handleMouseUp);
}
cancelAnimationFrame(animationFrameId);
};
}, [
seed,
brightColors,
cellCount,
shimmerSpeed,
glowIntensity,
instability,
shellDarkness,
theme,
]);
// Cut holes return (
offCtx.globalCompositeOperation = 'destination-out'; <canvas
points.forEach(p => { ref={canvasRef}
const holeSize = 15 + Math.sin(time + p.phase) * 5 + (instability * 10); style={{
position: "absolute",
// Main hole top: 0,
offCtx.beginPath(); left: 0,
offCtx.arc(p.x, p.y, holeSize, 0, Math.PI * 2); width: "100%",
offCtx.fill(); height: "100%",
pointerEvents: "none",
// Add secondary smaller holes for that "punctured" look zIndex: 1,
for(let j=0; j<3; j++) { }}
const angle = p.phase + (j * Math.PI * 0.6); />
const hx = p.x + Math.cos(angle) * (holeSize * 1.5); );
const hy = p.y + Math.sin(angle) * (holeSize * 1.5);
offCtx.beginPath();
offCtx.arc(hx, hy, holeSize * 0.4, 0, Math.PI * 2);
offCtx.fill();
}
});
ctx.drawImage(offCanvas, 0, 0, width, height);
}
// 3. Add Iridescent Edge Highlights
ctx.globalCompositeOperation = 'screen';
ctx.lineWidth = 2;
points.forEach(p => {
const pulse = 0.5 + Math.sin(time * 2 + p.phase) * 0.5;
ctx.strokeStyle = alpha(p.color, pulse * 0.4);
ctx.beginPath();
ctx.arc(p.x, p.y, 20 + pulse * 5, 0, Math.PI * 2);
ctx.stroke();
});
ctx.globalCompositeOperation = 'source-over';
animationFrameId = requestAnimationFrame(draw);
};
draw();
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
parent.removeEventListener('mousedown', handleMouseDown);
parent.removeEventListener('mouseup', handleMouseUp);
}
cancelAnimationFrame(animationFrameId);
};
}, [seed, brightColors, cellCount, shimmerSpeed, glowIntensity, instability, shellDarkness, theme]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1
}}
/>
);
}; };
export default HextechCorruptionBanner; export default HextechCorruptionBanner;
+201 -179
View File
@@ -1,199 +1,221 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from "react";
import { import {
TextField, TextField,
Popper, Popper,
Paper, Paper,
MenuList, MenuList,
MenuItem, MenuItem,
Avatar, Avatar,
ListItemAvatar, ListItemAvatar,
ListItemText, ListItemText,
Box, Box,
alpha alpha,
} from '@mui/material'; } from "@mui/material";
import api from '../api'; import api from "../api";
interface UserSuggestion { interface UserSuggestion {
id: number; id: number;
username: string; username: string;
avatar: string | null; avatar: string | null;
} }
interface MentionTextFieldProps { interface MentionTextFieldProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
placeholder?: string; placeholder?: string;
fullWidth?: boolean; fullWidth?: boolean;
multiline?: boolean; multiline?: boolean;
rows?: number; rows?: number;
size?: 'small' | 'medium'; size?: "small" | "medium";
onKeyDown?: (e: React.KeyboardEvent) => void; onKeyDown?: (e: React.KeyboardEvent) => void;
} }
const MentionTextField: React.FC<MentionTextFieldProps> = ({ const MentionTextField: React.FC<MentionTextFieldProps> = ({
value, value,
onChange, onChange,
placeholder, placeholder,
fullWidth = true, fullWidth = true,
multiline = false, multiline = false,
rows, rows,
size = 'small', size = "small",
onKeyDown onKeyDown,
}) => { }) => {
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]); const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
// Fetch users for suggestions (we could cache this or search on demand) // Fetch users for suggestions (we could cache this or search on demand)
const [allUsers, setAllUsers] = useState<UserSuggestion[]>([]); const [allUsers, setAllUsers] = useState<UserSuggestion[]>([]);
useEffect(() => { useEffect(() => {
const fetchUsers = async () => { const fetchUsers = async () => {
try { try {
const res = await api.get('flavors/search/'); const res = await api.get("flavors/search/");
const users = (res.data || []) const users = (res.data || [])
.filter((item: any) => item.type === 'user') .filter((item: any) => item.type === "user")
.map((item: any) => ({ .map((item: any) => ({
id: item.id, id: item.id,
username: item.name, username: item.name,
avatar: item.image_url avatar: item.image_url,
})); }));
setAllUsers(users); setAllUsers(users);
} catch (err) { } catch (err) {
console.error('Failed to fetch users for mentions'); console.error("Failed to fetch users for mentions");
} }
}; };
fetchUsers(); fetchUsers();
}, []); }, []);
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value; const newValue = e.target.value;
onChange(newValue); onChange(newValue);
const selectionStart = e.target.selectionStart || 0; const selectionStart = e.target.selectionStart || 0;
const textBeforeCursor = newValue.substring(0, selectionStart); const textBeforeCursor = newValue.substring(0, selectionStart);
const lastAtPos = textBeforeCursor.lastIndexOf('@'); const lastAtPos = textBeforeCursor.lastIndexOf("@");
if (lastAtPos !== -1) { if (lastAtPos !== -1) {
const query = textBeforeCursor.substring(lastAtPos + 1); const query = textBeforeCursor.substring(lastAtPos + 1);
// Ensure there's no space or newline between @ and the cursor // Ensure there's no space or newline between @ and the cursor
if (!query.includes(' ') && !query.includes('\n')) { if (!query.includes(" ") && !query.includes("\n")) {
const filtered = allUsers.filter(u => const filtered = allUsers.filter((u) =>
u.username.toLowerCase().startsWith(query.toLowerCase()) u.username.toLowerCase().startsWith(query.toLowerCase()),
); );
setSuggestions(filtered); setSuggestions(filtered);
setShowSuggestions(filtered.length > 0); setShowSuggestions(filtered.length > 0);
setSelectedIndex(0); setSelectedIndex(0);
setAnchorEl(inputRef.current); setAnchorEl(inputRef.current);
return; return;
} }
} }
setShowSuggestions(false);
};
const handleSelectSuggestion = (user: UserSuggestion) => {
const selectionStart = inputRef.current?.selectionStart || 0;
const textBeforeCursor = value.substring(0, selectionStart);
const textAfterCursor = value.substring(selectionStart);
const lastAtPos = textBeforeCursor.lastIndexOf("@");
const newValue =
value.substring(0, lastAtPos) +
"@" +
user.username +
" " +
textAfterCursor;
onChange(newValue);
setShowSuggestions(false);
// Return focus to input and move cursor after the mention
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
const newPos = lastAtPos + user.username.length + 2; // +1 for @, +1 for space
inputRef.current.setSelectionRange(newPos, newPos);
}
}, 0);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (showSuggestions) {
if (e.key === "Tab" || e.key === "Enter") {
e.preventDefault();
handleSelectSuggestion(suggestions[selectedIndex]);
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % suggestions.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex(
(prev) => (prev - 1 + suggestions.length) % suggestions.length,
);
return;
}
if (e.key === "Escape") {
setShowSuggestions(false); setShowSuggestions(false);
}; return;
}
}
if (onKeyDown) onKeyDown(e);
};
const handleSelectSuggestion = (user: UserSuggestion) => { return (
const selectionStart = inputRef.current?.selectionStart || 0; <Box sx={{ position: "relative", width: fullWidth ? "100%" : "auto" }}>
const textBeforeCursor = value.substring(0, selectionStart); <TextField
const textAfterCursor = value.substring(selectionStart); inputRef={inputRef}
const lastAtPos = textBeforeCursor.lastIndexOf('@'); fullWidth={fullWidth}
multiline={multiline}
const newValue = rows={rows}
value.substring(0, lastAtPos) + size={size}
'@' + user.username + ' ' + placeholder={placeholder}
textAfterCursor; value={value}
onChange={handleTextChange}
onChange(newValue); onKeyDown={handleKeyDown}
setShowSuggestions(false); onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
autoComplete="off"
// Return focus to input and move cursor after the mention />
setTimeout(() => { <Popper
if (inputRef.current) { open={showSuggestions}
inputRef.current.focus(); anchorEl={anchorEl}
const newPos = lastAtPos + user.username.length + 2; // +1 for @, +1 for space placement="bottom-start"
inputRef.current.setSelectionRange(newPos, newPos); style={{ zIndex: 2000, width: 250 }}
} >
}, 0); <Paper
}; elevation={8}
sx={{
const handleKeyDown = (e: React.KeyboardEvent) => { mt: 1,
if (showSuggestions) { borderRadius: 2,
if (e.key === 'Tab' || e.key === 'Enter') { overflow: "hidden",
e.preventDefault(); border: "1px solid",
handleSelectSuggestion(suggestions[selectedIndex]); borderColor: "divider",
return; }}
} >
if (e.key === 'ArrowDown') { <MenuList dense sx={{ py: 0 }}>
e.preventDefault(); {suggestions.map((user, index) => (
setSelectedIndex((prev) => (prev + 1) % suggestions.length); <MenuItem
return; key={user.id}
} selected={index === selectedIndex}
if (e.key === 'ArrowUp') { onClick={() => handleSelectSuggestion(user)}
e.preventDefault(); sx={{
setSelectedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length); py: 1,
return; "&.Mui-selected": {
} bgcolor: (theme) => alpha(theme.palette.primary.main, 0.15),
if (e.key === 'Escape') { "&:hover": {
setShowSuggestions(false); bgcolor: (theme) =>
return; alpha(theme.palette.primary.main, 0.25),
} },
} },
if (onKeyDown) onKeyDown(e); }}
}; >
<ListItemAvatar sx={{ minWidth: 40 }}>
return ( <Avatar
<Box sx={{ position: 'relative', width: fullWidth ? '100%' : 'auto' }}> src={user.avatar || undefined}
<TextField sx={{ width: 28, height: 28, fontSize: "0.8rem" }}
inputRef={inputRef} >
fullWidth={fullWidth} {user.username.charAt(0).toUpperCase()}
multiline={multiline} </Avatar>
rows={rows} </ListItemAvatar>
size={size} <ListItemText
placeholder={placeholder} primary={user.username}
value={value} primaryTypographyProps={{
onChange={handleTextChange} variant: "body2",
onKeyDown={handleKeyDown} fontWeight: "bold",
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)} }}
autoComplete="off" />
/> </MenuItem>
<Popper ))}
open={showSuggestions} </MenuList>
anchorEl={anchorEl} </Paper>
placement="bottom-start" </Popper>
style={{ zIndex: 2000, width: 250 }} </Box>
> );
<Paper elevation={8} sx={{ mt: 1, borderRadius: 2, overflow: 'hidden', border: '1px solid', borderColor: 'divider' }}>
<MenuList dense sx={{ py: 0 }}>
{suggestions.map((user, index) => (
<MenuItem
key={user.id}
selected={index === selectedIndex}
onClick={() => handleSelectSuggestion(user)}
sx={{
py: 1,
'&.Mui-selected': {
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.15),
'&:hover': { bgcolor: (theme) => alpha(theme.palette.primary.main, 0.25) }
}
}}
>
<ListItemAvatar sx={{ minWidth: 40 }}>
<Avatar src={user.avatar || undefined} sx={{ width: 28, height: 28, fontSize: '0.8rem' }}>
{user.username.charAt(0).toUpperCase()}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={user.username}
primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold' }}
/>
</MenuItem>
))}
</MenuList>
</Paper>
</Popper>
</Box>
);
}; };
export default MentionTextField; export default MentionTextField;
+314 -269
View File
@@ -1,294 +1,339 @@
import React, { useRef, useEffect, useMemo } from 'react'; import React, { useRef, useEffect, useMemo } from "react";
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from "@mui/material";
interface NebulaBannerProps { interface NebulaBannerProps {
username: string; username: string;
palette: string[]; palette: string[];
ratingsCount: number; ratingsCount: number;
followersCount: number; followersCount: number;
settings?: { settings?: {
particleCount?: number; particleCount?: number;
speed?: number; speed?: number;
opacity?: number; opacity?: number;
smearRadius?: number; smearRadius?: number;
turbulence?: number; turbulence?: number;
returnSpeed?: number; returnSpeed?: number;
starDensity?: number; starDensity?: number;
}; };
} }
const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => { const NebulaBanner: React.FC<NebulaBannerProps> = ({
const canvasRef = useRef<HTMLCanvasElement>(null); username,
const theme = useTheme(); palette,
ratingsCount,
followersCount,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const particleCount = settings?.particleCount ?? 400; const particleCount = settings?.particleCount ?? 400;
const speed = settings?.speed ?? 0.0015; const speed = settings?.speed ?? 0.0015;
const globalOpacity = settings?.opacity ?? 0.6; const globalOpacity = settings?.opacity ?? 0.6;
const smearRadius = settings?.smearRadius ?? 120; const smearRadius = settings?.smearRadius ?? 120;
const turbulenceStrength = settings?.turbulence ?? 0.4; const turbulenceStrength = settings?.turbulence ?? 0.4;
const returnSpeed = settings?.returnSpeed ?? 0.004; // Much lower for smoke-like feel const returnSpeed = settings?.returnSpeed ?? 0.004; // Much lower for smoke-like feel
const starDensity = settings?.starDensity ?? 150; const starDensity = settings?.starDensity ?? 150;
const getSeed = (str: string) => { const getSeed = (str: string) => {
let hash = 0; let hash = 0;
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash); hash = str.charCodeAt(i) + ((hash << 5) - hash);
} }
return hash; return hash;
};
const seed = useMemo(() => getSeed(username), [username]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return;
let animationFrameId: number;
const colors =
palette.length >= 2
? palette
: [
theme.palette.primary.main,
theme.palette.secondary.main,
theme.palette.info.main,
];
interface Particle {
x: number;
y: number;
baseX: number;
baseY: number;
vx: number;
vy: number;
size: number;
color: string;
alpha: number;
targetAlpha: number;
phase: number;
}
interface Star {
x: number;
y: number;
size: number;
opacity: number;
}
let particles: Particle[] = [];
let stars: Star[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
}; };
const seed = useMemo(() => getSeed(username), [username]); const initScene = (width: number, height: number) => {
// Init Stars
const newStars = [];
for (let i = 0; i < starDensity; i++) {
newStars.push({
x: pseudoRandom(i + 5000) * width,
y: pseudoRandom(i + 6000) * height,
size: pseudoRandom(i + 7000) * 1.5,
opacity: 0.1 + pseudoRandom(i + 8000) * 0.5,
});
}
useEffect(() => { // Init Gas Clusters
const canvas = canvasRef.current; const newParticles = [];
if (!canvas) return; const clusterCount = colors.length * 3; // More clusters
const groupCenters = Array.from({ length: clusterCount }).map((_, i) => ({
x: (pseudoRandom(i * 15) * 0.8 + 0.1) * width,
y: (pseudoRandom(i * 25 + 7) * 0.8 + 0.1) * height,
}));
const ctx = canvas.getContext('2d', { alpha: true }); for (let i = 0; i < particleCount; i++) {
if (!ctx) return; const clusterIdx = i % clusterCount;
const colorIdx = clusterIdx % colors.length;
const center = groupCenters[clusterIdx];
const angle = pseudoRandom(i + 100) * Math.PI * 2;
const dist = pseudoRandom(i + 200) * (width * 0.12);
const bx = center.x + Math.cos(angle) * dist;
const by = center.y + Math.sin(angle) * dist;
let animationFrameId: number; newParticles.push({
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main]; x: bx,
y: by,
baseX: bx,
baseY: by,
vx: 0,
vy: 0,
size: 40 + pseudoRandom(i + 300) * 80, // Larger, softer blobs
color: colors[colorIdx],
alpha: 0,
targetAlpha: 0.05 + pseudoRandom(i + 400) * 0.15,
phase: pseudoRandom(i + 500) * Math.PI * 2,
});
}
return { stars: newStars, particles: newParticles };
};
interface Particle { const resize = () => {
x: number; const parent = canvas.parentElement;
y: number; if (parent) {
baseX: number; const rect = parent.getBoundingClientRect();
baseY: number; const dpr = window.devicePixelRatio || 1;
vx: number; canvas.width = rect.width * dpr;
vy: number; canvas.height = rect.height * dpr;
size: number; ctx.scale(dpr, dpr);
color: string; canvas.style.width = `${rect.width}px`;
alpha: number; canvas.style.height = `${rect.height}px`;
targetAlpha: number; const scene = initScene(rect.width, rect.height);
phase: number; stars = scene.stars;
particles = scene.particles;
}
};
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
let mouse = { x: -1000, y: -1000, px: -1000, py: -1000, down: false };
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.px = mouse.x;
mouse.py = mouse.y;
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);
// 1. Dynamic Celestial Background
const isLight = theme.palette.mode === "light";
const skyGrad = ctx.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
width,
);
if (isLight) {
// Vibrant Deep Primary for light mode - but dark enough for glowing gas to pop
skyGrad.addColorStop(0, alpha(colors[colors.length - 1], 0.7));
skyGrad.addColorStop(0.5, alpha(colors[colors.length - 1], 0.9));
skyGrad.addColorStop(1, alpha(colors[colors.length - 1], 1.0));
} else {
// True Deep Space with visible tints for dark mode
skyGrad.addColorStop(0, "#1a1a3a"); // More blue/purple tint
skyGrad.addColorStop(0.5, "#0a0a1a");
skyGrad.addColorStop(1, "#050508");
}
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, width, height);
// 2. Draw Distant Stars
ctx.fillStyle = "#fff";
stars.forEach((s) => {
ctx.globalAlpha = s.opacity * (0.7 + Math.sin(time * 2 + s.x) * 0.3);
ctx.beginPath();
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1.0;
time += speed;
// 3. Draw Gas Clouds
particles.forEach((p) => {
// Restoration Force (Airy return)
const dxBase = p.baseX - p.x;
const dyBase = p.baseY - p.y;
p.vx += dxBase * returnSpeed;
p.vy += dyBase * returnSpeed;
// Mouse Interaction (Airy Smear)
const dx = mouse.x - p.x;
const dy = mouse.y - p.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < smearRadius) {
const force = (smearRadius - dist) / smearRadius;
if (mouse.down) {
// High turbulence on click
const angle = Math.atan2(dy, dx);
p.vx += Math.cos(angle + Math.PI / 2) * turbulenceStrength * 2;
p.vy += Math.sin(angle + Math.PI / 2) * turbulenceStrength * 2;
} else {
// Smoke-like smear
const mdx = mouse.x - mouse.px;
const mdy = mouse.y - mouse.py;
p.vx += mdx * force * 0.15;
p.vy += mdy * force * 0.15;
p.vx -= (dx / dist) * force * 0.8;
p.vy -= (dy / dist) * force * 0.8;
}
} }
interface Star { // Smooth drift
x: number; p.vx += Math.sin(time + p.phase) * 0.03;
y: number; p.vy += Math.cos(time * 0.8 + p.phase) * 0.03;
size: number;
opacity: number;
}
let particles: Particle[] = []; // High friction for gas feel
let stars: Star[] = []; p.vx *= 0.95;
p.vy *= 0.95;
p.x += p.vx;
p.y += p.vy;
const pseudoRandom = (offset: number) => { if (p.alpha < p.targetAlpha) p.alpha += 0.001;
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
const initScene = (width: number, height: number) => { // Render as very soft additive blob
// Init Stars const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
const newStars = []; const colorAlpha = p.alpha * globalOpacity;
for (let i = 0; i < starDensity; i++) { grad.addColorStop(0, alpha(p.color, colorAlpha));
newStars.push({ grad.addColorStop(0.6, alpha(p.color, colorAlpha * 0.2));
x: pseudoRandom(i + 5000) * width, grad.addColorStop(1, alpha(p.color, 0));
y: pseudoRandom(i + 6000) * height,
size: pseudoRandom(i + 7000) * 1.5,
opacity: 0.1 + pseudoRandom(i + 8000) * 0.5
});
}
// Init Gas Clusters ctx.fillStyle = grad;
const newParticles = []; ctx.globalCompositeOperation = "screen";
const clusterCount = colors.length * 3; // More clusters ctx.beginPath();
const groupCenters = Array.from({ length: clusterCount }).map((_, i) => ({ ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
x: (pseudoRandom(i * 15) * 0.8 + 0.1) * width, ctx.fill();
y: (pseudoRandom(i * 25 + 7) * 0.8 + 0.1) * height });
}));
for (let i = 0; i < particleCount; i++) { ctx.globalCompositeOperation = "source-over";
const clusterIdx = i % clusterCount; animationFrameId = requestAnimationFrame(draw);
const colorIdx = clusterIdx % colors.length; };
const center = groupCenters[clusterIdx];
const angle = pseudoRandom(i + 100) * Math.PI * 2;
const dist = pseudoRandom(i + 200) * (width * 0.12);
const bx = center.x + Math.cos(angle) * dist;
const by = center.y + Math.sin(angle) * dist;
newParticles.push({ draw();
x: bx, y: by,
baseX: bx, baseY: by,
vx: 0, vy: 0,
size: 40 + pseudoRandom(i + 300) * 80, // Larger, softer blobs
color: colors[colorIdx],
alpha: 0,
targetAlpha: 0.05 + pseudoRandom(i + 400) * 0.15,
phase: pseudoRandom(i + 500) * Math.PI * 2
});
}
return { stars: newStars, particles: newParticles };
};
const resize = () => { return () => {
const parent = canvas.parentElement; resizeObserver.disconnect();
if (parent) { if (parent) {
const rect = parent.getBoundingClientRect(); parent.removeEventListener("mousemove", handleMouseMove);
const dpr = window.devicePixelRatio || 1; parent.removeEventListener("mousedown", handleMouseDown);
canvas.width = rect.width * dpr; parent.removeEventListener("mouseup", handleMouseUp);
canvas.height = rect.height * dpr; }
ctx.scale(dpr, dpr); cancelAnimationFrame(animationFrameId);
canvas.style.width = `${rect.width}px`; };
canvas.style.height = `${rect.height}px`; }, [
const scene = initScene(rect.width, rect.height); seed,
stars = scene.stars; palette,
particles = scene.particles; ratingsCount,
} followersCount,
}; theme,
particleCount,
speed,
globalOpacity,
smearRadius,
turbulenceStrength,
returnSpeed,
starDensity,
]);
const resizeObserver = new ResizeObserver(() => resize()); return (
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement); <canvas
resize(); ref={canvasRef}
style={{
let mouse = { x: -1000, y: -1000, px: -1000, py: -1000, down: false }; position: "absolute",
const handleMouseMove = (e: MouseEvent) => { top: 0,
const rect = canvas.getBoundingClientRect(); left: 0,
mouse.px = mouse.x; mouse.py = mouse.y; width: "100%",
mouse.x = e.clientX - rect.left; height: "100%",
mouse.y = e.clientY - rect.top; pointerEvents: "none",
}; zIndex: 1,
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);
// 1. Dynamic Celestial Background
const isLight = theme.palette.mode === 'light';
const skyGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
if (isLight) {
// Vibrant Deep Primary for light mode - but dark enough for glowing gas to pop
skyGrad.addColorStop(0, alpha(colors[colors.length - 1], 0.7));
skyGrad.addColorStop(0.5, alpha(colors[colors.length - 1], 0.9));
skyGrad.addColorStop(1, alpha(colors[colors.length - 1], 1.0));
} else {
// True Deep Space with visible tints for dark mode
skyGrad.addColorStop(0, '#1a1a3a'); // More blue/purple tint
skyGrad.addColorStop(0.5, '#0a0a1a');
skyGrad.addColorStop(1, '#050508');
}
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, width, height);
// 2. Draw Distant Stars
ctx.fillStyle = '#fff';
stars.forEach(s => {
ctx.globalAlpha = s.opacity * (0.7 + Math.sin(time * 2 + s.x) * 0.3);
ctx.beginPath();
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1.0;
time += speed;
// 3. Draw Gas Clouds
particles.forEach((p) => {
// Restoration Force (Airy return)
const dxBase = p.baseX - p.x;
const dyBase = p.baseY - p.y;
p.vx += dxBase * returnSpeed;
p.vy += dyBase * returnSpeed;
// Mouse Interaction (Airy Smear)
const dx = mouse.x - p.x;
const dy = mouse.y - p.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < smearRadius) {
const force = (smearRadius - dist) / smearRadius;
if (mouse.down) {
// High turbulence on click
const angle = Math.atan2(dy, dx);
p.vx += Math.cos(angle + Math.PI/2) * turbulenceStrength * 2;
p.vy += Math.sin(angle + Math.PI/2) * turbulenceStrength * 2;
} else {
// Smoke-like smear
const mdx = mouse.x - mouse.px;
const mdy = mouse.y - mouse.py;
p.vx += mdx * force * 0.15;
p.vy += mdy * force * 0.15;
p.vx -= (dx / dist) * force * 0.8;
p.vy -= (dy / dist) * force * 0.8;
}
}
// Smooth drift
p.vx += Math.sin(time + p.phase) * 0.03;
p.vy += Math.cos(time * 0.8 + p.phase) * 0.03;
// High friction for gas feel
p.vx *= 0.95;
p.vy *= 0.95;
p.x += p.vx;
p.y += p.vy;
if (p.alpha < p.targetAlpha) p.alpha += 0.001;
// Render as very soft additive blob
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
const colorAlpha = p.alpha * globalOpacity;
grad.addColorStop(0, alpha(p.color, colorAlpha));
grad.addColorStop(0.6, alpha(p.color, colorAlpha * 0.2));
grad.addColorStop(1, alpha(p.color, 0));
ctx.fillStyle = grad;
ctx.globalCompositeOperation = 'screen';
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalCompositeOperation = 'source-over';
animationFrameId = requestAnimationFrame(draw);
};
draw();
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
parent.removeEventListener('mousedown', handleMouseDown);
parent.removeEventListener('mouseup', handleMouseUp);
}
cancelAnimationFrame(animationFrameId);
};
}, [seed, palette, ratingsCount, followersCount, theme, particleCount, speed, globalOpacity, smearRadius, turbulenceStrength, returnSpeed, starDensity]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1
}}
/>
);
}; };
export default NebulaBanner; export default NebulaBanner;
+63 -47
View File
@@ -1,55 +1,71 @@
import React from 'react'; import React from "react";
import { Box, Typography, useTheme } from '@mui/material'; import { Box, Typography, useTheme } from "@mui/material";
interface RatingBadgeProps { interface RatingBadgeProps {
score: number; score: number;
size?: 'small' | 'medium' | 'large'; size?: "small" | "medium" | "large";
sx?: any; sx?: any;
} }
const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx }) => { const RatingBadge: React.FC<RatingBadgeProps> = ({
const theme = useTheme(); score,
const isLarge = size === 'large'; size = "medium",
const isSmall = size === 'small'; sx,
}) => {
const theme = useTheme();
const isLarge = size === "large";
const isSmall = size === "small";
return ( return (
<Box sx={{ <Box
display: 'inline-flex', sx={{
alignItems: 'center', display: "inline-flex",
justifyContent: 'center', alignItems: "center",
gap: 0.5, justifyContent: "center",
bgcolor: 'primary.main', gap: 0.5,
color: 'primary.contrastText', bgcolor: "primary.main",
px: isLarge ? 2 : (isSmall ? 1 : 1.5), color: "primary.contrastText",
py: isLarge ? 0.5 : (isSmall ? 0.2 : 0.4), px: isLarge ? 2 : isSmall ? 1 : 1.5,
borderRadius: 2, py: isLarge ? 0.5 : isSmall ? 0.2 : 0.4,
minWidth: 'fit-content', borderRadius: 2,
height: 'fit-content', minWidth: "fit-content",
boxShadow: theme.palette.mode === 'dark' height: "fit-content",
? '0 4px 12px rgba(0,0,0,0.4)' boxShadow:
: '0 2px 8px rgba(0,0,0,0.1)', theme.palette.mode === "dark"
...sx ? "0 4px 12px rgba(0,0,0,0.4)"
}}> : "0 2px 8px rgba(0,0,0,0.1)",
<Typography ...sx,
variant={isLarge ? "h5" : (isSmall ? "caption" : "subtitle1")} }}
sx={{ fontWeight: '900', lineHeight: 1, display: 'flex', alignItems: 'center' }} >
> <Typography
{typeof score === 'number' ? (isLarge ? score.toFixed(1) : score.toFixed(0)) : score} variant={isLarge ? "h5" : isSmall ? "caption" : "subtitle1"}
</Typography> sx={{
<Typography fontWeight: "900",
variant="caption" lineHeight: 1,
sx={{ display: "flex",
fontSize: isLarge ? '0.75rem' : (isSmall ? '0.55rem' : '0.65rem'), alignItems: "center",
fontWeight: '900', }}
opacity: 0.9, >
lineHeight: 1, {typeof score === "number"
mt: 0.2 ? isLarge
}} ? score.toFixed(1)
> : score.toFixed(0)
/ 10 : score}
</Typography> </Typography>
</Box> <Typography
); variant="caption"
sx={{
fontSize: isLarge ? "0.75rem" : isSmall ? "0.55rem" : "0.65rem",
fontWeight: "900",
opacity: 0.9,
lineHeight: 1,
mt: 0.2,
}}
>
/ 10
</Typography>
</Box>
);
}; };
export default RatingBadge; export default RatingBadge;
+33 -33
View File
@@ -1,43 +1,43 @@
import React from 'react'; import React from "react";
import { Link } from 'react-router-dom'; import { Link } from "react-router-dom";
import { useTheme, alpha } from '@mui/material'; import { useTheme, alpha } from "@mui/material";
interface RichTextProps { interface RichTextProps {
text: string; text: string;
} }
const RichText: React.FC<RichTextProps> = ({ text }) => { const RichText: React.FC<RichTextProps> = ({ text }) => {
const theme = useTheme(); const theme = useTheme();
if (!text) return null; if (!text) return null;
const parts = text.split(/(@\w+)/g); const parts = text.split(/(@\w+)/g);
return ( return (
<span> <span>
{parts.map((part, i) => { {parts.map((part, i) => {
if (part.startsWith('@')) { if (part.startsWith("@")) {
const username = part.substring(1); const username = part.substring(1);
return ( return (
<Link <Link
key={i} key={i}
to={`/profile/${username}`} to={`/profile/${username}`}
style={{ style={{
color: theme.palette.primary.main, color: theme.palette.primary.main,
textDecoration: 'none', textDecoration: "none",
fontWeight: 'bold', fontWeight: "bold",
backgroundColor: alpha(theme.palette.primary.main, 0.1), backgroundColor: alpha(theme.palette.primary.main, 0.1),
padding: '0 4px', padding: "0 4px",
borderRadius: '4px' borderRadius: "4px",
}} }}
> >
{part} {part}
</Link> </Link>
); );
} }
return part; return part;
})} })}
</span> </span>
); );
}; };
export default RichText; export default RichText;
+55 -46
View File
@@ -1,56 +1,65 @@
import React from 'react'; import React from "react";
import { Box, Typography, alpha, useTheme } from '@mui/material'; import { Box, Typography, alpha, useTheme } from "@mui/material";
import HistoryIcon from '@mui/icons-material/History'; import HistoryIcon from "@mui/icons-material/History";
import BlockIcon from '@mui/icons-material/Block'; import BlockIcon from "@mui/icons-material/Block";
interface StatusBadgeProps { interface StatusBadgeProps {
isLegacy?: boolean; isLegacy?: boolean;
isAvailable?: boolean; isAvailable?: boolean;
size?: 'small' | 'medium'; size?: "small" | "medium";
sx?: any; sx?: any;
} }
const StatusBadge: React.FC<StatusBadgeProps> = ({ isLegacy, isAvailable, size = 'medium', sx }) => { const StatusBadge: React.FC<StatusBadgeProps> = ({
const theme = useTheme(); isLegacy,
const isSmall = size === 'small'; isAvailable,
size = "medium",
sx,
}) => {
const theme = useTheme();
const isSmall = size === "small";
if (isAvailable && !isLegacy) return null; if (isAvailable && !isLegacy) return null;
const label = isLegacy ? 'Limited' : 'Out of Stock'; const label = isLegacy ? "Limited" : "Out of Stock";
const color = isLegacy ? theme.palette.warning.main : theme.palette.error.main; const color = isLegacy
const Icon = isLegacy ? HistoryIcon : BlockIcon; ? theme.palette.warning.main
: theme.palette.error.main;
const Icon = isLegacy ? HistoryIcon : BlockIcon;
return ( return (
<Box sx={{ <Box
display: 'inline-flex', sx={{
alignItems: 'center', display: "inline-flex",
gap: 0.5, alignItems: "center",
bgcolor: alpha(color, 0.9), gap: 0.5,
color: '#fff', bgcolor: alpha(color, 0.9),
px: isSmall ? 0.8 : 1.2, color: "#fff",
py: isSmall ? 0.2 : 0.4, px: isSmall ? 0.8 : 1.2,
borderRadius: 1.5, py: isSmall ? 0.2 : 0.4,
boxShadow: '0 4px 12px rgba(0,0,0,0.2)', borderRadius: 1.5,
backdropFilter: 'blur(4px)', boxShadow: "0 4px 12px rgba(0,0,0,0.2)",
border: '1px solid', backdropFilter: "blur(4px)",
borderColor: alpha('#fff', 0.2), border: "1px solid",
...sx borderColor: alpha("#fff", 0.2),
}}> ...sx,
<Icon sx={{ fontSize: isSmall ? '0.7rem' : '0.9rem' }} /> }}
<Typography >
variant="caption" <Icon sx={{ fontSize: isSmall ? "0.7rem" : "0.9rem" }} />
sx={{ <Typography
fontWeight: '900', variant="caption"
textTransform: 'uppercase', sx={{
letterSpacing: 0.5, fontWeight: "900",
fontSize: isSmall ? '0.55rem' : '0.65rem', textTransform: "uppercase",
lineHeight: 1 letterSpacing: 0.5,
}} fontSize: isSmall ? "0.55rem" : "0.65rem",
> lineHeight: 1,
{label} }}
</Typography> >
</Box> {label}
); </Typography>
</Box>
);
}; };
export default StatusBadge; export default StatusBadge;
+1 -1
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect } from "react";
export const useTitle = (title: string) => { export const useTitle = (title: string) => {
useEffect(() => { useEffect(() => {
+520 -459
View File
File diff suppressed because it is too large. Load diff
+7 -7
View File
@@ -1,13 +1,13 @@
import { StrictMode } from 'react' import { StrictMode } from "react";
import { createRoot } from 'react-dom/client' import { createRoot } from "react-dom/client";
import { BrowserRouter as Router } from 'react-router-dom' import { BrowserRouter as Router } from "react-router-dom";
import './index.css' import "./index.css";
import App from './App.tsx' import App from "./App.tsx";
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<Router> <Router>
<App /> <App />
</Router> </Router>
</StrictMode>, </StrictMode>,
) );
+122 -53
View File
@@ -1,89 +1,158 @@
import React from 'react'; import React from "react";
import { Typography, Box, Container, Paper, Divider, Button, useMediaQuery } from '@mui/material'; import {
import { Link } from 'react-router-dom'; Typography,
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; Box,
import { useTranslation } from 'react-i18next'; Container,
import { useTitle } from '../hooks/useTitle'; Paper,
Divider,
Button,
useMediaQuery,
} from "@mui/material";
import { Link } from "react-router-dom";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { useTranslation } from "react-i18next";
import { useTitle } from "../hooks/useTitle";
const About: React.FC = () => { const About: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
useTitle(t('nav.about')); useTitle(t("nav.about"));
const isMobile = useMediaQuery('(max-width:600px)'); const isMobile = useMediaQuery("(max-width:600px)");
return ( return (
<Container maxWidth="md" sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}> <Container
maxWidth="md"
sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}
>
<Button <Button
variant="outlined" variant="outlined"
component={Link} component={Link}
to="/" to="/"
startIcon={<ArrowBackIcon />} startIcon={<ArrowBackIcon />}
sx={{ mb: 4, textTransform: 'none', borderRadius: 2 }} sx={{ mb: 4, textTransform: "none", borderRadius: 2 }}
> >
{t('common.backToArchive')} {t("common.backToArchive")}
</Button> </Button>
<Paper sx={{ p: { xs: 2.5, sm: 4, md: 6 }, borderRadius: 4, overflow: 'hidden' }}> <Paper
<Typography variant={isMobile ? "h4" : "h3"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.title')}</Typography> sx={{
<Typography variant="body1" paragraph sx={{ fontSize: { xs: '1rem', md: '1.1rem' }, lineHeight: 1.8 }}> p: { xs: 2.5, sm: 4, md: 6 },
{t('about.mission')} borderRadius: 4,
overflow: "hidden",
}}
>
<Typography
variant={isMobile ? "h4" : "h3"}
gutterBottom
sx={{ fontWeight: "bold" }}
>
{t("about.title")}
</Typography>
<Typography
variant="body1"
paragraph
sx={{ fontSize: { xs: "1rem", md: "1.1rem" }, lineHeight: 1.8 }}
>
{t("about.mission")}
</Typography> </Typography>
<Box sx={{ my: 4 }}> <Box sx={{ my: 4 }}>
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.canDoTitle')}</Typography> <Typography
<Typography component="ul" sx={{ lineHeight: 2, pl: 2 }}> variant={isMobile ? "h6" : "h5"}
<li>{t('about.featureExplore')}</li> gutterBottom
<li>{t('about.featureRate')}</li> sx={{ fontWeight: "bold" }}
<li>{t('about.featureCommunity')}</li> >
<li>{t('about.featureProfile')}</li> {t("about.canDoTitle")}
<li>{t('about.featurePersonalize')}</li> </Typography>
<li>{t('about.featureEngage')}</li> <Typography component="ul" sx={{ lineHeight: 2, pl: 2 }}>
<li>{t('about.featureSupport')}</li> <li>{t("about.featureExplore")}</li>
</Typography> <li>{t("about.featureRate")}</li>
<li>{t("about.featureCommunity")}</li>
<li>{t("about.featureProfile")}</li>
<li>{t("about.featurePersonalize")}</li>
<li>{t("about.featureEngage")}</li>
<li>{t("about.featureSupport")}</li>
</Typography>
</Box> </Box>
<Typography variant="body2" color="text.secondary" paragraph> <Typography variant="body2" color="text.secondary" paragraph>
{t('about.disclaimer')} {t("about.disclaimer")}
</Typography> </Typography>
<Divider sx={{ my: { xs: 4, md: 6 } }} /> <Divider sx={{ my: { xs: 4, md: 6 } }} />
<Typography variant={isMobile ? "h5" : "h4"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.impressumTitle')}</Typography> <Typography
variant={isMobile ? "h5" : "h4"}
gutterBottom
sx={{ fontWeight: "bold" }}
>
{t("about.impressumTitle")}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 2 }}> <Typography variant="body2" sx={{ lineHeight: 2 }}>
<strong>{t('about.legalInfo')}</strong><br /> <strong>{t("about.legalInfo")}</strong>
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name or Project Name]'}<br /> <br />
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}<br /> {import.meta.env.VITE_IMPRESSUM_NAME || "[Your Name or Project Name]"}
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}<br /> <br />
{t('about.region')} {import.meta.env.VITE_IMPRESSUM_STREET || "[Your Street Address]"}
<br />
{import.meta.env.VITE_IMPRESSUM_CITY || "[Postal Code and City]"}
<br />
{t("about.region")}
</Typography> </Typography>
<Box sx={{ mt: 3 }}> <Box sx={{ mt: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('about.contact')}:</Typography> <Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
<Typography variant="body2"> {t("about.contact")}:
E-Mail: {import.meta.env.VITE_IMPRESSUM_EMAIL || '[Your Support Email Address]'}<br /> </Typography>
{import.meta.env.VITE_IMPRESSUM_PHONE && ( <Typography variant="body2">
<>Telefon: {import.meta.env.VITE_IMPRESSUM_PHONE}<br /></> E-Mail:{" "}
)} {import.meta.env.VITE_IMPRESSUM_EMAIL ||
Internet: holy.narl.io "[Your Support Email Address]"}
</Typography> <br />
{import.meta.env.VITE_IMPRESSUM_PHONE && (
<>
Telefon: {import.meta.env.VITE_IMPRESSUM_PHONE}
<br />
</>
)}
Internet: holy.narl.io
</Typography>
</Box> </Box>
<Box sx={{ mt: 3 }}> <Box sx={{ mt: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('about.responsible')}</Typography> <Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
<Typography variant="body2"> {t("about.responsible")}
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}<br /> </Typography>
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}<br /> <Typography variant="body2">
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'} {import.meta.env.VITE_IMPRESSUM_NAME || "[Your Name]"}
</Typography> <br />
{import.meta.env.VITE_IMPRESSUM_STREET || "[Your Street Address]"}
<br />
{import.meta.env.VITE_IMPRESSUM_CITY || "[Postal Code and City]"}
</Typography>
</Box> </Box>
<Box sx={{ mt: 4 }}> <Box sx={{ mt: 4 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.disclaimerTitle')}</Typography> <Typography variant="h6" gutterBottom sx={{ fontWeight: "bold" }}>
<Typography variant="caption" color="text.secondary" component="p" sx={{ mb: 2, lineHeight: 1.6 }}> {t("about.disclaimerTitle")}
<strong>{t('about.liabilityContentTitle')}</strong> {t('about.liabilityContentDesc')} </Typography>
</Typography> <Typography
<Typography variant="caption" color="text.secondary" component="p" sx={{ lineHeight: 1.6 }}> variant="caption"
<strong>{t('about.copyrightTitle')}</strong> {t('about.copyrightDesc')} color="text.secondary"
</Typography> component="p"
sx={{ mb: 2, lineHeight: 1.6 }}
>
<strong>{t("about.liabilityContentTitle")}</strong>{" "}
{t("about.liabilityContentDesc")}
</Typography>
<Typography
variant="caption"
color="text.secondary"
component="p"
sx={{ lineHeight: 1.6 }}
>
<strong>{t("about.copyrightTitle")}</strong>{" "}
{t("about.copyrightDesc")}
</Typography>
</Box> </Box>
</Paper> </Paper>
</Container> </Container>
File diff suppressed because it is too large. Load diff
+123 -84
View File
@@ -1,98 +1,137 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { import {
Container, Typography, Box, Button, Container,
Stack, CircularProgress, TextField, Paper Typography,
} from '@mui/material'; Box,
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; Button,
import DeleteIcon from '@mui/icons-material/Delete'; Stack,
import { useTranslation } from 'react-i18next'; CircularProgress,
import { useNavigate, useParams } from 'react-router-dom'; TextField,
import api from '../api'; Paper,
import { useTitle } from '../hooks/useTitle'; } from "@mui/material";
import MentionTextField from '../components/MentionTextField'; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import DeleteIcon from "@mui/icons-material/Delete";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import api from "../api";
import { useTitle } from "../hooks/useTitle";
import MentionTextField from "../components/MentionTextField";
const AdminRatingDetail: React.FC = () => { const AdminRatingDetail: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [score, setScore] = useState(0); const [score, setScore] = useState(0);
const [comment, setComment] = useState(''); const [comment, setComment] = useState("");
const [rating, setRating] = useState<any>(null); const [rating, setRating] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fetchRating = async () => { const fetchRating = async () => {
try { try {
const res = await api.get(`ratings/${id}/`); const res = await api.get(`ratings/${id}/`);
setRating(res.data); setRating(res.data);
setScore(res.data.score); setScore(res.data.score);
setComment(res.data.comment || ''); setComment(res.data.comment || "");
} catch (err) { } catch (err) {
console.error(err); console.error(err);
navigate('/admin-panel'); navigate("/admin-panel");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
fetchRating(); fetchRating();
}, [id]); }, [id]);
useTitle(rating ? `${t('admin.manageRating')}: ${rating.flavor_name}` : 'Rating Management'); useTitle(
rating
? `${t("admin.manageRating")}: ${rating.flavor_name}`
: "Rating Management",
);
const handleUpdate = async () => { const handleUpdate = async () => {
try { try {
await api.patch(`ratings/${id}/`, { score, comment }); await api.patch(`ratings/${id}/`, { score, comment });
fetchRating(); fetchRating();
alert('Rating updated!'); alert("Rating updated!");
} catch (err) { alert('Update failed'); } } catch (err) {
}; alert("Update failed");
}
};
const handleDelete = async () => { const handleDelete = async () => {
if (!window.confirm('Delete this rating and all replies?')) return; if (!window.confirm("Delete this rating and all replies?")) return;
try { try {
await api.delete(`ratings/${id}/`); await api.delete(`ratings/${id}/`);
navigate('/admin-panel'); navigate("/admin-panel");
} catch (err) { alert('Delete failed'); } } catch (err) {
}; alert("Delete failed");
}
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>; };
if (loading)
return ( return (
<Container maxWidth="sm" sx={{ py: 4 }}> <Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
<Button <CircularProgress />
variant="outlined" </Box>
startIcon={<ArrowBackIcon />}
onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }}
>
{t('common.back')}
</Button>
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 4 }}>{t('admin.manageRating')}</Typography>
<Paper sx={{ p: 3, borderRadius: 4 }}>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
{rating.flavor_name} (User: {rating.user})
</Typography>
<Stack spacing={3} sx={{ mt: 3 }}>
<TextField
label="Score" type="number"
slotProps={{ input: { inputProps: { min: 1, max: 10 } } }}
value={score} onChange={(e) => setScore(Number(e.target.value))}
/>
<MentionTextField
multiline rows={4}
value={comment} onChange={setComment}
/>
<Stack direction="row" spacing={2}>
<Button variant="contained" fullWidth onClick={handleUpdate}>{t('common.save')}</Button>
<Button variant="outlined" color="error" fullWidth startIcon={<DeleteIcon />} onClick={handleDelete}>{t('common.delete')}</Button>
</Stack>
</Stack>
</Paper>
</Container>
); );
return (
<Container maxWidth="sm" sx={{ py: 4 }}>
<Button
variant="outlined"
startIcon={<ArrowBackIcon />}
onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }}
>
{t("common.back")}
</Button>
<Typography variant="h4" sx={{ fontWeight: "bold", mb: 4 }}>
{t("admin.manageRating")}
</Typography>
<Paper sx={{ p: 3, borderRadius: 4 }}>
<Typography
variant="subtitle1"
gutterBottom
sx={{ fontWeight: "bold" }}
>
{rating.flavor_name} (User: {rating.user})
</Typography>
<Stack spacing={3} sx={{ mt: 3 }}>
<TextField
label="Score"
type="number"
slotProps={{ input: { inputProps: { min: 1, max: 10 } } }}
value={score}
onChange={(e) => setScore(Number(e.target.value))}
/>
<MentionTextField
multiline
rows={4}
value={comment}
onChange={setComment}
/>
<Stack direction="row" spacing={2}>
<Button variant="contained" fullWidth onClick={handleUpdate}>
{t("common.save")}
</Button>
<Button
variant="outlined"
color="error"
fullWidth
startIcon={<DeleteIcon />}
onClick={handleDelete}
>
{t("common.delete")}
</Button>
</Stack>
</Stack>
</Paper>
</Container>
);
}; };
export default AdminRatingDetail; export default AdminRatingDetail;
+110 -78
View File
@@ -1,92 +1,124 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { import {
Container, Typography, Box, Button, Container,
Stack, CircularProgress, Paper Typography,
} from '@mui/material'; Box,
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; Button,
import DeleteIcon from '@mui/icons-material/Delete'; Stack,
import { useTranslation } from 'react-i18next'; CircularProgress,
import { useNavigate, useParams } from 'react-router-dom'; Paper,
import api from '../api'; } from "@mui/material";
import { useTitle } from '../hooks/useTitle'; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import MentionTextField from '../components/MentionTextField'; import DeleteIcon from "@mui/icons-material/Delete";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import api from "../api";
import { useTitle } from "../hooks/useTitle";
import MentionTextField from "../components/MentionTextField";
const AdminReplyDetail: React.FC = () => { const AdminReplyDetail: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [text, setText] = useState(''); const [text, setText] = useState("");
const [reply, setReply] = useState<any>(null); const [reply, setReply] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fetchReply = async () => { const fetchReply = async () => {
try { try {
// We use the replies list/detail endpoint // We use the replies list/detail endpoint
const res = await api.get(`replies/${id}/`); const res = await api.get(`replies/${id}/`);
setReply(res.data); setReply(res.data);
setText(res.data.text); setText(res.data.text);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
navigate('/admin-panel'); navigate("/admin-panel");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
fetchReply(); fetchReply();
}, [id]); }, [id]);
useTitle(reply ? `${t('admin.manageReply')}` : 'Reply Management'); useTitle(reply ? `${t("admin.manageReply")}` : "Reply Management");
const handleUpdate = async () => { const handleUpdate = async () => {
try { try {
await api.patch(`replies/${id}/`, { text }); await api.patch(`replies/${id}/`, { text });
fetchReply(); fetchReply();
alert('Reply updated!'); alert("Reply updated!");
} catch (err) { alert('Update failed'); } } catch (err) {
}; alert("Update failed");
}
};
const handleDelete = async () => { const handleDelete = async () => {
if (!window.confirm('Delete this reply?')) return; if (!window.confirm("Delete this reply?")) return;
try { try {
await api.delete(`replies/${id}/`); await api.delete(`replies/${id}/`);
navigate('/admin-panel'); navigate("/admin-panel");
} catch (err) { alert('Delete failed'); } } catch (err) {
}; alert("Delete failed");
}
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>; };
if (loading)
return ( return (
<Container maxWidth="sm" sx={{ py: 4 }}> <Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
<Button <CircularProgress />
variant="outlined" </Box>
startIcon={<ArrowBackIcon />}
onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }}
>
{t('common.back')}
</Button>
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 4 }}>{t('admin.manageReply')}</Typography>
<Paper sx={{ p: 3, borderRadius: 4 }}>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
Reply by {reply.user}
</Typography>
<Stack spacing={3} sx={{ mt: 3 }}>
<MentionTextField
multiline rows={4}
value={text} onChange={setText}
/>
<Stack direction="row" spacing={2}>
<Button variant="contained" fullWidth onClick={handleUpdate}>{t('common.save')}</Button>
<Button variant="outlined" color="error" fullWidth startIcon={<DeleteIcon />} onClick={handleDelete}>{t('common.delete')}</Button>
</Stack>
</Stack>
</Paper>
</Container>
); );
return (
<Container maxWidth="sm" sx={{ py: 4 }}>
<Button
variant="outlined"
startIcon={<ArrowBackIcon />}
onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }}
>
{t("common.back")}
</Button>
<Typography variant="h4" sx={{ fontWeight: "bold", mb: 4 }}>
{t("admin.manageReply")}
</Typography>
<Paper sx={{ p: 3, borderRadius: 4 }}>
<Typography
variant="subtitle1"
gutterBottom
sx={{ fontWeight: "bold" }}
>
Reply by {reply.user}
</Typography>
<Stack spacing={3} sx={{ mt: 3 }}>
<MentionTextField
multiline
rows={4}
value={text}
onChange={setText}
/>
<Stack direction="row" spacing={2}>
<Button variant="contained" fullWidth onClick={handleUpdate}>
{t("common.save")}
</Button>
<Button
variant="outlined"
color="error"
fullWidth
startIcon={<DeleteIcon />}
onClick={handleDelete}
>
{t("common.delete")}
</Button>
</Stack>
</Stack>
</Paper>
</Container>
);
}; };
export default AdminReplyDetail; export default AdminReplyDetail;
+591 -291
View File
@@ -1,314 +1,614 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { import {
Container, Typography, Box, Card, CardContent, Button, Container,
Stack, alpha, useTheme, CircularProgress, Chip, Paper, List, ListItem, ListItemText, Typography,
Avatar, Grid, TextField, Tabs, Tab, Divider Box,
} from '@mui/material'; Card,
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; CardContent,
import DeleteIcon from '@mui/icons-material/Delete'; Button,
import SecurityIcon from '@mui/icons-material/Security'; Stack,
import HistoryIcon from '@mui/icons-material/History'; alpha,
import RateReviewIcon from '@mui/icons-material/RateReview'; useTheme,
import { useTranslation } from 'react-i18next'; CircularProgress,
import { useNavigate, useParams } from 'react-router-dom'; Chip,
import api from '../api'; Paper,
import { useTitle } from '../hooks/useTitle'; List,
import { formatDate } from '../utils/date'; ListItem,
import MentionTextField from '../components/MentionTextField'; ListItemText,
import RichText from '../components/RichText'; Avatar,
Grid,
TextField,
Tabs,
Tab,
Divider,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import DeleteIcon from "@mui/icons-material/Delete";
import SecurityIcon from "@mui/icons-material/Security";
import HistoryIcon from "@mui/icons-material/History";
import RateReviewIcon from "@mui/icons-material/RateReview";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import api from "../api";
import { useTitle } from "../hooks/useTitle";
import { formatDate } from "../utils/date";
import MentionTextField from "../components/MentionTextField";
import RichText from "../components/RichText";
interface AdminUser { interface AdminUser {
id: number; id: number;
username: string; username: string;
email: string; email: string;
is_active: boolean; is_active: boolean;
is_superuser: boolean; is_superuser: boolean;
date_joined: string; date_joined: string;
last_login: string; last_login: string;
ips: string[]; ips: string[];
ratings: any[]; ratings: any[];
} }
const AdminUserDetail: React.FC = () => { const AdminUserDetail: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme(); const theme = useTheme();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [user, setUser] = useState<AdminUser | null>(null); const [user, setUser] = useState<AdminUser | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
// Edit state for Ratings // Edit state for Ratings
const [editingRatingId, setEditingRatingId] = useState<number | null>(null); const [editingRatingId, setEditingRatingId] = useState<number | null>(null);
const [editScore, setEditScore] = useState(0); const [editScore, setEditScore] = useState(0);
const [editComment, setEditComment] = useState(''); const [editComment, setEditComment] = useState("");
// Edit state for Replies // Edit state for Replies
const [editingReplyId, setEditingReplyId] = useState<number | null>(null); const [editingReplyId, setEditingReplyId] = useState<number | null>(null);
const [editReplyText, setEditReplyText] = useState(''); const [editReplyText, setEditReplyText] = useState("");
const fetchUser = async () => { const fetchUser = async () => {
try { try {
const res = await api.get(`admin-custom/${id}/user_detail/`); const res = await api.get(`admin-custom/${id}/user_detail/`);
setUser(res.data); setUser(res.data);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
navigate('/admin-panel'); navigate("/admin-panel");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
fetchUser(); fetchUser();
}, [id]); }, [id]);
useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : 'User Management'); useTitle(
user ? `${t("admin.manageUser")}: ${user.username}` : "User Management",
);
const handleToggleActive = async () => { const handleToggleActive = async () => {
if (!user) return; if (!user) return;
try { try {
await api.patch(`admin-custom/${id}/user_detail/`, { is_active: !user.is_active }); await api.patch(`admin-custom/${id}/user_detail/`, {
fetchUser(); is_active: !user.is_active,
} catch (err) { alert('Action failed'); } });
}; fetchUser();
} catch (err) {
alert("Action failed");
}
};
const handleUpdateRating = async (ratingId: number) => { const handleUpdateRating = async (ratingId: number) => {
try { try {
await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment }); await api.patch(`ratings/${ratingId}/`, {
setEditingRatingId(null); score: editScore,
fetchUser(); comment: editComment,
} catch (err) { alert('Update failed'); } });
}; setEditingRatingId(null);
fetchUser();
} catch (err) {
alert("Update failed");
}
};
const handleDeleteRating = async (ratingId: number) => { const handleDeleteRating = async (ratingId: number) => {
if (!confirm('Delete this rating?')) return; if (!confirm("Delete this rating?")) return;
try { try {
await api.delete(`ratings/${ratingId}/`); await api.delete(`ratings/${ratingId}/`);
fetchUser(); fetchUser();
} catch (err) { alert('Delete failed'); } } catch (err) {
}; alert("Delete failed");
}
};
const handleUpdateReply = async (replyId: number) => { const handleUpdateReply = async (replyId: number) => {
try { try {
await api.patch(`replies/${replyId}/`, { text: editReplyText }); await api.patch(`replies/${replyId}/`, { text: editReplyText });
setEditingReplyId(null); setEditingReplyId(null);
fetchUser(); fetchUser();
} catch (err) { alert('Update failed'); } } catch (err) {
}; alert("Update failed");
}
};
const handleDeleteReply = async (replyId: number) => { const handleDeleteReply = async (replyId: number) => {
if (!confirm('Delete this reply?')) return; if (!confirm("Delete this reply?")) return;
try { try {
await api.delete(`replies/${replyId}/`); await api.delete(`replies/${replyId}/`);
fetchUser(); fetchUser();
} catch (err) { alert('Delete failed'); } } catch (err) {
}; alert("Delete failed");
}
};
const handleDelete = async () => { const handleDelete = async () => {
if (!window.confirm('PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.')) return; if (
try { !window.confirm(
await api.delete(`admin-custom/${id}/user_detail/`); "PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.",
navigate('/admin-panel'); )
} catch (err) { alert('Delete failed'); } )
}; return;
try {
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>; await api.delete(`admin-custom/${id}/user_detail/`);
if (!user) return <Typography>User not found</Typography>; navigate("/admin-panel");
} catch (err) {
alert("Delete failed");
}
};
if (loading)
return ( return (
<Container maxWidth="lg" sx={{ py: 4 }}> <Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
<Button <CircularProgress />
variant="outlined" </Box>
startIcon={<ArrowBackIcon />}
onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }}
>
{t('common.back')}
</Button>
{/* Profile Header */}
<Card elevation={0} sx={{ borderRadius: 4, border: '1px solid', borderColor: 'divider', mb: 4 }}>
<CardContent sx={{ p: { xs: 2, md: 4 } }}>
<Grid container spacing={3} alignItems="center">
<Grid size={{ xs: 12, sm: 'auto' }}>
<Avatar sx={{ width: 80, height: 80, fontSize: '2rem', bgcolor: 'primary.main', mx: 'auto' }}>
{user.username.charAt(0).toUpperCase()}
</Avatar>
</Grid>
<Grid size={{ xs: 12, sm: 8, md: 9 }}>
<Box sx={{ textAlign: { xs: 'center', sm: 'left' } }}>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>{user.username}</Typography>
<Typography variant="body1" color="text.secondary">{user.email}</Typography>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.5 }}>
Joined: {formatDate(user.date_joined)} • Last Login: {formatDate(user.last_login)}
</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 1.5, justifyContent: { xs: 'center', sm: 'flex-start' } }}>
<Chip label={user.is_active ? 'Active' : 'Inactive'} color={user.is_active ? 'success' : 'default'} size="small" />
{user.is_superuser && <Chip label="Superuser" color="secondary" size="small" />}
</Stack>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 'auto' }}>
<Button
fullWidth
variant="contained"
color={user.is_active ? "warning" : "success"}
onClick={handleToggleActive}
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
>
{user.is_active ? t('admin.deactivate') : t('admin.activate')}
</Button>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Content Tabs */}
<Box sx={{ mb: 4 }}>
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} variant="fullWidth">
<Tab icon={<RateReviewIcon />} label={`Content (${user.ratings.length})`} sx={{ fontWeight: 'bold' }} />
<Tab icon={<HistoryIcon />} label="Security & IP" sx={{ fontWeight: 'bold' }} />
<Tab icon={<SecurityIcon />} label="Danger Zone" sx={{ fontWeight: 'bold', color: 'error.main' }} />
</Tabs>
<Divider />
</Box>
{/* TAB 0: USER CONTENT (Ratings & Replies) */}
{activeTab === 0 && (
<Box>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold' }}>User Ratings & Discussion</Typography>
<Stack spacing={2}>
{user.ratings.map(rating => (
<Card key={rating.id} variant="outlined" sx={{ borderRadius: 3, bgcolor: alpha(theme.palette.background.paper, 0.4) }}>
<CardContent sx={{ p: { xs: 2, md: 3 } }}>
{editingRatingId === rating.id ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Stack direction="row" spacing={2} alignItems="center">
<Typography variant="body2">Score:</Typography>
<TextField
type="number" size="small"
slotProps={{ input: { inputProps: { min: 1, max: 10 } } }}
value={editScore} onChange={(e) => setEditScore(Number(e.target.value))}
sx={{ width: 80 }}
/>
</Stack>
<MentionTextField
multiline rows={3}
value={editComment}
onChange={setEditComment}
/>
<Stack direction="row" spacing={1}>
<Button variant="contained" size="small" onClick={() => handleUpdateRating(rating.id)}>{t('common.save')}</Button>
<Button variant="outlined" size="small" onClick={() => setEditingRatingId(null)}>{t('common.cancel')}</Button>
</Stack>
</Box>
) : (
<>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{rating.flavor_name}</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Chip label={`${rating.score}/10`} size="small" color="primary" sx={{ fontWeight: '900' }} />
<Button size="small" onClick={() => { setEditingRatingId(rating.id); setEditScore(rating.score); setEditComment(rating.comment || ''); }}>Edit</Button>
<Button size="small" color="error" onClick={() => handleDeleteRating(rating.id)}>Delete</Button>
</Box>
</Stack>
<Box sx={{ bgcolor: 'action.hover', p: 1.5, borderRadius: 2, mb: 2 }}>
{rating.comment ? (
<Typography variant="body2" color="text.primary">
<RichText text={rating.comment} />
</Typography>
) : (
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>No comment</Typography>
)}
</Box>
{rating.replies.length > 0 && (
<Box sx={{ pl: 2, borderLeft: '2px solid', borderColor: 'divider' }}>
{rating.replies.map((reply: any) => (
<Box key={reply.id} sx={{ mb: 1.5 }}>
{editingReplyId === reply.id ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<MentionTextField value={editReplyText} onChange={setEditReplyText} />
<Stack direction="row" spacing={1}>
<Button variant="contained" size="small" onClick={() => handleUpdateReply(reply.id)}>{t('common.save')}</Button>
<Button variant="outlined" size="small" onClick={() => setEditingReplyId(null)}>{t('common.cancel')}</Button>
</Stack>
</Box>
) : (
<>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="caption" sx={{ fontWeight: 'bold', color: reply.user === user.username ? 'primary.main' : 'text.secondary' }}>
{reply.user}
</Typography>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Button size="small" sx={{ minWidth: 0, p: 0, fontSize: '0.7rem' }} onClick={() => { setEditingReplyId(reply.id); setEditReplyText(reply.text); }}>Edit</Button>
<Button size="small" color="error" sx={{ minWidth: 0, p: 0, fontSize: '0.7rem' }} onClick={() => handleDeleteReply(reply.id)}>Delete</Button>
</Box>
</Stack>
<Typography variant="caption" display="block" sx={{ mt: 0.2 }}><RichText text={reply.text} /></Typography>
</>
)}
</Box>
))}
</Box>
)}
</>
)}
</CardContent>
</Card>
))}
{user.ratings.length === 0 && (
<Box sx={{ py: 6, textAlign: 'center', bgcolor: 'action.hover', borderRadius: 3, border: '1px dashed', borderColor: 'divider' }}>
<Typography color="text.secondary">No ratings yet.</Typography>
</Box>
)}
</Stack>
</Box>
)}
{/* TAB 1: SECURITY & IP */}
{activeTab === 1 && (
<Paper sx={{ p: 3, borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
<SecurityIcon /> {t('admin.ipHistory')}
</Typography>
<List dense>
{user.ips.map((ip, idx) => (
<ListItem key={idx} divider={idx < user.ips.length - 1}>
<ListItemText
primary={ip}
primaryTypographyProps={{ sx: { fontWeight: 'bold', fontFamily: 'monospace' } }}
/>
</ListItem>
))}
{user.ips.length === 0 && <Typography variant="caption">No IP history available.</Typography>}
</List>
</Paper>
)}
{/* TAB 2: DANGER ZONE */}
{activeTab === 2 && (
<Paper sx={{ p: 4, borderRadius: 3, border: '1px solid', borderColor: 'error.main', bgcolor: (theme) => alpha(theme.palette.error.main, 0.05) }}>
<Typography variant="h5" color="error" gutterBottom sx={{ fontWeight: 'bold' }}>{t('admin.dangerZone')}</Typography>
<Typography variant="body1" sx={{ mb: 4, maxWidth: 600 }}>
Deleting this user will permanently remove their profile, all their ratings, replies, and IP history.
<strong> This action is irreversible.</strong>
</Typography>
<Button
variant="contained"
color="error"
size="large"
startIcon={<DeleteIcon />}
onClick={handleDelete}
sx={{ borderRadius: 2, fontWeight: 'bold', px: 4 }}
>
{t('admin.deleteUser')}
</Button>
</Paper>
)}
</Container>
); );
if (!user) return <Typography>User not found</Typography>;
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Button
variant="outlined"
startIcon={<ArrowBackIcon />}
onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }}
>
{t("common.back")}
</Button>
{/* Profile Header */}
<Card
elevation={0}
sx={{
borderRadius: 4,
border: "1px solid",
borderColor: "divider",
mb: 4,
}}
>
<CardContent sx={{ p: { xs: 2, md: 4 } }}>
<Grid container spacing={3} alignItems="center">
<Grid size={{ xs: 12, sm: "auto" }}>
<Avatar
sx={{
width: 80,
height: 80,
fontSize: "2rem",
bgcolor: "primary.main",
mx: "auto",
}}
>
{user.username.charAt(0).toUpperCase()}
</Avatar>
</Grid>
<Grid size={{ xs: 12, sm: 8, md: 9 }}>
<Box sx={{ textAlign: { xs: "center", sm: "left" } }}>
<Typography variant="h4" sx={{ fontWeight: "bold" }}>
{user.username}
</Typography>
<Typography variant="body1" color="text.secondary">
{user.email}
</Typography>
<Typography
variant="caption"
color="text.secondary"
display="block"
sx={{ mt: 0.5 }}
>
Joined: {formatDate(user.date_joined)} • Last Login:{" "}
{formatDate(user.last_login)}
</Typography>
<Stack
direction="row"
spacing={1}
sx={{
mt: 1.5,
justifyContent: { xs: "center", sm: "flex-start" },
}}
>
<Chip
label={user.is_active ? "Active" : "Inactive"}
color={user.is_active ? "success" : "default"}
size="small"
/>
{user.is_superuser && (
<Chip label="Superuser" color="secondary" size="small" />
)}
</Stack>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: "auto" }}>
<Button
fullWidth
variant="contained"
color={user.is_active ? "warning" : "success"}
onClick={handleToggleActive}
sx={{
borderRadius: 2,
textTransform: "none",
fontWeight: "bold",
}}
>
{user.is_active ? t("admin.deactivate") : t("admin.activate")}
</Button>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Content Tabs */}
<Box sx={{ mb: 4 }}>
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
variant="fullWidth"
>
<Tab
icon={<RateReviewIcon />}
label={`Content (${user.ratings.length})`}
sx={{ fontWeight: "bold" }}
/>
<Tab
icon={<HistoryIcon />}
label="Security & IP"
sx={{ fontWeight: "bold" }}
/>
<Tab
icon={<SecurityIcon />}
label="Danger Zone"
sx={{ fontWeight: "bold", color: "error.main" }}
/>
</Tabs>
<Divider />
</Box>
{/* TAB 0: USER CONTENT (Ratings & Replies) */}
{activeTab === 0 && (
<Box>
<Typography variant="h5" sx={{ mb: 3, fontWeight: "bold" }}>
User Ratings & Discussion
</Typography>
<Stack spacing={2}>
{user.ratings.map((rating) => (
<Card
key={rating.id}
variant="outlined"
sx={{
borderRadius: 3,
bgcolor: alpha(theme.palette.background.paper, 0.4),
}}
>
<CardContent sx={{ p: { xs: 2, md: 3 } }}>
{editingRatingId === rating.id ? (
<Box
sx={{ display: "flex", flexDirection: "column", gap: 2 }}
>
<Stack direction="row" spacing={2} alignItems="center">
<Typography variant="body2">Score:</Typography>
<TextField
type="number"
size="small"
slotProps={{
input: { inputProps: { min: 1, max: 10 } },
}}
value={editScore}
onChange={(e) => setEditScore(Number(e.target.value))}
sx={{ width: 80 }}
/>
</Stack>
<MentionTextField
multiline
rows={3}
value={editComment}
onChange={setEditComment}
/>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
size="small"
onClick={() => handleUpdateRating(rating.id)}
>
{t("common.save")}
</Button>
<Button
variant="outlined"
size="small"
onClick={() => setEditingRatingId(null)}
>
{t("common.cancel")}
</Button>
</Stack>
</Box>
) : (
<>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-start"
sx={{ mb: 1 }}
>
<Typography
variant="subtitle2"
sx={{ fontWeight: "bold" }}
>
{rating.flavor_name}
</Typography>
<Box
sx={{ display: "flex", gap: 1, alignItems: "center" }}
>
<Chip
label={`${rating.score}/10`}
size="small"
color="primary"
sx={{ fontWeight: "900" }}
/>
<Button
size="small"
onClick={() => {
setEditingRatingId(rating.id);
setEditScore(rating.score);
setEditComment(rating.comment || "");
}}
>
Edit
</Button>
<Button
size="small"
color="error"
onClick={() => handleDeleteRating(rating.id)}
>
Delete
</Button>
</Box>
</Stack>
<Box
sx={{
bgcolor: "action.hover",
p: 1.5,
borderRadius: 2,
mb: 2,
}}
>
{rating.comment ? (
<Typography variant="body2" color="text.primary">
<RichText text={rating.comment} />
</Typography>
) : (
<Typography
variant="body2"
color="text.secondary"
sx={{ fontStyle: "italic" }}
>
No comment
</Typography>
)}
</Box>
{rating.replies.length > 0 && (
<Box
sx={{
pl: 2,
borderLeft: "2px solid",
borderColor: "divider",
}}
>
{rating.replies.map((reply: any) => (
<Box key={reply.id} sx={{ mb: 1.5 }}>
{editingReplyId === reply.id ? (
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
<MentionTextField
value={editReplyText}
onChange={setEditReplyText}
/>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
size="small"
onClick={() =>
handleUpdateReply(reply.id)
}
>
{t("common.save")}
</Button>
<Button
variant="outlined"
size="small"
onClick={() => setEditingReplyId(null)}
>
{t("common.cancel")}
</Button>
</Stack>
</Box>
) : (
<>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Typography
variant="caption"
sx={{
fontWeight: "bold",
color:
reply.user === user.username
? "primary.main"
: "text.secondary",
}}
>
{reply.user}
</Typography>
<Box sx={{ display: "flex", gap: 0.5 }}>
<Button
size="small"
sx={{
minWidth: 0,
p: 0,
fontSize: "0.7rem",
}}
onClick={() => {
setEditingReplyId(reply.id);
setEditReplyText(reply.text);
}}
>
Edit
</Button>
<Button
size="small"
color="error"
sx={{
minWidth: 0,
p: 0,
fontSize: "0.7rem",
}}
onClick={() =>
handleDeleteReply(reply.id)
}
>
Delete
</Button>
</Box>
</Stack>
<Typography
variant="caption"
display="block"
sx={{ mt: 0.2 }}
>
<RichText text={reply.text} />
</Typography>
</>
)}
</Box>
))}
</Box>
)}
</>
)}
</CardContent>
</Card>
))}
{user.ratings.length === 0 && (
<Box
sx={{
py: 6,
textAlign: "center",
bgcolor: "action.hover",
borderRadius: 3,
border: "1px dashed",
borderColor: "divider",
}}
>
<Typography color="text.secondary">No ratings yet.</Typography>
</Box>
)}
</Stack>
</Box>
)}
{/* TAB 1: SECURITY & IP */}
{activeTab === 1 && (
<Paper
sx={{
p: 3,
borderRadius: 3,
border: "1px solid",
borderColor: "divider",
}}
>
<Typography
variant="h6"
gutterBottom
sx={{
fontWeight: "bold",
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<SecurityIcon /> {t("admin.ipHistory")}
</Typography>
<List dense>
{user.ips.map((ip, idx) => (
<ListItem key={idx} divider={idx < user.ips.length - 1}>
<ListItemText
primary={ip}
primaryTypographyProps={{
sx: { fontWeight: "bold", fontFamily: "monospace" },
}}
/>
</ListItem>
))}
{user.ips.length === 0 && (
<Typography variant="caption">
No IP history available.
</Typography>
)}
</List>
</Paper>
)}
{/* TAB 2: DANGER ZONE */}
{activeTab === 2 && (
<Paper
sx={{
p: 4,
borderRadius: 3,
border: "1px solid",
borderColor: "error.main",
bgcolor: (theme) => alpha(theme.palette.error.main, 0.05),
}}
>
<Typography
variant="h5"
color="error"
gutterBottom
sx={{ fontWeight: "bold" }}
>
{t("admin.dangerZone")}
</Typography>
<Typography variant="body1" sx={{ mb: 4, maxWidth: 600 }}>
Deleting this user will permanently remove their profile, all their
ratings, replies, and IP history.
<strong> This action is irreversible.</strong>
</Typography>
<Button
variant="contained"
color="error"
size="large"
startIcon={<DeleteIcon />}
onClick={handleDelete}
sx={{ borderRadius: 2, fontWeight: "bold", px: 4 }}
>
{t("admin.deleteUser")}
</Button>
</Paper>
)}
</Container>
);
}; };
export default AdminUserDetail; export default AdminUserDetail;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+204 -111
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from "react";
import { import {
Typography, Typography,
Card, Card,
@@ -9,18 +9,18 @@ import {
Accordion, Accordion,
AccordionSummary, AccordionSummary,
AccordionDetails, AccordionDetails,
Rating as MuiRating Rating as MuiRating,
} from '@mui/material'; } from "@mui/material";
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import api from '../api'; import api from "../api";
import RatingBadge from '../components/RatingBadge'; import RatingBadge from "../components/RatingBadge";
interface Rating { interface Rating {
id: number; id: number;
user: string; user: string;
score: number; score: number;
comment: string; comment: string;
created_at: string; created_at: string;
} }
interface Flavor { interface Flavor {
@@ -46,14 +46,16 @@ const FlavorList: React.FC = () => {
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [flavors, setFlavors] = useState<Flavor[]>([]); const [flavors, setFlavors] = useState<Flavor[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [ratingInput, setRatingInput] = useState<{[key: number]: {score: number, comment: string}}>({}); const [ratingInput, setRatingInput] = useState<{
[key: number]: { score: number; comment: string };
}>({});
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
const [catRes, flavRes] = await Promise.all([ const [catRes, flavRes] = await Promise.all([
api.get('categories/'), api.get("categories/"),
api.get('flavors/') api.get("flavors/"),
]); ]);
setCategories(catRes.data); setCategories(catRes.data);
setFlavors(flavRes.data); setFlavors(flavRes.data);
@@ -71,17 +73,19 @@ const FlavorList: React.FC = () => {
if (!input) return; if (!input) return;
try { try {
await api.post('ratings/', { await api.post("ratings/", {
flavor: flavorId, flavor: flavorId,
score: input.score, score: input.score,
comment: input.comment comment: input.comment,
}); });
// Refresh flavors // Refresh flavors
const res = await api.get('flavors/'); const res = await api.get("flavors/");
setFlavors(res.data); setFlavors(res.data);
alert('Rating submitted!'); alert("Rating submitted!");
} catch (err) { } catch (err) {
alert("Failed to submit rating. Make sure you are logged in and haven't rated this yet."); alert(
"Failed to submit rating. Make sure you are logged in and haven't rated this yet.",
);
} }
}; };
@@ -89,111 +93,200 @@ const FlavorList: React.FC = () => {
return ( return (
<Box> <Box>
<Typography variant="h4" gutterBottom>Holy Flavors</Typography> <Typography variant="h4" gutterBottom>
{categories.map(category => ( Holy Flavors
</Typography>
{categories.map((category) => (
<Box key={category.id} sx={{ mb: 4 }}> <Box key={category.id} sx={{ mb: 4 }}>
<Typography variant="h5" color="primary" gutterBottom>{category.name}</Typography> <Typography variant="h5" color="primary" gutterBottom>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}> {category.name}
{flavors.filter(f => f.category_name === category.name).map(flavor => ( </Typography>
<Box sx={{ flex: '1 1 30%', minWidth: 280 }} key={flavor.id}> <Box sx={{ display: "flex", flexWrap: "wrap", gap: 3 }}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', position: 'relative' }}> {flavors
{!flavor.is_available && ( .filter((f) => f.category_name === category.name)
<Box sx={{ .map((flavor) => (
position: 'absolute', <Box sx={{ flex: "1 1 30%", minWidth: 280 }} key={flavor.id}>
top: 10, <Card
right: 10, sx={{
bgcolor: 'error.main', height: "100%",
color: 'white', display: "flex",
px: 1, flexDirection: "column",
borderRadius: 1, position: "relative",
fontSize: '0.75rem', }}
zIndex: 1 >
}}> {!flavor.is_available && (
<Box
sx={{
position: "absolute",
top: 10,
right: 10,
bgcolor: "error.main",
color: "white",
px: 1,
borderRadius: 1,
fontSize: "0.75rem",
zIndex: 1,
}}
>
Out of Stock Out of Stock
</Box> </Box>
)} )}
{flavor.image_url && ( {flavor.image_url && (
<Box <Box
component="img" component="img"
src={flavor.image_url} src={flavor.image_url}
sx={{ sx={{
width: '100%', width: "100%",
height: 200, height: 200,
objectFit: 'contain', objectFit: "contain",
p: 2, p: 2,
bgcolor: 'rgba(255,255,255,0.05)' bgcolor: "rgba(255,255,255,0.05)",
}} }}
/> />
)} )}
<CardContent sx={{ flexGrow: 1 }}> <CardContent sx={{ flexGrow: 1 }}>
<Typography variant="h6">{flavor.name}</Typography> <Typography variant="h6">{flavor.name}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography
{flavor.description} variant="body2"
</Typography> color="text.secondary"
{flavor.shop_url && ( sx={{ mb: 2 }}
>
{flavor.description}
</Typography>
{flavor.shop_url && (
<Button <Button
variant="outlined" variant="outlined"
size="small" size="small"
component="a" component="a"
href={flavor.shop_url} href={flavor.shop_url}
target="_blank" target="_blank"
sx={{ mb: 2, fontSize: '0.7rem' }} sx={{ mb: 2, fontSize: "0.7rem" }}
> >
View in Shop View in Shop
</Button> </Button>
)} )}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}> <Box
<Typography variant="body2" sx={{ mr: 1 }}>Avg:</Typography> sx={{ display: "flex", alignItems: "center", mb: 1 }}
<RatingBadge score={flavor.average_rating || 0} size="small" /> >
</Box> <Typography variant="body2" sx={{ mr: 1 }}>
Avg:
</Typography>
<RatingBadge
score={flavor.average_rating || 0}
size="small"
/>
</Box>
{flavor.ratings && flavor.ratings.length > 0 && ( {flavor.ratings && flavor.ratings.length > 0 && (
<Accordion variant="outlined" sx={{ mt: 1, mb: 1 }}> <Accordion variant="outlined" sx={{ mt: 1, mb: 1 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="caption">Comments ({flavor.ratings.filter(r => r.comment).length})</Typography> <Typography variant="caption">
</AccordionSummary> Comments (
<AccordionDetails sx={{ maxHeight: 200, overflow: 'auto', p: 1 }}> {flavor.ratings.filter((r) => r.comment).length})
{flavor.ratings.filter(r => r.comment).map(r => ( </Typography>
<Box key={r.id} sx={{ mb: 1, borderBottom: '1px solid rgba(0,0,0,0.1)', pb: 0.5 }}> </AccordionSummary>
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block' }}>{r.user} ({r.score}/10):</Typography> <AccordionDetails
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>{r.comment}</Typography> sx={{ maxHeight: 200, overflow: "auto", p: 1 }}
</Box> >
))} {flavor.ratings
</AccordionDetails> .filter((r) => r.comment)
.map((r) => (
<Box
key={r.id}
sx={{
mb: 1,
borderBottom: "1px solid rgba(0,0,0,0.1)",
pb: 0.5,
}}
>
<Typography
variant="caption"
sx={{
fontWeight: "bold",
display: "block",
}}
>
{r.user} ({r.score}/10):
</Typography>
<Typography
variant="body2"
sx={{ fontSize: "0.8rem" }}
>
{r.comment}
</Typography>
</Box>
))}
</AccordionDetails>
</Accordion> </Accordion>
)} )}
{flavor.user_rating ? ( {flavor.user_rating ? (
<Typography variant="body2" color="secondary">Your Rating: {flavor.user_rating}/10</Typography> <Typography variant="body2" color="secondary">
) : ( Your Rating: {flavor.user_rating}/10
<Accordion variant="outlined" sx={{ mt: 2 }}> </Typography>
<AccordionSummary expandIcon={<ExpandMoreIcon />}> ) : (
<Typography variant="body2">Rate this flavor</Typography> <Accordion variant="outlined" sx={{ mt: 2 }}>
</AccordionSummary> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<AccordionDetails> <Typography variant="body2">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> Rate this flavor
<MuiRating </Typography>
max={10} </AccordionSummary>
value={ratingInput[flavor.id]?.score || 0} <AccordionDetails>
onChange={(_: any, val: number | null) => setRatingInput({...ratingInput, [flavor.id]: {...(ratingInput[flavor.id] || {comment: ''}), score: val || 0}})} <Box
/> sx={{
<TextField display: "flex",
label="Comment (Optional)" flexDirection: "column",
variant="outlined" gap: 1,
size="small" }}
multiline >
value={ratingInput[flavor.id]?.comment || ''} <MuiRating
onChange={(e) => setRatingInput({...ratingInput, [flavor.id]: {...(ratingInput[flavor.id] || {score: 0}), comment: e.target.value}})} max={10}
/> value={ratingInput[flavor.id]?.score || 0}
<Button variant="contained" size="small" onClick={() => handleRate(flavor.id)}>Submit</Button> onChange={(_: any, val: number | null) =>
</Box> setRatingInput({
</AccordionDetails> ...ratingInput,
</Accordion> [flavor.id]: {
)} ...(ratingInput[flavor.id] || {
</CardContent> comment: "",
</Card> }),
</Box> score: val || 0,
))} },
})
}
/>
<TextField
label="Comment (Optional)"
variant="outlined"
size="small"
multiline
value={ratingInput[flavor.id]?.comment || ""}
onChange={(e) =>
setRatingInput({
...ratingInput,
[flavor.id]: {
...(ratingInput[flavor.id] || {
score: 0,
}),
comment: e.target.value,
},
})
}
/>
<Button
variant="contained"
size="small"
onClick={() => handleRate(flavor.id)}
>
Submit
</Button>
</Box>
</AccordionDetails>
</Accordion>
)}
</CardContent>
</Card>
</Box>
))}
</Box> </Box>
</Box> </Box>
))} ))}
+306 -214
View File
@@ -1,254 +1,340 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { Typography, TextField, Button, Paper, Tab, Tabs, Alert, Container } from '@mui/material'; import {
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; Typography,
import { Link } from 'react-router-dom'; TextField,
import api from '../api'; Button,
import { useTitle } from '../hooks/useTitle'; Paper,
import { useTranslation } from 'react-i18next'; Tab,
Tabs,
Alert,
Container,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { Link } from "react-router-dom";
import api from "../api";
import { useTitle } from "../hooks/useTitle";
import { useTranslation } from "react-i18next";
interface LoginProps { interface LoginProps {}
}
const Login: React.FC<LoginProps> = () => { const Login: React.FC<LoginProps> = () => {
const { t } = useTranslation(); const { t } = useTranslation();
useTitle('Login'); useTitle("Login");
const [tab, setTab] = useState(0); const [tab, setTab] = useState(0);
const [username, setUsername] = useState(''); const [username, setUsername] = useState("");
const [password, setPassword] = useState(''); const [password, setPassword] = useState("");
const [email, setEmail] = useState(''); const [email, setEmail] = useState("");
const [verificationCode, setVerificationCode] = useState(''); const [verificationCode, setVerificationCode] = useState("");
const [isVerifying, setIsVerifying] = useState(false); const [isVerifying, setIsVerifying] = useState(false);
const [showResend, setShowResend] = useState(false); const [showResend, setShowResend] = useState(false);
const [isResetting, setIsResetting] = useState(false); const [isResetting, setIsResetting] = useState(false);
const [resetStep, setResetStep] = useState(1); // 1: email, 2: code/new pass const [resetStep, setResetStep] = useState(1); // 1: email, 2: code/new pass
const [message, setMessage] = useState<{type: 'success' | 'error', text: string} | null>(null); const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setMessage(null); setMessage(null);
try { try {
const res = await api.post('token/', { const res = await api.post("token/", {
username, username,
password, password,
}); });
localStorage.setItem('token', res.data.token); localStorage.setItem("token", res.data.token);
window.location.href = '/'; window.location.href = "/";
} catch (err: any) { } catch (err: any) {
const errorMsg = err.response?.data?.non_field_errors?.[0] || t('auth.loginFailed'); const errorMsg =
if (errorMsg.toLowerCase().includes('inactive') || errorMsg.toLowerCase().includes('verify') || errorMsg.includes('disabled')) { err.response?.data?.non_field_errors?.[0] || t("auth.loginFailed");
setMessage({ type: 'error', text: t('auth.unverifiedError') }); if (
setIsVerifying(true); errorMsg.toLowerCase().includes("inactive") ||
errorMsg.toLowerCase().includes("verify") ||
errorMsg.includes("disabled")
) {
setMessage({ type: "error", text: t("auth.unverifiedError") });
setIsVerifying(true);
} else { } else {
setMessage({ type: 'error', text: errorMsg }); setMessage({ type: "error", text: errorMsg });
} }
} }
}; };
const handleSignup = async (e: React.FormEvent) => { const handleSignup = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setMessage(null); setMessage(null);
try { try {
await api.post('users/signup/', { username, email, password }); await api.post("users/signup/", { username, email, password });
setIsVerifying(true); setIsVerifying(true);
setMessage({ type: 'success', text: t('auth.signupSuccess') }); setMessage({ type: "success", text: t("auth.signupSuccess") });
} catch (err: any) { } catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Signup failed' }); setMessage({
} type: "error",
text: err.response?.data?.error || "Signup failed",
});
}
}; };
const handleVerify = async (e: React.FormEvent) => { const handleVerify = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setMessage(null); setMessage(null);
try { try {
await api.post('users/verify_signup/', { username, code: verificationCode }); await api.post("users/verify_signup/", {
setMessage({ type: 'success', text: t('auth.verifySuccess') }); username,
setIsVerifying(false); code: verificationCode,
setTab(0); });
setPassword(''); setMessage({ type: "success", text: t("auth.verifySuccess") });
} catch (err: any) { setIsVerifying(false);
setMessage({ type: 'error', text: err.response?.data?.error || 'Verification failed' }); setTab(0);
setShowResend(true); setPassword("");
} } catch (err: any) {
setMessage({
type: "error",
text: err.response?.data?.error || "Verification failed",
});
setShowResend(true);
}
}; };
const handleResendCode = async () => { const handleResendCode = async () => {
setMessage(null); setMessage(null);
try { try {
await api.post('users/resend_verification/', { username }); await api.post("users/resend_verification/", { username });
setMessage({ type: 'success', text: t('auth.resendSuccess') }); setMessage({ type: "success", text: t("auth.resendSuccess") });
setShowResend(false); setShowResend(false);
} catch (err: any) { } catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to resend code' }); setMessage({
} type: "error",
text: err.response?.data?.error || "Failed to resend code",
});
}
}; };
const handleRequestReset = async (e: React.FormEvent) => { const handleRequestReset = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setMessage(null); setMessage(null);
try { try {
await api.post('users/request_password_reset/', { email }); await api.post("users/request_password_reset/", { email });
setResetStep(2); setResetStep(2);
setMessage({ type: 'success', text: 'If an account exists, a reset code has been sent.' }); setMessage({
} catch (err: any) { type: "success",
setMessage({ type: 'error', text: 'An error occurred. Please try again later.' }); text: "If an account exists, a reset code has been sent.",
} });
} catch (err: any) {
setMessage({
type: "error",
text: "An error occurred. Please try again later.",
});
}
}; };
const handleCompleteReset = async (e: React.FormEvent) => { const handleCompleteReset = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setMessage(null); setMessage(null);
try { try {
await api.post('users/complete_password_reset/', { email, code: verificationCode, password }); await api.post("users/complete_password_reset/", {
setMessage({ type: 'success', text: 'Password reset successful! You can now login.' }); email,
setIsResetting(false); code: verificationCode,
setResetStep(1); password,
setTab(0); });
setPassword(''); setMessage({
setVerificationCode(''); type: "success",
} catch (err: any) { text: "Password reset successful! You can now login.",
setMessage({ type: 'error', text: err.response?.data?.error || 'Reset failed' }); });
} setIsResetting(false);
setResetStep(1);
setTab(0);
setPassword("");
setVerificationCode("");
} catch (err: any) {
setMessage({
type: "error",
text: err.response?.data?.error || "Reset failed",
});
}
}; };
const renderContent = () => { const renderContent = () => {
if (isVerifying) { if (isVerifying) {
return (
<form onSubmit={handleVerify}>
<Typography variant="h6" gutterBottom>{t('auth.verifyTitle')}</Typography>
<Typography variant="body2" sx={{ mb: 2 }} dangerouslySetInnerHTML={{ __html: t('auth.verifyDesc', { username }) }} />
<TextField
fullWidth
label="Verification Code"
margin="normal"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
autoFocus
/>
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
Verify
</Button>
<Button
fullWidth
variant={showResend ? "contained" : "outlined"}
color={showResend ? "secondary" : "primary"}
onClick={handleResendCode}
sx={{ mt: 2, textTransform: 'none' }}
>
{t('auth.resendButton')}
</Button>
<Button fullWidth onClick={() => { setIsVerifying(false); setMessage(null); }} sx={{ mt: 1 }}>
{t('auth.backToAuth')}
</Button>
</form>
);
}
if (isResetting) {
return (
<form onSubmit={resetStep === 1 ? handleRequestReset : handleCompleteReset}>
<Typography variant="h6" gutterBottom>Reset Password</Typography>
<TextField
fullWidth
label="Email"
margin="normal"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={resetStep === 2}
/>
{resetStep === 2 && (
<>
<TextField
fullWidth
label="Reset Code"
margin="normal"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
/>
<TextField
fullWidth
label="New Password"
type="password"
margin="normal"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</>
)}
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
{resetStep === 1 ? 'Send Reset Code' : 'Reset Password'}
</Button>
<Button fullWidth onClick={() => { setIsResetting(false); setResetStep(1); setMessage(null); }} sx={{ mt: 1 }}>
Back to Login
</Button>
</form>
);
}
return ( return (
<> <form onSubmit={handleVerify}>
<Tabs value={tab} onChange={(_, v) => { setTab(v); setMessage(null); }} sx={{ mb: 2 }}> <Typography variant="h6" gutterBottom>
<Tab label={t('auth.loginTitle')} /> {t("auth.verifyTitle")}
<Tab label={t('auth.signupTitle')} /> </Typography>
</Tabs> <Typography
variant="body2"
sx={{ mb: 2 }}
dangerouslySetInnerHTML={{
__html: t("auth.verifyDesc", { username }),
}}
/>
<TextField
fullWidth
label="Verification Code"
margin="normal"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
autoFocus
/>
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
Verify
</Button>
{tab === 0 ? ( <Button
<form onSubmit={handleLogin}> fullWidth
<TextField variant={showResend ? "contained" : "outlined"}
fullWidth color={showResend ? "secondary" : "primary"}
label="Username" onClick={handleResendCode}
margin="normal" sx={{ mt: 2, textTransform: "none" }}
value={username} >
onChange={(e) => setUsername(e.target.value)} {t("auth.resendButton")}
/> </Button>
<TextField
fullWidth <Button
label="Password" fullWidth
type="password" onClick={() => {
margin="normal" setIsVerifying(false);
value={password} setMessage(null);
onChange={(e) => setPassword(e.target.value)} }}
/> sx={{ mt: 1 }}
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}> >
{t('auth.loginTitle')} {t("auth.backToAuth")}
</Button> </Button>
<Button fullWidth onClick={() => { setIsResetting(true); setMessage(null); }} sx={{ mt: 1, textTransform: 'none' }} size="small"> </form>
{t('auth.forgotPassword')}
</Button>
</form>
) : (
<form onSubmit={handleSignup}>
<TextField
fullWidth
label="Username"
margin="normal"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<TextField
fullWidth
label="Email"
type="email"
margin="normal"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<TextField
fullWidth
label="Password"
type="password"
margin="normal"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
{t('auth.signupTitle')}
</Button>
</form>
)}
</>
); );
}
if (isResetting) {
return (
<form
onSubmit={resetStep === 1 ? handleRequestReset : handleCompleteReset}
>
<Typography variant="h6" gutterBottom>
Reset Password
</Typography>
<TextField
fullWidth
label="Email"
margin="normal"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={resetStep === 2}
/>
{resetStep === 2 && (
<>
<TextField
fullWidth
label="Reset Code"
margin="normal"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
/>
<TextField
fullWidth
label="New Password"
type="password"
margin="normal"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</>
)}
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
{resetStep === 1 ? "Send Reset Code" : "Reset Password"}
</Button>
<Button
fullWidth
onClick={() => {
setIsResetting(false);
setResetStep(1);
setMessage(null);
}}
sx={{ mt: 1 }}
>
Back to Login
</Button>
</form>
);
}
return (
<>
<Tabs
value={tab}
onChange={(_, v) => {
setTab(v);
setMessage(null);
}}
sx={{ mb: 2 }}
>
<Tab label={t("auth.loginTitle")} />
<Tab label={t("auth.signupTitle")} />
</Tabs>
{tab === 0 ? (
<form onSubmit={handleLogin}>
<TextField
fullWidth
label="Username"
margin="normal"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<TextField
fullWidth
label="Password"
type="password"
margin="normal"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
{t("auth.loginTitle")}
</Button>
<Button
fullWidth
onClick={() => {
setIsResetting(true);
setMessage(null);
}}
sx={{ mt: 1, textTransform: "none" }}
size="small"
>
{t("auth.forgotPassword")}
</Button>
</form>
) : (
<form onSubmit={handleSignup}>
<TextField
fullWidth
label="Username"
margin="normal"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<TextField
fullWidth
label="Email"
type="email"
margin="normal"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<TextField
fullWidth
label="Password"
type="password"
margin="normal"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
{t("auth.signupTitle")}
</Button>
</form>
)}
</>
);
}; };
return ( return (
@@ -258,16 +344,22 @@ const Login: React.FC<LoginProps> = () => {
component={Link} component={Link}
to="/" to="/"
startIcon={<ArrowBackIcon />} startIcon={<ArrowBackIcon />}
sx={{ mb: 4, textTransform: 'none', borderRadius: 2 }} sx={{ mb: 4, textTransform: "none", borderRadius: 2 }}
> >
Back to Home Back to Home
</Button> </Button>
<Paper sx={{ <Paper
sx={{
p: 3, p: 3,
'& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 } "& .MuiInputLabel-root": { bgcolor: "background.paper", px: 0.5 },
}}> }}
{message && <Alert severity={message.type} sx={{ mb: 2 }}>{message.text}</Alert>} >
{message && (
<Alert severity={message.type} sx={{ mb: 2 }}>
{message.text}
</Alert>
)}
{renderContent()} {renderContent()}
</Paper> </Paper>
</Container> </Container>
File diff suppressed because it is too large. Load diff
+170 -82
View File
@@ -1,105 +1,193 @@
import React from 'react'; import React from "react";
import { Typography, Box, Container, Paper, Button, ToggleButton, ToggleButtonGroup, useMediaQuery } from '@mui/material'; import {
import { Link } from 'react-router-dom'; Typography,
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; Box,
import { useTitle } from '../hooks/useTitle'; Container,
import { useTranslation } from 'react-i18next'; Paper,
Button,
ToggleButton,
ToggleButtonGroup,
useMediaQuery,
} from "@mui/material";
import { Link } from "react-router-dom";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { useTitle } from "../hooks/useTitle";
import { useTranslation } from "react-i18next";
const Privacy: React.FC = () => { const Privacy: React.FC = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language.startsWith('de') ? 'de' : 'en'; const lang = i18n.language.startsWith("de") ? "de" : "en";
useTitle(t('privacy.title')); useTitle(t("privacy.title"));
const isMobile = useMediaQuery('(max-width:600px)'); const isMobile = useMediaQuery("(max-width:600px)");
const handleLangChange = (_: any, newLang: string | null) => { const handleLangChange = (_: any, newLang: string | null) => {
if (newLang) { if (newLang) {
i18n.changeLanguage(newLang); i18n.changeLanguage(newLang);
} }
}; };
return ( return (
<Container maxWidth="md" sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}> <Container
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4, flexWrap: 'wrap', gap: 2 }}> maxWidth="md"
<Button sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}
variant="outlined" >
component={Link} <Box
to="/" sx={{
startIcon={<ArrowBackIcon />} display: "flex",
sx={{ textTransform: 'none', borderRadius: 2 }} justifyContent: "space-between",
> alignItems: "center",
{t('common.back')} mb: 4,
</Button> flexWrap: "wrap",
gap: 2,
}}
>
<Button
variant="outlined"
component={Link}
to="/"
startIcon={<ArrowBackIcon />}
sx={{ textTransform: "none", borderRadius: 2 }}
>
{t("common.back")}
</Button>
<ToggleButtonGroup <ToggleButtonGroup
value={lang} value={lang}
exclusive exclusive
onChange={handleLangChange} onChange={handleLangChange}
size="small" size="small"
> >
<ToggleButton value="de">Deutsch</ToggleButton> <ToggleButton value="de">Deutsch</ToggleButton>
<ToggleButton value="en">English</ToggleButton> <ToggleButton value="en">English</ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
</Box> </Box>
<Paper sx={{ p: { xs: 2.5, sm: 4, md: 6 }, borderRadius: 4, overflow: 'hidden' }}> <Paper
<Typography variant={isMobile ? "h4" : "h3"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('privacy.title')}</Typography> sx={{
p: { xs: 2.5, sm: 4, md: 6 },
borderRadius: 4,
overflow: "hidden",
}}
>
<Typography
variant={isMobile ? "h4" : "h3"}
gutterBottom
sx={{ fontWeight: "bold" }}
>
{t("privacy.title")}
</Typography>
<Typography variant="body1" paragraph sx={{ lineHeight: 1.7 }}> <Typography variant="body1" paragraph sx={{ lineHeight: 1.7 }}>
{t('privacy.intro')} {t("privacy.intro")}
</Typography> </Typography>
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>{t('privacy.controllerTitle')}</Typography> <Typography
variant={isMobile ? "h6" : "h5"}
gutterBottom
sx={{ mt: 4, fontWeight: "bold" }}
>
{t("privacy.controllerTitle")}
</Typography>
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}> <Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
{t('privacy.controllerDesc')}<br /> {t("privacy.controllerDesc")}
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}<br /> <br />
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street]'}<br /> {import.meta.env.VITE_IMPRESSUM_NAME || "[Your Name]"}
{import.meta.env.VITE_IMPRESSUM_CITY || '[Your City]'} <br />
{import.meta.env.VITE_IMPRESSUM_STREET || "[Your Street]"}
<br />
{import.meta.env.VITE_IMPRESSUM_CITY || "[Your City]"}
</Typography> </Typography>
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>{t('privacy.collectionTitle')}</Typography> <Typography
variant={isMobile ? "h6" : "h5"}
<Box sx={{ mb: 2 }}> gutterBottom
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.regTitle')}</Typography> sx={{ mt: 4, fontWeight: "bold" }}
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.regDesc')}</Typography> >
</Box> {t("privacy.collectionTitle")}
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.ipTitle')}</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.ipDesc')}</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.profileTitle')}</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.profileDesc')}</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.socialTitle')}</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.socialDesc')}</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.supportTitle')}</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.supportDesc')}</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.personalizationTitle')}</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.personalizationDesc')}</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.deletionTitle')}</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.deletionDesc')}</Typography>
</Box>
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>{t('privacy.transferTitle')}</Typography>
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
{t('privacy.transferDesc')}
</Typography> </Typography>
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>{t('privacy.rightsTitle')}</Typography> <Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
{t("privacy.regTitle")}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
{t("privacy.regDesc")}
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
{t("privacy.ipTitle")}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
{t("privacy.ipDesc")}
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
{t("privacy.profileTitle")}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
{t("privacy.profileDesc")}
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
{t("privacy.socialTitle")}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
{t("privacy.socialDesc")}
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
{t("privacy.supportTitle")}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
{t("privacy.supportDesc")}
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
{t("privacy.personalizationTitle")}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
{t("privacy.personalizationDesc")}
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
{t("privacy.deletionTitle")}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
{t("privacy.deletionDesc")}
</Typography>
</Box>
<Typography
variant={isMobile ? "h6" : "h5"}
gutterBottom
sx={{ mt: 4, fontWeight: "bold" }}
>
{t("privacy.transferTitle")}
</Typography>
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}> <Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
{t('privacy.rightsDesc')} {import.meta.env.VITE_IMPRESSUM_EMAIL} {t("privacy.transferDesc")}
</Typography>
<Typography
variant={isMobile ? "h6" : "h5"}
gutterBottom
sx={{ mt: 4, fontWeight: "bold" }}
>
{t("privacy.rightsTitle")}
</Typography>
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
{t("privacy.rightsDesc")} {import.meta.env.VITE_IMPRESSUM_EMAIL}
</Typography> </Typography>
</Paper> </Paper>
</Container> </Container>
File diff suppressed because it is too large. Load diff
+448 -294
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { import {
Typography, Typography,
Box, Box,
@@ -14,53 +14,56 @@ import {
MenuItem, MenuItem,
ListSubheader, ListSubheader,
Divider, Divider,
Grid Grid,
} from '@mui/material'; } from "@mui/material";
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import api from '../api'; import api from "../api";
import { useTranslation } from 'react-i18next'; import { useTranslation } from "react-i18next";
import { useTitle } from '../hooks/useTitle'; import { useTitle } from "../hooks/useTitle";
import type { CatppuccinTheme } from '../theme'; import type { CatppuccinTheme } from "../theme";
interface SettingsProps { interface SettingsProps {
themeName: CatppuccinTheme; themeName: CatppuccinTheme;
onThemeChange: (newTheme: CatppuccinTheme) => void; onThemeChange: (newTheme: CatppuccinTheme) => void;
} }
const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => { const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
useTitle(t('nav.settings')); useTitle(t("nav.settings"));
const [username, setUsername] = useState(''); const [username, setUsername] = useState("");
const [email, setEmail] = useState(''); const [email, setEmail] = useState("");
const [currentEmail, setCurrentEmail] = useState(''); const [currentEmail, setCurrentEmail] = useState("");
const [avatar, setAvatar] = useState<string | null>(null); const [avatar, setAvatar] = useState<string | null>(null);
const [language, setLanguage] = useState(i18n.language.split('-')[0]); const [language, setLanguage] = useState(i18n.language.split("-")[0]);
const [confirmationCode, setConfirmationCode] = useState(''); const [confirmationCode, setConfirmationCode] = useState("");
const [pendingConfirmation, setPendingConfirmation] = useState(false); const [pendingConfirmation, setPendingConfirmation] = useState(false);
const [oldPassword, setOldPassword] = useState(''); const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState("");
const [deletionCode, setDeletionCode] = useState(''); const [deletionCode, setDeletionCode] = useState("");
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null); const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const [banners, setBanners] = useState<any[]>([]); const [banners, setBanners] = useState<any[]>([]);
const [selectedBannerId, setSelectedBannerId] = useState<number | string>(""); const [selectedBannerId, setSelectedBannerId] = useState<number | string>("");
const handleGoBack = () => { const handleGoBack = () => {
if (window.history.length > 1) { if (window.history.length > 1) {
navigate(-1); navigate(-1);
} else { } else {
navigate('/'); navigate("/");
} }
}; };
useEffect(() => { useEffect(() => {
const fetchUser = async () => { const fetchUser = async () => {
try { try {
const [userRes, bannersRes] = await Promise.all([ const [userRes, bannersRes] = await Promise.all([
api.get('users/me/'), api.get("users/me/"),
api.get('banners/') api.get("banners/"),
]); ]);
setUsername(userRes.data.username); setUsername(userRes.data.username);
@@ -70,11 +73,13 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
setSelectedBannerId(userRes.data.selected_banner || ""); setSelectedBannerId(userRes.data.selected_banner || "");
if (userRes.data.language) { if (userRes.data.language) {
setLanguage(userRes.data.language); setLanguage(userRes.data.language);
} }
// Handle both paginated and non-paginated // Handle both paginated and non-paginated
const bannersData = Array.isArray(bannersRes.data) ? bannersRes.data : (bannersRes.data.results || []); const bannersData = Array.isArray(bannersRes.data)
? bannersRes.data
: bannersRes.data.results || [];
setBanners(bannersData); setBanners(bannersData);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -84,99 +89,120 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
}, []); }, []);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
if (file.size > 2 * 1024 * 1024) { if (file.size > 2 * 1024 * 1024) {
setMessage({ type: 'error', text: 'File size exceeds 2MB limit' }); setMessage({ type: "error", text: "File size exceeds 2MB limit" });
return; return;
} }
const formData = new FormData(); const formData = new FormData();
formData.append('avatar', file); formData.append("avatar", file);
try { try {
const res = await api.post('users/update_avatar/', formData, { const res = await api.post("users/update_avatar/", formData, {
headers: { 'Content-Type': 'multipart/form-data' } headers: { "Content-Type": "multipart/form-data" },
}); });
setAvatar(res.data.avatar); setAvatar(res.data.avatar);
setMessage({ type: 'success', text: t('settings.avatarSuccess') }); setMessage({ type: "success", text: t("settings.avatarSuccess") });
} catch (err: any) { } catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to upload avatar' }); setMessage({
} type: "error",
text: err.response?.data?.error || "Failed to upload avatar",
});
}
}; };
const handleLanguageChange = async (newLang: string) => { const handleLanguageChange = async (newLang: string) => {
setLanguage(newLang); setLanguage(newLang);
i18n.changeLanguage(newLang); i18n.changeLanguage(newLang);
try { try {
await api.patch('users/update_preferences/', { language: newLang }); await api.patch("users/update_preferences/", { language: newLang });
} catch (err) { } catch (err) {
console.error('Failed to update language on server'); console.error("Failed to update language on server");
} }
}; };
const handleUpdateProfile = async (e: React.FormEvent) => { const handleUpdateProfile = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
const res = await api.patch('users/update_profile/', { username, email }); const res = await api.patch("users/update_profile/", { username, email });
if (res.data.message) { if (res.data.message) {
setPendingConfirmation(true); setPendingConfirmation(true);
setMessage({ type: 'success', text: `${t('settings.updateSuccess')} ${res.data.message}` }); setMessage({
type: "success",
text: `${t("settings.updateSuccess")} ${res.data.message}`,
});
} else { } else {
setMessage({ type: 'success', text: t('settings.updateSuccess') }); setMessage({ type: "success", text: t("settings.updateSuccess") });
setCurrentEmail(email); setCurrentEmail(email);
} }
} catch (err: any) { } catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to update profile' }); setMessage({
type: "error",
text: err.response?.data?.error || "Failed to update profile",
});
} }
}; };
const handleConfirmEmail = async (e: React.FormEvent) => { const handleConfirmEmail = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
const res = await api.post('users/confirm_email/', { code: confirmationCode }); const res = await api.post("users/confirm_email/", {
setMessage({ type: 'success', text: t('settings.updateSuccess') }); code: confirmationCode,
});
setMessage({ type: "success", text: t("settings.updateSuccess") });
setCurrentEmail(res.data.email); setCurrentEmail(res.data.email);
setPendingConfirmation(false); setPendingConfirmation(false);
setConfirmationCode(''); setConfirmationCode("");
} catch (err: any) { } catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Confirmation failed' }); setMessage({
type: "error",
text: err.response?.data?.error || "Confirmation failed",
});
} }
}; };
const handleChangePassword = async (e: React.FormEvent) => { const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
await api.post('users/change_password/', { old_password: oldPassword, new_password: newPassword }); await api.post("users/change_password/", {
setMessage({ type: 'success', text: t('settings.passwordSuccess') }); old_password: oldPassword,
setOldPassword(''); new_password: newPassword,
setNewPassword(''); });
setMessage({ type: "success", text: t("settings.passwordSuccess") });
setOldPassword("");
setNewPassword("");
} catch (err: any) { } catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to change password' }); setMessage({
type: "error",
text: err.response?.data?.error || "Failed to change password",
});
} }
}; };
const handleRequestDeletion = async () => { const handleRequestDeletion = async () => {
try { try {
await api.post('users/request_account_deletion/'); await api.post("users/request_account_deletion/");
setIsDeleting(true); setIsDeleting(true);
setMessage({ type: 'success', text: t('settings.codeSent') }); setMessage({ type: "success", text: t("settings.codeSent") });
} catch (err: any) { } catch (err: any) {
setMessage({ type: 'error', text: 'Failed to request deletion.' }); setMessage({ type: "error", text: "Failed to request deletion." });
} }
}; };
const handleConfirmDeletion = async (e: React.FormEvent) => { const handleConfirmDeletion = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!window.confirm('Are you absolutely sure? This cannot be undone.')) return; if (!window.confirm("Are you absolutely sure? This cannot be undone."))
try { return;
await api.post('users/confirm_account_deletion/', { code: deletionCode }); try {
localStorage.removeItem('token'); await api.post("users/confirm_account_deletion/", { code: deletionCode });
window.location.href = '/'; localStorage.removeItem("token");
} catch (err: any) { window.location.href = "/";
setMessage({ type: 'error', text: 'Invalid code. Deletion failed.' }); } catch (err: any) {
} setMessage({ type: "error", text: "Invalid code. Deletion failed." });
}
}; };
return ( return (
@@ -186,238 +212,366 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
onClick={handleGoBack} onClick={handleGoBack}
startIcon={<ArrowBackIcon />} startIcon={<ArrowBackIcon />}
sx={{ sx={{
mb: 4, mb: 4,
borderRadius: 2, borderRadius: 2,
textTransform: 'none', textTransform: "none",
fontWeight: 'bold', fontWeight: "bold",
color: 'text.secondary', color: "text.secondary",
borderColor: 'divider', borderColor: "divider",
'&:hover': { "&:hover": {
borderColor: 'primary.main', borderColor: "primary.main",
color: 'primary.main', color: "primary.main",
bgcolor: 'transparent' bgcolor: "transparent",
} },
}} }}
> >
{window.history.length > 1 ? t('common.back') : t('common.backToHome')} {window.history.length > 1 ? t("common.back") : t("common.backToHome")}
</Button> </Button>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 6 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 3, mb: 6 }}>
<Avatar src={avatar || undefined} sx={{ width: 80, height: 80, border: '3px solid', borderColor: 'primary.main' }}> <Avatar
{!avatar && username.charAt(0).toUpperCase()} src={avatar || undefined}
</Avatar> sx={{
<Box> width: 80,
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>{username}</Typography> height: 80,
<Typography variant="body2" color="text.secondary">{t('settings.subtitle')}</Typography> border: "3px solid",
</Box> borderColor: "primary.main",
}}
>
{!avatar && username.charAt(0).toUpperCase()}
</Avatar>
<Box>
<Typography variant="h4" sx={{ fontWeight: "bold" }}>
{username}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("settings.subtitle")}
</Typography>
</Box>
</Box> </Box>
{message && ( {message && (
<Alert severity={message.type} sx={{ mb: 4 }} onClose={() => setMessage(null)}> <Alert
severity={message.type}
sx={{ mb: 4 }}
onClose={() => setMessage(null)}
>
{message.text} {message.text}
</Alert> </Alert>
)} )}
<Grid container spacing={4}> <Grid container spacing={4}>
{/* Left Column: Visuals & Language */} {/* Left Column: Visuals & Language */}
<Grid size={{ xs: 12, lg: 5 }}> <Grid size={{ xs: 12, lg: 5 }}>
<Paper sx={{ p: 3, mb: 4, borderRadius: 3, '& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 } }}> <Paper
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('settings.appearanceTitle')}</Typography> sx={{
<FormControl fullWidth margin="normal"> p: 3,
<InputLabel>{t('settings.themeLabel')}</InputLabel> mb: 4,
<Select borderRadius: 3,
value={themeName} "& .MuiInputLabel-root": { bgcolor: "background.paper", px: 0.5 },
label={t('settings.themeLabel')} }}
onChange={(e) => onThemeChange(e.target.value as CatppuccinTheme)} >
> <Typography variant="h6" gutterBottom sx={{ fontWeight: "bold" }}>
<ListSubheader>Holy Archive</ListSubheader> {t("settings.appearanceTitle")}
<MenuItem value="holy_light">Holy Light</MenuItem> </Typography>
<MenuItem value="holy_dark">Holy Dark</MenuItem> <FormControl fullWidth margin="normal">
<InputLabel>{t("settings.themeLabel")}</InputLabel>
<Select
value={themeName}
label={t("settings.themeLabel")}
onChange={(e) =>
onThemeChange(e.target.value as CatppuccinTheme)
}
>
<ListSubheader>Holy Archive</ListSubheader>
<MenuItem value="holy_light">Holy Light</MenuItem>
<MenuItem value="holy_dark">Holy Dark</MenuItem>
<ListSubheader>Light Themes</ListSubheader> <ListSubheader>Light Themes</ListSubheader>
<MenuItem value="latte">Catppuccin Latte</MenuItem> <MenuItem value="latte">Catppuccin Latte</MenuItem>
<MenuItem value="pink_pastel">Pink Pastel</MenuItem> <MenuItem value="pink_pastel">Pink Pastel</MenuItem>
<MenuItem value="mint_pastel">Mint Pastel</MenuItem> <MenuItem value="mint_pastel">Mint Pastel</MenuItem>
<MenuItem value="lavender_pastel">Lavender Pastel</MenuItem> <MenuItem value="lavender_pastel">Lavender Pastel</MenuItem>
<MenuItem value="t0p_sai">Scaled and Icy (T0P)</MenuItem> <MenuItem value="t0p_sai">Scaled and Icy (T0P)</MenuItem>
<Divider /> <Divider />
<ListSubheader>Dark Themes</ListSubheader> <ListSubheader>Dark Themes</ListSubheader>
<MenuItem value="mocha">Catppuccin Mocha</MenuItem> <MenuItem value="mocha">Catppuccin Mocha</MenuItem>
<MenuItem value="frappe">Catppuccin Frappé</MenuItem> <MenuItem value="frappe">Catppuccin Frappé</MenuItem>
<MenuItem value="macchiato">Catppuccin Macchiato</MenuItem> <MenuItem value="macchiato">Catppuccin Macchiato</MenuItem>
<MenuItem value="dracula">Dracula</MenuItem> <MenuItem value="dracula">Dracula</MenuItem>
<MenuItem value="nord">Nordic Frost</MenuItem> <MenuItem value="nord">Nordic Frost</MenuItem>
<MenuItem value="gruvbox">Gruvbox Retro</MenuItem> <MenuItem value="gruvbox">Gruvbox Retro</MenuItem>
<MenuItem value="oceanic">Oceanic Deep</MenuItem> <MenuItem value="oceanic">Oceanic Deep</MenuItem>
<MenuItem value="t0p_trench">Trench (T0P)</MenuItem> <MenuItem value="t0p_trench">Trench (T0P)</MenuItem>
<MenuItem value="t0p_blurryface">Blurryface (T0P)</MenuItem> <MenuItem value="t0p_blurryface">Blurryface (T0P)</MenuItem>
<MenuItem value="t0p_clancy">Clancy (T0P)</MenuItem> <MenuItem value="t0p_clancy">Clancy (T0P)</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
<FormControl fullWidth margin="normal"> <FormControl fullWidth margin="normal">
<InputLabel>{t('settings.bannerLabel')}</InputLabel> <InputLabel>{t("settings.bannerLabel")}</InputLabel>
<Select <Select
value={selectedBannerId} value={selectedBannerId}
label={t('settings.bannerLabel')} label={t("settings.bannerLabel")}
onChange={async (e) => { onChange={async (e) => {
const newId = e.target.value; const newId = e.target.value;
setSelectedBannerId(newId); setSelectedBannerId(newId);
try { try {
await api.patch('users/update_preferences/', { selected_banner: newId }); await api.patch("users/update_preferences/", {
} catch (err) { selected_banner: newId,
console.error('Failed to update banner preference'); });
} } catch (err) {
}} console.error("Failed to update banner preference");
> }
<MenuItem value="">{t('settings.bannerDefault')}</MenuItem> }}
{banners.map(b => ( >
<MenuItem key={b.id} value={b.id}>{b.name}</MenuItem> <MenuItem value="">{t("settings.bannerDefault")}</MenuItem>
))} {banners.map((b) => (
</Select> <MenuItem key={b.id} value={b.id}>
</FormControl> {b.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth margin="normal"> <FormControl fullWidth margin="normal">
<InputLabel>{t('settings.langLabel')}</InputLabel> <InputLabel>{t("settings.langLabel")}</InputLabel>
<Select <Select
value={language} value={language}
label={t('settings.langLabel')} label={t("settings.langLabel")}
onChange={(e) => handleLanguageChange(e.target.value)} onChange={(e) => handleLanguageChange(e.target.value)}
> >
<MenuItem value="en">English</MenuItem> <MenuItem value="en">English</MenuItem>
<MenuItem value="de">Deutsch</MenuItem> <MenuItem value="de">Deutsch</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
</Paper> </Paper>
<Paper sx={{ p: 3, borderRadius: 3 }}> <Paper sx={{ p: 3, borderRadius: 3 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('settings.avatarTitle')}</Typography> <Typography variant="h6" gutterBottom sx={{ fontWeight: "bold" }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, my: 2 }}> {t("settings.avatarTitle")}
<Avatar src={avatar || undefined} sx={{ width: 100, height: 100 }}> </Typography>
{!avatar && username.charAt(0).toUpperCase()} <Box sx={{ display: "flex", alignItems: "center", gap: 3, my: 2 }}>
</Avatar> <Avatar
<Box> src={avatar || undefined}
<Button variant="outlined" component="label" size="small"> sx={{ width: 100, height: 100 }}
{t('settings.avatarButton')} >
<input type="file" hidden accept="image/*" onChange={handleAvatarUpload} /> {!avatar && username.charAt(0).toUpperCase()}
</Button> </Avatar>
<Typography variant="caption" display="block" sx={{ mt: 1 }} color="text.secondary"> <Box>
{t('settings.avatarHint')} <Button variant="outlined" component="label" size="small">
</Typography> {t("settings.avatarButton")}
</Box> <input
</Box> type="file"
</Paper> hidden
</Grid> accept="image/*"
onChange={handleAvatarUpload}
{/* Right Column: Account Info & Security */}
<Grid size={{ xs: 12, lg: 7 }}>
<Paper sx={{ p: 3, mb: 4, borderRadius: 3, '& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 } }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('settings.infoTitle')}</Typography>
<form onSubmit={handleUpdateProfile}>
<TextField
fullWidth
label={t('settings.usernameLabel')}
margin="normal"
value={username}
onChange={(e) => setUsername(e.target.value)}
InputLabelProps={{ shrink: true }}
/> />
<TextField </Button>
fullWidth <Typography
label={t('settings.emailLabel')} variant="caption"
margin="normal" display="block"
value={email} sx={{ mt: 1 }}
onChange={(e) => setEmail(e.target.value)} color="text.secondary"
helperText={email !== currentEmail ? t('settings.emailHint') : ""} >
InputLabelProps={{ shrink: true }} {t("settings.avatarHint")}
/>
<Button variant="contained" type="submit" sx={{ mt: 2, borderRadius: 2 }}>
{t('settings.updateButton')}
</Button>
</form>
{pendingConfirmation && (
<Box sx={{ mt: 4, pt: 3, borderTop: '1px solid', borderColor: 'divider' }}>
<Typography variant="h6" color="primary" gutterBottom>{t('settings.confirmEmailTitle')}</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>{t('settings.confirmEmailHint', { email })}</Typography>
<form onSubmit={handleConfirmEmail}>
<TextField
fullWidth
label={t('settings.deletionCodeLabel')}
margin="normal"
value={confirmationCode}
onChange={(e) => setConfirmationCode(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<Button variant="contained" type="submit" sx={{ mt: 1, borderRadius: 2 }} color="secondary">
{t('settings.confirmButton')}
</Button>
</form>
</Box>
)}
</Paper>
<Paper sx={{ p: 3, mb: 4, borderRadius: 3, '& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 } }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('settings.passwordTitle')}</Typography>
<form onSubmit={handleChangePassword}>
<TextField
fullWidth
label={t('settings.oldPasswordLabel')}
type="password"
margin="normal"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<TextField
fullWidth
label={t('settings.newPasswordLabel')}
type="password"
margin="normal"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<Button variant="contained" type="submit" sx={{ mt: 2, borderRadius: 2 }} color="warning">
{t('settings.passwordButton')}
</Button>
</form>
</Paper>
<Paper sx={{ p: 3, borderRadius: 3, border: '1px solid', borderColor: 'error.main', bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255,0,0,0.05)' : 'rgba(255,0,0,0.02)', '& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 } }}>
<Typography variant="h6" gutterBottom color="error" sx={{ fontWeight: 'bold' }}>{t('settings.dangerTitle')}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t('settings.dangerDesc')}
</Typography> </Typography>
</Box>
</Box>
</Paper>
</Grid>
{!isDeleting ? ( {/* Right Column: Account Info & Security */}
<Button variant="outlined" color="error" onClick={handleRequestDeletion} sx={{ borderRadius: 2 }}> <Grid size={{ xs: 12, lg: 7 }}>
{t('settings.deleteButton')} <Paper
</Button> sx={{
) : ( p: 3,
<form onSubmit={handleConfirmDeletion}> mb: 4,
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>{t('settings.deletionCodeHint')}</Typography> borderRadius: 3,
<TextField "& .MuiInputLabel-root": { bgcolor: "background.paper", px: 0.5 },
fullWidth }}
size="small" >
label={t('settings.deletionCodeLabel')} <Typography variant="h6" gutterBottom sx={{ fontWeight: "bold" }}>
value={deletionCode} {t("settings.infoTitle")}
onChange={(e) => setDeletionCode(e.target.value)} </Typography>
sx={{ mb: 2 }} <form onSubmit={handleUpdateProfile}>
InputLabelProps={{ shrink: true }} <TextField
/> fullWidth
<Box sx={{ display: 'flex', gap: 1 }}> label={t("settings.usernameLabel")}
<Button variant="contained" color="error" type="submit" disabled={!deletionCode} sx={{ borderRadius: 2 }}> margin="normal"
{t('settings.confirmDeleteButton')} value={username}
</Button> onChange={(e) => setUsername(e.target.value)}
<Button onClick={() => setIsDeleting(false)} sx={{ borderRadius: 2 }}>{t('settings.cancelButton')}</Button> InputLabelProps={{ shrink: true }}
</Box> />
</form> <TextField
)} fullWidth
</Paper> label={t("settings.emailLabel")}
</Grid> margin="normal"
value={email}
onChange={(e) => setEmail(e.target.value)}
helperText={
email !== currentEmail ? t("settings.emailHint") : ""
}
InputLabelProps={{ shrink: true }}
/>
<Button
variant="contained"
type="submit"
sx={{ mt: 2, borderRadius: 2 }}
>
{t("settings.updateButton")}
</Button>
</form>
{pendingConfirmation && (
<Box
sx={{
mt: 4,
pt: 3,
borderTop: "1px solid",
borderColor: "divider",
}}
>
<Typography variant="h6" color="primary" gutterBottom>
{t("settings.confirmEmailTitle")}
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
{t("settings.confirmEmailHint", { email })}
</Typography>
<form onSubmit={handleConfirmEmail}>
<TextField
fullWidth
label={t("settings.deletionCodeLabel")}
margin="normal"
value={confirmationCode}
onChange={(e) => setConfirmationCode(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<Button
variant="contained"
type="submit"
sx={{ mt: 1, borderRadius: 2 }}
color="secondary"
>
{t("settings.confirmButton")}
</Button>
</form>
</Box>
)}
</Paper>
<Paper
sx={{
p: 3,
mb: 4,
borderRadius: 3,
"& .MuiInputLabel-root": { bgcolor: "background.paper", px: 0.5 },
}}
>
<Typography variant="h6" gutterBottom sx={{ fontWeight: "bold" }}>
{t("settings.passwordTitle")}
</Typography>
<form onSubmit={handleChangePassword}>
<TextField
fullWidth
label={t("settings.oldPasswordLabel")}
type="password"
margin="normal"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<TextField
fullWidth
label={t("settings.newPasswordLabel")}
type="password"
margin="normal"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<Button
variant="contained"
type="submit"
sx={{ mt: 2, borderRadius: 2 }}
color="warning"
>
{t("settings.passwordButton")}
</Button>
</form>
</Paper>
<Paper
sx={{
p: 3,
borderRadius: 3,
border: "1px solid",
borderColor: "error.main",
bgcolor: (theme) =>
theme.palette.mode === "dark"
? "rgba(255,0,0,0.05)"
: "rgba(255,0,0,0.02)",
"& .MuiInputLabel-root": { bgcolor: "background.paper", px: 0.5 },
}}
>
<Typography
variant="h6"
gutterBottom
color="error"
sx={{ fontWeight: "bold" }}
>
{t("settings.dangerTitle")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t("settings.dangerDesc")}
</Typography>
{!isDeleting ? (
<Button
variant="outlined"
color="error"
onClick={handleRequestDeletion}
sx={{ borderRadius: 2 }}
>
{t("settings.deleteButton")}
</Button>
) : (
<form onSubmit={handleConfirmDeletion}>
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
{t("settings.deletionCodeHint")}
</Typography>
<TextField
fullWidth
size="small"
label={t("settings.deletionCodeLabel")}
value={deletionCode}
onChange={(e) => setDeletionCode(e.target.value)}
sx={{ mb: 2 }}
InputLabelProps={{ shrink: true }}
/>
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="contained"
color="error"
type="submit"
disabled={!deletionCode}
sx={{ borderRadius: 2 }}
>
{t("settings.confirmDeleteButton")}
</Button>
<Button
onClick={() => setIsDeleting(false)}
sx={{ borderRadius: 2 }}
>
{t("settings.cancelButton")}
</Button>
</Box>
</form>
)}
</Paper>
</Grid>
</Grid> </Grid>
</Container> </Container>
); );
+553 -319
View File
@@ -1,346 +1,580 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { import {
Container, Typography, Box, Card, CardContent, Button, Container,
TextField, Stack, Chip, Divider, Collapse, Paper, alpha, CircularProgress, Typography,
IconButton, Avatar, useTheme Box,
} from '@mui/material'; Card,
import SendIcon from '@mui/icons-material/Send'; CardContent,
import AddIcon from '@mui/icons-material/Add'; Button,
import DeleteIcon from '@mui/icons-material/Delete'; TextField,
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; Stack,
import { useTranslation } from 'react-i18next'; Chip,
import { useNavigate } from 'react-router-dom'; Divider,
import api from '../api'; Collapse,
import { useTitle } from '../hooks/useTitle'; Paper,
import { formatDate } from '../utils/date'; alpha,
CircularProgress,
IconButton,
Avatar,
useTheme,
} from "@mui/material";
import SendIcon from "@mui/icons-material/Send";
import AddIcon from "@mui/icons-material/Add";
import DeleteIcon from "@mui/icons-material/Delete";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
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";
interface Message { interface Message {
id: number; id: number;
username: string; username: string;
text: string; text: string;
created_at: string; created_at: string;
is_admin: boolean; is_admin: boolean;
} }
interface Ticket { interface Ticket {
id: number; id: number;
user: number; user: number;
username: string; username: string;
user_email: string; user_email: string;
user_avatar: string | null; user_avatar: string | null;
subject: string; subject: string;
description: string; description: string;
status: 'open' | 'in_progress' | 'resolved' | 'closed'; status: "open" | "in_progress" | "resolved" | "closed";
created_at: string; created_at: string;
messages: Message[]; messages: Message[];
} }
const Support: React.FC = () => { const Support: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
const navigate = useNavigate(); const navigate = useNavigate();
useTitle(t('support.title')); useTitle(t("support.title"));
const [tickets, setTickets] = useState<Ticket[]>([]); const [tickets, setTickets] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
// Create form // Create form
const [subject, setSubject] = useState(''); const [subject, setSubject] = useState("");
const [description, setDescription] = useState(''); const [description, setDescription] = useState("");
// Reply state // Reply state
const [replyText, setReplyText] = useState<{[key: number]: string}>({}); const [replyText, setReplyText] = useState<{ [key: number]: string }>({});
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
const [currentUser, setCurrentUser] = useState<any>(null); const [currentUser, setCurrentUser] = useState<any>(null);
const [unreadTicketIds, setUnreadTicketIds] = useState<Set<number>>(new Set()); const [unreadTicketIds, setUnreadTicketIds] = useState<Set<number>>(
new Set(),
);
const fetchTickets = async () => { const fetchTickets = async () => {
try { try {
const [ticketRes, userRes, notifRes] = await Promise.all([ const [ticketRes, userRes, notifRes] = await Promise.all([
api.get('tickets/'), api.get("tickets/"),
api.get('users/me/'), api.get("users/me/"),
api.get('notifications/') api.get("notifications/"),
]); ]);
setTickets(ticketRes.data.results || ticketRes.data); setTickets(ticketRes.data.results || ticketRes.data);
setCurrentUser(userRes.data); setCurrentUser(userRes.data);
const notifs = notifRes.data.results || notifRes.data; const notifs = notifRes.data.results || notifRes.data;
const unreadIds = new Set<number>( const unreadIds = new Set<number>(
notifs notifs
.filter((n: any) => !n.is_read && n.notification_type.startsWith('ticket') && n.ticket) .filter(
.map((n: any) => n.ticket) (n: any) =>
); !n.is_read &&
setUnreadTicketIds(unreadIds); n.notification_type.startsWith("ticket") &&
} catch (err) { n.ticket,
console.error(err); )
} finally { .map((n: any) => n.ticket),
setLoading(false); );
} setUnreadTicketIds(unreadIds);
}; } catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => { useEffect(() => {
fetchTickets(); fetchTickets();
}, []); }, []);
const handleExpand = async (ticketId: number) => { const handleExpand = async (ticketId: number) => {
if (expandedId === ticketId) { if (expandedId === ticketId) {
setExpandedId(null); setExpandedId(null);
return; return;
} }
setExpandedId(ticketId); setExpandedId(ticketId);
// Mark notifications for this ticket as read // Mark notifications for this ticket as read
if (unreadTicketIds.has(ticketId)) { if (unreadTicketIds.has(ticketId)) {
try { try {
// We'd need an endpoint to mark all for a specific ticket, // We'd need an endpoint to mark all for a specific ticket,
// or just rely on the user clicking the notification. // or just rely on the user clicking the notification.
// For now, let's just clear it from the local state. // For now, let's just clear it from the local state.
const newUnread = new Set(unreadTicketIds); const newUnread = new Set(unreadTicketIds);
newUnread.delete(ticketId); newUnread.delete(ticketId);
setUnreadTicketIds(newUnread); setUnreadTicketIds(newUnread);
} catch (e) {} } catch (e) {}
} }
}; };
const handleCreate = async (e: React.FormEvent) => { const handleCreate = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
await api.post('tickets/', { subject, description }); await api.post("tickets/", { subject, description });
setSubject(''); setSubject("");
setDescription(''); setDescription("");
setShowCreate(false); setShowCreate(false);
fetchTickets(); fetchTickets();
} catch (err) { } catch (err) {
alert('Failed to create ticket'); alert("Failed to create ticket");
} }
}; };
const handleReply = async (ticketId: number) => { const handleReply = async (ticketId: number) => {
const text = replyText[ticketId]; const text = replyText[ticketId];
if (!text) return; if (!text) return;
try { try {
await api.post(`tickets/${ticketId}/add_message/`, { text }); await api.post(`tickets/${ticketId}/add_message/`, { text });
setReplyText({ ...replyText, [ticketId]: '' }); setReplyText({ ...replyText, [ticketId]: "" });
fetchTickets(); fetchTickets();
} catch (err) { } catch (err) {
alert('Failed to send message'); alert("Failed to send message");
} }
}; };
const handleUpdateStatus = async (ticketId: number, status: string) => { const handleUpdateStatus = async (ticketId: number, status: string) => {
try { try {
await api.post(`tickets/${ticketId}/update_status/`, { status }); await api.post(`tickets/${ticketId}/update_status/`, { status });
fetchTickets(); fetchTickets();
} catch (err) { } catch (err) {
alert('Failed to update status'); alert("Failed to update status");
} }
}; };
const handleDeleteTicket = async (ticketId: number) => { const handleDeleteTicket = async (ticketId: number) => {
if (!confirm('Delete this ticket?')) return; if (!confirm("Delete this ticket?")) return;
try { try {
await api.delete(`tickets/${ticketId}/`); await api.delete(`tickets/${ticketId}/`);
fetchTickets(); fetchTickets();
} catch (err) { } catch (err) {
alert('Failed to delete ticket'); alert("Failed to delete ticket");
} }
}; };
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'open': return 'error'; case "open":
case 'in_progress': return 'warning'; return "error";
case 'resolved': return 'success'; case "in_progress":
default: return 'default'; return "warning";
} case "resolved":
}; return "success";
default:
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>; return "default";
}
};
if (loading)
return ( return (
<Container maxWidth="md" sx={{ py: 4 }}> <Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
<Button <CircularProgress />
variant="outlined" </Box>
startIcon={<ArrowBackIcon />}
onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }}
>
{t('common.back')}
</Button>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 4 }}>
<Typography variant="h3" sx={{ fontWeight: 'bold' }}>{t('support.title')}</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setShowCreate(!showCreate)}
sx={{ borderRadius: 2 }}
>
{t('support.createTicket')}
</Button>
</Stack>
<Collapse in={showCreate}>
<Paper sx={{ p: 3, mb: 4, borderRadius: 3, border: '1px solid', borderColor: 'primary.main' }}>
<form onSubmit={handleCreate}>
<TextField
fullWidth label={t('support.subject')} margin="normal"
value={subject} onChange={(e) => setSubject(e.target.value)} required
/>
<TextField
fullWidth multiline rows={4} label={t('support.description')} margin="normal"
value={description} onChange={(e) => setDescription(e.target.value)} required
/>
<Button type="submit" variant="contained" sx={{ mt: 2, borderRadius: 2 }}>
{t('support.createTicket')}
</Button>
</form>
</Paper>
</Collapse>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 'bold' }}>
{currentUser?.is_superuser ? t('admin.users') + ' Tickets' : t('support.myTickets')}
</Typography>
{tickets.length === 0 ? (
<Box sx={{ py: 8, textAlign: 'center', bgcolor: 'action.hover', borderRadius: 4 }}>
<Typography color="text.secondary">{t('support.noTickets')}</Typography>
</Box>
) : (
<Stack spacing={2}>
{tickets.map((ticket: Ticket) => (
<Card key={ticket.id} sx={{
borderRadius: 3,
border: '1px solid',
borderColor: unreadTicketIds.has(ticket.id) ? 'primary.main' : 'divider',
bgcolor: unreadTicketIds.has(ticket.id) ? alpha(theme.palette.primary.main, 0.02) : 'inherit'
}}>
<CardContent sx={{ p: 0 }}>
<Box
onClick={() => handleExpand(ticket.id)}
sx={{ p: 2, cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
{unreadTicketIds.has(ticket.id) && (
<Box sx={{ width: 10, height: 10, bgcolor: 'primary.main', borderRadius: '50%' }} />
)}
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
{ticket.subject}
{currentUser?.is_superuser && (
<Typography component="span" variant="caption" sx={{ ml: 1, color: 'primary.main' }}>
({ticket.username})
</Typography>
)}
</Typography>
<Typography variant="caption" color="text.secondary">{formatDate(ticket.created_at)}</Typography>
</Box>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
{unreadTicketIds.has(ticket.id) && (
<Chip label={t('support.unread')} color="primary" size="small" sx={{ fontWeight: 'bold', height: 20, fontSize: '0.6rem' }} />
)}
<Chip label={t(`support.${ticket.status}`)} color={getStatusColor(ticket.status) as any} size="small" sx={{ fontWeight: 'bold' }} />
{currentUser?.is_superuser && (
<IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); handleDeleteTicket(ticket.id); }}>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</Stack>
</Box>
{currentUser?.is_superuser && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 1, p: 1, borderRadius: 2, bgcolor: alpha('#000', 0.03) }}>
<Avatar src={ticket.user_avatar || undefined} sx={{ width: 24, height: 24, fontSize: '0.7rem' }}>
{ticket.username.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{ticket.username} • <span style={{ opacity: 0.7 }}>{ticket.user_email}</span>
</Typography>
</Box>
<Button
size="small"
onClick={(e) => { e.stopPropagation(); navigate(`/profile/${ticket.username}`); }}
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
>
View Profile
</Button>
<Button
size="small"
onClick={(e) => { e.stopPropagation(); navigate(`/admin-panel/user/${ticket.user}`); }}
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
>
{t('admin.manageUser')}
</Button>
</Box>
)}
</Box>
<Collapse in={expandedId === ticket.id}>
<Divider />
<Box sx={{ p: 2, bgcolor: (theme) => alpha(theme.palette.background.default, 0.5) }}>
{currentUser?.is_superuser && (
<Stack direction="row" spacing={1} sx={{ mb: 2, overflowX: 'auto', pb: 1 }}>
{['open', 'in_progress', 'resolved', 'closed'].map((s) => (
<Button
key={s} size="small" variant={ticket.status === s ? "contained" : "outlined"}
onClick={() => handleUpdateStatus(ticket.id, s)}
color={getStatusColor(s) as any}
sx={{ textTransform: 'none', borderRadius: 4, fontSize: '0.7rem', flexShrink: 0 }}
>
{t(`support.${s}`)}
</Button>
))}
</Stack>
)}
<Typography variant="body2" paragraph sx={{ whiteSpace: 'pre-wrap', mb: 3, bgcolor: 'background.paper', p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
{ticket.description}
</Typography>
<Stack spacing={2} sx={{ mb: 3 }}>
{ticket.messages.map((msg: Message) => (
<Box key={msg.id} sx={{
alignSelf: msg.is_admin ? 'flex-start' : 'flex-end',
maxWidth: '85%',
p: 1.5,
borderRadius: 2,
bgcolor: msg.is_admin ? 'primary.main' : 'action.selected',
color: msg.is_admin ? 'white' : 'inherit',
boxShadow: msg.is_admin ? 2 : 0
}}>
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block' }}>
{msg.username} {msg.is_admin && '(Support)'}
</Typography>
<Typography variant="body2" sx={{ my: 0.5 }}>{msg.text}</Typography>
<Typography variant="caption" sx={{ opacity: 0.7, display: 'block', textAlign: 'right' }}>{formatDate(msg.created_at)}</Typography>
</Box>
))}
</Stack>
{ticket.status !== 'closed' && (
<Stack direction="row" spacing={1}>
<TextField
fullWidth size="small" placeholder={t('community.writeReply')}
value={replyText[ticket.id] || ''}
onChange={(e) => setReplyText({ ...replyText, [ticket.id]: e.target.value })}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleReply(ticket.id)}
/>
<IconButton color="primary" onClick={() => handleReply(ticket.id)} disabled={!replyText[ticket.id]}>
<SendIcon />
</IconButton>
</Stack>
)}
</Box>
</Collapse>
</CardContent>
</Card>
))}
</Stack>
)}
</Container>
); );
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<Button
variant="outlined"
startIcon={<ArrowBackIcon />}
onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }}
>
{t("common.back")}
</Button>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
sx={{ mb: 4 }}
>
<Typography variant="h3" sx={{ fontWeight: "bold" }}>
{t("support.title")}
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setShowCreate(!showCreate)}
sx={{ borderRadius: 2 }}
>
{t("support.createTicket")}
</Button>
</Stack>
<Collapse in={showCreate}>
<Paper
sx={{
p: 3,
mb: 4,
borderRadius: 3,
border: "1px solid",
borderColor: "primary.main",
}}
>
<form onSubmit={handleCreate}>
<TextField
fullWidth
label={t("support.subject")}
margin="normal"
value={subject}
onChange={(e) => setSubject(e.target.value)}
required
/>
<TextField
fullWidth
multiline
rows={4}
label={t("support.description")}
margin="normal"
value={description}
onChange={(e) => setDescription(e.target.value)}
required
/>
<Button
type="submit"
variant="contained"
sx={{ mt: 2, borderRadius: 2 }}
>
{t("support.createTicket")}
</Button>
</form>
</Paper>
</Collapse>
<Typography variant="h5" sx={{ mb: 2, fontWeight: "bold" }}>
{currentUser?.is_superuser
? t("admin.users") + " Tickets"
: t("support.myTickets")}
</Typography>
{tickets.length === 0 ? (
<Box
sx={{
py: 8,
textAlign: "center",
bgcolor: "action.hover",
borderRadius: 4,
}}
>
<Typography color="text.secondary">
{t("support.noTickets")}
</Typography>
</Box>
) : (
<Stack spacing={2}>
{tickets.map((ticket: Ticket) => (
<Card
key={ticket.id}
sx={{
borderRadius: 3,
border: "1px solid",
borderColor: unreadTicketIds.has(ticket.id)
? "primary.main"
: "divider",
bgcolor: unreadTicketIds.has(ticket.id)
? alpha(theme.palette.primary.main, 0.02)
: "inherit",
}}
>
<CardContent sx={{ p: 0 }}>
<Box
onClick={() => handleExpand(ticket.id)}
sx={{
p: 2,
cursor: "pointer",
"&:hover": { bgcolor: "action.hover" },
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
mb: 1,
}}
>
<Box
sx={{ display: "flex", alignItems: "center", gap: 1.5 }}
>
{unreadTicketIds.has(ticket.id) && (
<Box
sx={{
width: 10,
height: 10,
bgcolor: "primary.main",
borderRadius: "50%",
}}
/>
)}
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: "bold" }}
>
{ticket.subject}
{currentUser?.is_superuser && (
<Typography
component="span"
variant="caption"
sx={{ ml: 1, color: "primary.main" }}
>
({ticket.username})
</Typography>
)}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(ticket.created_at)}
</Typography>
</Box>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
{unreadTicketIds.has(ticket.id) && (
<Chip
label={t("support.unread")}
color="primary"
size="small"
sx={{
fontWeight: "bold",
height: 20,
fontSize: "0.6rem",
}}
/>
)}
<Chip
label={t(`support.${ticket.status}`)}
color={getStatusColor(ticket.status) as any}
size="small"
sx={{ fontWeight: "bold" }}
/>
{currentUser?.is_superuser && (
<IconButton
size="small"
color="error"
onClick={(e) => {
e.stopPropagation();
handleDeleteTicket(ticket.id);
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</Stack>
</Box>
{currentUser?.is_superuser && (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
mt: 1,
p: 1,
borderRadius: 2,
bgcolor: alpha("#000", 0.03),
}}
>
<Avatar
src={ticket.user_avatar || undefined}
sx={{ width: 24, height: 24, fontSize: "0.7rem" }}
>
{ticket.username.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="caption"
sx={{
fontWeight: "bold",
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{ticket.username} •{" "}
<span style={{ opacity: 0.7 }}>
{ticket.user_email}
</span>
</Typography>
</Box>
<Button
size="small"
onClick={(e) => {
e.stopPropagation();
navigate(`/profile/${ticket.username}`);
}}
sx={{
minWidth: 0,
py: 0,
textTransform: "none",
fontSize: "0.7rem",
}}
>
View Profile
</Button>
<Button
size="small"
onClick={(e) => {
e.stopPropagation();
navigate(`/admin-panel/user/${ticket.user}`);
}}
sx={{
minWidth: 0,
py: 0,
textTransform: "none",
fontSize: "0.7rem",
}}
>
{t("admin.manageUser")}
</Button>
</Box>
)}
</Box>
<Collapse in={expandedId === ticket.id}>
<Divider />
<Box
sx={{
p: 2,
bgcolor: (theme) =>
alpha(theme.palette.background.default, 0.5),
}}
>
{currentUser?.is_superuser && (
<Stack
direction="row"
spacing={1}
sx={{ mb: 2, overflowX: "auto", pb: 1 }}
>
{["open", "in_progress", "resolved", "closed"].map(
(s) => (
<Button
key={s}
size="small"
variant={
ticket.status === s ? "contained" : "outlined"
}
onClick={() => handleUpdateStatus(ticket.id, s)}
color={getStatusColor(s) as any}
sx={{
textTransform: "none",
borderRadius: 4,
fontSize: "0.7rem",
flexShrink: 0,
}}
>
{t(`support.${s}`)}
</Button>
),
)}
</Stack>
)}
<Typography
variant="body2"
paragraph
sx={{
whiteSpace: "pre-wrap",
mb: 3,
bgcolor: "background.paper",
p: 2,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
}}
>
{ticket.description}
</Typography>
<Stack spacing={2} sx={{ mb: 3 }}>
{ticket.messages.map((msg: Message) => (
<Box
key={msg.id}
sx={{
alignSelf: msg.is_admin ? "flex-start" : "flex-end",
maxWidth: "85%",
p: 1.5,
borderRadius: 2,
bgcolor: msg.is_admin
? "primary.main"
: "action.selected",
color: msg.is_admin ? "white" : "inherit",
boxShadow: msg.is_admin ? 2 : 0,
}}
>
<Typography
variant="caption"
sx={{ fontWeight: "bold", display: "block" }}
>
{msg.username} {msg.is_admin && "(Support)"}
</Typography>
<Typography variant="body2" sx={{ my: 0.5 }}>
{msg.text}
</Typography>
<Typography
variant="caption"
sx={{
opacity: 0.7,
display: "block",
textAlign: "right",
}}
>
{formatDate(msg.created_at)}
</Typography>
</Box>
))}
</Stack>
{ticket.status !== "closed" && (
<Stack direction="row" spacing={1}>
<TextField
fullWidth
size="small"
placeholder={t("community.writeReply")}
value={replyText[ticket.id] || ""}
onChange={(e) =>
setReplyText({
...replyText,
[ticket.id]: e.target.value,
})
}
onKeyDown={(e) =>
e.key === "Enter" &&
!e.shiftKey &&
handleReply(ticket.id)
}
/>
<IconButton
color="primary"
onClick={() => handleReply(ticket.id)}
disabled={!replyText[ticket.id]}
>
<SendIcon />
</IconButton>
</Stack>
)}
</Box>
</Collapse>
</CardContent>
</Card>
))}
</Stack>
)}
</Container>
);
}; };
export default Support; export default Support;
+111 -86
View File
@@ -1,99 +1,124 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from "react";
import { useLocation, useNavigate, Link } from 'react-router-dom'; import { useLocation, useNavigate, Link } from "react-router-dom";
import { Container, Paper, Typography, Box, CircularProgress, Button, Alert } from '@mui/material'; import {
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; Container,
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; Paper,
import { useTranslation } from 'react-i18next'; Typography,
import api from '../api'; Box,
import { useTitle } from '../hooks/useTitle'; CircularProgress,
Button,
Alert,
} from "@mui/material";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import { useTranslation } from "react-i18next";
import api from "../api";
import { useTitle } from "../hooks/useTitle";
const VerifyEmail: React.FC = () => { const VerifyEmail: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); const [status, setStatus] = useState<"loading" | "success" | "error">(
const [errorMsg, setErrorMsg] = useState(''); "loading",
);
const [errorMsg, setErrorMsg] = useState("");
useTitle(t('auth.verifyTitle')); useTitle(t("auth.verifyTitle"));
useEffect(() => { useEffect(() => {
const query = new URLSearchParams(location.search); const query = new URLSearchParams(location.search);
const username = query.get('username'); const username = query.get("username");
const code = query.get('code'); const code = query.get("code");
if (!username || !code) { if (!username || !code) {
setStatus('error'); setStatus("error");
setErrorMsg('Invalid verification link.'); setErrorMsg("Invalid verification link.");
return; return;
} }
const verify = async () => { const verify = async () => {
try { try {
await api.post('users/verify_signup/', { username, code }); await api.post("users/verify_signup/", { username, code });
setStatus('success'); setStatus("success");
// Optional: Auto redirect after 3 seconds // Optional: Auto redirect after 3 seconds
setTimeout(() => { setTimeout(() => {
navigate('/login'); navigate("/login");
}, 3000); }, 3000);
} catch (err: any) { } catch (err: any) {
setStatus('error'); setStatus("error");
setErrorMsg(err.response?.data?.error || 'Verification failed.'); setErrorMsg(err.response?.data?.error || "Verification failed.");
} }
}; };
verify(); verify();
}, [location, navigate]); }, [location, navigate]);
return ( return (
<Container maxWidth="sm" sx={{ py: 12 }}> <Container maxWidth="sm" sx={{ py: 12 }}>
<Paper sx={{ p: 6, textAlign: 'center', borderRadius: 4 }}> <Paper sx={{ p: 6, textAlign: "center", borderRadius: 4 }}>
{status === 'loading' && ( {status === "loading" && (
<Box> <Box>
<CircularProgress size={60} sx={{ mb: 4 }} /> <CircularProgress size={60} sx={{ mb: 4 }} />
<Typography variant="h5" sx={{ fontWeight: 'bold' }}> <Typography variant="h5" sx={{ fontWeight: "bold" }}>
Verifying your account... Verifying your account...
</Typography> </Typography>
<Typography color="text.secondary" sx={{ mt: 1 }}> <Typography color="text.secondary" sx={{ mt: 1 }}>
Please wait while we confirm your email. Please wait while we confirm your email.
</Typography> </Typography>
</Box> </Box>
)} )}
{status === 'success' && ( {status === "success" && (
<Box> <Box>
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 80, mb: 2 }} /> <CheckCircleOutlineIcon
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 2 }}> color="success"
{t('auth.verifySuccess')} sx={{ fontSize: 80, mb: 2 }}
</Typography> />
<Typography color="text.secondary" sx={{ mb: 4 }}> <Typography variant="h4" sx={{ fontWeight: "bold", mb: 2 }}>
You will be redirected to the login page shortly. {t("auth.verifySuccess")}
</Typography> </Typography>
<Button variant="contained" component={Link} to="/login" size="large" sx={{ borderRadius: 2 }}> <Typography color="text.secondary" sx={{ mb: 4 }}>
Go to Login Now You will be redirected to the login page shortly.
</Button> </Typography>
</Box> <Button
)} variant="contained"
component={Link}
to="/login"
size="large"
sx={{ borderRadius: 2 }}
>
Go to Login Now
</Button>
</Box>
)}
{status === 'error' && ( {status === "error" && (
<Box> <Box>
<ErrorOutlineIcon color="error" sx={{ fontSize: 80, mb: 2 }} /> <ErrorOutlineIcon color="error" sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 2 }}> <Typography variant="h4" sx={{ fontWeight: "bold", mb: 2 }}>
Verification Failed Verification Failed
</Typography> </Typography>
<Alert severity="error" sx={{ mb: 4, textAlign: 'left' }}> <Alert severity="error" sx={{ mb: 4, textAlign: "left" }}>
{errorMsg} {errorMsg}
</Alert> </Alert>
<Typography color="text.secondary" sx={{ mb: 4 }}> <Typography color="text.secondary" sx={{ mb: 4 }}>
The link might be expired or already used. The link might be expired or already used.
</Typography> </Typography>
<Button variant="outlined" component={Link} to="/login" size="large" sx={{ borderRadius: 2 }}> <Button
Back to Login / Signup variant="outlined"
</Button> component={Link}
</Box> to="/login"
)} size="large"
</Paper> sx={{ borderRadius: 2 }}
</Container> >
); Back to Login / Signup
</Button>
</Box>
)}
</Paper>
</Container>
);
}; };
export default VerifyEmail; export default VerifyEmail;
+130 -85
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from "react";
import { useParams, Link, useNavigate } from 'react-router-dom'; import { useParams, Link, useNavigate } from "react-router-dom";
import { import {
Typography, Typography,
Box, Box,
@@ -7,21 +7,21 @@ import {
CardContent, CardContent,
CircularProgress, CircularProgress,
Container, Container,
Button Button,
} from '@mui/material'; } from "@mui/material";
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import api from '../../api'; import api from "../../api";
import { useTitle } from '../../hooks/useTitle'; import { useTitle } from "../../hooks/useTitle";
import RatingBadge from '../../components/RatingBadge'; import RatingBadge from "../../components/RatingBadge";
import StatusBadge from '../../components/StatusBadge'; import StatusBadge from "../../components/StatusBadge";
import { useTranslation } from 'react-i18next'; import { useTranslation } from "react-i18next";
interface Rating { interface Rating {
id: number; id: number;
user: string; user: string;
score: number; score: number;
comment: string; comment: string;
created_at: string; created_at: string;
} }
interface Flavor { interface Flavor {
@@ -43,15 +43,15 @@ const CategoryFlavors: React.FC = () => {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [flavors, setFlavors] = useState<Flavor[]>([]); const [flavors, setFlavors] = useState<Flavor[]>([]);
const [categoryName, setCategoryName] = useState(''); const [categoryName, setCategoryName] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const handleGoBack = () => { const handleGoBack = () => {
if (window.history.length > 1) { if (window.history.length > 1) {
navigate(-1); navigate(-1);
} else { } else {
navigate('/'); navigate("/");
} }
}; };
useEffect(() => { useEffect(() => {
@@ -59,7 +59,9 @@ const CategoryFlavors: React.FC = () => {
setLoading(true); setLoading(true);
try { try {
const res = await api.get(`flavors/?category__slug=${slug}`); const res = await api.get(`flavors/?category__slug=${slug}`);
const data = Array.isArray(res.data) ? res.data : (res.data.results || []); const data = Array.isArray(res.data)
? res.data
: res.data.results || [];
setFlavors(data); setFlavors(data);
if (data.length > 0) { if (data.length > 0) {
setCategoryName(data[0].category_name); setCategoryName(data[0].category_name);
@@ -73,85 +75,128 @@ const CategoryFlavors: React.FC = () => {
fetchFlavors(); fetchFlavors();
}, [slug]); }, [slug]);
useTitle(categoryName || 'Flavors'); useTitle(categoryName || "Flavors");
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>; if (loading)
return (
<Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
<CircularProgress />
</Box>
);
return ( return (
<Container maxWidth={false} sx={{ px: { xs: 2, sm: 4, md: 6 }, py: 4 }}> <Container maxWidth={false} sx={{ px: { xs: 2, sm: 4, md: 6 }, py: 4 }}>
<Box sx={{ mb: 4 }}> <Box sx={{ mb: 4 }}>
<Typography variant="h4" gutterBottom>{categoryName || 'Flavors'}</Typography> <Typography variant="h4" gutterBottom>
<Button {categoryName || "Flavors"}
variant="outlined" </Typography>
onClick={handleGoBack} <Button
startIcon={<ArrowBackIcon />} variant="outlined"
sx={{ onClick={handleGoBack}
borderRadius: 2, startIcon={<ArrowBackIcon />}
textTransform: 'none', sx={{
fontWeight: 'bold', borderRadius: 2,
color: 'text.secondary', textTransform: "none",
borderColor: 'divider', fontWeight: "bold",
'&:hover': { color: "text.secondary",
borderColor: 'primary.main', borderColor: "divider",
color: 'primary.main', "&:hover": {
bgcolor: 'transparent' borderColor: "primary.main",
} color: "primary.main",
}} bgcolor: "transparent",
> },
{window.history.length > 1 ? t('common.back') : t('common.backToHome')} }}
</Button> >
{window.history.length > 1
? t("common.back")
: t("common.backToHome")}
</Button>
</Box> </Box>
<Box sx={{ <Box
display: 'grid', sx={{
display: "grid",
gridTemplateColumns: { gridTemplateColumns: {
xs: '1fr', xs: "1fr",
sm: 'repeat(auto-fill, minmax(280px, 1fr))', sm: "repeat(auto-fill, minmax(280px, 1fr))",
lg: 'repeat(auto-fill, minmax(240px, 1fr))' lg: "repeat(auto-fill, minmax(240px, 1fr))",
}, },
gap: 3 gap: 3,
}}> }}
{flavors.map(flavor => ( >
{flavors.map((flavor) => (
<Box key={flavor.id}> <Box key={flavor.id}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', position: 'relative', transition: 'transform 0.2s', '&:hover': { transform: 'scale(1.02)' } }}> <Card
<Box component={Link} to={`/flavor/${flavor.id}`} sx={{ textDecoration: 'none', color: 'inherit', flexGrow: 1, display: 'flex', flexDirection: 'column' }}> sx={{
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 1 }}> height: "100%",
<StatusBadge isLegacy={flavor.is_legacy} isAvailable={flavor.is_available} size="small" /> display: "flex",
flexDirection: "column",
position: "relative",
transition: "transform 0.2s",
"&:hover": { transform: "scale(1.02)" },
}}
>
<Box
component={Link}
to={`/flavor/${flavor.id}`}
sx={{
textDecoration: "none",
color: "inherit",
flexGrow: 1,
display: "flex",
flexDirection: "column",
}}
>
<Box
sx={{ position: "absolute", top: 10, right: 10, zIndex: 1 }}
>
<StatusBadge
isLegacy={flavor.is_legacy}
isAvailable={flavor.is_available}
size="small"
/>
</Box> </Box>
{flavor.image_url && ( {flavor.image_url && (
<Box
sx={{
width: "100%",
aspectRatio: "1/1",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "background.default",
borderBottom: "1px solid",
borderColor: "divider",
overflow: "hidden",
}}
>
<Box <Box
sx={{ component="img"
width: '100%', src={flavor.image_url}
aspectRatio: '1/1', sx={{
display: 'flex', width: "100%",
alignItems: 'center', height: "100%",
justifyContent: 'center', objectFit: "cover",
bgcolor: 'background.default', transition: "transform 0.5s ease",
borderBottom: '1px solid', "&:hover": { transform: "scale(1.1)" },
borderColor: 'divider', }}
overflow: 'hidden' />
}} </Box>
>
<Box
component="img"
src={flavor.image_url}
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
transition: 'transform 0.5s ease',
'&:hover': { transform: 'scale(1.1)' }
}}
/>
</Box>
)} )}
<CardContent sx={{ flexGrow: 1 }}> <CardContent sx={{ flexGrow: 1 }}>
<Typography variant="h6" sx={{ fontSize: '1.1rem', mb: 1 }}>{flavor.name}</Typography> <Typography variant="h6" sx={{ fontSize: "1.1rem", mb: 1 }}>
{flavor.name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}> <Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
<RatingBadge score={flavor.average_rating || 0} size="small" /> <RatingBadge
</Box> score={flavor.average_rating || 0}
<Typography variant="caption" color="text.secondary">{flavor.ratings.length} reviews</Typography> size="small"
/>
</Box>
<Typography variant="caption" color="text.secondary">
{flavor.ratings.length} reviews
</Typography>
</CardContent> </CardContent>
</Box> </Box>
</Card> </Card>
File diff suppressed because it is too large. Load diff
+275 -251
View File
@@ -1,190 +1,210 @@
import { createTheme, type ThemeOptions, alpha } from '@mui/material/styles'; import { createTheme, type ThemeOptions, alpha } from "@mui/material/styles";
import { flavors } from '@catppuccin/palette'; import { flavors } from "@catppuccin/palette";
export type CatppuccinTheme = export type CatppuccinTheme =
| 'holy_light' | 'holy_dark' | "holy_light"
| 'latte' | 'frappe' | 'macchiato' | 'mocha' | "holy_dark"
| 'pink_pastel' | 'mint_pastel' | 'lavender_pastel' | 'dracula' | 'nord' | 'gruvbox' | 'oceanic' | "latte"
| 't0p_sai' | 't0p_trench' | 't0p_blurryface' | 't0p_clancy'; | "frappe"
| "macchiato"
| "mocha"
| "pink_pastel"
| "mint_pastel"
| "lavender_pastel"
| "dracula"
| "nord"
| "gruvbox"
| "oceanic"
| "t0p_sai"
| "t0p_trench"
| "t0p_blurryface"
| "t0p_clancy";
export const isLightTheme = (mode: CatppuccinTheme): boolean => { export const isLightTheme = (mode: CatppuccinTheme): boolean => {
return ['holy_light', 'latte', 'pink_pastel', 'mint_pastel', 'lavender_pastel', 't0p_sai'].includes(mode); return [
"holy_light",
"latte",
"pink_pastel",
"mint_pastel",
"lavender_pastel",
"t0p_sai",
].includes(mode);
}; };
export const getTheme = (mode: CatppuccinTheme) => { export const getTheme = (mode: CatppuccinTheme) => {
let palette: any; let palette: any;
// Define Holy branding colors // Define Holy branding colors
const holyPurple = { light: '#7c3aed', dark: '#a78bfa' }; const holyPurple = { light: "#7c3aed", dark: "#a78bfa" };
const holyPink = { light: '#db2777', dark: '#f472b6' }; const holyPink = { light: "#db2777", dark: "#f472b6" };
if (mode === 'holy_light') { if (mode === "holy_light") {
palette = { palette = {
base: { hex: '#f8fafc' }, base: { hex: "#f8fafc" },
surface0: { hex: '#ffffff' }, surface0: { hex: "#ffffff" },
crust: { hex: '#f1f5f9' }, crust: { hex: "#f1f5f9" },
text: { hex: '#0f172a' }, text: { hex: "#0f172a" },
subtext0: { hex: '#64748b' }, subtext0: { hex: "#64748b" },
primary: { hex: holyPurple.light }, primary: { hex: holyPurple.light },
secondary: { hex: holyPink.light }, secondary: { hex: holyPink.light },
}; };
} else if (mode === 'holy_dark') { } else if (mode === "holy_dark") {
palette = { palette = {
base: { hex: '#020617' }, base: { hex: "#020617" },
surface0: { hex: '#0f172a' }, surface0: { hex: "#0f172a" },
crust: { hex: '#000000' }, crust: { hex: "#000000" },
text: { hex: '#f8fafc' }, text: { hex: "#f8fafc" },
subtext0: { hex: '#94a3b8' }, subtext0: { hex: "#94a3b8" },
primary: { hex: holyPurple.dark }, primary: { hex: holyPurple.dark },
secondary: { hex: holyPink.dark }, secondary: { hex: holyPink.dark },
}; };
} else if (mode === 'pink_pastel') { } else if (mode === "pink_pastel") {
palette = { palette = {
base: { hex: '#fff1f2' }, base: { hex: "#fff1f2" },
surface0: { hex: '#ffffff' }, surface0: { hex: "#ffffff" },
crust: { hex: '#ffe4e6' }, crust: { hex: "#ffe4e6" },
text: { hex: '#881337' }, text: { hex: "#881337" },
subtext0: { hex: '#be123c' }, subtext0: { hex: "#be123c" },
primary: { hex: '#fb7185' }, primary: { hex: "#fb7185" },
secondary: { hex: '#2dd4bf' }, secondary: { hex: "#2dd4bf" },
}; };
} else if (mode === 'mint_pastel') { } else if (mode === "mint_pastel") {
palette = { palette = {
base: { hex: '#f0fdf4' }, base: { hex: "#f0fdf4" },
surface0: { hex: '#ffffff' }, surface0: { hex: "#ffffff" },
crust: { hex: '#dcfce7' }, crust: { hex: "#dcfce7" },
text: { hex: '#14532d' }, text: { hex: "#14532d" },
subtext0: { hex: '#16a34a' }, subtext0: { hex: "#16a34a" },
primary: { hex: '#4ade80' }, primary: { hex: "#4ade80" },
secondary: { hex: '#fb7185' }, secondary: { hex: "#fb7185" },
}; };
} else if (mode === 'lavender_pastel') { } else if (mode === "lavender_pastel") {
palette = { palette = {
base: { hex: '#f5f3ff' }, base: { hex: "#f5f3ff" },
surface0: { hex: '#ffffff' }, surface0: { hex: "#ffffff" },
crust: { hex: '#ede9fe' }, crust: { hex: "#ede9fe" },
text: { hex: '#4c1d95' }, text: { hex: "#4c1d95" },
subtext0: { hex: '#7c3aed' }, subtext0: { hex: "#7c3aed" },
primary: { hex: '#8b5cf6' }, primary: { hex: "#8b5cf6" },
secondary: { hex: '#facc15' }, secondary: { hex: "#facc15" },
}; };
} else if (mode === 't0p_sai') { } else if (mode === "t0p_sai") {
palette = { palette = {
base: { hex: '#fdf2f8' }, // Very light SAI Pink base: { hex: "#fdf2f8" }, // Very light SAI Pink
surface0: { hex: '#ffffff' }, surface0: { hex: "#ffffff" },
crust: { hex: '#fce7f3' }, // SAI Pink accent crust: { hex: "#fce7f3" }, // SAI Pink accent
text: { hex: '#1e1b4b' }, text: { hex: "#1e1b4b" },
subtext0: { hex: '#4b5563' }, subtext0: { hex: "#4b5563" },
primary: { hex: '#00d7ff' }, // SAI Cyan primary: { hex: "#00d7ff" }, // SAI Cyan
secondary: { hex: '#ff8ad8' }, // SAI Pink secondary: { hex: "#ff8ad8" }, // SAI Pink
}; };
} else if (mode === 't0p_trench') { } else if (mode === "t0p_trench") {
palette = { palette = {
base: { hex: '#1a1a1a' }, base: { hex: "#1a1a1a" },
surface0: { hex: '#242424' }, surface0: { hex: "#242424" },
crust: { hex: '#000000' }, crust: { hex: "#000000" },
text: { hex: '#fce300' }, // Trench Yellow text: { hex: "#fce300" }, // Trench Yellow
subtext0: { hex: '#a0a0a0' }, subtext0: { hex: "#a0a0a0" },
primary: { hex: '#fce300' }, primary: { hex: "#fce300" },
secondary: { hex: '#4b5320' }, // Olive secondary: { hex: "#4b5320" }, // Olive
}; };
} else if (mode === 't0p_blurryface') { } else if (mode === "t0p_blurryface") {
palette = { palette = {
base: { hex: '#000000' }, base: { hex: "#000000" },
surface0: { hex: '#1a1a1a' }, surface0: { hex: "#1a1a1a" },
crust: { hex: '#000000' }, crust: { hex: "#000000" },
text: { hex: '#ffffff' }, text: { hex: "#ffffff" },
subtext0: { hex: '#e31e24' }, subtext0: { hex: "#e31e24" },
primary: { hex: '#e31e24' }, // BF Red primary: { hex: "#e31e24" }, // BF Red
secondary: { hex: '#ffffff' }, secondary: { hex: "#ffffff" },
}; };
} else if (mode === 't0p_clancy') { } else if (mode === "t0p_clancy") {
palette = { palette = {
base: { hex: '#000000' }, base: { hex: "#000000" },
surface0: { hex: '#121212' }, surface0: { hex: "#121212" },
crust: { hex: '#000000' }, crust: { hex: "#000000" },
text: { hex: '#ffffff' }, text: { hex: "#ffffff" },
subtext0: { hex: '#fce300' }, subtext0: { hex: "#fce300" },
primary: { hex: '#e31e24' }, // Clancy Red primary: { hex: "#e31e24" }, // Clancy Red
secondary: { hex: '#fce300' }, // Clancy Yellow secondary: { hex: "#fce300" }, // Clancy Yellow
}; };
} else if (mode === 'dracula') { } else if (mode === "dracula") {
palette = { palette = {
base: { hex: '#282a36' }, base: { hex: "#282a36" },
surface0: { hex: '#44475a' }, surface0: { hex: "#44475a" },
crust: { hex: '#191a21' }, crust: { hex: "#191a21" },
text: { hex: '#f8f8f2' }, text: { hex: "#f8f8f2" },
subtext0: { hex: '#6272a4' }, subtext0: { hex: "#6272a4" },
primary: { hex: '#bd93f9' }, primary: { hex: "#bd93f9" },
secondary: { hex: '#ff79c6' }, secondary: { hex: "#ff79c6" },
}; };
} else if (mode === 'nord') { } else if (mode === "nord") {
palette = { palette = {
base: { hex: '#2e3440' }, base: { hex: "#2e3440" },
surface0: { hex: '#3b4252' }, surface0: { hex: "#3b4252" },
crust: { hex: '#242933' }, crust: { hex: "#242933" },
text: { hex: '#eceff4' }, text: { hex: "#eceff4" },
subtext0: { hex: '#d8dee9' }, subtext0: { hex: "#d8dee9" },
primary: { hex: '#88c0d0' }, primary: { hex: "#88c0d0" },
secondary: { hex: '#b48ead' }, secondary: { hex: "#b48ead" },
}; };
} else if (mode === 'gruvbox') { } else if (mode === "gruvbox") {
palette = { palette = {
base: { hex: '#282828' }, base: { hex: "#282828" },
surface0: { hex: '#3c3836' }, surface0: { hex: "#3c3836" },
crust: { hex: '#1d2021' }, crust: { hex: "#1d2021" },
text: { hex: '#ebdbb2' }, text: { hex: "#ebdbb2" },
subtext0: { hex: '#928374' }, subtext0: { hex: "#928374" },
primary: { hex: '#fabd2f' }, primary: { hex: "#fabd2f" },
secondary: { hex: '#fb4934' }, secondary: { hex: "#fb4934" },
}; };
} else if (mode === 'oceanic') { } else if (mode === "oceanic") {
palette = { palette = {
base: { hex: '#1b262c' }, base: { hex: "#1b262c" },
surface0: { hex: '#0f4c75' }, surface0: { hex: "#0f4c75" },
crust: { hex: '#0f161c' }, crust: { hex: "#0f161c" },
text: { hex: '#bbe1fa' }, text: { hex: "#bbe1fa" },
subtext0: { hex: '#3282b8' }, subtext0: { hex: "#3282b8" },
primary: { hex: '#3282b8' }, primary: { hex: "#3282b8" },
secondary: { hex: '#bbe1fa' }, secondary: { hex: "#bbe1fa" },
}; };
} else { } else {
// Handle Catppuccin flavors // Handle Catppuccin flavors
const flavor = (flavors as any)[mode]; const flavor = (flavors as any)[mode];
if (flavor) { if (flavor) {
const colors = flavor.colors; const colors = flavor.colors;
palette = { palette = {
base: colors.base, base: colors.base,
surface0: colors.surface0, surface0: colors.surface0,
crust: colors.crust, crust: colors.crust,
text: colors.text, text: colors.text,
subtext0: colors.subtext0, subtext0: colors.subtext0,
primary: colors.mauve, primary: colors.mauve,
secondary: colors.pink, secondary: colors.pink,
}; };
} else { } else {
// Fallback to holy_light // Fallback to holy_light
palette = { palette = {
base: { hex: '#f8fafc' }, base: { hex: "#f8fafc" },
surface0: { hex: '#ffffff' }, surface0: { hex: "#ffffff" },
crust: { hex: '#f1f5f9' }, crust: { hex: "#f1f5f9" },
text: { hex: '#0f172a' }, text: { hex: "#0f172a" },
subtext0: { hex: '#64748b' }, subtext0: { hex: "#64748b" },
primary: { hex: holyPurple.light }, primary: { hex: holyPurple.light },
secondary: { hex: holyPink.light }, secondary: { hex: holyPink.light },
}; };
mode = 'holy_light'; mode = "holy_light";
} }
} }
const isLight = isLightTheme(mode); const isLight = isLightTheme(mode);
const themeOptions: ThemeOptions = { const themeOptions: ThemeOptions = {
palette: { palette: {
mode: isLight ? 'light' : 'dark', mode: isLight ? "light" : "dark",
primary: { primary: {
main: palette.primary.hex, main: palette.primary.hex,
contrastText: isLight ? '#ffffff' : '#000000', contrastText: isLight ? "#ffffff" : "#000000",
}, },
secondary: { secondary: {
main: palette.secondary.hex, main: palette.secondary.hex,
@@ -199,12 +219,12 @@ export const getTheme = (mode: CatppuccinTheme) => {
}, },
divider: alpha(palette.text.hex, 0.1), divider: alpha(palette.text.hex, 0.1),
action: { action: {
hover: alpha(palette.primary.hex, 0.08), hover: alpha(palette.primary.hex, 0.08),
selected: alpha(palette.primary.hex, 0.12), selected: alpha(palette.primary.hex, 0.12),
} },
}, },
shape: { shape: {
borderRadius: 8, borderRadius: 8,
}, },
typography: { typography: {
fontFamily: '"Plus Jakarta Sans", "Inter", "Roboto", sans-serif', fontFamily: '"Plus Jakarta Sans", "Inter", "Roboto", sans-serif',
@@ -214,93 +234,97 @@ export const getTheme = (mode: CatppuccinTheme) => {
h4: { fontWeight: 700 }, h4: { fontWeight: 700 },
h5: { fontWeight: 600 }, h5: { fontWeight: 600 },
h6: { fontWeight: 600 }, h6: { fontWeight: 600 },
button: { textTransform: 'none', fontWeight: 600 }, button: { textTransform: "none", fontWeight: 600 },
}, },
components: { components: {
MuiCssBaseline: { MuiCssBaseline: {
styleOverrides: { styleOverrides: {
body: { body: {
scrollbarColor: `${alpha(palette.text.hex, 0.2)} transparent`, scrollbarColor: `${alpha(palette.text.hex, 0.2)} transparent`,
'&::-webkit-scrollbar': { width: '8px', height: '8px' }, "&::-webkit-scrollbar": { width: "8px", height: "8px" },
'&::-webkit-scrollbar-thumb': { "&::-webkit-scrollbar-thumb": {
backgroundColor: alpha(palette.text.hex, 0.2), backgroundColor: alpha(palette.text.hex, 0.2),
borderRadius: '10px', borderRadius: "10px",
'&:hover': { backgroundColor: alpha(palette.text.hex, 0.3) } "&:hover": { backgroundColor: alpha(palette.text.hex, 0.3) },
},
}
}
},
MuiPaper: {
styleOverrides: {
root: {
backgroundColor: isLight ? palette.surface0.hex : alpha(palette.surface0.hex, 0.8),
backgroundImage: 'none',
backdropFilter: isLight ? 'none' : 'blur(16px)',
border: '1px solid',
borderColor: alpha(palette.text.hex, 0.08),
boxShadow: isLight
? '0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.1)'
: '0 4px 20px rgba(0,0,0,0.2)',
}
}
},
MuiCard: {
defaultProps: {
elevation: 0,
}, },
styleOverrides: { },
root: {
backgroundColor: isLight ? palette.surface0.hex : alpha(palette.surface0.hex, 0.6),
backdropFilter: 'blur(12px)',
border: '1px solid',
borderColor: alpha(palette.text.hex, 0.1),
borderRadius: '12px',
transition: 'all 0.2s ease-in-out',
}
}
}, },
MuiAppBar: { },
styleOverrides: { MuiPaper: {
root: { styleOverrides: {
backgroundColor: alpha(palette.base.hex, 0.8), root: {
backdropFilter: 'blur(20px)', backgroundColor: isLight
borderBottom: '1px solid', ? palette.surface0.hex
borderColor: alpha(palette.text.hex, 0.08), : alpha(palette.surface0.hex, 0.8),
color: palette.text.hex, backgroundImage: "none",
} backdropFilter: isLight ? "none" : "blur(16px)",
} border: "1px solid",
borderColor: alpha(palette.text.hex, 0.08),
boxShadow: isLight
? "0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.1)"
: "0 4px 20px rgba(0,0,0,0.2)",
},
}, },
MuiButton: { },
styleOverrides: { MuiCard: {
root: { defaultProps: {
borderRadius: '8px', elevation: 0,
padding: '8px 20px',
},
containedPrimary: {
boxShadow: `0 4px 14px 0 ${alpha(palette.primary.hex, 0.39)}`,
'&:hover': {
boxShadow: `0 6px 20px rgba(0,0,0,0.23)`,
}
}
}
}, },
MuiTab: { styleOverrides: {
styleOverrides: { root: {
root: { backgroundColor: isLight
minHeight: '48px', ? palette.surface0.hex
fontWeight: 700, : alpha(palette.surface0.hex, 0.6),
borderRadius: '8px', backdropFilter: "blur(12px)",
} border: "1px solid",
} borderColor: alpha(palette.text.hex, 0.1),
borderRadius: "12px",
transition: "all 0.2s ease-in-out",
},
}, },
MuiOutlinedInput: { },
styleOverrides: { MuiAppBar: {
root: { styleOverrides: {
borderRadius: '8px', root: {
} backgroundColor: alpha(palette.base.hex, 0.8),
} backdropFilter: "blur(20px)",
} borderBottom: "1px solid",
} borderColor: alpha(palette.text.hex, 0.08),
color: palette.text.hex,
},
},
},
MuiButton: {
styleOverrides: {
root: {
borderRadius: "8px",
padding: "8px 20px",
},
containedPrimary: {
boxShadow: `0 4px 14px 0 ${alpha(palette.primary.hex, 0.39)}`,
"&:hover": {
boxShadow: `0 6px 20px rgba(0,0,0,0.23)`,
},
},
},
},
MuiTab: {
styleOverrides: {
root: {
minHeight: "48px",
fontWeight: 700,
borderRadius: "8px",
},
},
},
MuiOutlinedInput: {
styleOverrides: {
root: {
borderRadius: "8px",
},
},
},
},
}; };
return createTheme(themeOptions); return createTheme(themeOptions);
+8 -8
View File
@@ -1,10 +1,10 @@
export const formatDate = (dateStr: string) => { export const formatDate = (dateStr: string) => {
if (!dateStr) return ''; if (!dateStr) return "";
const d = new Date(dateStr); const d = new Date(dateStr);
const day = String(d.getDate()).padStart(2, '0'); const day = String(d.getDate()).padStart(2, "0");
const month = String(d.getMonth() + 1).padStart(2, '0'); const month = String(d.getMonth() + 1).padStart(2, "0");
const year = d.getFullYear(); const year = d.getFullYear();
const hours = String(d.getHours()).padStart(2, '0'); const hours = String(d.getHours()).padStart(2, "0");
const minutes = String(d.getMinutes()).padStart(2, '0'); const minutes = String(d.getMinutes()).padStart(2, "0");
return `${day}/${month}/${year} ${hours}:${minutes}`; return `${day}/${month}/${year} ${hours}:${minutes}`;
}; };