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
+7893 -3088

No files matched your search

+538 -199
View File
File diff suppressed because it is too large. Load diff
+13 -9
View File
@@ -1,13 +1,13 @@
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(';');
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 + '=')) {
if (cookie.substring(0, name.length + 1) === name + "=") {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
@@ -17,22 +17,26 @@ function getCookie(name: string): string | null {
}
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 (
csrfToken &&
method &&
["POST", "PUT", "PATCH", "DELETE"].includes(method)
) {
if (config.headers) {
config.headers['X-CSRFToken'] = csrfToken;
config.headers["X-CSRFToken"] = csrfToken;
}
}
@@ -1,17 +1,27 @@
import React, { useState, useEffect } from 'react';
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';
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';
type: "number" | "slider" | "text" | "boolean";
min?: number;
max?: number;
step?: number;
@@ -35,16 +45,21 @@ interface BannerSettingsDialogProps {
onSave: () => void;
}
const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClose, banner, onSave }) => {
const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
open,
onClose,
banner,
onSave,
}) => {
const { t } = useTranslation();
const [localSettings, setLocalSettings] = useState<any>({});
const [error, setError] = useState('');
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (banner) {
setLocalSettings(banner.settings || {});
setError('');
setError("");
}
}, [banner]);
@@ -57,7 +72,7 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClo
onSave();
onClose();
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to save settings');
setError(err.response?.data?.error || "Failed to save settings");
} finally {
setSaving(false);
}
@@ -66,29 +81,29 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClo
const updateSetting = (key: string, value: any) => {
setLocalSettings((prev: any) => ({
...prev,
[key]: value
[key]: value,
}));
};
const renderField = (schema: BannerSettingSchema) => {
const value = localSettings[schema.key] ?? '';
const value = localSettings[schema.key] ?? "";
return (
<Box key={schema.key} sx={{ mb: 3 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
<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 }} />
<HelpOutlineIcon sx={{ fontSize: "1rem", opacity: 0.6 }} />
</IconButton>
</Tooltip>
)}
</Stack>
{schema.type === 'slider' ? (
{schema.type === "slider" ? (
<Box sx={{ px: 1 }}>
<Slider
value={Number(value)}
@@ -99,7 +114,7 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClo
valueLabelDisplay="auto"
/>
</Box>
) : schema.type === 'number' ? (
) : schema.type === "number" ? (
<TextField
type="number"
fullWidth
@@ -118,7 +133,11 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClo
)}
{schema.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mt: 0.5 }}
>
{schema.description}
</Typography>
)}
@@ -128,31 +147,41 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClo
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 'bold' }}>
<DialogTitle sx={{ fontWeight: "bold" }}>
Banner Settings: {banner?.name}
</DialogTitle>
<DialogContent dividers>
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
{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))}
{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
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={onClose} color="inherit">
{t("common.cancel")}
</Button>
<Button
onClick={handleSave}
variant="contained"
disabled={saving}
sx={{ px: 4 }}
>
{saving ? t('common.loading') : t('common.save')}
{saving ? t("common.loading") : t("common.save")}
</Button>
</DialogActions>
</Dialog>
+63 -32
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect } from "react";
import {
Typography,
Button,
@@ -7,34 +7,37 @@ import {
DialogContent,
DialogTitle,
DialogActions,
Box
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import type { CatppuccinTheme } from '../theme';
Box,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import type { CatppuccinTheme } from "../theme";
interface CookieBannerProps {
onThemeChange: (newTheme: CatppuccinTheme) => void;
currentTheme: CatppuccinTheme;
}
const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme }) => {
const CookieBanner: React.FC<CookieBannerProps> = ({
onThemeChange,
currentTheme,
}) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
useEffect(() => {
const consent = localStorage.getItem('cookie-consent');
const consent = localStorage.getItem("cookie-consent");
if (!consent) {
setOpen(true);
}
}, []);
const handleAccept = () => {
localStorage.setItem('cookie-consent', 'true');
localStorage.setItem("cookie-consent", "true");
setOpen(false);
};
const handleToggleTheme = () => {
const newTheme = currentTheme === 'holy_light' ? 'holy_dark' : 'holy_light';
const newTheme = currentTheme === "holy_light" ? "holy_dark" : "holy_light";
onThemeChange(newTheme);
};
@@ -48,57 +51,85 @@ const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme
PaperProps={{
sx: {
borderRadius: 4,
border: '1px solid',
borderColor: 'primary.main',
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)}`,
}
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 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')}
{t("cookieBanner.description")}
</Typography>
<Box sx={{
<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' }}>
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.
• 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' } }}>
<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' }}
sx={{
borderRadius: 2,
textTransform: "none",
fontWeight: "bold",
m: "0 !important",
}}
>
{currentTheme === 'holy_light' ? t('cookieBanner.switchToDark') : t('cookieBanner.switchToLight')}
{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' }}
sx={{
borderRadius: 2,
textTransform: "none",
fontWeight: "bold",
px: 4,
m: "0 !important",
}}
>
{t('cookieBanner.accept')}
{t("cookieBanner.accept")}
</Button>
</DialogActions>
</Dialog>
+50 -28
View File
@@ -1,11 +1,11 @@
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;
@@ -24,20 +24,42 @@ interface DynamicBannerProps {
}
// Future expansion: Add more banner components here
const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({ username, settings }) => {
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
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
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>
))}
@@ -61,12 +83,12 @@ const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
useEffect(() => {
const fetchActiveBanner = async () => {
try {
const res = await api.get('banners/active/', {
params: { username: props.username }
const res = await api.get("banners/active/", {
params: { username: props.username },
});
setBanner(res.data);
} catch (err) {
console.error('Failed to fetch active banner:', err);
console.error("Failed to fetch active banner:", err);
} finally {
setLoading(false);
}
@@ -78,31 +100,31 @@ const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
if (loading) return null;
// Default to GenerativeBanner if no active banner or unrecognized slug
if (!banner || banner.slug === 'generative-web') {
if (!banner || banner.slug === "generative-web") {
return <GenerativeBanner {...props} settings={banner?.settings} />;
}
if (banner.slug === 'matrix') {
if (banner.slug === "matrix") {
return <MatrixBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'nebula') {
if (banner.slug === "nebula") {
return <NebulaBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'fireflies') {
if (banner.slug === "fireflies") {
return <FirefliesBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'hextech') {
if (banner.slug === "hextech") {
return <HextechBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'hextech-corruption') {
if (banner.slug === "hextech-corruption") {
return <HextechCorruptionBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'generative-flow') {
if (banner.slug === "generative-flow") {
return <GenerativeFlowBanner {...props} settings={banner.settings} />;
}
+64 -24
View File
@@ -1,5 +1,5 @@
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;
@@ -16,7 +16,11 @@ interface FirefliesBannerProps {
};
}
const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, settings }) => {
const FirefliesBanner: React.FC<FirefliesBannerProps> = ({
username,
palette,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
@@ -38,14 +42,23 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
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 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));
const sorted = [...colors].sort(
(a, b) => getBrightness(b) - getBrightness(a),
);
return sorted.slice(0, 3);
}, [palette, theme]);
@@ -53,7 +66,7 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return;
let animationFrameId: number;
@@ -89,7 +102,7 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
color: brightColors[i % brightColors.length],
phase: pseudoRandom(i + 2500) * Math.PI * 2,
noiseOffset: pseudoRandom(i + 3000) * 100,
isEvading: false
isEvading: false,
});
}
return newSwarm;
@@ -122,8 +135,11 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
const parent = canvas.parentElement;
if (parent) {
parent.addEventListener('mousemove', handleMouseMove);
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; });
parent.addEventListener("mousemove", handleMouseMove);
parent.addEventListener("mouseleave", () => {
mouse.x = -1000;
mouse.y = -1000;
});
}
let time = 0;
@@ -138,16 +154,23 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
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);
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));
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');
bgGrad.addColorStop(0, "#141a2a"); // Noticeable blue-grey tint
bgGrad.addColorStop(1, "#05080f");
}
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
@@ -208,7 +231,14 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
const opacity = flicker > 0 ? flicker : 0;
// Render
const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.size * glowSizeMultiplier);
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));
@@ -218,7 +248,7 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
ctx.arc(f.x, f.y, f.size * glowSizeMultiplier, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = alpha('#fff', opacity * 0.8);
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();
@@ -232,23 +262,33 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
parent.removeEventListener("mousemove", handleMouseMove);
}
cancelAnimationFrame(animationFrameId);
};
}, [seed, brightColors, count, baseSpeed, evasionRadius, evasionStrength, glowSizeMultiplier, flickerSpeed, theme]);
}, [
seed,
brightColors,
count,
baseSpeed,
evasionRadius,
evasionStrength,
glowSizeMultiplier,
flickerSpeed,
theme,
]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
position: "absolute",
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 1,
}}
/>
);
+64 -12
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' }}>
<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' } }}>
<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' } }}>
<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>
+78 -33
View File
@@ -1,5 +1,5 @@
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;
@@ -14,7 +14,13 @@ interface GenerativeBannerProps {
};
}
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => {
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({
username,
palette,
ratingsCount,
followersCount,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
@@ -35,15 +41,25 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
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];
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;
@@ -69,11 +85,14 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
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,
x: bx,
y: by,
baseX: bx,
baseY: by,
vx: 0,
vy: 0,
color: colors[i % colors.length],
size: 1 + pseudoRandom(i + 200) * 3
size: 1 + pseudoRandom(i + 200) * 3,
});
}
return newNodes;
@@ -113,18 +132,29 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
}
};
const handleMouseDown = () => { mouse.down = true; };
const handleMouseUp = () => { mouse.down = false; };
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; });
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 = () => {
@@ -197,7 +227,12 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
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);
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));
@@ -226,7 +261,7 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
}
// Draw Nodes
nodes.forEach(node => {
nodes.forEach((node) => {
ctx.beginPath();
ctx.arc(node.x, node.y, node.size, 0, Math.PI * 2);
ctx.fillStyle = node.color;
@@ -247,29 +282,39 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
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);
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]);
}, [
username,
palette,
ratingsCount,
followersCount,
theme,
nodeCountBase,
connectionDist,
speed,
globalOpacity,
]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
position: "absolute",
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 1,
mixBlendMode: theme.palette.mode === 'light' ? 'multiply' : 'screen'
mixBlendMode: theme.palette.mode === "light" ? "multiply" : "screen",
}}
/>
);
@@ -1,5 +1,5 @@
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;
@@ -15,7 +15,11 @@ interface GenerativeFlowProps {
};
}
const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette, settings }) => {
const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({
username,
palette,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
@@ -36,14 +40,23 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
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 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));
const sorted = [...colors].sort(
(a, b) => getBrightness(b) - getBrightness(a),
);
return sorted.slice(0, 4);
}, [palette, theme]);
@@ -51,7 +64,7 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return;
let animationFrameId: number;
@@ -83,13 +96,16 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
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),
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)
speedFactor: 0.5 + pseudoRandom(i + 2000),
});
}
return newBlobs;
@@ -122,8 +138,11 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
const parent = canvas.parentElement;
if (parent) {
parent.addEventListener('mousemove', handleMouseMove);
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; });
parent.addEventListener("mousemove", handleMouseMove);
parent.addEventListener("mouseleave", () => {
mouse.x = -1000;
mouse.y = -1000;
});
}
const draw = () => {
@@ -137,15 +156,28 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
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);
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));
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');
bgGrad.addColorStop(0, "#0a0a1a");
bgGrad.addColorStop(1, "#020205");
}
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
@@ -154,12 +186,13 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
// Apply filter for "Liquid" look
ctx.filter = `blur(${blurAmount}px)`;
ctx.globalCompositeOperation = 'screen';
ctx.globalCompositeOperation = "screen";
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 driftY =
Math.cos(time * 0.8 * b.speedFactor + b.phase) * (height / 5);
// Mouse distortion
const dxMouse = mouse.x - b.ox;
@@ -177,7 +210,14 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
const pulse = 1 + Math.sin(time + b.phase) * 0.1;
const currentSize = b.size * pulse;
const grad = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, currentSize);
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));
@@ -187,8 +227,8 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
ctx.fill();
});
ctx.filter = 'none';
ctx.globalCompositeOperation = 'source-over';
ctx.filter = "none";
ctx.globalCompositeOperation = "source-over";
animationFrameId = requestAnimationFrame(draw);
};
@@ -198,23 +238,32 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
parent.removeEventListener("mousemove", handleMouseMove);
}
cancelAnimationFrame(animationFrameId);
};
}, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme]);
}, [
seed,
brightColors,
blobCount,
flowSpeed,
vibrancy,
blurAmount,
distortionFactor,
theme,
]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
position: "absolute",
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 1,
}}
/>
);
+95 -38
View File
@@ -1,5 +1,5 @@
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;
@@ -15,7 +15,11 @@ interface HextechBannerProps {
};
}
const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settings }) => {
const HextechBanner: React.FC<HextechBannerProps> = ({
username,
palette,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
@@ -36,14 +40,23 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
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 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));
const sorted = [...colors].sort(
(a, b) => getBrightness(b) - getBrightness(a),
);
return sorted.slice(0, 3);
}, [palette, theme]);
@@ -51,7 +64,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return;
let animationFrameId: number;
@@ -64,7 +77,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
// Mana Stream Logic
interface Stream {
segments: { x: number, y: number }[];
segments: { x: number; y: number }[];
color: string;
life: number;
speed: number;
@@ -73,7 +86,8 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
let streams: Stream[] = [];
const createStream = (width: number, height: number) => {
const color = brightColors[Math.floor(pseudoRandom(time) * brightColors.length)];
const color =
brightColors[Math.floor(pseudoRandom(time) * brightColors.length)];
let x = pseudoRandom(time + 1) * width;
let y = pseudoRandom(time + 2) * height;
@@ -81,7 +95,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
segments: [{ x, y }],
color,
life: 1.0,
speed: 2 + pseudoRandom(time + 3) * 4
speed: 2 + pseudoRandom(time + 3) * 4,
};
};
@@ -120,15 +134,23 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
};
const handleMouseDown = () => { mouse.down = true; };
const handleMouseUp = () => { mouse.down = false; };
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; });
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 = () => {
@@ -142,15 +164,22 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
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);
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');
bgGrad.addColorStop(0, "#0a051a"); // Arcane Purple tint
bgGrad.addColorStop(1, "#020105");
}
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
@@ -164,22 +193,34 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
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 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 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 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 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);
@@ -200,7 +241,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
streams.push(createStream(width, height));
}
ctx.globalCompositeOperation = 'screen';
ctx.globalCompositeOperation = "screen";
streams.forEach((s, idx) => {
s.life -= 0.005;
const last = s.segments[s.segments.length - 1];
@@ -222,7 +263,14 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
ctx.stroke();
const head = s.segments[s.segments.length - 1];
const g = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 15);
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;
@@ -230,9 +278,9 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
ctx.arc(head.x, head.y, 15, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalCompositeOperation = 'source-over';
ctx.globalCompositeOperation = "source-over";
streams = streams.filter(s => s.life > 0);
streams = streams.filter((s) => s.life > 0);
animationFrameId = requestAnimationFrame(draw);
};
@@ -242,25 +290,34 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
parent.removeEventListener('mousedown', handleMouseDown);
parent.removeEventListener('mouseup', handleMouseUp);
parent.removeEventListener("mousemove", handleMouseMove);
parent.removeEventListener("mousedown", handleMouseDown);
parent.removeEventListener("mouseup", handleMouseUp);
}
cancelAnimationFrame(animationFrameId);
};
}, [seed, brightColors, gridSize, pulseSpeed, glowIntensity, streamCount, distortionFactor, theme]);
}, [
seed,
brightColors,
gridSize,
pulseSpeed,
glowIntensity,
streamCount,
distortionFactor,
theme,
]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
position: "absolute",
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 1,
}}
/>
);
@@ -1,5 +1,5 @@
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;
@@ -15,7 +15,11 @@ interface HextechCorruptionBannerProps {
};
}
const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ username, palette, settings }) => {
const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({
username,
palette,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
@@ -36,14 +40,23 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
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 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));
const sorted = [...colors].sort(
(a, b) => getBrightness(b) - getBrightness(a),
);
return sorted.slice(0, 3);
}, [palette, theme]);
@@ -51,7 +64,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return;
let animationFrameId: number;
@@ -81,11 +94,14 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
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,
x: rx,
y: ry,
ox: rx,
oy: ry,
vx: 0,
vy: 0,
phase: pseudoRandom(i + 1000) * Math.PI * 2,
color: brightColors[i % brightColors.length]
color: brightColors[i % brightColors.length],
});
}
return newPoints;
@@ -115,15 +131,23 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
};
const handleMouseDown = () => { mouse.down = true; };
const handleMouseUp = () => { mouse.down = false; };
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; });
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 = () => {
@@ -138,7 +162,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
time += shimmerSpeed;
// 1. Draw Under-Glow (Inner Cores)
ctx.globalCompositeOperation = 'source-over';
ctx.globalCompositeOperation = "source-over";
points.forEach((p) => {
// Harmonic motion + mouse interaction
const dxMouse = mouse.x - p.x;
@@ -153,7 +177,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
p.y = p.oy + driftY + (mouse.down ? -dyMouse * mouseForce * 0.5 : 0);
const pulse = 0.7 + Math.sin(time * 3 + p.phase) * 0.3;
const glowSize = 60 + (mouseForce * 40);
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));
@@ -168,29 +192,32 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
// 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.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');
const offCanvas = document.createElement("canvas");
offCanvas.width = canvas.width;
offCanvas.height = canvas.height;
const offCtx = offCanvas.getContext('2d');
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);
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);
offCtx.globalCompositeOperation = "destination-out";
points.forEach((p) => {
const holeSize = 15 + Math.sin(time + p.phase) * 5 + instability * 10;
// Main hole
offCtx.beginPath();
@@ -199,7 +226,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
// 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 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();
@@ -212,9 +239,9 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
}
// 3. Add Iridescent Edge Highlights
ctx.globalCompositeOperation = 'screen';
ctx.globalCompositeOperation = "screen";
ctx.lineWidth = 2;
points.forEach(p => {
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();
@@ -222,7 +249,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
ctx.stroke();
});
ctx.globalCompositeOperation = 'source-over';
ctx.globalCompositeOperation = "source-over";
animationFrameId = requestAnimationFrame(draw);
};
@@ -231,25 +258,34 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
parent.removeEventListener('mousedown', handleMouseDown);
parent.removeEventListener('mouseup', handleMouseUp);
parent.removeEventListener("mousemove", handleMouseMove);
parent.removeEventListener("mousedown", handleMouseDown);
parent.removeEventListener("mouseup", handleMouseUp);
}
cancelAnimationFrame(animationFrameId);
};
}, [seed, brightColors, cellCount, shimmerSpeed, glowIntensity, instability, shellDarkness, theme]);
}, [
seed,
brightColors,
cellCount,
shimmerSpeed,
glowIntensity,
instability,
shellDarkness,
theme,
]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
position: "absolute",
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 1,
}}
/>
);
+51 -29
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef } from "react";
import {
TextField,
Popper,
@@ -9,9 +9,9 @@ import {
ListItemAvatar,
ListItemText,
Box,
alpha
} from '@mui/material';
import api from '../api';
alpha,
} from "@mui/material";
import api from "../api";
interface UserSuggestion {
id: number;
@@ -26,7 +26,7 @@ interface MentionTextFieldProps {
fullWidth?: boolean;
multiline?: boolean;
rows?: number;
size?: 'small' | 'medium';
size?: "small" | "medium";
onKeyDown?: (e: React.KeyboardEvent) => void;
}
@@ -37,8 +37,8 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
fullWidth = true,
multiline = false,
rows,
size = 'small',
onKeyDown
size = "small",
onKeyDown,
}) => {
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
@@ -52,17 +52,17 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
useEffect(() => {
const fetchUsers = async () => {
try {
const res = await api.get('flavors/search/');
const res = await api.get("flavors/search/");
const users = (res.data || [])
.filter((item: any) => item.type === 'user')
.filter((item: any) => item.type === "user")
.map((item: any) => ({
id: item.id,
username: item.name,
avatar: item.image_url
avatar: item.image_url,
}));
setAllUsers(users);
} catch (err) {
console.error('Failed to fetch users for mentions');
console.error("Failed to fetch users for mentions");
}
};
fetchUsers();
@@ -74,14 +74,14 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
const selectionStart = e.target.selectionStart || 0;
const textBeforeCursor = newValue.substring(0, selectionStart);
const lastAtPos = textBeforeCursor.lastIndexOf('@');
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())
if (!query.includes(" ") && !query.includes("\n")) {
const filtered = allUsers.filter((u) =>
u.username.toLowerCase().startsWith(query.toLowerCase()),
);
setSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
@@ -97,11 +97,13 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
const selectionStart = inputRef.current?.selectionStart || 0;
const textBeforeCursor = value.substring(0, selectionStart);
const textAfterCursor = value.substring(selectionStart);
const lastAtPos = textBeforeCursor.lastIndexOf('@');
const lastAtPos = textBeforeCursor.lastIndexOf("@");
const newValue =
value.substring(0, lastAtPos) +
'@' + user.username + ' ' +
"@" +
user.username +
" " +
textAfterCursor;
onChange(newValue);
@@ -119,22 +121,24 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
const handleKeyDown = (e: React.KeyboardEvent) => {
if (showSuggestions) {
if (e.key === 'Tab' || e.key === 'Enter') {
if (e.key === "Tab" || e.key === "Enter") {
e.preventDefault();
handleSelectSuggestion(suggestions[selectedIndex]);
return;
}
if (e.key === 'ArrowDown') {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % suggestions.length);
return;
}
if (e.key === 'ArrowUp') {
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length);
setSelectedIndex(
(prev) => (prev - 1 + suggestions.length) % suggestions.length,
);
return;
}
if (e.key === 'Escape') {
if (e.key === "Escape") {
setShowSuggestions(false);
return;
}
@@ -143,7 +147,7 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
};
return (
<Box sx={{ position: 'relative', width: fullWidth ? '100%' : 'auto' }}>
<Box sx={{ position: "relative", width: fullWidth ? "100%" : "auto" }}>
<TextField
inputRef={inputRef}
fullWidth={fullWidth}
@@ -163,7 +167,16 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
placement="bottom-start"
style={{ zIndex: 2000, width: 250 }}
>
<Paper elevation={8} sx={{ mt: 1, borderRadius: 2, overflow: 'hidden', border: '1px solid', borderColor: 'divider' }}>
<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
@@ -172,20 +185,29 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
onClick={() => handleSelectSuggestion(user)}
sx={{
py: 1,
'&.Mui-selected': {
"&.Mui-selected": {
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.15),
'&:hover': { bgcolor: (theme) => alpha(theme.palette.primary.main, 0.25) }
}
"&: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' }}>
<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' }}
primaryTypographyProps={{
variant: "body2",
fontWeight: "bold",
}}
/>
</MenuItem>
))}
+81 -36
View File
@@ -1,5 +1,5 @@
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;
@@ -17,7 +17,13 @@ interface NebulaBannerProps {
};
}
const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => {
const NebulaBanner: React.FC<NebulaBannerProps> = ({
username,
palette,
ratingsCount,
followersCount,
settings,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
@@ -43,11 +49,18 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
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];
const colors =
palette.length >= 2
? palette
: [
theme.palette.primary.main,
theme.palette.secondary.main,
theme.palette.info.main,
];
interface Particle {
x: number;
@@ -86,7 +99,7 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
x: pseudoRandom(i + 5000) * width,
y: pseudoRandom(i + 6000) * height,
size: pseudoRandom(i + 7000) * 1.5,
opacity: 0.1 + pseudoRandom(i + 8000) * 0.5
opacity: 0.1 + pseudoRandom(i + 8000) * 0.5,
});
}
@@ -95,7 +108,7 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
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
y: (pseudoRandom(i * 25 + 7) * 0.8 + 0.1) * height,
}));
for (let i = 0; i < particleCount; i++) {
@@ -108,14 +121,17 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
const by = center.y + Math.sin(angle) * dist;
newParticles.push({
x: bx, y: by,
baseX: bx, baseY: by,
vx: 0, vy: 0,
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
phase: pseudoRandom(i + 500) * Math.PI * 2,
});
}
return { stars: newStars, particles: newParticles };
@@ -144,19 +160,28 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
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.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 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; });
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;
@@ -171,8 +196,15 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
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);
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
@@ -181,17 +213,17 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
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');
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.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);
@@ -252,13 +284,13 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
grad.addColorStop(1, alpha(p.color, 0));
ctx.fillStyle = grad;
ctx.globalCompositeOperation = 'screen';
ctx.globalCompositeOperation = "screen";
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalCompositeOperation = 'source-over';
ctx.globalCompositeOperation = "source-over";
animationFrameId = requestAnimationFrame(draw);
};
@@ -267,25 +299,38 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
return () => {
resizeObserver.disconnect();
if (parent) {
parent.removeEventListener('mousemove', handleMouseMove);
parent.removeEventListener('mousedown', handleMouseDown);
parent.removeEventListener('mouseup', handleMouseUp);
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]);
}, [
seed,
palette,
ratingsCount,
followersCount,
theme,
particleCount,
speed,
globalOpacity,
smearRadius,
turbulenceStrength,
returnSpeed,
starDensity,
]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
position: "absolute",
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 1,
}}
/>
);
+44 -28
View File
@@ -1,49 +1,65 @@
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';
size?: "small" | "medium" | "large";
sx?: any;
}
const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx }) => {
const RatingBadge: React.FC<RatingBadgeProps> = ({
score,
size = "medium",
sx,
}) => {
const theme = useTheme();
const isLarge = size === 'large';
const isSmall = size === 'small';
const isLarge = size === "large";
const isSmall = size === "small";
return (
<Box sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
<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),
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' }}
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,
}}
>
{typeof score === 'number' ? (isLarge ? score.toFixed(1) : score.toFixed(0)) : score}
<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',
fontSize: isLarge ? "0.75rem" : isSmall ? "0.55rem" : "0.65rem",
fontWeight: "900",
opacity: 0.9,
lineHeight: 1,
mt: 0.2
mt: 0.2,
}}
>
/ 10
+8 -8
View File
@@ -1,6 +1,6 @@
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;
@@ -15,7 +15,7 @@ const RichText: React.FC<RichTextProps> = ({ text }) => {
return (
<span>
{parts.map((part, i) => {
if (part.startsWith('@')) {
if (part.startsWith("@")) {
const username = part.substring(1);
return (
<Link
@@ -23,11 +23,11 @@ const RichText: React.FC<RichTextProps> = ({ text }) => {
to={`/profile/${username}`}
style={{
color: theme.palette.primary.main,
textDecoration: 'none',
fontWeight: 'bold',
textDecoration: "none",
fontWeight: "bold",
backgroundColor: alpha(theme.palette.primary.main, 0.1),
padding: '0 4px',
borderRadius: '4px'
padding: "0 4px",
borderRadius: "4px",
}}
>
{part}
+33 -24
View File
@@ -1,50 +1,59 @@
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';
size?: "small" | "medium";
sx?: any;
}
const StatusBadge: React.FC<StatusBadgeProps> = ({ isLegacy, isAvailable, size = 'medium', sx }) => {
const StatusBadge: React.FC<StatusBadgeProps> = ({
isLegacy,
isAvailable,
size = "medium",
sx,
}) => {
const theme = useTheme();
const isSmall = size === 'small';
const isSmall = size === "small";
if (isAvailable && !isLegacy) return null;
const label = isLegacy ? 'Limited' : 'Out of Stock';
const color = isLegacy ? theme.palette.warning.main : theme.palette.error.main;
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',
<Box
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.5,
bgcolor: alpha(color, 0.9),
color: '#fff',
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' }} />
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',
fontWeight: "900",
textTransform: "uppercase",
letterSpacing: 0.5,
fontSize: isSmall ? '0.55rem' : '0.65rem',
lineHeight: 1
fontSize: isSmall ? "0.55rem" : "0.65rem",
lineHeight: 1,
}}
>
{label}
+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>,
)
);
+111 -42
View File
@@ -1,88 +1,157 @@
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 } }}>
<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
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>
<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="subtitle1" sx={{ fontWeight: "bold" }}>
{t("about.contact")}:
</Typography>
<Typography variant="body2">
E-Mail: {import.meta.env.VITE_IMPRESSUM_EMAIL || '[Your Support Email Address]'}<br />
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 /></>
<>
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="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]'}
{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 variant="h6" gutterBottom sx={{ fontWeight: "bold" }}>
{t("about.disclaimerTitle")}
</Typography>
<Typography variant="caption" color="text.secondary" component="p" sx={{ lineHeight: 1.6 }}>
<strong>{t('about.copyrightTitle')}</strong> {t('about.copyrightDesc')}
<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>
File diff suppressed because it is too large. Load diff
+69 -30
View File
@@ -1,22 +1,28 @@
import React, { useState, useEffect } from 'react';
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';
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 [comment, setComment] = useState("");
const [rating, setRating] = useState<any>(null);
const [loading, setLoading] = useState(true);
@@ -25,10 +31,10 @@ const AdminRatingDetail: React.FC = () => {
const res = await api.get(`ratings/${id}/`);
setRating(res.data);
setScore(res.data.score);
setComment(res.data.comment || '');
setComment(res.data.comment || "");
} catch (err) {
console.error(err);
navigate('/admin-panel');
navigate("/admin-panel");
} finally {
setLoading(false);
}
@@ -38,25 +44,38 @@ const AdminRatingDetail: React.FC = () => {
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'); }
alert("Rating updated!");
} catch (err) {
alert("Update failed");
}
};
const handleDelete = async () => {
if (!window.confirm('Delete this rating and all replies?')) return;
if (!window.confirm("Delete this rating and all replies?")) return;
try {
await api.delete(`ratings/${id}/`);
navigate('/admin-panel');
} catch (err) { alert('Delete failed'); }
navigate("/admin-panel");
} catch (err) {
alert("Delete failed");
}
};
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="sm" sx={{ py: 4 }}>
@@ -66,28 +85,48 @@ const AdminRatingDetail: React.FC = () => {
onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }}
>
{t('common.back')}
{t("common.back")}
</Button>
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 4 }}>{t('admin.manageRating')}</Typography>
<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' }}>
<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"
label="Score"
type="number"
slotProps={{ input: { inputProps: { min: 1, max: 10 } } }}
value={score} onChange={(e) => setScore(Number(e.target.value))}
value={score}
onChange={(e) => setScore(Number(e.target.value))}
/>
<MentionTextField
multiline rows={4}
value={comment} onChange={setComment}
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>
<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>
+59 -27
View File
@@ -1,21 +1,26 @@
import React, { useState, useEffect } from 'react';
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';
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 [text, setText] = useState("");
const [reply, setReply] = useState<any>(null);
const [loading, setLoading] = useState(true);
@@ -27,7 +32,7 @@ const AdminReplyDetail: React.FC = () => {
setText(res.data.text);
} catch (err) {
console.error(err);
navigate('/admin-panel');
navigate("/admin-panel");
} finally {
setLoading(false);
}
@@ -37,25 +42,34 @@ const AdminReplyDetail: React.FC = () => {
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'); }
alert("Reply updated!");
} catch (err) {
alert("Update failed");
}
};
const handleDelete = async () => {
if (!window.confirm('Delete this reply?')) return;
if (!window.confirm("Delete this reply?")) return;
try {
await api.delete(`replies/${id}/`);
navigate('/admin-panel');
} catch (err) { alert('Delete failed'); }
navigate("/admin-panel");
} catch (err) {
alert("Delete failed");
}
};
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="sm" sx={{ py: 4 }}>
@@ -65,23 +79,41 @@ const AdminReplyDetail: React.FC = () => {
onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }}
>
{t('common.back')}
{t("common.back")}
</Button>
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 4 }}>{t('admin.manageReply')}</Typography>
<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' }}>
<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}
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>
<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>
+393 -93
View File
@@ -1,21 +1,39 @@
import React, { useState, useEffect } from 'react';
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';
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;
@@ -41,11 +59,11 @@ const AdminUserDetail: React.FC = () => {
// Edit state for Ratings
const [editingRatingId, setEditingRatingId] = useState<number | null>(null);
const [editScore, setEditScore] = useState(0);
const [editComment, setEditComment] = useState('');
const [editComment, setEditComment] = useState("");
// Edit state for Replies
const [editingReplyId, setEditingReplyId] = useState<number | null>(null);
const [editReplyText, setEditReplyText] = useState('');
const [editReplyText, setEditReplyText] = useState("");
const fetchUser = async () => {
try {
@@ -53,7 +71,7 @@ const AdminUserDetail: React.FC = () => {
setUser(res.data);
} catch (err) {
console.error(err);
navigate('/admin-panel');
navigate("/admin-panel");
} finally {
setLoading(false);
}
@@ -63,30 +81,43 @@ const AdminUserDetail: React.FC = () => {
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 });
await api.patch(`admin-custom/${id}/user_detail/`, {
is_active: !user.is_active,
});
fetchUser();
} catch (err) { alert('Action failed'); }
} catch (err) {
alert("Action failed");
}
};
const handleUpdateRating = async (ratingId: number) => {
try {
await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment });
await api.patch(`ratings/${ratingId}/`, {
score: editScore,
comment: editComment,
});
setEditingRatingId(null);
fetchUser();
} catch (err) { alert('Update failed'); }
} catch (err) {
alert("Update failed");
}
};
const handleDeleteRating = async (ratingId: number) => {
if (!confirm('Delete this rating?')) return;
if (!confirm("Delete this rating?")) return;
try {
await api.delete(`ratings/${ratingId}/`);
fetchUser();
} catch (err) { alert('Delete failed'); }
} catch (err) {
alert("Delete failed");
}
};
const handleUpdateReply = async (replyId: number) => {
@@ -94,26 +125,42 @@ const AdminUserDetail: React.FC = () => {
await api.patch(`replies/${replyId}/`, { text: editReplyText });
setEditingReplyId(null);
fetchUser();
} catch (err) { alert('Update failed'); }
} catch (err) {
alert("Update failed");
}
};
const handleDeleteReply = async (replyId: number) => {
if (!confirm('Delete this reply?')) return;
if (!confirm("Delete this reply?")) return;
try {
await api.delete(`replies/${replyId}/`);
fetchUser();
} catch (err) { alert('Delete failed'); }
} catch (err) {
alert("Delete failed");
}
};
const handleDelete = async () => {
if (!window.confirm('PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.')) return;
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'); }
navigate("/admin-panel");
} catch (err) {
alert("Delete failed");
}
};
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>
);
if (!user) return <Typography>User not found</Typography>;
return (
@@ -124,40 +171,83 @@ const AdminUserDetail: React.FC = () => {
onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }}
>
{t('common.back')}
{t("common.back")}
</Button>
{/* Profile Header */}
<Card elevation={0} sx={{ borderRadius: 4, border: '1px solid', borderColor: 'divider', mb: 4 }}>
<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' }}>
<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)}
<Box sx={{ textAlign: { xs: "center", sm: "left" } }}>
<Typography variant="h4" sx={{ fontWeight: "bold" }}>
{user.username}
</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" />}
<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' }}>
<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' }}
sx={{
borderRadius: 2,
textTransform: "none",
fontWeight: "bold",
}}
>
{user.is_active ? t('admin.deactivate') : t('admin.activate')}
{user.is_active ? t("admin.deactivate") : t("admin.activate")}
</Button>
</Grid>
</Grid>
@@ -166,10 +256,26 @@ const AdminUserDetail: React.FC = () => {
{/* 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
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>
@@ -177,76 +283,223 @@ const AdminUserDetail: React.FC = () => {
{/* TAB 0: USER CONTENT (Ratings & Replies) */}
{activeTab === 0 && (
<Box>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold' }}>User Ratings & Discussion</Typography>
<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) }}>
{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 }}>
<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))}
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}
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>
<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>
<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 }}>
<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>
<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' }}>
<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} />
<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>
<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' }}>
<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 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>
<Typography
variant="caption"
display="block"
sx={{ mt: 0.2 }}
>
<RichText text={reply.text} />
</Typography>
</>
)}
</Box>
@@ -259,7 +512,16 @@ const AdminUserDetail: React.FC = () => {
</Card>
))}
{user.ratings.length === 0 && (
<Box sx={{ py: 6, textAlign: 'center', bgcolor: 'action.hover', borderRadius: 3, border: '1px dashed', borderColor: 'divider' }}>
<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>
)}
@@ -269,30 +531,68 @@ const AdminUserDetail: React.FC = () => {
{/* 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')}
<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' } }}
primaryTypographyProps={{
sx: { fontWeight: "bold", fontFamily: "monospace" },
}}
/>
</ListItem>
))}
{user.ips.length === 0 && <Typography variant="caption">No IP history available.</Typography>}
{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>
<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.
Deleting this user will permanently remove their profile, all their
ratings, replies, and IP history.
<strong> This action is irreversible.</strong>
</Typography>
<Button
@@ -301,9 +601,9 @@ const AdminUserDetail: React.FC = () => {
size="large"
startIcon={<DeleteIcon />}
onClick={handleDelete}
sx={{ borderRadius: 2, fontWeight: 'bold', px: 4 }}
sx={{ borderRadius: 2, fontWeight: "bold", px: 4 }}
>
{t('admin.deleteUser')}
{t("admin.deleteUser")}
</Button>
</Paper>
)}
+481 -157
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState } from "react";
import {
Typography,
Box,
@@ -19,23 +19,23 @@ import {
alpha,
IconButton,
Collapse,
Link as MuiLink
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import CommentIcon from '@mui/icons-material/Comment';
import SendIcon from '@mui/icons-material/Send';
import WhatshotIcon from '@mui/icons-material/Whatshot';
import NotificationsIcon from '@mui/icons-material/Notifications';
import PeopleIcon from '@mui/icons-material/People';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { Link, useNavigate } from 'react-router-dom';
import api from '../api';
import { useTitle } from '../hooks/useTitle';
import RichText from '../components/RichText';
import { formatDate } from '../utils/date';
import { useTranslation } from 'react-i18next';
import MentionTextField from '../components/MentionTextField';
import RatingBadge from '../components/RatingBadge';
Link as MuiLink,
} from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";
import CommentIcon from "@mui/icons-material/Comment";
import SendIcon from "@mui/icons-material/Send";
import WhatshotIcon from "@mui/icons-material/Whatshot";
import NotificationsIcon from "@mui/icons-material/Notifications";
import PeopleIcon from "@mui/icons-material/People";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { Link, useNavigate } from "react-router-dom";
import api from "../api";
import { useTitle } from "../hooks/useTitle";
import RichText from "../components/RichText";
import { formatDate } from "../utils/date";
import { useTranslation } from "react-i18next";
import MentionTextField from "../components/MentionTextField";
import RatingBadge from "../components/RatingBadge";
interface Reply {
id: number;
@@ -70,7 +70,13 @@ interface Notification {
id: number;
actor_username: string;
actor_avatar: string | null;
notification_type: 'reply' | 'mention' | 'follow' | 'ticket_new' | 'ticket_reply' | 'profile_comment';
notification_type:
| "reply"
| "mention"
| "follow"
| "ticket_new"
| "ticket_reply"
| "profile_comment";
rating: number | null;
reply: number | null;
is_read: boolean;
@@ -85,7 +91,7 @@ interface CommunityFeedProps {
const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
const { t } = useTranslation();
useTitle(t('community.title'));
useTitle(t("community.title"));
const navigate = useNavigate();
const [ratings, setRatings] = useState<FeedRating[]>([]);
const [following, setFollowing] = useState<FollowedUser[]>([]);
@@ -93,65 +99,77 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [followingSearch, setFollowingSearch] = useState('');
const [followingSearch, setFollowingSearch] = useState("");
// Top Rated Flavors by followed users
const [topFollowed, setTopFollowed] = useState<any[]>([]);
// UI State
const [replyInputs, setReplyInputs] = useState<{ [key: number]: string }>({});
const [expandedReplies, setExpandedReplies] = useState<{[key: number]: boolean}>({});
const [expandedReplies, setExpandedReplies] = useState<{
[key: number]: boolean;
}>({});
const filteredFollowing = following.filter(user =>
user.username.toLowerCase().includes(followingSearch.toLowerCase())
const filteredFollowing = following.filter((user) =>
user.username.toLowerCase().includes(followingSearch.toLowerCase()),
);
const handleGoBack = () => {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate('/');
navigate("/");
}
};
const fetchFeedData = async (pageNum: number) => {
setLoading(true);
try {
const [feedRes, followingRes, topFollowedRes, notifRes] = await Promise.all([
const [feedRes, followingRes, topFollowedRes, notifRes] =
await Promise.all([
api.get(`ratings/feed/?page=${pageNum}`),
api.get('users/following_list/'),
api.get('flavors/followed_top/'),
api.get('notifications/')
api.get("users/following_list/"),
api.get("flavors/followed_top/"),
api.get("notifications/"),
]);
const feedData = feedRes.data.results || (Array.isArray(feedRes.data) ? feedRes.data : []);
const feedData =
feedRes.data.results ||
(Array.isArray(feedRes.data) ? feedRes.data : []);
setRatings(feedData);
const count = feedRes.data.count || 0;
setTotalPages(Math.ceil(count / 10));
const followData = Array.isArray(followingRes.data) ? followingRes.data : (followingRes.data.results || []);
const followData = Array.isArray(followingRes.data)
? followingRes.data
: followingRes.data.results || [];
setFollowing(followData);
setTopFollowed(Array.isArray(topFollowedRes.data) ? topFollowedRes.data.slice(0, 5) : []);
setTopFollowed(
Array.isArray(topFollowedRes.data)
? topFollowedRes.data.slice(0, 5)
: [],
);
const notifData = Array.isArray(notifRes.data) ? notifRes.data : (notifRes.data.results || []);
const notifData = Array.isArray(notifRes.data)
? notifRes.data
: notifRes.data.results || [];
setNotifications(notifData.slice(0, 5)); // Just the latest 5 for sidebar
} catch (err: any) {
if (err.response?.status === 401) {
navigate('/login');
navigate("/login");
}
console.error('Failed to fetch feed data', err);
console.error("Failed to fetch feed data", err);
} finally {
setLoading(false);
}
};
useEffect(() => {
const token = localStorage.getItem('token');
const token = localStorage.getItem("token");
if (!token) {
navigate('/login');
navigate("/login");
return;
}
fetchFeedData(page);
@@ -159,11 +177,11 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => {
setPage(value);
window.scrollTo({ top: 0, behavior: 'smooth' });
window.scrollTo({ top: 0, behavior: "smooth" });
};
const handleReplyToggle = (ratingId: number) => {
setExpandedReplies(prev => ({ ...prev, [ratingId]: !prev[ratingId] }));
setExpandedReplies((prev) => ({ ...prev, [ratingId]: !prev[ratingId] }));
};
const handleReplySubmit = async (ratingId: number) => {
@@ -171,15 +189,17 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
if (!text) return;
try {
const res = await api.post(`ratings/${ratingId}/reply/`, { text });
setRatings(prevRatings => prevRatings.map(r => {
setRatings((prevRatings) =>
prevRatings.map((r) => {
if (r.id === ratingId) {
return { ...r, replies: [...r.replies, res.data] };
}
return r;
}));
setReplyInputs(prev => ({ ...prev, [ratingId]: '' }));
}),
);
setReplyInputs((prev) => ({ ...prev, [ratingId]: "" }));
} catch (err) {
alert('Failed to send reply');
alert("Failed to send reply");
}
};
@@ -187,7 +207,12 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
return formatDate(dateStr);
};
if (loading && ratings.length === 0) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
if (loading && ratings.length === 0)
return (
<Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
<CircularProgress />
</Box>
);
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
@@ -198,25 +223,29 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
startIcon={<ArrowBackIcon />}
sx={{
borderRadius: 2,
textTransform: 'none',
fontWeight: 'bold',
color: 'text.secondary',
borderColor: 'divider',
'&:hover': {
borderColor: 'primary.main',
color: 'primary.main',
bgcolor: 'transparent'
}
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>
<Box sx={{ mb: 6 }}>
<Typography variant="h3" sx={{ fontWeight: '800', mb: 1 }}>{t('community.title')}</Typography>
<Typography variant="h3" sx={{ fontWeight: "800", mb: 1 }}>
{t("community.title")}
</Typography>
<Typography variant="h6" color="text.secondary">
{t('community.subtitle')}
{t("community.subtitle")}
</Typography>
</Box>
@@ -224,53 +253,96 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
{/* Main Feed Column */}
<Grid size={{ xs: 12, lg: 8 }}>
{ratings.length === 0 ? (
<Box sx={{
<Box
sx={{
p: 6,
textAlign: 'center',
textAlign: "center",
borderRadius: 4,
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.6),
backdropFilter: 'blur(10px)',
border: '1px dashed',
borderColor: 'divider'
}}>
<Typography variant="h6" gutterBottom color="text.secondary">{t('community.quietFeed')}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{t('community.followMore')}
backdropFilter: "blur(10px)",
border: "1px dashed",
borderColor: "divider",
}}
>
<Typography variant="h6" gutterBottom color="text.secondary">
{t("community.quietFeed")}
</Typography>
<Button variant="contained" component={Link} to="/" sx={{ borderRadius: 2 }}>
{t('home.exploreFlavors')}
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{t("community.followMore")}
</Typography>
<Button
variant="contained"
component={Link}
to="/"
sx={{ borderRadius: 2 }}
>
{t("home.exploreFlavors")}
</Button>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{ratings.map(rating => (
<Card key={rating.id} elevation={0} sx={{
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
{ratings.map((rating) => (
<Card
key={rating.id}
elevation={0}
sx={{
borderRadius: 4,
border: '1px solid',
borderColor: 'divider',
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.8),
transition: 'transform 0.2s',
'&:hover': {
transform: 'translateY(-2px)',
borderColor: (theme) => alpha(theme.palette.primary.main, 0.2)
}
}}>
border: "1px solid",
borderColor: "divider",
bgcolor: (theme) =>
alpha(theme.palette.background.paper, 0.8),
transition: "transform 0.2s",
"&:hover": {
transform: "translateY(-2px)",
borderColor: (theme) =>
alpha(theme.palette.primary.main, 0.2),
},
}}
>
<CardContent sx={{ p: { xs: 2, sm: 3 } }}>
{/* User Info */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', gap: 2 }}>
<Link to={`/profile/${rating.user}`} style={{ textDecoration: 'none' }}>
<Avatar src={rating.user_avatar || undefined} sx={{ width: 48, height: 48, border: '2px solid', borderColor: 'divider' }}>
{!rating.user_avatar && rating.user.charAt(0).toUpperCase()}
<Box
sx={{
display: "flex",
justifyContent: "space-between",
mb: 2,
}}
>
<Box sx={{ display: "flex", gap: 2 }}>
<Link
to={`/profile/${rating.user}`}
style={{ textDecoration: "none" }}
>
<Avatar
src={rating.user_avatar || undefined}
sx={{
width: 48,
height: 48,
border: "2px solid",
borderColor: "divider",
}}
>
{!rating.user_avatar &&
rating.user.charAt(0).toUpperCase()}
</Avatar>
</Link>
<Box>
<Link to={`/profile/${rating.user}`} style={{ textDecoration: 'none', color: 'inherit' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', lineHeight: 1.2 }}>
<Link
to={`/profile/${rating.user}`}
style={{ textDecoration: "none", color: "inherit" }}
>
<Typography
variant="subtitle1"
sx={{ fontWeight: "bold", lineHeight: 1.2 }}
>
{rating.user}
</Typography>
</Link>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block" }}
>
{formatTimestamp(rating.created_at)}
</Typography>
</Box>
@@ -280,31 +352,89 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
{adminMode && (
<Button
size="small" variant="outlined" color="secondary"
onClick={() => navigate(`/admin-panel/rating/${rating.id}`)}
sx={{ mb: 2, borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
size="small"
variant="outlined"
color="secondary"
onClick={() =>
navigate(`/admin-panel/rating/${rating.id}`)
}
sx={{
mb: 2,
borderRadius: 2,
textTransform: "none",
fontWeight: "bold",
}}
>
{t('admin.manageRating')}
{t("admin.manageRating")}
</Button>
)}
{/* Flavor and Comment */}
<Box sx={{ display: 'flex', gap: 3 }}>
<Box sx={{ display: "flex", gap: 3 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{t('community.rated')} <MuiLink component={Link} to={`/flavor/${rating.flavor}`} sx={{ color: 'primary.main', fontWeight: 'bold', textDecoration: 'none' }}>{rating.flavor_name}</MuiLink>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 1 }}
>
{t("community.rated")}{" "}
<MuiLink
component={Link}
to={`/flavor/${rating.flavor}`}
sx={{
color: "primary.main",
fontWeight: "bold",
textDecoration: "none",
}}
>
{rating.flavor_name}
</MuiLink>
</Typography>
{rating.comment ? (
<Typography variant="body1" sx={{ lineHeight: 1.6, fontStyle: 'italic', color: 'text.primary' }}>
<Typography
variant="body1"
sx={{
lineHeight: 1.6,
fontStyle: "italic",
color: "text.primary",
}}
>
<RichText text={rating.comment} />
</Typography>
) : (
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>No comment provided.</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ fontStyle: "italic" }}
>
No comment provided.
</Typography>
)}
</Box>
<Link to={`/flavor/${rating.flavor}`} style={{ flexShrink: 0 }}>
<Box sx={{ width: 70, height: 70, borderRadius: 1, overflow: 'hidden', bgcolor: 'background.default', border: '1px solid', borderColor: 'divider' }}>
<Box component="img" src={rating.flavor_image || undefined} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<Link
to={`/flavor/${rating.flavor}`}
style={{ flexShrink: 0 }}
>
<Box
sx={{
width: 70,
height: 70,
borderRadius: 1,
overflow: "hidden",
bgcolor: "background.default",
border: "1px solid",
borderColor: "divider",
}}
>
<Box
component="img"
src={rating.flavor_image || undefined}
sx={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
</Box>
</Link>
</Box>
@@ -316,44 +446,93 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
size="small"
startIcon={<CommentIcon fontSize="small" />}
onClick={() => handleReplyToggle(rating.id)}
sx={{ textTransform: 'none', color: 'text.secondary' }}
sx={{ textTransform: "none", color: "text.secondary" }}
>
{rating.replies.length > 0 ? `${rating.replies.length} ${t('common.replies')}` : t('common.reply')}
{rating.replies.length > 0
? `${rating.replies.length} ${t("common.replies")}`
: t("common.reply")}
</Button>
<Collapse in={expandedReplies[rating.id]}>
<Box sx={{ mt: 2, pl: 2, borderLeft: '2px solid', borderColor: 'divider' }}>
<Box
sx={{
mt: 2,
pl: 2,
borderLeft: "2px solid",
borderColor: "divider",
}}
>
{rating.replies.map((reply) => (
<Box key={reply.id} sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', fontSize: '0.8rem' }}>{reply.user}</Typography>
<Typography variant="caption" color="text.secondary">{formatTimestamp(reply.created_at)}</Typography>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
mb: 0.5,
}}
>
<Typography
variant="subtitle2"
sx={{ fontWeight: "bold", fontSize: "0.8rem" }}
>
{reply.user}
</Typography>
<Typography
variant="caption"
color="text.secondary"
>
{formatTimestamp(reply.created_at)}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="body2"><RichText text={reply.text} /></Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Typography variant="body2">
<RichText text={reply.text} />
</Typography>
{adminMode && (
<Button
size="small" sx={{ minWidth: 0, py: 0, fontSize: '0.7rem' }}
onClick={() => navigate(`/admin-panel/reply/${reply.id}`)}
size="small"
sx={{
minWidth: 0,
py: 0,
fontSize: "0.7rem",
}}
onClick={() =>
navigate(`/admin-panel/reply/${reply.id}`)
}
>
{t('admin.manageReply')}
{t("admin.manageReply")}
</Button>
)}
</Box>
</Box>
))}
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
<Box sx={{ mt: 2, display: "flex", gap: 1 }}>
<MentionTextField
placeholder={t('community.writeReply')}
value={replyInputs[rating.id] || ''}
onChange={(val) => setReplyInputs(prev => ({ ...prev, [rating.id]: val }))}
placeholder={t("community.writeReply")}
value={replyInputs[rating.id] || ""}
onChange={(val) =>
setReplyInputs((prev) => ({
...prev,
[rating.id]: val,
}))
}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
if (e.key === "Enter" && !e.shiftKey) {
handleReplySubmit(rating.id);
}
}}
/>
<IconButton color="primary" onClick={() => handleReplySubmit(rating.id)} disabled={!replyInputs[rating.id]}>
<IconButton
color="primary"
onClick={() => handleReplySubmit(rating.id)}
disabled={!replyInputs[rating.id]}
>
<SendIcon />
</IconButton>
</Box>
@@ -363,8 +542,13 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
</Card>
))}
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Pagination count={totalPages} page={page} onChange={handlePageChange} color="primary" />
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
<Pagination
count={totalPages}
page={page}
onChange={handlePageChange}
color="primary"
/>
</Box>
)}
</Box>
@@ -373,21 +557,43 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
{/* Sidebar Column */}
<Grid size={{ xs: 12, lg: 4 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
{/* Notifications Widget */}
<Card elevation={0} sx={{ borderRadius: 1, border: '1px solid', borderColor: 'divider' }}>
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider', bgcolor: alpha('#000', 0.02), display: 'flex', alignItems: 'center', gap: 1 }}>
<Card
elevation={0}
sx={{
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
}}
>
<Box
sx={{
p: 2,
borderBottom: "1px solid",
borderColor: "divider",
bgcolor: alpha("#000", 0.02),
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<NotificationsIcon color="primary" fontSize="small" />
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('community.notifications')}</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
{t("community.notifications")}
</Typography>
</Box>
<List disablePadding>
{notifications.length === 0 ? (
<Box sx={{ p: 3, textAlign: 'center' }}><Typography variant="body2" color="text.secondary">{t('community.noNotifications')}</Typography></Box>
<Box sx={{ p: 3, textAlign: "center" }}>
<Typography variant="body2" color="text.secondary">
{t("community.noNotifications")}
</Typography>
</Box>
) : (
notifications.map(n => {
notifications.map((n) => {
const handleClick = () => {
if (n.notification_type === 'follow') {
if (n.notification_type === "follow") {
navigate(`/profile/${n.actor_username}`);
} else if (n.flavor_id) {
navigate(`/flavor/${n.flavor_id}`);
@@ -395,23 +601,39 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
};
return (
<ListItemButton key={n.id} onClick={handleClick} sx={{ py: 1.5 }}>
<ListItemButton
key={n.id}
onClick={handleClick}
sx={{ py: 1.5 }}
>
<ListItemAvatar sx={{ minWidth: 40 }}>
<Avatar src={n.actor_avatar || undefined} sx={{ width: 30, height: 30, fontSize: '0.8rem' }}>{n.actor_username.charAt(0)}</Avatar>
<Avatar
src={n.actor_avatar || undefined}
sx={{ width: 30, height: 30, fontSize: "0.8rem" }}
>
{n.actor_username.charAt(0)}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
<strong>{n.actor_username}</strong> {
n.notification_type === 'reply' ? t('community.notifReply') :
n.notification_type === 'mention' ? t('community.notifMention') :
n.notification_type === 'follow' ? t('community.notifFollow') :
n.notification_type
}
<Typography
variant="body2"
sx={{ fontSize: "0.8rem" }}
>
<strong>{n.actor_username}</strong>{" "}
{n.notification_type === "reply"
? t("community.notifReply")
: n.notification_type === "mention"
? t("community.notifMention")
: n.notification_type === "follow"
? t("community.notifFollow")
: n.notification_type}
</Typography>
}
secondary={formatTimestamp(n.created_at)}
secondaryTypographyProps={{ sx: { fontSize: '0.7rem' } }}
secondaryTypographyProps={{
sx: { fontSize: "0.7rem" },
}}
/>
</ListItemButton>
);
@@ -421,48 +643,150 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
</Card>
{/* Following Widget */}
<Card elevation={0} sx={{ borderRadius: 1, border: '1px solid', borderColor: 'divider' }}>
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider', bgcolor: alpha('#000', 0.02), display: 'flex', alignItems: 'center', gap: 1 }}>
<Card
elevation={0}
sx={{
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
}}
>
<Box
sx={{
p: 2,
borderBottom: "1px solid",
borderColor: "divider",
bgcolor: alpha("#000", 0.02),
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<PeopleIcon color="primary" fontSize="small" />
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('nav.following')}</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
{t("nav.following")}
</Typography>
</Box>
<Box sx={{ p: 1.5 }}>
<TextField
fullWidth size="small" placeholder={t('community.searchFriends')}
fullWidth
size="small"
placeholder={t("community.searchFriends")}
value={followingSearch}
onChange={(e) => setFollowingSearch(e.target.value)}
InputProps={{ startAdornment: <SearchIcon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} /> }}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 1 } }}
InputProps={{
startAdornment: (
<SearchIcon
fontSize="small"
sx={{ mr: 1, color: "text.secondary" }}
/>
),
}}
sx={{ "& .MuiOutlinedInput-root": { borderRadius: 1 } }}
/>
</Box>
<List disablePadding sx={{ maxHeight: 250, overflowY: 'auto' }}>
{filteredFollowing.map(user => (
<ListItemButton key={user.id} component={Link} to={`/profile/${user.username}`}>
<Avatar src={user.avatar || undefined} sx={{ width: 28, height: 28, mr: 2, fontSize: '0.8rem' }}>{user.username.charAt(0)}</Avatar>
<ListItemText primary={user.username} primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold' }} />
<List disablePadding sx={{ maxHeight: 250, overflowY: "auto" }}>
{filteredFollowing.map((user) => (
<ListItemButton
key={user.id}
component={Link}
to={`/profile/${user.username}`}
>
<Avatar
src={user.avatar || undefined}
sx={{ width: 28, height: 28, mr: 2, fontSize: "0.8rem" }}
>
{user.username.charAt(0)}
</Avatar>
<ListItemText
primary={user.username}
primaryTypographyProps={{
variant: "body2",
fontWeight: "bold",
}}
/>
</ListItemButton>
))}
</List>
</Card>
{/* Top Rated by Circle */}
<Card elevation={0} sx={{ borderRadius: 1, border: '1px solid', borderColor: 'divider' }}>
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider', bgcolor: alpha('#000', 0.02), display: 'flex', alignItems: 'center', gap: 1 }}>
<Card
elevation={0}
sx={{
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
}}
>
<Box
sx={{
p: 2,
borderBottom: "1px solid",
borderColor: "divider",
bgcolor: alpha("#000", 0.02),
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<WhatshotIcon color="error" fontSize="small" />
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('community.topRated')}</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
{t("community.topRated")}
</Typography>
</Box>
<List disablePadding>
{topFollowed.map((flavor, idx) => (
<ListItemButton key={flavor.id} component={Link} to={`/flavor/${flavor.id}`}>
<Box sx={{ mr: 2, fontWeight: 'bold', color: 'text.secondary', width: 15 }}>{idx + 1}</Box>
<Box sx={{ width: 32, height: 32, mr: 2, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider', borderRadius: 0.5, overflow: 'hidden', flexShrink: 0 }}>
<Box component="img" src={flavor.image_url} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<ListItemButton
key={flavor.id}
component={Link}
to={`/flavor/${flavor.id}`}
>
<Box
sx={{
mr: 2,
fontWeight: "bold",
color: "text.secondary",
width: 15,
}}
>
{idx + 1}
</Box>
<Box
sx={{
width: 32,
height: 32,
mr: 2,
bgcolor: "background.default",
border: "1px solid",
borderColor: "divider",
borderRadius: 0.5,
overflow: "hidden",
flexShrink: 0,
}}
>
<Box
component="img"
src={flavor.image_url}
sx={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
</Box>
<ListItemText
primary={flavor.name}
primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold', noWrap: true }}
primaryTypographyProps={{
variant: "body2",
fontWeight: "bold",
noWrap: true,
}}
/>
<RatingBadge
score={flavor.average_rating || 0}
size="small"
/>
<RatingBadge score={flavor.average_rating || 0} size="small" />
</ListItemButton>
))}
</List>
File diff suppressed because it is too large. Load diff
+142 -49
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState } from "react";
import {
Typography,
Card,
@@ -9,11 +9,11 @@ import {
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;
@@ -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,26 +93,41 @@ 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' }}>
<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',
<Box
sx={{
position: "absolute",
top: 10,
right: 10,
bgcolor: 'error.main',
color: 'white',
bgcolor: "error.main",
color: "white",
px: 1,
borderRadius: 1,
fontSize: '0.75rem',
zIndex: 1
}}>
fontSize: "0.75rem",
zIndex: 1,
}}
>
Out of Stock
</Box>
)}
@@ -117,17 +136,21 @@ const FlavorList: React.FC = () => {
component="img"
src={flavor.image_url}
sx={{
width: '100%',
width: "100%",
height: 200,
objectFit: 'contain',
objectFit: "contain",
p: 2,
bgcolor: 'rgba(255,255,255,0.05)'
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 }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 2 }}
>
{flavor.description}
</Typography>
{flavor.shop_url && (
@@ -137,26 +160,60 @@ const FlavorList: React.FC = () => {
component="a"
href={flavor.shop_url}
target="_blank"
sx={{ mb: 2, fontSize: '0.7rem' }}
sx={{ mb: 2, fontSize: "0.7rem" }}
>
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
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 && (
<Accordion variant="outlined" sx={{ mt: 1, mb: 1 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="caption">Comments ({flavor.ratings.filter(r => r.comment).length})</Typography>
<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>
<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>
@@ -164,28 +221,64 @@ const FlavorList: React.FC = () => {
)}
{flavor.user_rating ? (
<Typography variant="body2" color="secondary">Your Rating: {flavor.user_rating}/10</Typography>
<Typography variant="body2" color="secondary">
Your Rating: {flavor.user_rating}/10
</Typography>
) : (
<Accordion variant="outlined" sx={{ mt: 2 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="body2">Rate this flavor</Typography>
<Typography variant="body2">
Rate this flavor
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<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}})}
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}})}
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>
<Button
variant="contained"
size="small"
onClick={() => handleRate(flavor.id)}
>
Submit
</Button>
</Box>
</AccordionDetails>
</Accordion>
+154 -62
View File
@@ -1,45 +1,61 @@
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') });
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 });
}
}
};
@@ -48,11 +64,14 @@ const Login: React.FC<LoginProps> = () => {
e.preventDefault();
setMessage(null);
try {
await api.post('users/signup/', { username, email, password });
await api.post("users/signup/", { username, email, password });
setIsVerifying(true);
setMessage({ type: 'success', text: t('auth.signupSuccess') });
setMessage({ type: "success", text: t("auth.signupSuccess") });
} catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Signup failed' });
setMessage({
type: "error",
text: err.response?.data?.error || "Signup failed",
});
}
};
@@ -60,13 +79,19 @@ const Login: React.FC<LoginProps> = () => {
e.preventDefault();
setMessage(null);
try {
await api.post('users/verify_signup/', { username, code: verificationCode });
setMessage({ type: 'success', text: t('auth.verifySuccess') });
await api.post("users/verify_signup/", {
username,
code: verificationCode,
});
setMessage({ type: "success", text: t("auth.verifySuccess") });
setIsVerifying(false);
setTab(0);
setPassword('');
setPassword("");
} catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Verification failed' });
setMessage({
type: "error",
text: err.response?.data?.error || "Verification failed",
});
setShowResend(true);
}
};
@@ -74,11 +99,14 @@ const Login: React.FC<LoginProps> = () => {
const handleResendCode = async () => {
setMessage(null);
try {
await api.post('users/resend_verification/', { username });
setMessage({ type: 'success', text: t('auth.resendSuccess') });
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({
type: "error",
text: err.response?.data?.error || "Failed to resend code",
});
}
};
@@ -86,11 +114,17 @@ const Login: React.FC<LoginProps> = () => {
e.preventDefault();
setMessage(null);
try {
await api.post('users/request_password_reset/', { email });
await api.post("users/request_password_reset/", { email });
setResetStep(2);
setMessage({ type: 'success', text: 'If an account exists, a reset code has been sent.' });
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.' });
setMessage({
type: "error",
text: "An error occurred. Please try again later.",
});
}
};
@@ -98,15 +132,25 @@ const Login: React.FC<LoginProps> = () => {
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.' });
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('');
setPassword("");
setVerificationCode("");
} catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Reset failed' });
setMessage({
type: "error",
text: err.response?.data?.error || "Reset failed",
});
}
};
@@ -114,8 +158,16 @@ const Login: React.FC<LoginProps> = () => {
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 }) }} />
<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"
@@ -133,13 +185,20 @@ const Login: React.FC<LoginProps> = () => {
variant={showResend ? "contained" : "outlined"}
color={showResend ? "secondary" : "primary"}
onClick={handleResendCode}
sx={{ mt: 2, textTransform: 'none' }}
sx={{ mt: 2, textTransform: "none" }}
>
{t('auth.resendButton')}
{t("auth.resendButton")}
</Button>
<Button fullWidth onClick={() => { setIsVerifying(false); setMessage(null); }} sx={{ mt: 1 }}>
{t('auth.backToAuth')}
<Button
fullWidth
onClick={() => {
setIsVerifying(false);
setMessage(null);
}}
sx={{ mt: 1 }}
>
{t("auth.backToAuth")}
</Button>
</form>
);
@@ -147,8 +206,12 @@ const Login: React.FC<LoginProps> = () => {
if (isResetting) {
return (
<form onSubmit={resetStep === 1 ? handleRequestReset : handleCompleteReset}>
<Typography variant="h6" gutterBottom>Reset Password</Typography>
<form
onSubmit={resetStep === 1 ? handleRequestReset : handleCompleteReset}
>
<Typography variant="h6" gutterBottom>
Reset Password
</Typography>
<TextField
fullWidth
label="Email"
@@ -177,9 +240,17 @@ const Login: React.FC<LoginProps> = () => {
</>
)}
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
{resetStep === 1 ? 'Send Reset Code' : 'Reset Password'}
{resetStep === 1 ? "Send Reset Code" : "Reset Password"}
</Button>
<Button fullWidth onClick={() => { setIsResetting(false); setResetStep(1); setMessage(null); }} sx={{ mt: 1 }}>
<Button
fullWidth
onClick={() => {
setIsResetting(false);
setResetStep(1);
setMessage(null);
}}
sx={{ mt: 1 }}
>
Back to Login
</Button>
</form>
@@ -188,9 +259,16 @@ const Login: React.FC<LoginProps> = () => {
return (
<>
<Tabs value={tab} onChange={(_, v) => { setTab(v); setMessage(null); }} sx={{ mb: 2 }}>
<Tab label={t('auth.loginTitle')} />
<Tab label={t('auth.signupTitle')} />
<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 ? (
@@ -211,10 +289,18 @@ const Login: React.FC<LoginProps> = () => {
onChange={(e) => setPassword(e.target.value)}
/>
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
{t('auth.loginTitle')}
{t("auth.loginTitle")}
</Button>
<Button fullWidth onClick={() => { setIsResetting(true); setMessage(null); }} sx={{ mt: 1, textTransform: 'none' }} size="small">
{t('auth.forgotPassword')}
<Button
fullWidth
onClick={() => {
setIsResetting(true);
setMessage(null);
}}
sx={{ mt: 1, textTransform: "none" }}
size="small"
>
{t("auth.forgotPassword")}
</Button>
</form>
) : (
@@ -243,7 +329,7 @@ const Login: React.FC<LoginProps> = () => {
onChange={(e) => setPassword(e.target.value)}
/>
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
{t('auth.signupTitle')}
{t("auth.signupTitle")}
</Button>
</form>
)}
@@ -258,16 +344,22 @@ const Login: React.FC<LoginProps> = () => {
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
+151 -63
View File
@@ -1,15 +1,24 @@
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) {
@@ -18,16 +27,28 @@ const Privacy: React.FC = () => {
};
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 }}>
<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 }}
sx={{ textTransform: "none", borderRadius: 2 }}
>
{t('common.back')}
{t("common.back")}
</Button>
<ToggleButtonGroup
@@ -41,65 +62,132 @@ const Privacy: React.FC = () => {
</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
+272 -118
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect } from "react";
import {
Typography,
Box,
@@ -14,14 +14,14 @@ 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;
@@ -31,19 +31,22 @@ interface SettingsProps {
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>("");
@@ -51,7 +54,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate('/');
navigate("/");
}
};
@@ -59,8 +62,8 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
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);
@@ -74,7 +77,9 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
}
// 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);
@@ -88,21 +93,24 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
setMessage({ type: 'error', text: 'File size exceeds 2MB limit' });
setMessage({ type: "error", text: "File size exceeds 2MB limit" });
return;
}
const formData = new FormData();
formData.append('avatar', file);
formData.append("avatar", file);
try {
const res = await api.post('users/update_avatar/', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
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') });
setMessage({ type: "success", text: t("settings.avatarSuccess") });
} catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to upload avatar' });
setMessage({
type: "error",
text: err.response?.data?.error || "Failed to upload avatar",
});
}
};
@@ -110,72 +118,90 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
setLanguage(newLang);
i18n.changeLanguage(newLang);
try {
await api.patch('users/update_preferences/', { language: newLang });
await api.patch("users/update_preferences/", { language: newLang });
} catch (err) {
console.error('Failed to update language on server');
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/');
await api.post("users/request_account_deletion/");
setIsDeleting(true);
setMessage({ type: 'success', text: t('settings.codeSent') });
setMessage({ type: "success", text: t("settings.codeSent") });
} catch (err: any) {
setMessage({ type: 'error', text: 'Failed to request deletion.' });
setMessage({ type: "error", text: "Failed to request deletion." });
}
};
const handleConfirmDeletion = async (e: React.FormEvent) => {
e.preventDefault();
if (!window.confirm('Are you absolutely sure? This cannot be undone.')) return;
if (!window.confirm("Are you absolutely sure? This cannot be undone."))
return;
try {
await api.post('users/confirm_account_deletion/', { code: deletionCode });
localStorage.removeItem('token');
window.location.href = '/';
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.' });
setMessage({ type: "error", text: "Invalid code. Deletion failed." });
}
};
@@ -188,32 +214,48 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
sx={{
mb: 4,
borderRadius: 2,
textTransform: 'none',
fontWeight: 'bold',
color: 'text.secondary',
borderColor: 'divider',
'&:hover': {
borderColor: 'primary.main',
color: 'primary.main',
bgcolor: 'transparent'
}
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' }}>
<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>
<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>
)}
@@ -221,14 +263,25 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
<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>
<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>
<InputLabel>{t("settings.themeLabel")}</InputLabel>
<Select
value={themeName}
label={t('settings.themeLabel')}
onChange={(e) => onThemeChange(e.target.value as CatppuccinTheme)}
label={t("settings.themeLabel")}
onChange={(e) =>
onThemeChange(e.target.value as CatppuccinTheme)
}
>
<ListSubheader>Holy Archive</ListSubheader>
<MenuItem value="holy_light">Holy Light</MenuItem>
@@ -258,32 +311,36 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
</FormControl>
<FormControl fullWidth margin="normal">
<InputLabel>{t('settings.bannerLabel')}</InputLabel>
<InputLabel>{t("settings.bannerLabel")}</InputLabel>
<Select
value={selectedBannerId}
label={t('settings.bannerLabel')}
label={t("settings.bannerLabel")}
onChange={async (e) => {
const newId = e.target.value;
setSelectedBannerId(newId);
try {
await api.patch('users/update_preferences/', { selected_banner: newId });
await api.patch("users/update_preferences/", {
selected_banner: newId,
});
} catch (err) {
console.error('Failed to update banner preference');
console.error("Failed to update banner preference");
}
}}
>
<MenuItem value="">{t('settings.bannerDefault')}</MenuItem>
{banners.map(b => (
<MenuItem key={b.id} value={b.id}>{b.name}</MenuItem>
<MenuItem value="">{t("settings.bannerDefault")}</MenuItem>
{banners.map((b) => (
<MenuItem key={b.id} value={b.id}>
{b.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth margin="normal">
<InputLabel>{t('settings.langLabel')}</InputLabel>
<InputLabel>{t("settings.langLabel")}</InputLabel>
<Select
value={language}
label={t('settings.langLabel')}
label={t("settings.langLabel")}
onChange={(e) => handleLanguageChange(e.target.value)}
>
<MenuItem value="en">English</MenuItem>
@@ -293,18 +350,33 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
</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 }}>
<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} />
{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
variant="caption"
display="block"
sx={{ mt: 1 }}
color="text.secondary"
>
{t("settings.avatarHint")}
</Typography>
</Box>
</Box>
@@ -313,12 +385,21 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
{/* 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>
<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')}
label={t("settings.usernameLabel")}
margin="normal"
value={username}
onChange={(e) => setUsername(e.target.value)}
@@ -326,45 +407,76 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
/>
<TextField
fullWidth
label={t('settings.emailLabel')}
label={t("settings.emailLabel")}
margin="normal"
value={email}
onChange={(e) => setEmail(e.target.value)}
helperText={email !== currentEmail ? t('settings.emailHint') : ""}
helperText={
email !== currentEmail ? t("settings.emailHint") : ""
}
InputLabelProps={{ shrink: true }}
/>
<Button variant="contained" type="submit" sx={{ mt: 2, borderRadius: 2 }}>
{t('settings.updateButton')}
<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>
<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')}
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
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>
<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')}
label={t("settings.oldPasswordLabel")}
type="password"
margin="normal"
value={oldPassword}
@@ -373,46 +485,88 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
/>
<TextField
fullWidth
label={t('settings.newPasswordLabel')}
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
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>
<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')}
{t("settings.dangerDesc")}
</Typography>
{!isDeleting ? (
<Button variant="outlined" color="error" onClick={handleRequestDeletion} sx={{ borderRadius: 2 }}>
{t('settings.deleteButton')}
<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>
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
{t("settings.deletionCodeHint")}
</Typography>
<TextField
fullWidth
size="small"
label={t('settings.deletionCodeLabel')}
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')}
<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>
<Button onClick={() => setIsDeleting(false)} sx={{ borderRadius: 2 }}>{t('settings.cancelButton')}</Button>
</Box>
</form>
)}
+335 -101
View File
@@ -1,18 +1,32 @@
import React, { useState, useEffect } from 'react';
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';
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;
@@ -30,7 +44,7 @@ interface Ticket {
user_avatar: string | null;
subject: string;
description: string;
status: 'open' | 'in_progress' | 'resolved' | 'closed';
status: "open" | "in_progress" | "resolved" | "closed";
created_at: string;
messages: Message[];
}
@@ -39,27 +53,29 @@ const Support: React.FC = () => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
useTitle(t('support.title'));
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('');
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 [unreadTicketIds, setUnreadTicketIds] = useState<Set<number>>(
new Set(),
);
const fetchTickets = async () => {
try {
const [ticketRes, userRes, notifRes] = await Promise.all([
api.get('tickets/'),
api.get('users/me/'),
api.get('notifications/')
api.get("tickets/"),
api.get("users/me/"),
api.get("notifications/"),
]);
setTickets(ticketRes.data.results || ticketRes.data);
setCurrentUser(userRes.data);
@@ -67,8 +83,13 @@ const Support: React.FC = () => {
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)
.filter(
(n: any) =>
!n.is_read &&
n.notification_type.startsWith("ticket") &&
n.ticket,
)
.map((n: any) => n.ticket),
);
setUnreadTicketIds(unreadIds);
} catch (err) {
@@ -105,13 +126,13 @@ const Support: React.FC = () => {
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
await api.post('tickets/', { subject, description });
setSubject('');
setDescription('');
await api.post("tickets/", { subject, description });
setSubject("");
setDescription("");
setShowCreate(false);
fetchTickets();
} catch (err) {
alert('Failed to create ticket');
alert("Failed to create ticket");
}
};
@@ -120,10 +141,10 @@ const Support: React.FC = () => {
if (!text) return;
try {
await api.post(`tickets/${ticketId}/add_message/`, { text });
setReplyText({ ...replyText, [ticketId]: '' });
setReplyText({ ...replyText, [ticketId]: "" });
fetchTickets();
} catch (err) {
alert('Failed to send message');
alert("Failed to send message");
}
};
@@ -132,30 +153,39 @@ const Support: React.FC = () => {
await api.post(`tickets/${ticketId}/update_status/`, { status });
fetchTickets();
} catch (err) {
alert('Failed to update status');
alert("Failed to update status");
}
};
const handleDeleteTicket = async (ticketId: number) => {
if (!confirm('Delete this ticket?')) return;
if (!confirm("Delete this ticket?")) return;
try {
await api.delete(`tickets/${ticketId}/`);
fetchTickets();
} catch (err) {
alert('Failed to delete ticket');
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';
case "open":
return "error";
case "in_progress":
return "warning";
case "resolved":
return "success";
default:
return "default";
}
};
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="md" sx={{ py: 4 }}>
@@ -165,84 +195,181 @@ const Support: React.FC = () => {
onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }}
>
{t('common.back')}
{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>
<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')}
{t("support.createTicket")}
</Button>
</Stack>
<Collapse in={showCreate}>
<Paper sx={{ p: 3, mb: 4, borderRadius: 3, border: '1px solid', borderColor: 'primary.main' }}>
<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
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
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
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 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
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={{
<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'
}}>
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' } }}
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 }}
>
<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
sx={{
width: 10,
height: 10,
bgcolor: "primary.main",
borderRadius: "50%",
}}
/>
)}
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
<Typography
variant="subtitle1"
sx={{ fontWeight: "bold" }}
>
{ticket.subject}
{currentUser?.is_superuser && (
<Typography component="span" variant="caption" sx={{ ml: 1, color: 'primary.main' }}>
<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>
<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.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' }} />
<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); }}>
<IconButton
size="small"
color="error"
onClick={(e) => {
e.stopPropagation();
handleDeleteTicket(ticket.id);
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
@@ -250,28 +377,68 @@ const Support: React.FC = () => {
</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' }}>
<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
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' }}
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' }}
onClick={(e) => {
e.stopPropagation();
navigate(`/admin-panel/user/${ticket.user}`);
}}
sx={{
minWidth: 0,
py: 0,
textTransform: "none",
fontSize: "0.7rem",
}}
>
{t('admin.manageUser')}
{t("admin.manageUser")}
</Button>
</Box>
)}
@@ -279,55 +446,122 @@ const Support: React.FC = () => {
<Collapse in={expandedId === ticket.id}>
<Divider />
<Box sx={{ p: 2, bgcolor: (theme) => alpha(theme.palette.background.default, 0.5) }}>
<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) => (
<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"}
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 }}
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' }}>
<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%',
<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)'}
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>
<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' && (
{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)}
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]}>
<IconButton
color="primary"
onClick={() => handleReply(ticket.id)}
disabled={!replyText[ticket.id]}
>
<SendIcon />
</IconButton>
</Stack>
+57 -32
View File
@@ -1,43 +1,53 @@
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('');
const [status, setStatus] = useState<"loading" | "success" | "error">(
"loading",
);
const [errorMsg, setErrorMsg] = useState("");
useTitle(t('auth.verifyTitle'));
useTitle(t("auth.verifyTitle"));
useEffect(() => {
const query = new URLSearchParams(location.search);
const username = query.get('username');
const code = query.get('code');
const username = query.get("username");
const code = query.get("code");
if (!username || !code) {
setStatus('error');
setErrorMsg('Invalid verification link.');
setStatus("error");
setErrorMsg("Invalid verification link.");
return;
}
const verify = async () => {
try {
await api.post('users/verify_signup/', { username, code });
setStatus('success');
await api.post("users/verify_signup/", { username, code });
setStatus("success");
// Optional: Auto redirect after 3 seconds
setTimeout(() => {
navigate('/login');
navigate("/login");
}, 3000);
} catch (err: any) {
setStatus('error');
setErrorMsg(err.response?.data?.error || 'Verification failed.');
setStatus("error");
setErrorMsg(err.response?.data?.error || "Verification failed.");
}
};
@@ -46,11 +56,11 @@ const VerifyEmail: React.FC = () => {
return (
<Container maxWidth="sm" sx={{ py: 12 }}>
<Paper sx={{ p: 6, textAlign: 'center', borderRadius: 4 }}>
{status === 'loading' && (
<Paper sx={{ p: 6, textAlign: "center", borderRadius: 4 }}>
{status === "loading" && (
<Box>
<CircularProgress size={60} sx={{ mb: 4 }} />
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
<Typography variant="h5" sx={{ fontWeight: "bold" }}>
Verifying your account...
</Typography>
<Typography color="text.secondary" sx={{ mt: 1 }}>
@@ -59,34 +69,49 @@ const VerifyEmail: React.FC = () => {
</Box>
)}
{status === 'success' && (
{status === "success" && (
<Box>
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 2 }}>
{t('auth.verifySuccess')}
<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 }}>
<Button
variant="contained"
component={Link}
to="/login"
size="large"
sx={{ borderRadius: 2 }}
>
Go to Login Now
</Button>
</Box>
)}
{status === 'error' && (
{status === "error" && (
<Box>
<ErrorOutlineIcon color="error" sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 2 }}>
<Typography variant="h4" sx={{ fontWeight: "bold", mb: 2 }}>
Verification Failed
</Typography>
<Alert severity="error" sx={{ mb: 4, textAlign: 'left' }}>
<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 }}>
<Button
variant="outlined"
component={Link}
to="/login"
size="large"
sx={{ borderRadius: 2 }}
>
Back to Login / Signup
</Button>
</Box>
+101 -56
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import React, { useEffect, useState } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import {
Typography,
Box,
@@ -7,14 +7,14 @@ import {
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;
@@ -43,14 +43,14 @@ 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('/');
navigate("/");
}
};
@@ -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>
<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'
}
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>
<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'
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)' }
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>
<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 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="caption" color="text.secondary">
{flavor.ratings.length} reviews
</Typography>
</CardContent>
</Box>
</Card>
File diff suppressed because it is too large. Load diff
+183 -159
View File
@@ -1,152 +1,172 @@
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';
| "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') {
if (mode === "holy_light") {
palette = {
base: { hex: '#f8fafc' },
surface0: { hex: '#ffffff' },
crust: { hex: '#f1f5f9' },
text: { hex: '#0f172a' },
subtext0: { hex: '#64748b' },
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') {
} else if (mode === "holy_dark") {
palette = {
base: { hex: '#020617' },
surface0: { hex: '#0f172a' },
crust: { hex: '#000000' },
text: { hex: '#f8fafc' },
subtext0: { hex: '#94a3b8' },
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') {
} 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' },
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') {
} 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' },
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') {
} 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' },
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') {
} 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
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') {
} 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
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') {
} 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' },
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') {
} 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
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') {
} 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' },
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') {
} 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' },
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') {
} 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' },
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') {
} 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' },
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
@@ -165,15 +185,15 @@ export const getTheme = (mode: CatppuccinTheme) => {
} else {
// Fallback to holy_light
palette = {
base: { hex: '#f8fafc' },
surface0: { hex: '#ffffff' },
crust: { hex: '#f1f5f9' },
text: { hex: '#0f172a' },
subtext0: { hex: '#64748b' },
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';
mode = "holy_light";
}
}
@@ -181,10 +201,10 @@ export const getTheme = (mode: CatppuccinTheme) => {
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,
@@ -201,7 +221,7 @@ export const getTheme = (mode: CatppuccinTheme) => {
action: {
hover: alpha(palette.primary.hex, 0.08),
selected: alpha(palette.primary.hex, 0.12),
}
},
},
shape: {
borderRadius: 8,
@@ -214,35 +234,37 @@ 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': {
"&::-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) }
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',
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)',
}
}
? "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: {
@@ -250,57 +272,59 @@ export const getTheme = (mode: CatppuccinTheme) => {
},
styleOverrides: {
root: {
backgroundColor: isLight ? palette.surface0.hex : alpha(palette.surface0.hex, 0.6),
backdropFilter: 'blur(12px)',
border: '1px solid',
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',
}
}
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',
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',
borderRadius: "8px",
padding: "8px 20px",
},
containedPrimary: {
boxShadow: `0 4px 14px 0 ${alpha(palette.primary.hex, 0.39)}`,
'&:hover': {
"&:hover": {
boxShadow: `0 6px 20px rgba(0,0,0,0.23)`,
}
}
}
},
},
},
},
MuiTab: {
styleOverrides: {
root: {
minHeight: '48px',
minHeight: "48px",
fontWeight: 700,
borderRadius: '8px',
}
}
borderRadius: "8px",
},
},
},
MuiOutlinedInput: {
styleOverrides: {
root: {
borderRadius: '8px',
}
}
}
}
borderRadius: "8px",
},
},
},
},
};
return createTheme(themeOptions);
+5 -5
View File
@@ -1,10 +1,10 @@
export const formatDate = (dateStr: string) => {
if (!dateStr) return '';
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 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');
const hours = String(d.getHours()).padStart(2, "0");
const minutes = String(d.getMinutes()).padStart(2, "0");
return `${day}/${month}/${year} ${hours}:${minutes}`;
};