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
+12938 -8133

No files matched your search

+904 -565
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 {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
let cookieValue = null;
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === name + "=") {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
return cookieValue;
}
return cookieValue;
}
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) => {
// Set DRF Auth Token if available from localStorage
const token = localStorage.getItem('token');
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Token ${token}`;
}
// Set CSRF token for state-changing methods
const csrfToken = getCookie('csrftoken');
const csrfToken = getCookie("csrftoken");
const method = config.method?.toUpperCase();
if (csrfToken && method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
if (config.headers) {
config.headers['X-CSRFToken'] = csrfToken;
}
if (
csrfToken &&
method &&
["POST", "PUT", "PATCH", "DELETE"].includes(method)
) {
if (config.headers) {
config.headers["X-CSRFToken"] = csrfToken;
}
}
return config;
+170 -141
View File
@@ -1,162 +1,191 @@
import React, { useState, useEffect } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions, Button,
TextField, 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';
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
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 {
key: string;
label: string;
type: 'number' | 'slider' | 'text' | 'boolean';
min?: number;
max?: number;
step?: number;
description?: string;
key: string;
label: string;
type: "number" | "slider" | "text" | "boolean";
min?: number;
max?: number;
step?: number;
description?: string;
}
interface Banner {
id: number;
name: string;
slug: string;
description: string;
is_active: boolean;
settings: any;
schema: BannerSettingSchema[];
id: number;
name: string;
slug: string;
description: string;
is_active: boolean;
settings: any;
schema: BannerSettingSchema[];
}
interface BannerSettingsDialogProps {
open: boolean;
onClose: () => void;
banner: Banner | null;
onSave: () => void;
open: boolean;
onClose: () => void;
banner: Banner | null;
onSave: () => void;
}
const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClose, banner, onSave }) => {
const { t } = useTranslation();
const [localSettings, setLocalSettings] = useState<any>({});
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
open,
onClose,
banner,
onSave,
}) => {
const { t } = useTranslation();
const [localSettings, setLocalSettings] = useState<any>({});
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (banner) {
setLocalSettings(banner.settings || {});
setError('');
}
}, [banner]);
useEffect(() => {
if (banner) {
setLocalSettings(banner.settings || {});
setError("");
}
}, [banner]);
const handleSave = async () => {
if (!banner) return;
try {
setSaving(true);
await api.patch(`banners/${banner.id}/`, { settings: localSettings });
onSave();
onClose();
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to save settings');
} finally {
setSaving(false);
}
};
const handleSave = async () => {
if (!banner) return;
const updateSetting = (key: string, value: any) => {
setLocalSettings((prev: any) => ({
...prev,
[key]: value
}));
};
try {
setSaving(true);
await api.patch(`banners/${banner.id}/`, { settings: localSettings });
onSave();
onClose();
} catch (err: any) {
setError(err.response?.data?.error || "Failed to save settings");
} finally {
setSaving(false);
}
};
const renderField = (schema: BannerSettingSchema) => {
const value = localSettings[schema.key] ?? '';
const updateSetting = (key: string, value: any) => {
setLocalSettings((prev: any) => ({
...prev,
[key]: value,
}));
};
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>
);
};
const renderField = (schema: BannerSettingSchema) => {
const value = localSettings[schema.key] ?? "";
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>
<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 (
<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;
+127 -96
View File
@@ -1,108 +1,139 @@
import React, { useState, useEffect } from 'react';
import {
Typography,
Button,
alpha,
Dialog,
DialogContent,
DialogTitle,
DialogActions,
Box
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import type { CatppuccinTheme } from '../theme';
import React, { useState, useEffect } from "react";
import {
Typography,
Button,
alpha,
Dialog,
DialogContent,
DialogTitle,
DialogActions,
Box,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import type { CatppuccinTheme } from "../theme";
interface CookieBannerProps {
onThemeChange: (newTheme: CatppuccinTheme) => void;
currentTheme: CatppuccinTheme;
onThemeChange: (newTheme: CatppuccinTheme) => void;
currentTheme: CatppuccinTheme;
}
const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const CookieBanner: React.FC<CookieBannerProps> = ({
onThemeChange,
currentTheme,
}) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
useEffect(() => {
const consent = localStorage.getItem('cookie-consent');
if (!consent) {
setOpen(true);
}
}, []);
useEffect(() => {
const consent = localStorage.getItem("cookie-consent");
if (!consent) {
setOpen(true);
}
}, []);
const handleAccept = () => {
localStorage.setItem('cookie-consent', 'true');
setOpen(false);
};
const handleAccept = () => {
localStorage.setItem("cookie-consent", "true");
setOpen(false);
};
const handleToggleTheme = () => {
const newTheme = currentTheme === 'holy_light' ? 'holy_dark' : 'holy_light';
onThemeChange(newTheme);
};
const handleToggleTheme = () => {
const newTheme = currentTheme === "holy_light" ? "holy_dark" : "holy_light";
onThemeChange(newTheme);
};
return (
<Dialog
open={open}
onClose={() => {}} // Disable closing by clicking backdrop
disableEscapeKeyDown
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: 4,
border: '1px solid',
borderColor: 'primary.main',
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.98),
backdropFilter: 'blur(10px)',
boxShadow: (theme) => `0 20px 50px ${alpha(theme.palette.primary.main, 0.2)}`,
}
}}
return (
<Dialog
open={open}
onClose={() => {}} // Disable closing by clicking backdrop
disableEscapeKeyDown
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: 4,
border: "1px solid",
borderColor: "primary.main",
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.98),
backdropFilter: "blur(10px)",
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 }}>
{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
}}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', display: 'block', mb: 1, textTransform: 'uppercase' }}>
Technical Details:
</Typography>
<Typography variant="caption" color="text.secondary">
• Session ID: Required for login & authentication.<br />
• CSRF Token: Protects your account from malicious attacks.<br />
• Preference Store: Remembers your theme and language choices.
</Typography>
</Box>
</DialogContent>
<DialogActions sx={{ p: 3, gap: 2, flexDirection: { xs: 'column', sm: 'row' } }}>
<Button
fullWidth
variant="outlined"
color="primary"
onClick={handleToggleTheme}
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', m: '0 !important' }}
>
{currentTheme === 'holy_light' ? t('cookieBanner.switchToDark') : t('cookieBanner.switchToLight')}
</Button>
<Button
fullWidth
variant="contained"
color="primary"
onClick={handleAccept}
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', px: 4, m: '0 !important' }}
>
{t('cookieBanner.accept')}
</Button>
</DialogActions>
</Dialog>
);
<Typography
variant="caption"
color="text.secondary"
sx={{
fontWeight: "bold",
display: "block",
mb: 1,
textTransform: "uppercase",
}}
>
Technical Details:
</Typography>
<Typography variant="caption" color="text.secondary">
• Session ID: Required for login & authentication.
<br />
• CSRF Token: Protects your account from malicious attacks.
<br />• Preference Store: Remembers your theme and language choices.
</Typography>
</Box>
</DialogContent>
<DialogActions
sx={{ p: 3, gap: 2, flexDirection: { xs: "column", sm: "row" } }}
>
<Button
fullWidth
variant="outlined"
color="primary"
onClick={handleToggleTheme}
sx={{
borderRadius: 2,
textTransform: "none",
fontWeight: "bold",
m: "0 !important",
}}
>
{currentTheme === "holy_light"
? t("cookieBanner.switchToDark")
: t("cookieBanner.switchToLight")}
</Button>
<Button
fullWidth
variant="contained"
color="primary"
onClick={handleAccept}
sx={{
borderRadius: 2,
textTransform: "none",
fontWeight: "bold",
px: 4,
m: "0 !important",
}}
>
{t("cookieBanner.accept")}
</Button>
</DialogActions>
</Dialog>
);
};
export default CookieBanner;
+105 -83
View File
@@ -1,113 +1,135 @@
import React, { useState, useEffect } from 'react';
import api from '../api';
import GenerativeBanner from './GenerativeBanner';
import NebulaBanner from './NebulaBanner';
import FirefliesBanner from './FirefliesBanner';
import HextechBanner from './HextechBanner';
import HextechCorruptionBanner from './HextechCorruptionBanner';
import GenerativeFlowBanner from './GenerativeFlowBanner';
import React, { useState, useEffect } from "react";
import api from "../api";
import GenerativeBanner from "./GenerativeBanner";
import NebulaBanner from "./NebulaBanner";
import FirefliesBanner from "./FirefliesBanner";
import HextechBanner from "./HextechBanner";
import HextechCorruptionBanner from "./HextechCorruptionBanner";
import GenerativeFlowBanner from "./GenerativeFlowBanner";
interface Banner {
id: number;
name: string;
slug: string;
description: string;
is_active: boolean;
settings: any;
id: number;
name: string;
slug: string;
description: string;
is_active: boolean;
settings: any;
}
interface DynamicBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
}
// Future expansion: Add more banner components here
const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({ username, settings }) => {
// Placeholder for a second model
return (
<div style={{
position: 'absolute', top: 0, left: 0, width: '100%', height: '100%',
background: 'rgba(0,0,0,0.8)', color: '#0f0', display: 'flex',
alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace',
zIndex: 1, overflow: 'hidden', opacity: settings.opacity || 0.5
}}>
<div>
{Array.from({ length: 20 }).map((_, i) => (
<div key={i} style={{ animation: `matrixFall ${2 + Math.random() * 3}s linear infinite` }}>
{username.split('').map((char, j) => (
<span key={j} style={{ display: 'block' }}>{char}</span>
))}
</div>
))}
</div>
<style>
{`
const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({
username,
settings,
}) => {
// Placeholder for a second model
return (
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
background: "rgba(0,0,0,0.8)",
color: "#0f0",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "monospace",
zIndex: 1,
overflow: "hidden",
opacity: settings.opacity || 0.5,
}}
>
<div>
{Array.from({ length: 20 }).map((_, i) => (
<div
key={i}
style={{
animation: `matrixFall ${2 + Math.random() * 3}s linear infinite`,
}}
>
{username.split("").map((char, j) => (
<span key={j} style={{ display: "block" }}>
{char}
</span>
))}
</div>
))}
</div>
<style>
{`
@keyframes matrixFall {
0% { transform: translateY(-100%); }
100% { transform: translateY(100%); }
}
`}
</style>
</div>
);
</style>
</div>
);
};
const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
const [banner, setBanner] = useState<Banner | null>(null);
const [loading, setLoading] = useState(true);
const [banner, setBanner] = useState<Banner | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchActiveBanner = async () => {
try {
const res = await api.get('banners/active/', {
params: { username: props.username }
});
setBanner(res.data);
} catch (err) {
console.error('Failed to fetch active banner:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
const fetchActiveBanner = async () => {
try {
const res = await api.get("banners/active/", {
params: { username: props.username },
});
setBanner(res.data);
} catch (err) {
console.error("Failed to fetch active banner:", err);
} finally {
setLoading(false);
}
};
fetchActiveBanner();
}, [props.username]);
fetchActiveBanner();
}, [props.username]);
if (loading) return null;
if (loading) return null;
// Default to GenerativeBanner if no active banner or unrecognized slug
if (!banner || banner.slug === 'generative-web') {
return <GenerativeBanner {...props} settings={banner?.settings} />;
}
// Default to GenerativeBanner if no active banner or unrecognized slug
if (!banner || banner.slug === "generative-web") {
return <GenerativeBanner {...props} settings={banner?.settings} />;
}
if (banner.slug === 'matrix') {
return <MatrixBanner {...props} settings={banner.settings} />;
}
if (banner.slug === "matrix") {
return <MatrixBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'nebula') {
return <NebulaBanner {...props} settings={banner.settings} />;
}
if (banner.slug === "nebula") {
return <NebulaBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'fireflies') {
return <FirefliesBanner {...props} settings={banner.settings} />;
}
if (banner.slug === "fireflies") {
return <FirefliesBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'hextech') {
return <HextechBanner {...props} settings={banner.settings} />;
}
if (banner.slug === "hextech") {
return <HextechBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'hextech-corruption') {
return <HextechCorruptionBanner {...props} settings={banner.settings} />;
}
if (banner.slug === "hextech-corruption") {
return <HextechCorruptionBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'generative-flow') {
return <GenerativeFlowBanner {...props} settings={banner.settings} />;
}
if (banner.slug === "generative-flow") {
return <GenerativeFlowBanner {...props} settings={banner.settings} />;
}
// Fallback
return <GenerativeBanner {...props} />;
// Fallback
return <GenerativeBanner {...props} />;
};
export default DynamicBanner;
+267 -227
View File
@@ -1,257 +1,297 @@
import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
import React, { useRef, useEffect, useMemo } from "react";
import { alpha, useTheme } from "@mui/material";
interface FirefliesBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
count?: number;
speed?: number;
evasionRadius?: number;
evasionStrength?: number;
glowSize?: number;
flickerSpeed?: number;
};
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
count?: number;
speed?: number;
evasionRadius?: number;
evasionStrength?: number;
glowSize?: number;
flickerSpeed?: number;
};
}
const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const count = settings?.count ?? 150;
const baseSpeed = settings?.speed ?? 0.8; // Slower default cruise
const evasionRadius = settings?.evasionRadius ?? 180;
const evasionStrength = settings?.evasionStrength ?? 0.6; // High evasion
const glowSizeMultiplier = settings?.glowSize ?? 4;
const flickerSpeed = settings?.flickerSpeed ?? 0.05;
const FirefliesBanner: React.FC<FirefliesBannerProps> = ({
username,
palette,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
const count = settings?.count ?? 150;
const baseSpeed = settings?.speed ?? 0.8; // Slower default cruise
const evasionRadius = settings?.evasionRadius ?? 180;
const evasionStrength = settings?.evasionStrength ?? 0.6; // High evasion
const glowSizeMultiplier = settings?.glowSize ?? 4;
const flickerSpeed = settings?.flickerSpeed ?? 0.05;
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - 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 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]);
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);
}
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
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;
};
let animationFrameId: number;
const parent = canvas.parentElement;
if (parent) {
parent.addEventListener("mousemove", handleMouseMove);
parent.addEventListener("mouseleave", () => {
mouse.x = -1000;
mouse.y = -1000;
});
}
interface Firefly {
x: number;
y: number;
vx: number;
vy: number;
size: number;
color: string;
phase: number;
noiseOffset: number;
isEvading: boolean;
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);
// 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[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
// 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 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; });
if (currentSpeed > maxSpeed) {
f.vx = (f.vx / currentSpeed) * maxSpeed;
f.vy = (f.vy / currentSpeed) * maxSpeed;
}
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;
}
// 4. Smooth Friction
f.vx *= 0.97;
f.vy *= 0.97;
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);
f.x += f.vx;
f.y += f.vy;
time += flickerSpeed;
// 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;
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;
// Flicker
const flicker = 0.4 + Math.sin(time + f.phase) * 0.6;
const opacity = flicker > 0 ? flicker : 0;
f.vx += driftX + jitterX;
f.vy += driftY + jitterY;
// 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));
// 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;
}
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(f.x, f.y, f.size * glowSizeMultiplier, 0, Math.PI * 2);
ctx.fill();
// 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;
if (currentSpeed > maxSpeed) {
f.vx = (f.vx / currentSpeed) * maxSpeed;
f.vy = (f.vy / currentSpeed) * maxSpeed;
}
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();
});
// 4. Smooth Friction
f.vx *= 0.97;
f.vy *= 0.97;
animationFrameId = requestAnimationFrame(draw);
};
f.x += f.vx;
f.y += f.vy;
draw();
// 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;
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener("mousemove", handleMouseMove);
}
cancelAnimationFrame(animationFrameId);
};
}, [
seed,
brightColors,
count,
baseSpeed,
evasionRadius,
evasionStrength,
glowSizeMultiplier,
flickerSpeed,
theme,
]);
// 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
}}
/>
);
return (
<canvas
ref={canvasRef}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 1,
}}
/>
);
};
export default FirefliesBanner;
+72 -20
View File
@@ -1,32 +1,84 @@
import React from 'react';
import { Box, Container, Typography, Link as MuiLink, Divider } from '@mui/material';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import React from "react";
import {
Box,
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 { t } = useTranslation();
const year = new Date().getFullYear();
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">
<Box sx={{ display: 'flex', 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 }}>
<MuiLink component={Link} to="/about" color="inherit" sx={{ fontSize: '0.8rem', 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
sx={{
display: "flex",
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 }}>
<MuiLink
component={Link}
to="/about"
color="inherit"
sx={{
fontSize: "0.8rem",
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>
<Divider sx={{ my: 2, opacity: 0.5 }} />
<Typography variant="caption" color="text.secondary" align="center" display="block">
{t('nav.about')} • Fan Project
<Typography
variant="caption"
color="text.secondary"
align="center"
display="block"
>
{t("nav.about")} • Fan Project
</Typography>
</Container>
</Box>
+299 -254
View File
@@ -1,278 +1,323 @@
import React, { useRef, useEffect } from 'react';
import { alpha, useTheme } from '@mui/material';
import React, { useRef, useEffect } from "react";
import { alpha, useTheme } from "@mui/material";
interface GenerativeBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
nodeCountBase?: number;
connectionDist?: number;
speed?: number;
opacity?: number;
};
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
nodeCountBase?: number;
connectionDist?: number;
speed?: number;
opacity?: number;
};
}
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const nodeCountBase = settings?.nodeCountBase ?? 60;
const connectionDist = settings?.connectionDist ?? 140;
const speed = settings?.speed ?? 0.005;
const globalOpacity = settings?.opacity ?? 0.35;
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({
username,
palette,
ratingsCount,
followersCount,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
const nodeCountBase = settings?.nodeCountBase ?? 60;
const connectionDist = settings?.connectionDist ?? 140;
const speed = settings?.speed ?? 0.005;
const globalOpacity = settings?.opacity ?? 0.35;
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return;
let animationFrameId: number;
const seed = getSeed(username);
// 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 canvas = canvasRef.current;
if (!canvas) return;
const initNodes = (width: number, height: number) => {
const newNodes = [];
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 });
if (!ctx) return;
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);
}
};
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];
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
interface Node {
x: number;
y: number;
vx: number;
vy: number;
baseX: number;
baseY: number;
color: string;
size: number;
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 = () => {
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[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
// Damping (Friction) - stronger damping for less "fast" feel
node.vx *= 0.88;
node.vy *= 0.88;
const initNodes = (width: number, height: number) => {
const newNodes = [];
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
});
// 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;
}
}
}
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 = () => {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
// 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();
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;
// 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();
});
// 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;
}
}
animationFrameId = requestAnimationFrame(draw);
};
// Damping (Friction) - stronger damping for less "fast" feel
node.vx *= 0.88;
node.vy *= 0.88;
draw();
// Update Position
node.x += node.vx;
node.y += node.vy;
});
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,
]);
// 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'
}}
/>
);
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;
+241 -192
View File
@@ -1,223 +1,272 @@
import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
import React, { useRef, useEffect, useMemo } from "react";
import { alpha, useTheme } from "@mui/material";
interface GenerativeFlowProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
blobCount?: number;
flowSpeed?: number;
vibrancy?: number;
blur?: number;
distortion?: number;
};
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
blobCount?: number;
flowSpeed?: number;
vibrancy?: number;
blur?: number;
distortion?: number;
};
}
const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const blobCount = settings?.blobCount ?? 8;
const flowSpeed = settings?.flowSpeed ?? 0.008;
const vibrancy = settings?.vibrancy ?? 0.8;
const blurAmount = settings?.blur ?? 60;
const distortionFactor = settings?.distortion ?? 1.2;
const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({
username,
palette,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
const blobCount = settings?.blobCount ?? 8;
const flowSpeed = settings?.flowSpeed ?? 0.008;
const vibrancy = settings?.vibrancy ?? 0.8;
const blurAmount = settings?.blur ?? 60;
const distortionFactor = settings?.distortion ?? 1.2;
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - 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 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]);
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`;
blobs = initBlobs(rect.width, rect.height);
}
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
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;
};
let animationFrameId: number;
let time = 0;
const parent = canvas.parentElement;
if (parent) {
parent.addEventListener("mousemove", handleMouseMove);
parent.addEventListener("mouseleave", () => {
mouse.x = -1000;
mouse.y = -1000;
});
}
interface Blob {
x: number;
y: number;
ox: number;
oy: number;
vx: number;
vy: number;
size: number;
color: string;
phase: number;
speedFactor: number;
}
const draw = () => {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
let blobs: Blob[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
ctx.clearRect(0, 0, width, height);
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;
};
// Background Base
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[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 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`;
blobs = initBlobs(rect.width, rect.height);
}
};
time += flowSpeed;
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
// Apply filter for "Liquid" look
ctx.filter = `blur(${blurAmount}px)`;
ctx.globalCompositeOperation = "screen";
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;
};
blobs.forEach((b) => {
// 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);
const parent = canvas.parentElement;
if (parent) {
parent.addEventListener('mousemove', handleMouseMove);
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; });
}
// Mouse distortion
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 draw = () => {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
const mX = dxMouse * mouseForce * distortionFactor;
const mY = dyMouse * mouseForce * distortionFactor;
ctx.clearRect(0, 0, width, height);
// Background Base
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[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);
b.x = b.ox + driftX + mX;
b.y = b.oy + driftY + mY;
time += flowSpeed;
// Pulsing size
const pulse = 1 + Math.sin(time + b.phase) * 0.1;
const currentSize = b.size * pulse;
// Apply filter for "Liquid" look
ctx.filter = `blur(${blurAmount}px)`;
ctx.globalCompositeOperation = 'screen';
const grad = ctx.createRadialGradient(
b.x,
b.y,
0,
b.x,
b.y,
currentSize,
);
grad.addColorStop(0, alpha(b.color, vibrancy));
grad.addColorStop(1, alpha(b.color, 0));
blobs.forEach((b) => {
// 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
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;
const mY = dyMouse * mouseForce * distortionFactor;
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(b.x, b.y, currentSize, 0, Math.PI * 2);
ctx.fill();
});
b.x = b.ox + driftX + mX;
b.y = b.oy + driftY + mY;
ctx.filter = "none";
ctx.globalCompositeOperation = "source-over";
// Pulsing size
const pulse = 1 + Math.sin(time + b.phase) * 0.1;
const currentSize = b.size * pulse;
animationFrameId = requestAnimationFrame(draw);
};
const grad = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, currentSize);
grad.addColorStop(0, alpha(b.color, vibrancy));
grad.addColorStop(1, alpha(b.color, 0));
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(b.x, b.y, currentSize, 0, Math.PI * 2);
ctx.fill();
});
draw();
ctx.filter = 'none';
ctx.globalCompositeOperation = 'source-over';
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener("mousemove", handleMouseMove);
}
cancelAnimationFrame(animationFrameId);
};
}, [
seed,
brightColors,
blobCount,
flowSpeed,
vibrancy,
blurAmount,
distortionFactor,
theme,
]);
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
}}
/>
);
return (
<canvas
ref={canvasRef}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 1,
}}
/>
);
};
export default GenerativeFlowBanner;
+298 -241
View File
@@ -1,269 +1,326 @@
import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
import React, { useRef, useEffect, useMemo } from "react";
import { alpha, useTheme } from "@mui/material";
interface HextechBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
gridSize?: number;
pulseSpeed?: number;
glowIntensity?: number;
streamCount?: number;
distortion?: number;
};
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
gridSize?: number;
pulseSpeed?: number;
glowIntensity?: number;
streamCount?: number;
distortion?: number;
};
}
const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const gridSize = settings?.gridSize ?? 40;
const pulseSpeed = settings?.pulseSpeed ?? 0.02;
const glowIntensity = settings?.glowIntensity ?? 0.8;
const streamCount = settings?.streamCount ?? 12;
const distortionFactor = settings?.distortion ?? 0.5;
const HextechBanner: React.FC<HextechBannerProps> = ({
username,
palette,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
const gridSize = settings?.gridSize ?? 40;
const pulseSpeed = settings?.pulseSpeed ?? 0.02;
const glowIntensity = settings?.glowIntensity ?? 0.8;
const streamCount = settings?.streamCount ?? 12;
const distortionFactor = settings?.distortion ?? 0.5;
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - 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(() => {
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]);
let streams: Stream[] = [];
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const createStream = (width: number, height: number) => {
const color =
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 });
if (!ctx) return;
return {
segments: [{ x, y }],
color,
life: 1.0,
speed: 2 + pseudoRandom(time + 3) * 4,
};
};
let animationFrameId: number;
let time = 0;
const drawHexagon = (x: number, y: number, r: number, tilt: number) => {
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 x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
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`;
}
};
// Mana Stream Logic
interface Stream {
segments: { x: number, y: number }[];
color: string;
life: number;
speed: number;
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
let mouse = { x: -1000, y: -1000, down: false };
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
};
const handleMouseDown = () => {
mouse.down = true;
};
const handleMouseUp = () => {
mouse.down = false;
};
const parent = canvas.parentElement;
if (parent) {
parent.addEventListener("mousemove", handleMouseMove);
parent.addEventListener("mousedown", handleMouseDown);
parent.addEventListener("mouseup", handleMouseUp);
parent.addEventListener("mouseleave", () => {
mouse.x = -1000;
mouse.y = -1000;
mouse.down = false;
});
}
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) => {
const color = brightColors[Math.floor(pseudoRandom(time) * brightColors.length)];
let x = pseudoRandom(time + 1) * width;
let y = pseudoRandom(time + 2) * height;
return {
segments: [{ x, y }],
color,
life: 1.0,
speed: 2 + pseudoRandom(time + 3) * 4
};
};
ctx.globalCompositeOperation = "screen";
streams.forEach((s, idx) => {
s.life -= 0.005;
const last = s.segments[s.segments.length - 1];
const drawHexagon = (x: number, y: number, r: number, tilt: number) => {
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 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;
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`;
}
};
s.segments.push({ x: nextX, y: nextY });
if (s.segments.length > 20) s.segments.shift();
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
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();
let mouse = { x: -1000, y: -1000, down: false };
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
};
const handleMouseDown = () => { mouse.down = true; };
const handleMouseUp = () => { mouse.down = false; };
const 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";
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; });
}
streams = streams.filter((s) => s.life > 0);
const draw = () => {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
animationFrameId = requestAnimationFrame(draw);
};
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);
draw();
time += pulseSpeed;
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,
]);
// 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
}}
/>
);
return (
<canvas
ref={canvasRef}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 1,
}}
/>
);
};
export default HextechBanner;
@@ -1,258 +1,294 @@
import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
import React, { useRef, useEffect, useMemo } from "react";
import { alpha, useTheme } from "@mui/material";
interface HextechCorruptionBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
cellCount?: number;
shimmerSpeed?: number;
glowIntensity?: number;
instability?: number;
shellDarkness?: number;
};
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
cellCount?: number;
shimmerSpeed?: number;
glowIntensity?: number;
instability?: number;
shellDarkness?: number;
};
}
const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ username, palette, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const cellCount = settings?.cellCount ?? 40;
const shimmerSpeed = settings?.shimmerSpeed ?? 0.015;
const glowIntensity = settings?.glowIntensity ?? 0.9;
const instability = settings?.instability ?? 0.6;
const shellDarkness = settings?.shellDarkness ?? 0.85;
const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({
username,
palette,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
const cellCount = settings?.cellCount ?? 40;
const shimmerSpeed = settings?.shimmerSpeed ?? 0.015;
const glowIntensity = settings?.glowIntensity ?? 0.9;
const instability = settings?.instability ?? 0.6;
const shellDarkness = settings?.shellDarkness ?? 0.85;
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - 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 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]);
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`;
points = initPoints(rect.width, rect.height);
}
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
let mouse = { x: -1000, y: -1000, down: false };
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
};
const handleMouseDown = () => {
mouse.down = true;
};
const handleMouseUp = () => {
mouse.down = false;
};
let animationFrameId: number;
let time = 0;
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;
});
}
interface ControlPoint {
x: number;
y: number;
ox: number;
oy: number;
vx: number;
vy: number;
phase: number;
color: string;
}
const draw = () => {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
let points: ControlPoint[] = [];
ctx.clearRect(0, 0, width, height);
time += shimmerSpeed;
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
// 1. Draw Under-Glow (Inner Cores)
ctx.globalCompositeOperation = "source-over";
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 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 driftX = Math.sin(time + p.phase) * 20 * instability;
const driftY = Math.cos(time * 0.8 + p.phase) * 20 * instability;
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`;
points = initPoints(rect.width, rect.height);
}
};
p.x = p.ox + driftX + (mouse.down ? -dxMouse * mouseForce * 0.5 : 0);
p.y = p.oy + driftY + (mouse.down ? -dyMouse * mouseForce * 0.5 : 0);
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
const pulse = 0.7 + Math.sin(time * 3 + p.phase) * 0.3;
const glowSize = 60 + mouseForce * 40;
let mouse = { x: -1000, y: -1000, down: false };
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
};
const handleMouseDown = () => { mouse.down = true; };
const handleMouseUp = () => { mouse.down = false; };
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize);
grad.addColorStop(0, alpha(p.color, 0.8 * glowIntensity * pulse));
grad.addColorStop(0.5, alpha(p.color, 0.3 * glowIntensity));
grad.addColorStop(1, alpha(p.color, 0));
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; });
}
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(p.x, p.y, glowSize, 0, Math.PI * 2);
ctx.fill();
});
const draw = () => {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
// 2. Draw Dark "Void" Shell (Voronoi-based cutout)
// We use a temporary canvas to draw the shell then mask it, or just use a heavy overlay
ctx.globalCompositeOperation = "source-over";
ctx.clearRect(0, 0, width, height);
time += shimmerSpeed;
// Create the "holes" effect by using a pattern of circles that are actually transparent
// 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)
ctx.globalCompositeOperation = 'source-over';
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 driftX = Math.sin(time + p.phase) * 20 * instability;
const driftY = Math.cos(time * 0.8 + p.phase) * 20 * instability;
p.x = p.ox + driftX + (mouse.down ? -dxMouse * mouseForce * 0.5 : 0);
p.y = p.oy + driftY + (mouse.down ? -dyMouse * mouseForce * 0.5 : 0);
const offCanvas = document.createElement("canvas");
offCanvas.width = canvas.width;
offCanvas.height = canvas.height;
const offCtx = offCanvas.getContext("2d");
if (offCtx) {
const dpr = window.devicePixelRatio || 1;
offCtx.scale(dpr, dpr);
const pulse = 0.7 + Math.sin(time * 3 + p.phase) * 0.3;
const glowSize = 60 + (mouseForce * 40);
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize);
grad.addColorStop(0, alpha(p.color, 0.8 * glowIntensity * pulse));
grad.addColorStop(0.5, alpha(p.color, 0.3 * glowIntensity));
grad.addColorStop(1, alpha(p.color, 0));
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(p.x, p.y, glowSize, 0, Math.PI * 2);
ctx.fill();
});
// Draw full dark shell
const shellColor =
theme.palette.mode === "light"
? alpha("#1a0a2a", shellDarkness)
: alpha("#05020a", shellDarkness);
offCtx.fillStyle = shellColor;
offCtx.fillRect(0, 0, width, height);
// 2. Draw Dark "Void" Shell (Voronoi-based cutout)
// We use a temporary canvas to draw the shell then mask it, or just use a heavy overlay
ctx.globalCompositeOperation = 'source-over';
// Create the "holes" effect by using a pattern of circles that are actually transparent
// 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.
const offCanvas = document.createElement('canvas');
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
const shellColor = theme.palette.mode === 'light' ? alpha('#1a0a2a', shellDarkness) : alpha('#05020a', shellDarkness);
offCtx.fillStyle = shellColor;
offCtx.fillRect(0, 0, width, height);
// Cut holes
offCtx.globalCompositeOperation = 'destination-out';
points.forEach(p => {
const holeSize = 15 + Math.sin(time + p.phase) * 5 + (instability * 10);
// Main hole
offCtx.beginPath();
offCtx.arc(p.x, p.y, holeSize, 0, Math.PI * 2);
offCtx.fill();
// Add secondary smaller holes for that "punctured" look
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);
}
// Cut holes
offCtx.globalCompositeOperation = "destination-out";
points.forEach((p) => {
const holeSize = 15 + Math.sin(time + p.phase) * 5 + instability * 10;
// 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();
});
// Main hole
offCtx.beginPath();
offCtx.arc(p.x, p.y, holeSize, 0, Math.PI * 2);
offCtx.fill();
ctx.globalCompositeOperation = 'source-over';
animationFrameId = requestAnimationFrame(draw);
};
// Add secondary smaller holes for that "punctured" look
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();
}
});
draw();
ctx.drawImage(offCanvas, 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, cellCount, shimmerSpeed, glowIntensity, instability, shellDarkness, theme]);
// 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();
});
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1
}}
/>
);
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;
+202 -180
View File
@@ -1,199 +1,221 @@
import React, { useState, useEffect, useRef } from 'react';
import {
TextField,
Popper,
Paper,
MenuList,
MenuItem,
Avatar,
ListItemAvatar,
ListItemText,
Box,
alpha
} from '@mui/material';
import api from '../api';
import React, { useState, useEffect, useRef } from "react";
import {
TextField,
Popper,
Paper,
MenuList,
MenuItem,
Avatar,
ListItemAvatar,
ListItemText,
Box,
alpha,
} from "@mui/material";
import api from "../api";
interface UserSuggestion {
id: number;
username: string;
avatar: string | null;
id: number;
username: string;
avatar: string | null;
}
interface MentionTextFieldProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
fullWidth?: boolean;
multiline?: boolean;
rows?: number;
size?: 'small' | 'medium';
onKeyDown?: (e: React.KeyboardEvent) => void;
value: string;
onChange: (value: string) => void;
placeholder?: string;
fullWidth?: boolean;
multiline?: boolean;
rows?: number;
size?: "small" | "medium";
onKeyDown?: (e: React.KeyboardEvent) => void;
}
const MentionTextField: React.FC<MentionTextFieldProps> = ({
value,
onChange,
placeholder,
fullWidth = true,
multiline = false,
rows,
size = 'small',
onKeyDown
value,
onChange,
placeholder,
fullWidth = true,
multiline = false,
rows,
size = "small",
onKeyDown,
}) => {
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
// Fetch users for suggestions (we could cache this or search on demand)
const [allUsers, setAllUsers] = useState<UserSuggestion[]>([]);
// Fetch users for suggestions (we could cache this or search on demand)
const [allUsers, setAllUsers] = useState<UserSuggestion[]>([]);
useEffect(() => {
const fetchUsers = async () => {
try {
const res = await api.get('flavors/search/');
const users = (res.data || [])
.filter((item: any) => item.type === 'user')
.map((item: any) => ({
id: item.id,
username: item.name,
avatar: item.image_url
}));
setAllUsers(users);
} catch (err) {
console.error('Failed to fetch users for mentions');
}
};
fetchUsers();
}, []);
useEffect(() => {
const fetchUsers = async () => {
try {
const res = await api.get("flavors/search/");
const users = (res.data || [])
.filter((item: any) => item.type === "user")
.map((item: any) => ({
id: item.id,
username: item.name,
avatar: item.image_url,
}));
setAllUsers(users);
} catch (err) {
console.error("Failed to fetch users for mentions");
}
};
fetchUsers();
}, []);
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
onChange(newValue);
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
onChange(newValue);
const selectionStart = e.target.selectionStart || 0;
const textBeforeCursor = newValue.substring(0, selectionStart);
const lastAtPos = textBeforeCursor.lastIndexOf('@');
const selectionStart = e.target.selectionStart || 0;
const textBeforeCursor = newValue.substring(0, selectionStart);
const lastAtPos = textBeforeCursor.lastIndexOf("@");
if (lastAtPos !== -1) {
const query = textBeforeCursor.substring(lastAtPos + 1);
// Ensure there's no space or newline between @ and the cursor
if (!query.includes(' ') && !query.includes('\n')) {
const filtered = allUsers.filter(u =>
u.username.toLowerCase().startsWith(query.toLowerCase())
);
setSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
setSelectedIndex(0);
setAnchorEl(inputRef.current);
return;
}
}
if (lastAtPos !== -1) {
const query = textBeforeCursor.substring(lastAtPos + 1);
// Ensure there's no space or newline between @ and the cursor
if (!query.includes(" ") && !query.includes("\n")) {
const filtered = allUsers.filter((u) =>
u.username.toLowerCase().startsWith(query.toLowerCase()),
);
setSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
setSelectedIndex(0);
setAnchorEl(inputRef.current);
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);
};
return;
}
}
if (onKeyDown) onKeyDown(e);
};
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);
return;
}
}
if (onKeyDown) onKeyDown(e);
};
return (
<Box sx={{ position: 'relative', width: fullWidth ? '100%' : 'auto' }}>
<TextField
inputRef={inputRef}
fullWidth={fullWidth}
multiline={multiline}
rows={rows}
size={size}
placeholder={placeholder}
value={value}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
autoComplete="off"
/>
<Popper
open={showSuggestions}
anchorEl={anchorEl}
placement="bottom-start"
style={{ zIndex: 2000, width: 250 }}
>
<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>
);
return (
<Box sx={{ position: "relative", width: fullWidth ? "100%" : "auto" }}>
<TextField
inputRef={inputRef}
fullWidth={fullWidth}
multiline={multiline}
rows={rows}
size={size}
placeholder={placeholder}
value={value}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
autoComplete="off"
/>
<Popper
open={showSuggestions}
anchorEl={anchorEl}
placement="bottom-start"
style={{ zIndex: 2000, width: 250 }}
>
<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;
+315 -270
View File
@@ -1,294 +1,339 @@
import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
import React, { useRef, useEffect, useMemo } from "react";
import { alpha, useTheme } from "@mui/material";
interface NebulaBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
particleCount?: number;
speed?: number;
opacity?: number;
smearRadius?: number;
turbulence?: number;
returnSpeed?: number;
starDensity?: number;
};
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
settings?: {
particleCount?: number;
speed?: number;
opacity?: number;
smearRadius?: number;
turbulence?: number;
returnSpeed?: number;
starDensity?: number;
};
}
const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const particleCount = settings?.particleCount ?? 400;
const speed = settings?.speed ?? 0.0015;
const globalOpacity = settings?.opacity ?? 0.6;
const smearRadius = settings?.smearRadius ?? 120;
const turbulenceStrength = settings?.turbulence ?? 0.4;
const returnSpeed = settings?.returnSpeed ?? 0.004; // Much lower for smoke-like feel
const starDensity = settings?.starDensity ?? 150;
const NebulaBanner: React.FC<NebulaBannerProps> = ({
username,
palette,
ratingsCount,
followersCount,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
const particleCount = settings?.particleCount ?? 400;
const speed = settings?.speed ?? 0.0015;
const globalOpacity = settings?.opacity ?? 0.6;
const smearRadius = settings?.smearRadius ?? 120;
const turbulenceStrength = settings?.turbulence ?? 0.4;
const returnSpeed = settings?.returnSpeed ?? 0.004; // Much lower for smoke-like feel
const starDensity = settings?.starDensity ?? 150;
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - 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(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Init Gas Clusters
const newParticles = [];
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 });
if (!ctx) return;
for (let i = 0; i < particleCount; i++) {
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;
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
newParticles.push({
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 {
x: number;
y: number;
baseX: number;
baseY: number;
vx: number;
vy: number;
size: number;
color: string;
alpha: number;
targetAlpha: number;
phase: number;
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`;
const scene = initScene(rect.width, rect.height);
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 {
x: number;
y: number;
size: number;
opacity: number;
}
// Smooth drift
p.vx += Math.sin(time + p.phase) * 0.03;
p.vy += Math.cos(time * 0.8 + p.phase) * 0.03;
let particles: Particle[] = [];
let stars: Star[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
// High friction for gas feel
p.vx *= 0.95;
p.vy *= 0.95;
p.x += p.vx;
p.y += p.vy;
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
});
}
if (p.alpha < p.targetAlpha) p.alpha += 0.001;
// Init Gas Clusters
const newParticles = [];
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
}));
// 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));
for (let i = 0; i < particleCount; i++) {
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;
ctx.fillStyle = grad;
ctx.globalCompositeOperation = "screen";
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
});
newParticles.push({
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 };
};
ctx.globalCompositeOperation = "source-over";
animationFrameId = requestAnimationFrame(draw);
};
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`;
const scene = initScene(rect.width, rect.height);
stars = scene.stars;
particles = scene.particles;
}
};
draw();
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
resize();
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,
]);
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;
}
}
// 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
}}
/>
);
return (
<canvas
ref={canvasRef}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 1,
}}
/>
);
};
export default NebulaBanner;
+64 -48
View File
@@ -1,55 +1,71 @@
import React from 'react';
import { Box, Typography, useTheme } from '@mui/material';
import React from "react";
import { Box, Typography, useTheme } from "@mui/material";
interface RatingBadgeProps {
score: number;
size?: 'small' | 'medium' | 'large';
sx?: any;
score: number;
size?: "small" | "medium" | "large";
sx?: any;
}
const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx }) => {
const theme = useTheme();
const isLarge = size === 'large';
const isSmall = size === 'small';
return (
<Box sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
bgcolor: 'primary.main',
color: 'primary.contrastText',
px: isLarge ? 2 : (isSmall ? 1 : 1.5),
py: isLarge ? 0.5 : (isSmall ? 0.2 : 0.4),
borderRadius: 2,
minWidth: 'fit-content',
height: 'fit-content',
boxShadow: theme.palette.mode === 'dark'
? '0 4px 12px rgba(0,0,0,0.4)'
: '0 2px 8px rgba(0,0,0,0.1)',
...sx
}}>
<Typography
variant={isLarge ? "h5" : (isSmall ? "caption" : "subtitle1")}
sx={{ fontWeight: '900', lineHeight: 1, display: 'flex', alignItems: 'center' }}
>
{typeof score === 'number' ? (isLarge ? score.toFixed(1) : score.toFixed(0)) : score}
</Typography>
<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>
);
const RatingBadge: React.FC<RatingBadgeProps> = ({
score,
size = "medium",
sx,
}) => {
const theme = useTheme();
const isLarge = size === "large";
const isSmall = size === "small";
return (
<Box
sx={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 0.5,
bgcolor: "primary.main",
color: "primary.contrastText",
px: isLarge ? 2 : isSmall ? 1 : 1.5,
py: isLarge ? 0.5 : isSmall ? 0.2 : 0.4,
borderRadius: 2,
minWidth: "fit-content",
height: "fit-content",
boxShadow:
theme.palette.mode === "dark"
? "0 4px 12px rgba(0,0,0,0.4)"
: "0 2px 8px rgba(0,0,0,0.1)",
...sx,
}}
>
<Typography
variant={isLarge ? "h5" : isSmall ? "caption" : "subtitle1"}
sx={{
fontWeight: "900",
lineHeight: 1,
display: "flex",
alignItems: "center",
}}
>
{typeof score === "number"
? isLarge
? score.toFixed(1)
: score.toFixed(0)
: score}
</Typography>
<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;
+33 -33
View File
@@ -1,43 +1,43 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useTheme, alpha } from '@mui/material';
import React from "react";
import { Link } from "react-router-dom";
import { useTheme, alpha } from "@mui/material";
interface RichTextProps {
text: string;
text: string;
}
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);
return (
<span>
{parts.map((part, i) => {
if (part.startsWith('@')) {
const username = part.substring(1);
return (
<Link
key={i}
to={`/profile/${username}`}
style={{
color: theme.palette.primary.main,
textDecoration: 'none',
fontWeight: 'bold',
backgroundColor: alpha(theme.palette.primary.main, 0.1),
padding: '0 4px',
borderRadius: '4px'
}}
>
{part}
</Link>
);
}
return part;
})}
</span>
);
const parts = text.split(/(@\w+)/g);
return (
<span>
{parts.map((part, i) => {
if (part.startsWith("@")) {
const username = part.substring(1);
return (
<Link
key={i}
to={`/profile/${username}`}
style={{
color: theme.palette.primary.main,
textDecoration: "none",
fontWeight: "bold",
backgroundColor: alpha(theme.palette.primary.main, 0.1),
padding: "0 4px",
borderRadius: "4px",
}}
>
{part}
</Link>
);
}
return part;
})}
</span>
);
};
export default RichText;
+55 -46
View File
@@ -1,56 +1,65 @@
import React from 'react';
import { Box, Typography, alpha, useTheme } from '@mui/material';
import HistoryIcon from '@mui/icons-material/History';
import BlockIcon from '@mui/icons-material/Block';
import React from "react";
import { Box, Typography, alpha, useTheme } from "@mui/material";
import HistoryIcon from "@mui/icons-material/History";
import BlockIcon from "@mui/icons-material/Block";
interface StatusBadgeProps {
isLegacy?: boolean;
isAvailable?: boolean;
size?: 'small' | 'medium';
sx?: any;
isLegacy?: boolean;
isAvailable?: boolean;
size?: "small" | "medium";
sx?: any;
}
const StatusBadge: React.FC<StatusBadgeProps> = ({ isLegacy, isAvailable, size = 'medium', sx }) => {
const theme = useTheme();
const isSmall = size === 'small';
const StatusBadge: React.FC<StatusBadgeProps> = ({
isLegacy,
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 color = isLegacy ? theme.palette.warning.main : theme.palette.error.main;
const Icon = isLegacy ? HistoryIcon : BlockIcon;
const label = isLegacy ? "Limited" : "Out of Stock";
const color = isLegacy
? theme.palette.warning.main
: theme.palette.error.main;
const Icon = isLegacy ? HistoryIcon : BlockIcon;
return (
<Box sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
bgcolor: alpha(color, 0.9),
color: '#fff',
px: isSmall ? 0.8 : 1.2,
py: isSmall ? 0.2 : 0.4,
borderRadius: 1.5,
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
backdropFilter: 'blur(4px)',
border: '1px solid',
borderColor: alpha('#fff', 0.2),
...sx
}}>
<Icon sx={{ fontSize: isSmall ? '0.7rem' : '0.9rem' }} />
<Typography
variant="caption"
sx={{
fontWeight: '900',
textTransform: 'uppercase',
letterSpacing: 0.5,
fontSize: isSmall ? '0.55rem' : '0.65rem',
lineHeight: 1
}}
>
{label}
</Typography>
</Box>
);
return (
<Box
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.5,
bgcolor: alpha(color, 0.9),
color: "#fff",
px: isSmall ? 0.8 : 1.2,
py: isSmall ? 0.2 : 0.4,
borderRadius: 1.5,
boxShadow: "0 4px 12px rgba(0,0,0,0.2)",
backdropFilter: "blur(4px)",
border: "1px solid",
borderColor: alpha("#fff", 0.2),
...sx,
}}
>
<Icon sx={{ fontSize: isSmall ? "0.7rem" : "0.9rem" }} />
<Typography
variant="caption"
sx={{
fontWeight: "900",
textTransform: "uppercase",
letterSpacing: 0.5,
fontSize: isSmall ? "0.55rem" : "0.65rem",
lineHeight: 1,
}}
>
{label}
</Typography>
</Box>
);
};
export default StatusBadge;
+1 -1
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect } from "react";
export const useTitle = (title: string) => {
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 { createRoot } from 'react-dom/client'
import { BrowserRouter as Router } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter as Router } from "react-router-dom";
import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById('root')!).render(
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Router>
<App />
</Router>
</StrictMode>,
)
);
+127 -58
View File
@@ -1,89 +1,158 @@
import React from 'react';
import { Typography, Box, Container, 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';
import React from "react";
import {
Typography,
Box,
Container,
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 { t } = useTranslation();
useTitle(t('nav.about'));
const isMobile = useMediaQuery('(max-width:600px)');
useTitle(t("nav.about"));
const isMobile = useMediaQuery("(max-width:600px)");
return (
<Container maxWidth="md" sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}>
<Button
variant="outlined"
component={Link}
to="/"
<Container
maxWidth="md"
sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}
>
<Button
variant="outlined"
component={Link}
to="/"
startIcon={<ArrowBackIcon />}
sx={{ mb: 4, textTransform: 'none', borderRadius: 2 }}
sx={{ mb: 4, textTransform: "none", borderRadius: 2 }}
>
{t('common.backToArchive')}
{t("common.backToArchive")}
</Button>
<Paper sx={{ p: { xs: 2.5, sm: 4, md: 6 }, 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')}
<Paper
sx={{
p: { xs: 2.5, sm: 4, md: 6 },
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>
<Box sx={{ my: 4 }}>
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.canDoTitle')}</Typography>
<Typography component="ul" sx={{ lineHeight: 2, pl: 2 }}>
<li>{t('about.featureExplore')}</li>
<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>
<Typography
variant={isMobile ? "h6" : "h5"}
gutterBottom
sx={{ fontWeight: "bold" }}
>
{t("about.canDoTitle")}
</Typography>
<Typography component="ul" sx={{ lineHeight: 2, pl: 2 }}>
<li>{t("about.featureExplore")}</li>
<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>
<Typography variant="body2" color="text.secondary" paragraph>
{t('about.disclaimer')}
{t("about.disclaimer")}
</Typography>
<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 }}>
<strong>{t('about.legalInfo')}</strong><br />
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name or Project Name]'}<br />
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}<br />
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}<br />
{t('about.region')}
<strong>{t("about.legalInfo")}</strong>
<br />
{import.meta.env.VITE_IMPRESSUM_NAME || "[Your Name or Project Name]"}
<br />
{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>
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('about.contact')}:</Typography>
<Typography variant="body2">
E-Mail: {import.meta.env.VITE_IMPRESSUM_EMAIL || '[Your Support Email Address]'}<br />
{import.meta.env.VITE_IMPRESSUM_PHONE && (
<>Telefon: {import.meta.env.VITE_IMPRESSUM_PHONE}<br /></>
)}
Internet: holy.narl.io
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
{t("about.contact")}:
</Typography>
<Typography variant="body2">
E-Mail:{" "}
{import.meta.env.VITE_IMPRESSUM_EMAIL ||
"[Your Support Email Address]"}
<br />
{import.meta.env.VITE_IMPRESSUM_PHONE && (
<>
Telefon: {import.meta.env.VITE_IMPRESSUM_PHONE}
<br />
</>
)}
Internet: holy.narl.io
</Typography>
</Box>
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('about.responsible')}</Typography>
<Typography variant="body2">
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}<br />
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}<br />
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
{t("about.responsible")}
</Typography>
<Typography variant="body2">
{import.meta.env.VITE_IMPRESSUM_NAME || "[Your Name]"}
<br />
{import.meta.env.VITE_IMPRESSUM_STREET || "[Your Street Address]"}
<br />
{import.meta.env.VITE_IMPRESSUM_CITY || "[Postal Code and City]"}
</Typography>
</Box>
<Box sx={{ mt: 4 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.disclaimerTitle')}</Typography>
<Typography variant="caption" color="text.secondary" 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>
<Typography variant="h6" gutterBottom sx={{ fontWeight: "bold" }}>
{t("about.disclaimerTitle")}
</Typography>
<Typography
variant="caption"
color="text.secondary"
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>
</Paper>
</Container>
File diff suppressed because it is too large. Load diff
+124 -85
View File
@@ -1,98 +1,137 @@
import React, { useState, useEffect } from 'react';
import {
Container, Typography, Box, Button,
Stack, CircularProgress, TextField, Paper
} from '@mui/material';
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';
import React, { useState, useEffect } from "react";
import {
Container,
Typography,
Box,
Button,
Stack,
CircularProgress,
TextField,
Paper,
} from "@mui/material";
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 { t } = useTranslation();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [score, setScore] = useState(0);
const [comment, setComment] = useState('');
const [rating, setRating] = useState<any>(null);
const [loading, setLoading] = useState(true);
const { t } = useTranslation();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [score, setScore] = useState(0);
const [comment, setComment] = useState("");
const [rating, setRating] = useState<any>(null);
const [loading, setLoading] = useState(true);
const fetchRating = async () => {
try {
const res = await api.get(`ratings/${id}/`);
setRating(res.data);
setScore(res.data.score);
setComment(res.data.comment || '');
} catch (err) {
console.error(err);
navigate('/admin-panel');
} finally {
setLoading(false);
}
};
const fetchRating = async () => {
try {
const res = await api.get(`ratings/${id}/`);
setRating(res.data);
setScore(res.data.score);
setComment(res.data.comment || "");
} catch (err) {
console.error(err);
navigate("/admin-panel");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchRating();
}, [id]);
useEffect(() => {
fetchRating();
}, [id]);
useTitle(rating ? `${t('admin.manageRating')}: ${rating.flavor_name}` : 'Rating Management');
useTitle(
rating
? `${t("admin.manageRating")}: ${rating.flavor_name}`
: "Rating Management",
);
const handleUpdate = async () => {
try {
await api.patch(`ratings/${id}/`, { score, comment });
fetchRating();
alert('Rating updated!');
} catch (err) { alert('Update failed'); }
};
const handleUpdate = async () => {
try {
await api.patch(`ratings/${id}/`, { score, comment });
fetchRating();
alert("Rating updated!");
} catch (err) {
alert("Update failed");
}
};
const handleDelete = async () => {
if (!window.confirm('Delete this rating and all replies?')) return;
try {
await api.delete(`ratings/${id}/`);
navigate('/admin-panel');
} catch (err) { alert('Delete failed'); }
};
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
const handleDelete = async () => {
if (!window.confirm("Delete this rating and all replies?")) return;
try {
await api.delete(`ratings/${id}/`);
navigate("/admin-panel");
} catch (err) {
alert("Delete failed");
}
};
if (loading)
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>
<Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
<CircularProgress />
</Box>
);
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;
+111 -79
View File
@@ -1,92 +1,124 @@
import React, { useState, useEffect } from 'react';
import {
Container, Typography, Box, Button,
Stack, CircularProgress, Paper
} from '@mui/material';
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';
import React, { useState, useEffect } from "react";
import {
Container,
Typography,
Box,
Button,
Stack,
CircularProgress,
Paper,
} from "@mui/material";
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 AdminReplyDetail: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [text, setText] = useState('');
const [reply, setReply] = useState<any>(null);
const [loading, setLoading] = useState(true);
const { t } = useTranslation();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [text, setText] = useState("");
const [reply, setReply] = useState<any>(null);
const [loading, setLoading] = useState(true);
const fetchReply = async () => {
try {
// We use the replies list/detail endpoint
const res = await api.get(`replies/${id}/`);
setReply(res.data);
setText(res.data.text);
} catch (err) {
console.error(err);
navigate('/admin-panel');
} finally {
setLoading(false);
}
};
const fetchReply = async () => {
try {
// We use the replies list/detail endpoint
const res = await api.get(`replies/${id}/`);
setReply(res.data);
setText(res.data.text);
} catch (err) {
console.error(err);
navigate("/admin-panel");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchReply();
}, [id]);
useEffect(() => {
fetchReply();
}, [id]);
useTitle(reply ? `${t('admin.manageReply')}` : 'Reply Management');
useTitle(reply ? `${t("admin.manageReply")}` : "Reply Management");
const handleUpdate = async () => {
try {
await api.patch(`replies/${id}/`, { text });
fetchReply();
alert('Reply updated!');
} catch (err) { alert('Update failed'); }
};
const handleUpdate = async () => {
try {
await api.patch(`replies/${id}/`, { text });
fetchReply();
alert("Reply updated!");
} catch (err) {
alert("Update failed");
}
};
const handleDelete = async () => {
if (!window.confirm('Delete this reply?')) return;
try {
await api.delete(`replies/${id}/`);
navigate('/admin-panel');
} catch (err) { alert('Delete failed'); }
};
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
const handleDelete = async () => {
if (!window.confirm("Delete this reply?")) return;
try {
await api.delete(`replies/${id}/`);
navigate("/admin-panel");
} catch (err) {
alert("Delete failed");
}
};
if (loading)
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>
<Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
<CircularProgress />
</Box>
);
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;
+592 -292
View File
@@ -1,314 +1,614 @@
import React, { useState, useEffect } from 'react';
import {
Container, Typography, Box, Card, CardContent, Button,
Stack, alpha, useTheme, CircularProgress, Chip, Paper, List, ListItem, ListItemText,
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';
import React, { useState, useEffect } from "react";
import {
Container,
Typography,
Box,
Card,
CardContent,
Button,
Stack,
alpha,
useTheme,
CircularProgress,
Chip,
Paper,
List,
ListItem,
ListItemText,
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 {
id: number;
username: string;
email: string;
is_active: boolean;
is_superuser: boolean;
date_joined: string;
last_login: string;
ips: string[];
ratings: any[];
id: number;
username: string;
email: string;
is_active: boolean;
is_superuser: boolean;
date_joined: string;
last_login: string;
ips: string[];
ratings: any[];
}
const AdminUserDetail: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const theme = useTheme();
const { id } = useParams<{ id: string }>();
const [user, setUser] = useState<AdminUser | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState(0);
const { t } = useTranslation();
const navigate = useNavigate();
const theme = useTheme();
const { id } = useParams<{ id: string }>();
const [user, setUser] = useState<AdminUser | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState(0);
// Edit state for Ratings
const [editingRatingId, setEditingRatingId] = useState<number | null>(null);
const [editScore, setEditScore] = useState(0);
const [editComment, setEditComment] = useState('');
// Edit state for Ratings
const [editingRatingId, setEditingRatingId] = useState<number | null>(null);
const [editScore, setEditScore] = useState(0);
const [editComment, setEditComment] = useState("");
// Edit state for Replies
const [editingReplyId, setEditingReplyId] = useState<number | null>(null);
const [editReplyText, setEditReplyText] = useState('');
// Edit state for Replies
const [editingReplyId, setEditingReplyId] = useState<number | null>(null);
const [editReplyText, setEditReplyText] = useState("");
const fetchUser = async () => {
try {
const res = await api.get(`admin-custom/${id}/user_detail/`);
setUser(res.data);
} catch (err) {
console.error(err);
navigate('/admin-panel');
} finally {
setLoading(false);
}
};
const fetchUser = async () => {
try {
const res = await api.get(`admin-custom/${id}/user_detail/`);
setUser(res.data);
} catch (err) {
console.error(err);
navigate("/admin-panel");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUser();
}, [id]);
useEffect(() => {
fetchUser();
}, [id]);
useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : 'User Management');
useTitle(
user ? `${t("admin.manageUser")}: ${user.username}` : "User Management",
);
const handleToggleActive = async () => {
if (!user) return;
try {
await api.patch(`admin-custom/${id}/user_detail/`, { is_active: !user.is_active });
fetchUser();
} catch (err) { alert('Action failed'); }
};
const handleToggleActive = async () => {
if (!user) return;
try {
await api.patch(`admin-custom/${id}/user_detail/`, {
is_active: !user.is_active,
});
fetchUser();
} catch (err) {
alert("Action failed");
}
};
const handleUpdateRating = async (ratingId: number) => {
try {
await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment });
setEditingRatingId(null);
fetchUser();
} catch (err) { alert('Update failed'); }
};
const handleUpdateRating = async (ratingId: number) => {
try {
await api.patch(`ratings/${ratingId}/`, {
score: editScore,
comment: editComment,
});
setEditingRatingId(null);
fetchUser();
} catch (err) {
alert("Update failed");
}
};
const handleDeleteRating = async (ratingId: number) => {
if (!confirm('Delete this rating?')) return;
try {
await api.delete(`ratings/${ratingId}/`);
fetchUser();
} catch (err) { alert('Delete failed'); }
};
const handleDeleteRating = async (ratingId: number) => {
if (!confirm("Delete this rating?")) return;
try {
await api.delete(`ratings/${ratingId}/`);
fetchUser();
} catch (err) {
alert("Delete failed");
}
};
const handleUpdateReply = async (replyId: number) => {
try {
await api.patch(`replies/${replyId}/`, { text: editReplyText });
setEditingReplyId(null);
fetchUser();
} catch (err) { alert('Update failed'); }
};
const handleUpdateReply = async (replyId: number) => {
try {
await api.patch(`replies/${replyId}/`, { text: editReplyText });
setEditingReplyId(null);
fetchUser();
} catch (err) {
alert("Update failed");
}
};
const handleDeleteReply = async (replyId: number) => {
if (!confirm('Delete this reply?')) return;
try {
await api.delete(`replies/${replyId}/`);
fetchUser();
} catch (err) { alert('Delete failed'); }
};
const handleDeleteReply = async (replyId: number) => {
if (!confirm("Delete this reply?")) return;
try {
await api.delete(`replies/${replyId}/`);
fetchUser();
} catch (err) {
alert("Delete failed");
}
};
const handleDelete = async () => {
if (!window.confirm('PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.')) return;
try {
await api.delete(`admin-custom/${id}/user_detail/`);
navigate('/admin-panel');
} catch (err) { alert('Delete failed'); }
};
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
if (!user) return <Typography>User not found</Typography>;
const handleDelete = async () => {
if (
!window.confirm(
"PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.",
)
)
return;
try {
await api.delete(`admin-custom/${id}/user_detail/`);
navigate("/admin-panel");
} catch (err) {
alert("Delete failed");
}
};
if (loading)
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>
<Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
<CircularProgress />
</Box>
);
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;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+218 -125
View File
@@ -1,26 +1,26 @@
import React, { useEffect, useState } from 'react';
import {
Typography,
Card,
CardContent,
Box,
TextField,
Button,
Accordion,
AccordionSummary,
import React, { useEffect, useState } from "react";
import {
Typography,
Card,
CardContent,
Box,
TextField,
Button,
Accordion,
AccordionSummary,
AccordionDetails,
Rating as MuiRating
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import api from '../api';
import RatingBadge from '../components/RatingBadge';
Rating as MuiRating,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import api from "../api";
import RatingBadge from "../components/RatingBadge";
interface Rating {
id: number;
user: string;
score: number;
comment: string;
created_at: string;
id: number;
user: string;
score: number;
comment: string;
created_at: string;
}
interface Flavor {
@@ -46,14 +46,16 @@ const FlavorList: React.FC = () => {
const [categories, setCategories] = useState<Category[]>([]);
const [flavors, setFlavors] = useState<Flavor[]>([]);
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(() => {
const fetchData = async () => {
try {
const [catRes, flavRes] = await Promise.all([
api.get('categories/'),
api.get('flavors/')
api.get("categories/"),
api.get("flavors/"),
]);
setCategories(catRes.data);
setFlavors(flavRes.data);
@@ -71,17 +73,19 @@ const FlavorList: React.FC = () => {
if (!input) return;
try {
await api.post('ratings/', {
await api.post("ratings/", {
flavor: flavorId,
score: input.score,
comment: input.comment
comment: input.comment,
});
// Refresh flavors
const res = await api.get('flavors/');
const res = await api.get("flavors/");
setFlavors(res.data);
alert('Rating submitted!');
alert("Rating submitted!");
} 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 (
<Box>
<Typography variant="h4" gutterBottom>Holy Flavors</Typography>
{categories.map(category => (
<Typography variant="h4" gutterBottom>
Holy Flavors
</Typography>
{categories.map((category) => (
<Box key={category.id} sx={{ mb: 4 }}>
<Typography variant="h5" color="primary" gutterBottom>{category.name}</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
{flavors.filter(f => f.category_name === category.name).map(flavor => (
<Box sx={{ flex: '1 1 30%', minWidth: 280 }} key={flavor.id}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', position: 'relative' }}>
{!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
}}>
<Typography variant="h5" color="primary" gutterBottom>
{category.name}
</Typography>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 3 }}>
{flavors
.filter((f) => f.category_name === category.name)
.map((flavor) => (
<Box sx={{ flex: "1 1 30%", minWidth: 280 }} key={flavor.id}>
<Card
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
position: "relative",
}}
>
{!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
</Box>
)}
{flavor.image_url && (
<Box
component="img"
src={flavor.image_url}
sx={{
width: '100%',
height: 200,
objectFit: 'contain',
p: 2,
bgcolor: 'rgba(255,255,255,0.05)'
}}
/>
)}
<CardContent sx={{ flexGrow: 1 }}>
<Typography variant="h6">{flavor.name}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{flavor.description}
</Typography>
{flavor.shop_url && (
<Button
variant="outlined"
size="small"
component="a"
href={flavor.shop_url}
target="_blank"
sx={{ mb: 2, fontSize: '0.7rem' }}
</Box>
)}
{flavor.image_url && (
<Box
component="img"
src={flavor.image_url}
sx={{
width: "100%",
height: 200,
objectFit: "contain",
p: 2,
bgcolor: "rgba(255,255,255,0.05)",
}}
/>
)}
<CardContent sx={{ flexGrow: 1 }}>
<Typography variant="h6">{flavor.name}</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 2 }}
>
{flavor.description}
</Typography>
{flavor.shop_url && (
<Button
variant="outlined"
size="small"
component="a"
href={flavor.shop_url}
target="_blank"
sx={{ mb: 2, fontSize: "0.7rem" }}
>
View in Shop
View in Shop
</Button>
)}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Typography variant="body2" sx={{ mr: 1 }}>Avg:</Typography>
<RatingBadge score={flavor.average_rating || 0} size="small" />
</Box>
)}
<Box
sx={{ display: "flex", alignItems: "center", mb: 1 }}
>
<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 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="caption">Comments ({flavor.ratings.filter(r => r.comment).length})</Typography>
</AccordionSummary>
<AccordionDetails sx={{ maxHeight: 200, overflow: 'auto', p: 1 }}>
{flavor.ratings.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>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="caption">
Comments (
{flavor.ratings.filter((r) => r.comment).length})
</Typography>
</AccordionSummary>
<AccordionDetails
sx={{ maxHeight: 200, overflow: "auto", p: 1 }}
>
{flavor.ratings
.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>
)}
)}
{flavor.user_rating ? (
<Typography variant="body2" color="secondary">Your Rating: {flavor.user_rating}/10</Typography>
) : (
<Accordion variant="outlined" sx={{ mt: 2 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="body2">Rate this flavor</Typography>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<MuiRating
max={10}
value={ratingInput[flavor.id]?.score || 0}
onChange={(_: any, val: number | null) => setRatingInput({...ratingInput, [flavor.id]: {...(ratingInput[flavor.id] || {comment: ''}), 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>
))}
{flavor.user_rating ? (
<Typography variant="body2" color="secondary">
Your Rating: {flavor.user_rating}/10
</Typography>
) : (
<Accordion variant="outlined" sx={{ mt: 2 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="body2">
Rate this flavor
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
<MuiRating
max={10}
value={ratingInput[flavor.id]?.score || 0}
onChange={(_: any, val: number | null) =>
setRatingInput({
...ratingInput,
[flavor.id]: {
...(ratingInput[flavor.id] || {
comment: "",
}),
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>
))}
+311 -219
View File
@@ -1,273 +1,365 @@
import React, { useState } from 'react';
import { Typography, TextField, Button, Paper, 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';
import React, { useState } from "react";
import {
Typography,
TextField,
Button,
Paper,
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 { t } = useTranslation();
useTitle('Login');
useTitle("Login");
const [tab, setTab] = useState(0);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [email, setEmail] = useState("");
const [verificationCode, setVerificationCode] = useState("");
const [isVerifying, setIsVerifying] = useState(false);
const [showResend, setShowResend] = useState(false);
const [isResetting, setIsResetting] = useState(false);
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) => {
e.preventDefault();
setMessage(null);
try {
const res = await api.post('token/', {
const res = await api.post("token/", {
username,
password,
});
localStorage.setItem('token', res.data.token);
window.location.href = '/';
localStorage.setItem("token", res.data.token);
window.location.href = "/";
} catch (err: any) {
const errorMsg = err.response?.data?.non_field_errors?.[0] || t('auth.loginFailed');
if (errorMsg.toLowerCase().includes('inactive') || errorMsg.toLowerCase().includes('verify') || errorMsg.includes('disabled')) {
setMessage({ type: 'error', text: t('auth.unverifiedError') });
setIsVerifying(true);
const errorMsg =
err.response?.data?.non_field_errors?.[0] || t("auth.loginFailed");
if (
errorMsg.toLowerCase().includes("inactive") ||
errorMsg.toLowerCase().includes("verify") ||
errorMsg.includes("disabled")
) {
setMessage({ type: "error", text: t("auth.unverifiedError") });
setIsVerifying(true);
} else {
setMessage({ type: 'error', text: errorMsg });
setMessage({ type: "error", text: errorMsg });
}
}
};
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
setMessage(null);
try {
await api.post('users/signup/', { username, email, password });
setIsVerifying(true);
setMessage({ type: 'success', text: t('auth.signupSuccess') });
} catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Signup failed' });
}
e.preventDefault();
setMessage(null);
try {
await api.post("users/signup/", { username, email, password });
setIsVerifying(true);
setMessage({ type: "success", text: t("auth.signupSuccess") });
} catch (err: any) {
setMessage({
type: "error",
text: err.response?.data?.error || "Signup failed",
});
}
};
const handleVerify = async (e: React.FormEvent) => {
e.preventDefault();
setMessage(null);
try {
await api.post('users/verify_signup/', { username, code: verificationCode });
setMessage({ type: 'success', text: t('auth.verifySuccess') });
setIsVerifying(false);
setTab(0);
setPassword('');
} catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Verification failed' });
setShowResend(true);
}
e.preventDefault();
setMessage(null);
try {
await api.post("users/verify_signup/", {
username,
code: verificationCode,
});
setMessage({ type: "success", text: t("auth.verifySuccess") });
setIsVerifying(false);
setTab(0);
setPassword("");
} catch (err: any) {
setMessage({
type: "error",
text: err.response?.data?.error || "Verification failed",
});
setShowResend(true);
}
};
const handleResendCode = async () => {
setMessage(null);
try {
await api.post('users/resend_verification/', { username });
setMessage({ type: 'success', text: t('auth.resendSuccess') });
setShowResend(false);
} catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to resend code' });
}
setMessage(null);
try {
await api.post("users/resend_verification/", { username });
setMessage({ type: "success", text: t("auth.resendSuccess") });
setShowResend(false);
} catch (err: any) {
setMessage({
type: "error",
text: err.response?.data?.error || "Failed to resend code",
});
}
};
const handleRequestReset = async (e: React.FormEvent) => {
e.preventDefault();
setMessage(null);
try {
await api.post('users/request_password_reset/', { email });
setResetStep(2);
setMessage({ type: 'success', 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.' });
}
e.preventDefault();
setMessage(null);
try {
await api.post("users/request_password_reset/", { email });
setResetStep(2);
setMessage({
type: "success",
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) => {
e.preventDefault();
setMessage(null);
try {
await api.post('users/complete_password_reset/', { email, code: verificationCode, password });
setMessage({ type: 'success', text: 'Password reset successful! You can now login.' });
setIsResetting(false);
setResetStep(1);
setTab(0);
setPassword('');
setVerificationCode('');
} catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Reset failed' });
}
e.preventDefault();
setMessage(null);
try {
await api.post("users/complete_password_reset/", {
email,
code: verificationCode,
password,
});
setMessage({
type: "success",
text: "Password reset successful! You can now login.",
});
setIsResetting(false);
setResetStep(1);
setTab(0);
setPassword("");
setVerificationCode("");
} catch (err: any) {
setMessage({
type: "error",
text: err.response?.data?.error || "Reset failed",
});
}
};
const renderContent = () => {
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>
);
}
if (isVerifying) {
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>
)}
</>
<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 (
<>
<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 (
<Container maxWidth="xs" sx={{ py: 8 }}>
<Button
variant="outlined"
component={Link}
to="/"
<Button
variant="outlined"
component={Link}
to="/"
startIcon={<ArrowBackIcon />}
sx={{ mb: 4, textTransform: 'none', borderRadius: 2 }}
sx={{ mb: 4, textTransform: "none", borderRadius: 2 }}
>
Back to Home
</Button>
<Paper sx={{
<Paper
sx={{
p: 3,
'& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 }
}}>
{message && <Alert severity={message.type} sx={{ mb: 2 }}>{message.text}</Alert>}
"& .MuiInputLabel-root": { bgcolor: "background.paper", px: 0.5 },
}}
>
{message && (
<Alert severity={message.type} sx={{ mb: 2 }}>
{message.text}
</Alert>
)}
{renderContent()}
</Paper>
</Container>
File diff suppressed because it is too large. Load diff
+170 -82
View File
@@ -1,105 +1,193 @@
import React from 'react';
import { Typography, Box, Container, 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';
import React from "react";
import {
Typography,
Box,
Container,
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 { t, i18n } = useTranslation();
const lang = i18n.language.startsWith('de') ? 'de' : 'en';
useTitle(t('privacy.title'));
const isMobile = useMediaQuery('(max-width:600px)');
const lang = i18n.language.startsWith("de") ? "de" : "en";
useTitle(t("privacy.title"));
const isMobile = useMediaQuery("(max-width:600px)");
const handleLangChange = (_: any, newLang: string | null) => {
if (newLang) {
i18n.changeLanguage(newLang);
}
if (newLang) {
i18n.changeLanguage(newLang);
}
};
return (
<Container maxWidth="md" sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4, flexWrap: 'wrap', gap: 2 }}>
<Button
variant="outlined"
component={Link}
to="/"
startIcon={<ArrowBackIcon />}
sx={{ textTransform: 'none', borderRadius: 2 }}
>
{t('common.back')}
</Button>
<Container
maxWidth="md"
sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 4,
flexWrap: "wrap",
gap: 2,
}}
>
<Button
variant="outlined"
component={Link}
to="/"
startIcon={<ArrowBackIcon />}
sx={{ textTransform: "none", borderRadius: 2 }}
>
{t("common.back")}
</Button>
<ToggleButtonGroup
value={lang}
exclusive
onChange={handleLangChange}
size="small"
>
<ToggleButton value="de">Deutsch</ToggleButton>
<ToggleButton value="en">English</ToggleButton>
</ToggleButtonGroup>
<ToggleButtonGroup
value={lang}
exclusive
onChange={handleLangChange}
size="small"
>
<ToggleButton value="de">Deutsch</ToggleButton>
<ToggleButton value="en">English</ToggleButton>
</ToggleButtonGroup>
</Box>
<Paper 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>
<Paper
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 }}>
{t('privacy.intro')}
{t("privacy.intro")}
</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 }}>
{t('privacy.controllerDesc')}<br />
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}<br />
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street]'}<br />
{import.meta.env.VITE_IMPRESSUM_CITY || '[Your City]'}
{t("privacy.controllerDesc")}
<br />
{import.meta.env.VITE_IMPRESSUM_NAME || "[Your Name]"}
<br />
{import.meta.env.VITE_IMPRESSUM_STREET || "[Your Street]"}
<br />
{import.meta.env.VITE_IMPRESSUM_CITY || "[Your City]"}
</Typography>
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>{t('privacy.collectionTitle')}</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 }}>
{t('privacy.transferDesc')}
<Typography
variant={isMobile ? "h6" : "h5"}
gutterBottom
sx={{ mt: 4, fontWeight: "bold" }}
>
{t("privacy.collectionTitle")}
</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 }}>
{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>
</Paper>
</Container>
File diff suppressed because it is too large. Load diff
+462 -308
View File
@@ -1,10 +1,10 @@
import React, { useState, useEffect } from 'react';
import {
Typography,
Box,
TextField,
Button,
Paper,
import React, { useState, useEffect } from "react";
import {
Typography,
Box,
TextField,
Button,
Paper,
Alert,
Container,
Avatar,
@@ -14,67 +14,72 @@ import {
MenuItem,
ListSubheader,
Divider,
Grid
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useNavigate } from 'react-router-dom';
import api from '../api';
import { useTranslation } from 'react-i18next';
import { useTitle } from '../hooks/useTitle';
import type { CatppuccinTheme } from '../theme';
Grid,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { useNavigate } from "react-router-dom";
import api from "../api";
import { useTranslation } from "react-i18next";
import { useTitle } from "../hooks/useTitle";
import type { CatppuccinTheme } from "../theme";
interface SettingsProps {
themeName: CatppuccinTheme;
onThemeChange: (newTheme: CatppuccinTheme) => void;
themeName: CatppuccinTheme;
onThemeChange: (newTheme: CatppuccinTheme) => void;
}
const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
useTitle(t('nav.settings'));
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [currentEmail, setCurrentEmail] = useState('');
useTitle(t("nav.settings"));
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [currentEmail, setCurrentEmail] = useState("");
const [avatar, setAvatar] = useState<string | null>(null);
const [language, setLanguage] = useState(i18n.language.split('-')[0]);
const [confirmationCode, setConfirmationCode] = useState('');
const [language, setLanguage] = useState(i18n.language.split("-")[0]);
const [confirmationCode, setConfirmationCode] = useState("");
const [pendingConfirmation, setPendingConfirmation] = useState(false);
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [deletionCode, setDeletionCode] = useState('');
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [deletionCode, setDeletionCode] = useState("");
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 [selectedBannerId, setSelectedBannerId] = useState<number | string>("");
const handleGoBack = () => {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate('/');
}
if (window.history.length > 1) {
navigate(-1);
} else {
navigate("/");
}
};
useEffect(() => {
const fetchUser = async () => {
try {
const [userRes, bannersRes] = await Promise.all([
api.get('users/me/'),
api.get('banners/')
api.get("users/me/"),
api.get("banners/"),
]);
setUsername(userRes.data.username);
setEmail(userRes.data.email);
setCurrentEmail(userRes.data.email);
setAvatar(userRes.data.avatar);
setSelectedBannerId(userRes.data.selected_banner || "");
if (userRes.data.language) {
setLanguage(userRes.data.language);
setLanguage(userRes.data.language);
}
// 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);
} catch (err) {
console.error(err);
@@ -84,340 +89,489 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
}, []);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
setMessage({ type: 'error', text: 'File size exceeds 2MB limit' });
return;
}
if (file.size > 2 * 1024 * 1024) {
setMessage({ type: "error", text: "File size exceeds 2MB limit" });
return;
}
const formData = new FormData();
formData.append('avatar', file);
const formData = new FormData();
formData.append("avatar", file);
try {
const res = await api.post('users/update_avatar/', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
setAvatar(res.data.avatar);
setMessage({ type: 'success', text: t('settings.avatarSuccess') });
} catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to upload avatar' });
}
try {
const res = await api.post("users/update_avatar/", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
setAvatar(res.data.avatar);
setMessage({ type: "success", text: t("settings.avatarSuccess") });
} catch (err: any) {
setMessage({
type: "error",
text: err.response?.data?.error || "Failed to upload avatar",
});
}
};
const handleLanguageChange = async (newLang: string) => {
setLanguage(newLang);
i18n.changeLanguage(newLang);
try {
await api.patch('users/update_preferences/', { language: newLang });
} catch (err) {
console.error('Failed to update language on server');
}
setLanguage(newLang);
i18n.changeLanguage(newLang);
try {
await api.patch("users/update_preferences/", { language: newLang });
} catch (err) {
console.error("Failed to update language on server");
}
};
const handleUpdateProfile = async (e: React.FormEvent) => {
e.preventDefault();
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) {
setPendingConfirmation(true);
setMessage({ type: 'success', text: `${t('settings.updateSuccess')} ${res.data.message}` });
setMessage({
type: "success",
text: `${t("settings.updateSuccess")} ${res.data.message}`,
});
} else {
setMessage({ type: 'success', text: t('settings.updateSuccess') });
setMessage({ type: "success", text: t("settings.updateSuccess") });
setCurrentEmail(email);
}
} 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) => {
e.preventDefault();
try {
const res = await api.post('users/confirm_email/', { code: confirmationCode });
setMessage({ type: 'success', text: t('settings.updateSuccess') });
const res = await api.post("users/confirm_email/", {
code: confirmationCode,
});
setMessage({ type: "success", text: t("settings.updateSuccess") });
setCurrentEmail(res.data.email);
setPendingConfirmation(false);
setConfirmationCode('');
setConfirmationCode("");
} 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) => {
e.preventDefault();
try {
await api.post('users/change_password/', { old_password: oldPassword, new_password: newPassword });
setMessage({ type: 'success', text: t('settings.passwordSuccess') });
setOldPassword('');
setNewPassword('');
await api.post("users/change_password/", {
old_password: oldPassword,
new_password: newPassword,
});
setMessage({ type: "success", text: t("settings.passwordSuccess") });
setOldPassword("");
setNewPassword("");
} 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 () => {
try {
await api.post('users/request_account_deletion/');
setIsDeleting(true);
setMessage({ type: 'success', text: t('settings.codeSent') });
} catch (err: any) {
setMessage({ type: 'error', text: 'Failed to request deletion.' });
}
try {
await api.post("users/request_account_deletion/");
setIsDeleting(true);
setMessage({ type: "success", text: t("settings.codeSent") });
} catch (err: any) {
setMessage({ type: "error", text: "Failed to request deletion." });
}
};
const handleConfirmDeletion = async (e: React.FormEvent) => {
e.preventDefault();
if (!window.confirm('Are you absolutely sure? This cannot be undone.')) return;
try {
await api.post('users/confirm_account_deletion/', { code: deletionCode });
localStorage.removeItem('token');
window.location.href = '/';
} catch (err: any) {
setMessage({ type: 'error', text: 'Invalid code. Deletion failed.' });
}
e.preventDefault();
if (!window.confirm("Are you absolutely sure? This cannot be undone."))
return;
try {
await api.post("users/confirm_account_deletion/", { code: deletionCode });
localStorage.removeItem("token");
window.location.href = "/";
} catch (err: any) {
setMessage({ type: "error", text: "Invalid code. Deletion failed." });
}
};
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Button
variant="outlined"
<Button
variant="outlined"
onClick={handleGoBack}
startIcon={<ArrowBackIcon />}
sx={{
mb: 4,
borderRadius: 2,
textTransform: 'none',
fontWeight: 'bold',
color: 'text.secondary',
borderColor: 'divider',
'&:hover': {
borderColor: 'primary.main',
color: 'primary.main',
bgcolor: 'transparent'
}
sx={{
mb: 4,
borderRadius: 2,
textTransform: "none",
fontWeight: "bold",
color: "text.secondary",
borderColor: "divider",
"&:hover": {
borderColor: "primary.main",
color: "primary.main",
bgcolor: "transparent",
},
}}
>
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
{window.history.length > 1 ? t("common.back") : t("common.backToHome")}
</Button>
<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 && 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 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 && 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>
{message && (
<Alert severity={message.type} sx={{ mb: 4 }} onClose={() => setMessage(null)}>
<Alert
severity={message.type}
sx={{ mb: 4 }}
onClose={() => setMessage(null)}
>
{message.text}
</Alert>
)}
<Grid container spacing={4}>
{/* Left Column: Visuals & Language */}
<Grid size={{ xs: 12, lg: 5 }}>
<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.appearanceTitle')}</Typography>
<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>
{/* Left Column: Visuals & Language */}
<Grid size={{ xs: 12, lg: 5 }}>
<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.appearanceTitle")}
</Typography>
<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>
<MenuItem value="latte">Catppuccin Latte</MenuItem>
<MenuItem value="pink_pastel">Pink Pastel</MenuItem>
<MenuItem value="mint_pastel">Mint Pastel</MenuItem>
<MenuItem value="lavender_pastel">Lavender Pastel</MenuItem>
<MenuItem value="t0p_sai">Scaled and Icy (T0P)</MenuItem>
<Divider />
<ListSubheader>Dark Themes</ListSubheader>
<MenuItem value="mocha">Catppuccin Mocha</MenuItem>
<MenuItem value="frappe">Catppuccin Frappé</MenuItem>
<MenuItem value="macchiato">Catppuccin Macchiato</MenuItem>
<MenuItem value="dracula">Dracula</MenuItem>
<MenuItem value="nord">Nordic Frost</MenuItem>
<MenuItem value="gruvbox">Gruvbox Retro</MenuItem>
<MenuItem value="oceanic">Oceanic Deep</MenuItem>
<MenuItem value="t0p_trench">Trench (T0P)</MenuItem>
<MenuItem value="t0p_blurryface">Blurryface (T0P)</MenuItem>
<MenuItem value="t0p_clancy">Clancy (T0P)</MenuItem>
</Select>
</FormControl>
<ListSubheader>Light Themes</ListSubheader>
<MenuItem value="latte">Catppuccin Latte</MenuItem>
<MenuItem value="pink_pastel">Pink Pastel</MenuItem>
<MenuItem value="mint_pastel">Mint Pastel</MenuItem>
<MenuItem value="lavender_pastel">Lavender Pastel</MenuItem>
<MenuItem value="t0p_sai">Scaled and Icy (T0P)</MenuItem>
<FormControl fullWidth margin="normal">
<InputLabel>{t('settings.bannerLabel')}</InputLabel>
<Select
value={selectedBannerId}
label={t('settings.bannerLabel')}
onChange={async (e) => {
const newId = e.target.value;
setSelectedBannerId(newId);
try {
await api.patch('users/update_preferences/', { selected_banner: newId });
} 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>
))}
</Select>
</FormControl>
<Divider />
<FormControl fullWidth margin="normal">
<InputLabel>{t('settings.langLabel')}</InputLabel>
<Select
value={language}
label={t('settings.langLabel')}
onChange={(e) => handleLanguageChange(e.target.value)}
>
<MenuItem value="en">English</MenuItem>
<MenuItem value="de">Deutsch</MenuItem>
</Select>
</FormControl>
</Paper>
<ListSubheader>Dark Themes</ListSubheader>
<MenuItem value="mocha">Catppuccin Mocha</MenuItem>
<MenuItem value="frappe">Catppuccin Frappé</MenuItem>
<MenuItem value="macchiato">Catppuccin Macchiato</MenuItem>
<MenuItem value="dracula">Dracula</MenuItem>
<MenuItem value="nord">Nordic Frost</MenuItem>
<MenuItem value="gruvbox">Gruvbox Retro</MenuItem>
<MenuItem value="oceanic">Oceanic Deep</MenuItem>
<MenuItem value="t0p_trench">Trench (T0P)</MenuItem>
<MenuItem value="t0p_blurryface">Blurryface (T0P)</MenuItem>
<MenuItem value="t0p_clancy">Clancy (T0P)</MenuItem>
</Select>
</FormControl>
<Paper sx={{ p: 3, borderRadius: 3 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('settings.avatarTitle')}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, my: 2 }}>
<Avatar src={avatar || undefined} sx={{ width: 100, height: 100 }}>
{!avatar && username.charAt(0).toUpperCase()}
</Avatar>
<Box>
<Button variant="outlined" component="label" size="small">
{t('settings.avatarButton')}
<input type="file" hidden accept="image/*" onChange={handleAvatarUpload} />
</Button>
<Typography variant="caption" display="block" sx={{ mt: 1 }} color="text.secondary">
{t('settings.avatarHint')}
</Typography>
</Box>
</Box>
</Paper>
</Grid>
<FormControl fullWidth margin="normal">
<InputLabel>{t("settings.bannerLabel")}</InputLabel>
<Select
value={selectedBannerId}
label={t("settings.bannerLabel")}
onChange={async (e) => {
const newId = e.target.value;
setSelectedBannerId(newId);
try {
await api.patch("users/update_preferences/", {
selected_banner: newId,
});
} 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>
))}
</Select>
</FormControl>
{/* 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 }}
<FormControl fullWidth margin="normal">
<InputLabel>{t("settings.langLabel")}</InputLabel>
<Select
value={language}
label={t("settings.langLabel")}
onChange={(e) => handleLanguageChange(e.target.value)}
>
<MenuItem value="en">English</MenuItem>
<MenuItem value="de">Deutsch</MenuItem>
</Select>
</FormControl>
</Paper>
<Paper sx={{ p: 3, borderRadius: 3 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: "bold" }}>
{t("settings.avatarTitle")}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 3, my: 2 }}>
<Avatar
src={avatar || undefined}
sx={{ width: 100, height: 100 }}
>
{!avatar && username.charAt(0).toUpperCase()}
</Avatar>
<Box>
<Button variant="outlined" component="label" size="small">
{t("settings.avatarButton")}
<input
type="file"
hidden
accept="image/*"
onChange={handleAvatarUpload}
/>
<TextField
fullWidth
label={t('settings.emailLabel')}
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')}
</Button>
<Typography
variant="caption"
display="block"
sx={{ mt: 1 }}
color="text.secondary"
>
{t("settings.avatarHint")}
</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>
</Box>
</Box>
</Paper>
</Grid>
{/* 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
fullWidth
label={t("settings.emailLabel")}
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>
</Container>
);
+557 -323
View File
@@ -1,346 +1,580 @@
import React, { useState, useEffect } from 'react';
import {
Container, Typography, Box, Card, CardContent, Button,
TextField, Stack, Chip, Divider, Collapse, Paper, 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';
import React, { useState, useEffect } from "react";
import {
Container,
Typography,
Box,
Card,
CardContent,
Button,
TextField,
Stack,
Chip,
Divider,
Collapse,
Paper,
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 {
id: number;
username: string;
text: string;
created_at: string;
is_admin: boolean;
id: number;
username: string;
text: string;
created_at: string;
is_admin: boolean;
}
interface Ticket {
id: number;
user: number;
username: string;
user_email: string;
user_avatar: string | null;
subject: string;
description: string;
status: 'open' | 'in_progress' | 'resolved' | 'closed';
created_at: string;
messages: Message[];
id: number;
user: number;
username: string;
user_email: string;
user_avatar: string | null;
subject: string;
description: string;
status: "open" | "in_progress" | "resolved" | "closed";
created_at: string;
messages: Message[];
}
const Support: React.FC = () => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
useTitle(t('support.title'));
const [tickets, setTickets] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
// Create form
const [subject, setSubject] = useState('');
const [description, setDescription] = useState('');
// Reply state
const [replyText, setReplyText] = useState<{[key: number]: string}>({});
const [expandedId, setExpandedId] = useState<number | null>(null);
const [currentUser, setCurrentUser] = useState<any>(null);
const [unreadTicketIds, setUnreadTicketIds] = useState<Set<number>>(new Set());
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
useTitle(t("support.title"));
const [tickets, setTickets] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const fetchTickets = async () => {
try {
const [ticketRes, userRes, notifRes] = await Promise.all([
api.get('tickets/'),
api.get('users/me/'),
api.get('notifications/')
]);
setTickets(ticketRes.data.results || ticketRes.data);
setCurrentUser(userRes.data);
const notifs = notifRes.data.results || notifRes.data;
const unreadIds = new Set<number>(
notifs
.filter((n: any) => !n.is_read && n.notification_type.startsWith('ticket') && n.ticket)
.map((n: any) => n.ticket)
);
setUnreadTicketIds(unreadIds);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
// Create form
const [subject, setSubject] = useState("");
const [description, setDescription] = useState("");
useEffect(() => {
fetchTickets();
}, []);
// Reply state
const [replyText, setReplyText] = useState<{ [key: number]: string }>({});
const [expandedId, setExpandedId] = useState<number | null>(null);
const [currentUser, setCurrentUser] = useState<any>(null);
const [unreadTicketIds, setUnreadTicketIds] = useState<Set<number>>(
new Set(),
);
const handleExpand = async (ticketId: number) => {
if (expandedId === ticketId) {
setExpandedId(null);
return;
}
setExpandedId(ticketId);
// Mark notifications for this ticket as read
if (unreadTicketIds.has(ticketId)) {
try {
// We'd need an endpoint to mark all for a specific ticket,
// or just rely on the user clicking the notification.
// For now, let's just clear it from the local state.
const newUnread = new Set(unreadTicketIds);
newUnread.delete(ticketId);
setUnreadTicketIds(newUnread);
} catch (e) {}
}
};
const fetchTickets = async () => {
try {
const [ticketRes, userRes, notifRes] = await Promise.all([
api.get("tickets/"),
api.get("users/me/"),
api.get("notifications/"),
]);
setTickets(ticketRes.data.results || ticketRes.data);
setCurrentUser(userRes.data);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
await api.post('tickets/', { subject, description });
setSubject('');
setDescription('');
setShowCreate(false);
fetchTickets();
} catch (err) {
alert('Failed to create ticket');
}
};
const notifs = notifRes.data.results || notifRes.data;
const unreadIds = new Set<number>(
notifs
.filter(
(n: any) =>
!n.is_read &&
n.notification_type.startsWith("ticket") &&
n.ticket,
)
.map((n: any) => n.ticket),
);
setUnreadTicketIds(unreadIds);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const handleReply = async (ticketId: number) => {
const text = replyText[ticketId];
if (!text) return;
try {
await api.post(`tickets/${ticketId}/add_message/`, { text });
setReplyText({ ...replyText, [ticketId]: '' });
fetchTickets();
} catch (err) {
alert('Failed to send message');
}
};
useEffect(() => {
fetchTickets();
}, []);
const handleUpdateStatus = async (ticketId: number, status: string) => {
try {
await api.post(`tickets/${ticketId}/update_status/`, { status });
fetchTickets();
} catch (err) {
alert('Failed to update status');
}
};
const handleExpand = async (ticketId: number) => {
if (expandedId === ticketId) {
setExpandedId(null);
return;
}
setExpandedId(ticketId);
const handleDeleteTicket = async (ticketId: number) => {
if (!confirm('Delete this ticket?')) return;
try {
await api.delete(`tickets/${ticketId}/`);
fetchTickets();
} catch (err) {
alert('Failed to delete ticket');
}
};
// Mark notifications for this ticket as read
if (unreadTicketIds.has(ticketId)) {
try {
// We'd need an endpoint to mark all for a specific ticket,
// or just rely on the user clicking the notification.
// For now, let's just clear it from the local state.
const newUnread = new Set(unreadTicketIds);
newUnread.delete(ticketId);
setUnreadTicketIds(newUnread);
} catch (e) {}
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'open': return 'error';
case 'in_progress': return 'warning';
case 'resolved': return 'success';
default: return 'default';
}
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
await api.post("tickets/", { subject, description });
setSubject("");
setDescription("");
setShowCreate(false);
fetchTickets();
} catch (err) {
alert("Failed to create ticket");
}
};
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
const handleReply = async (ticketId: number) => {
const text = replyText[ticketId];
if (!text) return;
try {
await api.post(`tickets/${ticketId}/add_message/`, { text });
setReplyText({ ...replyText, [ticketId]: "" });
fetchTickets();
} catch (err) {
alert("Failed to send message");
}
};
const handleUpdateStatus = async (ticketId: number, status: string) => {
try {
await api.post(`tickets/${ticketId}/update_status/`, { status });
fetchTickets();
} catch (err) {
alert("Failed to update status");
}
};
const handleDeleteTicket = async (ticketId: number) => {
if (!confirm("Delete this ticket?")) return;
try {
await api.delete(`tickets/${ticketId}/`);
fetchTickets();
} catch (err) {
alert("Failed to delete ticket");
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "open":
return "error";
case "in_progress":
return "warning";
case "resolved":
return "success";
default:
return "default";
}
};
if (loading)
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>
<Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
<CircularProgress />
</Box>
);
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;
+112 -87
View File
@@ -1,99 +1,124 @@
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate, Link } from 'react-router-dom';
import { Container, Paper, Typography, Box, 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';
import React, { useEffect, useState } from "react";
import { useLocation, useNavigate, Link } from "react-router-dom";
import {
Container,
Paper,
Typography,
Box,
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 { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [errorMsg, setErrorMsg] = useState('');
useTitle(t('auth.verifyTitle'));
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const [status, setStatus] = useState<"loading" | "success" | "error">(
"loading",
);
const [errorMsg, setErrorMsg] = useState("");
useEffect(() => {
const query = new URLSearchParams(location.search);
const username = query.get('username');
const code = query.get('code');
useTitle(t("auth.verifyTitle"));
if (!username || !code) {
setStatus('error');
setErrorMsg('Invalid verification link.');
return;
}
useEffect(() => {
const query = new URLSearchParams(location.search);
const username = query.get("username");
const code = query.get("code");
const verify = async () => {
try {
await api.post('users/verify_signup/', { username, code });
setStatus('success');
// Optional: Auto redirect after 3 seconds
setTimeout(() => {
navigate('/login');
}, 3000);
} catch (err: any) {
setStatus('error');
setErrorMsg(err.response?.data?.error || 'Verification failed.');
}
};
if (!username || !code) {
setStatus("error");
setErrorMsg("Invalid verification link.");
return;
}
verify();
}, [location, navigate]);
const verify = async () => {
try {
await api.post("users/verify_signup/", { username, code });
setStatus("success");
// Optional: Auto redirect after 3 seconds
setTimeout(() => {
navigate("/login");
}, 3000);
} catch (err: any) {
setStatus("error");
setErrorMsg(err.response?.data?.error || "Verification failed.");
}
};
return (
<Container maxWidth="sm" sx={{ py: 12 }}>
<Paper sx={{ p: 6, textAlign: 'center', borderRadius: 4 }}>
{status === 'loading' && (
<Box>
<CircularProgress size={60} sx={{ mb: 4 }} />
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
Verifying your account...
</Typography>
<Typography color="text.secondary" sx={{ mt: 1 }}>
Please wait while we confirm your email.
</Typography>
</Box>
)}
verify();
}, [location, navigate]);
{status === 'success' && (
<Box>
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 2 }}>
{t('auth.verifySuccess')}
</Typography>
<Typography color="text.secondary" sx={{ mb: 4 }}>
You will be redirected to the login page shortly.
</Typography>
<Button variant="contained" component={Link} to="/login" size="large" sx={{ borderRadius: 2 }}>
Go to Login Now
</Button>
</Box>
)}
return (
<Container maxWidth="sm" sx={{ py: 12 }}>
<Paper sx={{ p: 6, textAlign: "center", borderRadius: 4 }}>
{status === "loading" && (
<Box>
<CircularProgress size={60} sx={{ mb: 4 }} />
<Typography variant="h5" sx={{ fontWeight: "bold" }}>
Verifying your account...
</Typography>
<Typography color="text.secondary" sx={{ mt: 1 }}>
Please wait while we confirm your email.
</Typography>
</Box>
)}
{status === 'error' && (
<Box>
<ErrorOutlineIcon color="error" sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 2 }}>
Verification Failed
</Typography>
<Alert severity="error" sx={{ mb: 4, textAlign: 'left' }}>
{errorMsg}
</Alert>
<Typography color="text.secondary" sx={{ mb: 4 }}>
The link might be expired or already used.
</Typography>
<Button variant="outlined" component={Link} to="/login" size="large" sx={{ borderRadius: 2 }}>
Back to Login / Signup
</Button>
</Box>
)}
</Paper>
</Container>
);
{status === "success" && (
<Box>
<CheckCircleOutlineIcon
color="success"
sx={{ fontSize: 80, mb: 2 }}
/>
<Typography variant="h4" sx={{ fontWeight: "bold", mb: 2 }}>
{t("auth.verifySuccess")}
</Typography>
<Typography color="text.secondary" sx={{ mb: 4 }}>
You will be redirected to the login page shortly.
</Typography>
<Button
variant="contained"
component={Link}
to="/login"
size="large"
sx={{ borderRadius: 2 }}
>
Go to Login Now
</Button>
</Box>
)}
{status === "error" && (
<Box>
<ErrorOutlineIcon color="error" sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h4" sx={{ fontWeight: "bold", mb: 2 }}>
Verification Failed
</Typography>
<Alert severity="error" sx={{ mb: 4, textAlign: "left" }}>
{errorMsg}
</Alert>
<Typography color="text.secondary" sx={{ mb: 4 }}>
The link might be expired or already used.
</Typography>
<Button
variant="outlined"
component={Link}
to="/login"
size="large"
sx={{ borderRadius: 2 }}
>
Back to Login / Signup
</Button>
</Box>
)}
</Paper>
</Container>
);
};
export default VerifyEmail;
+137 -92
View File
@@ -1,27 +1,27 @@
import React, { useEffect, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import {
Typography,
Box,
Card,
CardContent,
import React, { useEffect, useState } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import {
Typography,
Box,
Card,
CardContent,
CircularProgress,
Container,
Button
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import api from '../../api';
import { useTitle } from '../../hooks/useTitle';
import RatingBadge from '../../components/RatingBadge';
import StatusBadge from '../../components/StatusBadge';
import { useTranslation } from 'react-i18next';
Button,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import api from "../../api";
import { useTitle } from "../../hooks/useTitle";
import RatingBadge from "../../components/RatingBadge";
import StatusBadge from "../../components/StatusBadge";
import { useTranslation } from "react-i18next";
interface Rating {
id: number;
user: string;
score: number;
comment: string;
created_at: string;
id: number;
user: string;
score: number;
comment: string;
created_at: string;
}
interface Flavor {
@@ -43,15 +43,15 @@ const CategoryFlavors: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const [flavors, setFlavors] = useState<Flavor[]>([]);
const [categoryName, setCategoryName] = useState('');
const [categoryName, setCategoryName] = useState("");
const [loading, setLoading] = useState(true);
const handleGoBack = () => {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate('/');
}
if (window.history.length > 1) {
navigate(-1);
} else {
navigate("/");
}
};
useEffect(() => {
@@ -59,7 +59,9 @@ const CategoryFlavors: React.FC = () => {
setLoading(true);
try {
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);
if (data.length > 0) {
setCategoryName(data[0].category_name);
@@ -73,85 +75,128 @@ const CategoryFlavors: React.FC = () => {
fetchFlavors();
}, [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 (
<Container maxWidth={false} sx={{ px: { xs: 2, sm: 4, md: 6 }, py: 4 }}>
<Box sx={{ mb: 4 }}>
<Typography variant="h4" gutterBottom>{categoryName || 'Flavors'}</Typography>
<Button
variant="outlined"
onClick={handleGoBack}
startIcon={<ArrowBackIcon />}
sx={{
borderRadius: 2,
textTransform: 'none',
fontWeight: 'bold',
color: 'text.secondary',
borderColor: 'divider',
'&:hover': {
borderColor: 'primary.main',
color: 'primary.main',
bgcolor: 'transparent'
}
}}
>
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
</Button>
<Typography variant="h4" gutterBottom>
{categoryName || "Flavors"}
</Typography>
<Button
variant="outlined"
onClick={handleGoBack}
startIcon={<ArrowBackIcon />}
sx={{
borderRadius: 2,
textTransform: "none",
fontWeight: "bold",
color: "text.secondary",
borderColor: "divider",
"&:hover": {
borderColor: "primary.main",
color: "primary.main",
bgcolor: "transparent",
},
}}
>
{window.history.length > 1
? t("common.back")
: t("common.backToHome")}
</Button>
</Box>
<Box sx={{
display: 'grid',
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(auto-fill, minmax(280px, 1fr))',
lg: 'repeat(auto-fill, minmax(240px, 1fr))'
xs: "1fr",
sm: "repeat(auto-fill, minmax(280px, 1fr))",
lg: "repeat(auto-fill, minmax(240px, 1fr))",
},
gap: 3
}}>
{flavors.map(flavor => (
gap: 3,
}}
>
{flavors.map((flavor) => (
<Box key={flavor.id}>
<Card sx={{ height: '100%', 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" />
<Card
sx={{
height: "100%",
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>
{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
component="img"
src={flavor.image_url}
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
transition: 'transform 0.5s ease',
'&:hover': { transform: 'scale(1.1)' }
}}
/>
</Box>
<Box
sx={{
width: "100%",
aspectRatio: "1/1",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "background.default",
borderBottom: "1px solid",
borderColor: "divider",
overflow: "hidden",
}}
>
<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 }}>
<Typography variant="h6" sx={{ fontSize: '1.1rem', mb: 1 }}>{flavor.name}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<RatingBadge score={flavor.average_rating || 0} size="small" />
</Box>
<Typography variant="caption" color="text.secondary">{flavor.ratings.length} reviews</Typography>
<Typography variant="h6" sx={{ fontSize: "1.1rem", mb: 1 }}>
{flavor.name}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
<RatingBadge
score={flavor.average_rating || 0}
size="small"
/>
</Box>
<Typography variant="caption" color="text.secondary">
{flavor.ratings.length} reviews
</Typography>
</CardContent>
</Box>
</Card>
File diff suppressed because it is too large. Load diff
+277 -253
View File
@@ -1,190 +1,210 @@
import { createTheme, type ThemeOptions, alpha } from '@mui/material/styles';
import { flavors } from '@catppuccin/palette';
import { createTheme, type ThemeOptions, alpha } from "@mui/material/styles";
import { flavors } from "@catppuccin/palette";
export type CatppuccinTheme =
| 'holy_light' | 'holy_dark'
| 'latte' | 'frappe' | 'macchiato' | 'mocha'
| 'pink_pastel' | 'mint_pastel' | 'lavender_pastel' | 'dracula' | 'nord' | 'gruvbox' | 'oceanic'
| 't0p_sai' | 't0p_trench' | 't0p_blurryface' | 't0p_clancy';
export type CatppuccinTheme =
| "holy_light"
| "holy_dark"
| "latte"
| "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 => {
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) => {
let palette: any;
// Define Holy branding colors
const holyPurple = { light: '#7c3aed', dark: '#a78bfa' };
const holyPink = { light: '#db2777', dark: '#f472b6' };
const holyPurple = { light: "#7c3aed", dark: "#a78bfa" };
const holyPink = { light: "#db2777", dark: "#f472b6" };
if (mode === 'holy_light') {
palette = {
base: { hex: '#f8fafc' },
surface0: { hex: '#ffffff' },
crust: { hex: '#f1f5f9' },
text: { hex: '#0f172a' },
subtext0: { hex: '#64748b' },
primary: { hex: holyPurple.light },
secondary: { hex: holyPink.light },
};
} else if (mode === 'holy_dark') {
palette = {
base: { hex: '#020617' },
surface0: { hex: '#0f172a' },
crust: { hex: '#000000' },
text: { hex: '#f8fafc' },
subtext0: { hex: '#94a3b8' },
primary: { hex: holyPurple.dark },
secondary: { hex: holyPink.dark },
};
} else if (mode === 'pink_pastel') {
palette = {
base: { hex: '#fff1f2' },
surface0: { hex: '#ffffff' },
crust: { hex: '#ffe4e6' },
text: { hex: '#881337' },
subtext0: { hex: '#be123c' },
primary: { hex: '#fb7185' },
secondary: { hex: '#2dd4bf' },
};
} else if (mode === 'mint_pastel') {
palette = {
base: { hex: '#f0fdf4' },
surface0: { hex: '#ffffff' },
crust: { hex: '#dcfce7' },
text: { hex: '#14532d' },
subtext0: { hex: '#16a34a' },
primary: { hex: '#4ade80' },
secondary: { hex: '#fb7185' },
};
} else if (mode === 'lavender_pastel') {
palette = {
base: { hex: '#f5f3ff' },
surface0: { hex: '#ffffff' },
crust: { hex: '#ede9fe' },
text: { hex: '#4c1d95' },
subtext0: { hex: '#7c3aed' },
primary: { hex: '#8b5cf6' },
secondary: { hex: '#facc15' },
};
} else if (mode === 't0p_sai') {
palette = {
base: { hex: '#fdf2f8' }, // Very light SAI Pink
surface0: { hex: '#ffffff' },
crust: { hex: '#fce7f3' }, // SAI Pink accent
text: { hex: '#1e1b4b' },
subtext0: { hex: '#4b5563' },
primary: { hex: '#00d7ff' }, // SAI Cyan
secondary: { hex: '#ff8ad8' }, // SAI Pink
};
} else if (mode === 't0p_trench') {
palette = {
base: { hex: '#1a1a1a' },
surface0: { hex: '#242424' },
crust: { hex: '#000000' },
text: { hex: '#fce300' }, // Trench Yellow
subtext0: { hex: '#a0a0a0' },
primary: { hex: '#fce300' },
secondary: { hex: '#4b5320' }, // Olive
};
} else if (mode === 't0p_blurryface') {
palette = {
base: { hex: '#000000' },
surface0: { hex: '#1a1a1a' },
crust: { hex: '#000000' },
text: { hex: '#ffffff' },
subtext0: { hex: '#e31e24' },
primary: { hex: '#e31e24' }, // BF Red
secondary: { hex: '#ffffff' },
};
} else if (mode === 't0p_clancy') {
palette = {
base: { hex: '#000000' },
surface0: { hex: '#121212' },
crust: { hex: '#000000' },
text: { hex: '#ffffff' },
subtext0: { hex: '#fce300' },
primary: { hex: '#e31e24' }, // Clancy Red
secondary: { hex: '#fce300' }, // Clancy Yellow
};
} else if (mode === 'dracula') {
palette = {
base: { hex: '#282a36' },
surface0: { hex: '#44475a' },
crust: { hex: '#191a21' },
text: { hex: '#f8f8f2' },
subtext0: { hex: '#6272a4' },
primary: { hex: '#bd93f9' },
secondary: { hex: '#ff79c6' },
};
} else if (mode === 'nord') {
palette = {
base: { hex: '#2e3440' },
surface0: { hex: '#3b4252' },
crust: { hex: '#242933' },
text: { hex: '#eceff4' },
subtext0: { hex: '#d8dee9' },
primary: { hex: '#88c0d0' },
secondary: { hex: '#b48ead' },
};
} else if (mode === 'gruvbox') {
palette = {
base: { hex: '#282828' },
surface0: { hex: '#3c3836' },
crust: { hex: '#1d2021' },
text: { hex: '#ebdbb2' },
subtext0: { hex: '#928374' },
primary: { hex: '#fabd2f' },
secondary: { hex: '#fb4934' },
};
} else if (mode === 'oceanic') {
palette = {
base: { hex: '#1b262c' },
surface0: { hex: '#0f4c75' },
crust: { hex: '#0f161c' },
text: { hex: '#bbe1fa' },
subtext0: { hex: '#3282b8' },
primary: { hex: '#3282b8' },
secondary: { hex: '#bbe1fa' },
};
if (mode === "holy_light") {
palette = {
base: { hex: "#f8fafc" },
surface0: { hex: "#ffffff" },
crust: { hex: "#f1f5f9" },
text: { hex: "#0f172a" },
subtext0: { hex: "#64748b" },
primary: { hex: holyPurple.light },
secondary: { hex: holyPink.light },
};
} else if (mode === "holy_dark") {
palette = {
base: { hex: "#020617" },
surface0: { hex: "#0f172a" },
crust: { hex: "#000000" },
text: { hex: "#f8fafc" },
subtext0: { hex: "#94a3b8" },
primary: { hex: holyPurple.dark },
secondary: { hex: holyPink.dark },
};
} else if (mode === "pink_pastel") {
palette = {
base: { hex: "#fff1f2" },
surface0: { hex: "#ffffff" },
crust: { hex: "#ffe4e6" },
text: { hex: "#881337" },
subtext0: { hex: "#be123c" },
primary: { hex: "#fb7185" },
secondary: { hex: "#2dd4bf" },
};
} else if (mode === "mint_pastel") {
palette = {
base: { hex: "#f0fdf4" },
surface0: { hex: "#ffffff" },
crust: { hex: "#dcfce7" },
text: { hex: "#14532d" },
subtext0: { hex: "#16a34a" },
primary: { hex: "#4ade80" },
secondary: { hex: "#fb7185" },
};
} else if (mode === "lavender_pastel") {
palette = {
base: { hex: "#f5f3ff" },
surface0: { hex: "#ffffff" },
crust: { hex: "#ede9fe" },
text: { hex: "#4c1d95" },
subtext0: { hex: "#7c3aed" },
primary: { hex: "#8b5cf6" },
secondary: { hex: "#facc15" },
};
} else if (mode === "t0p_sai") {
palette = {
base: { hex: "#fdf2f8" }, // Very light SAI Pink
surface0: { hex: "#ffffff" },
crust: { hex: "#fce7f3" }, // SAI Pink accent
text: { hex: "#1e1b4b" },
subtext0: { hex: "#4b5563" },
primary: { hex: "#00d7ff" }, // SAI Cyan
secondary: { hex: "#ff8ad8" }, // SAI Pink
};
} else if (mode === "t0p_trench") {
palette = {
base: { hex: "#1a1a1a" },
surface0: { hex: "#242424" },
crust: { hex: "#000000" },
text: { hex: "#fce300" }, // Trench Yellow
subtext0: { hex: "#a0a0a0" },
primary: { hex: "#fce300" },
secondary: { hex: "#4b5320" }, // Olive
};
} else if (mode === "t0p_blurryface") {
palette = {
base: { hex: "#000000" },
surface0: { hex: "#1a1a1a" },
crust: { hex: "#000000" },
text: { hex: "#ffffff" },
subtext0: { hex: "#e31e24" },
primary: { hex: "#e31e24" }, // BF Red
secondary: { hex: "#ffffff" },
};
} else if (mode === "t0p_clancy") {
palette = {
base: { hex: "#000000" },
surface0: { hex: "#121212" },
crust: { hex: "#000000" },
text: { hex: "#ffffff" },
subtext0: { hex: "#fce300" },
primary: { hex: "#e31e24" }, // Clancy Red
secondary: { hex: "#fce300" }, // Clancy Yellow
};
} else if (mode === "dracula") {
palette = {
base: { hex: "#282a36" },
surface0: { hex: "#44475a" },
crust: { hex: "#191a21" },
text: { hex: "#f8f8f2" },
subtext0: { hex: "#6272a4" },
primary: { hex: "#bd93f9" },
secondary: { hex: "#ff79c6" },
};
} else if (mode === "nord") {
palette = {
base: { hex: "#2e3440" },
surface0: { hex: "#3b4252" },
crust: { hex: "#242933" },
text: { hex: "#eceff4" },
subtext0: { hex: "#d8dee9" },
primary: { hex: "#88c0d0" },
secondary: { hex: "#b48ead" },
};
} else if (mode === "gruvbox") {
palette = {
base: { hex: "#282828" },
surface0: { hex: "#3c3836" },
crust: { hex: "#1d2021" },
text: { hex: "#ebdbb2" },
subtext0: { hex: "#928374" },
primary: { hex: "#fabd2f" },
secondary: { hex: "#fb4934" },
};
} else if (mode === "oceanic") {
palette = {
base: { hex: "#1b262c" },
surface0: { hex: "#0f4c75" },
crust: { hex: "#0f161c" },
text: { hex: "#bbe1fa" },
subtext0: { hex: "#3282b8" },
primary: { hex: "#3282b8" },
secondary: { hex: "#bbe1fa" },
};
} else {
// Handle Catppuccin flavors
const flavor = (flavors as any)[mode];
if (flavor) {
const colors = flavor.colors;
palette = {
base: colors.base,
surface0: colors.surface0,
crust: colors.crust,
text: colors.text,
subtext0: colors.subtext0,
primary: colors.mauve,
secondary: colors.pink,
};
} else {
// Fallback to holy_light
palette = {
base: { hex: '#f8fafc' },
surface0: { hex: '#ffffff' },
crust: { hex: '#f1f5f9' },
text: { hex: '#0f172a' },
subtext0: { hex: '#64748b' },
primary: { hex: holyPurple.light },
secondary: { hex: holyPink.light },
};
mode = 'holy_light';
}
// Handle Catppuccin flavors
const flavor = (flavors as any)[mode];
if (flavor) {
const colors = flavor.colors;
palette = {
base: colors.base,
surface0: colors.surface0,
crust: colors.crust,
text: colors.text,
subtext0: colors.subtext0,
primary: colors.mauve,
secondary: colors.pink,
};
} else {
// Fallback to holy_light
palette = {
base: { hex: "#f8fafc" },
surface0: { hex: "#ffffff" },
crust: { hex: "#f1f5f9" },
text: { hex: "#0f172a" },
subtext0: { hex: "#64748b" },
primary: { hex: holyPurple.light },
secondary: { hex: holyPink.light },
};
mode = "holy_light";
}
}
const isLight = isLightTheme(mode);
const themeOptions: ThemeOptions = {
palette: {
mode: isLight ? 'light' : 'dark',
mode: isLight ? "light" : "dark",
primary: {
main: palette.primary.hex,
contrastText: isLight ? '#ffffff' : '#000000',
contrastText: isLight ? "#ffffff" : "#000000",
},
secondary: {
main: palette.secondary.hex,
@@ -199,12 +219,12 @@ export const getTheme = (mode: CatppuccinTheme) => {
},
divider: alpha(palette.text.hex, 0.1),
action: {
hover: alpha(palette.primary.hex, 0.08),
selected: alpha(palette.primary.hex, 0.12),
}
hover: alpha(palette.primary.hex, 0.08),
selected: alpha(palette.primary.hex, 0.12),
},
},
shape: {
borderRadius: 8,
borderRadius: 8,
},
typography: {
fontFamily: '"Plus Jakarta Sans", "Inter", "Roboto", sans-serif',
@@ -214,93 +234,97 @@ export const getTheme = (mode: CatppuccinTheme) => {
h4: { fontWeight: 700 },
h5: { fontWeight: 600 },
h6: { fontWeight: 600 },
button: { textTransform: 'none', fontWeight: 600 },
button: { textTransform: "none", fontWeight: 600 },
},
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
scrollbarColor: `${alpha(palette.text.hex, 0.2)} transparent`,
'&::-webkit-scrollbar': { width: '8px', height: '8px' },
'&::-webkit-scrollbar-thumb': {
backgroundColor: alpha(palette.text.hex, 0.2),
borderRadius: '10px',
'&: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,
MuiCssBaseline: {
styleOverrides: {
body: {
scrollbarColor: `${alpha(palette.text.hex, 0.2)} transparent`,
"&::-webkit-scrollbar": { width: "8px", height: "8px" },
"&::-webkit-scrollbar-thumb": {
backgroundColor: alpha(palette.text.hex, 0.2),
borderRadius: "10px",
"&:hover": { backgroundColor: alpha(palette.text.hex, 0.3) },
},
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: {
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,
}
}
},
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)",
},
},
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)`,
}
}
}
},
MuiCard: {
defaultProps: {
elevation: 0,
},
MuiTab: {
styleOverrides: {
root: {
minHeight: '48px',
fontWeight: 700,
borderRadius: '8px',
}
}
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",
},
},
MuiOutlinedInput: {
styleOverrides: {
root: {
borderRadius: '8px',
}
}
}
}
},
MuiAppBar: {
styleOverrides: {
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);
+8 -8
View File
@@ -1,10 +1,10 @@
export const formatDate = (dateStr: string) => {
if (!dateStr) return '';
const d = new Date(dateStr);
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
return `${day}/${month}/${year} ${hours}:${minutes}`;
if (!dateStr) return "";
const d = new Date(dateStr);
const day = String(d.getDate()).padStart(2, "0");
const month = String(d.getMonth() + 1).padStart(2, "0");
const year = d.getFullYear();
const hours = String(d.getHours()).padStart(2, "0");
const minutes = String(d.getMinutes()).padStart(2, "0");
return `${day}/${month}/${year} ${hours}:${minutes}`;
};