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 { function getCookie(name: string): string | null {
let cookieValue = null; let cookieValue = null;
if (document.cookie && document.cookie !== '') { if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(';'); const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) { for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim(); const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want? // Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === (name + '=')) { if (cookie.substring(0, name.length + 1) === name + "=") {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break; break;
} }
@@ -17,22 +17,26 @@ function getCookie(name: string): string | null {
} }
const api = axios.create({ const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/', baseURL: import.meta.env.VITE_API_URL || "http://localhost:8000/api/",
}); });
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
// Set DRF Auth Token if available from localStorage // Set DRF Auth Token if available from localStorage
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (token) { if (token) {
config.headers.Authorization = `Token ${token}`; config.headers.Authorization = `Token ${token}`;
} }
// Set CSRF token for state-changing methods // Set CSRF token for state-changing methods
const csrfToken = getCookie('csrftoken'); const csrfToken = getCookie("csrftoken");
const method = config.method?.toUpperCase(); const method = config.method?.toUpperCase();
if (csrfToken && method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { if (
csrfToken &&
method &&
["POST", "PUT", "PATCH", "DELETE"].includes(method)
) {
if (config.headers) { 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 { import {
Dialog, DialogTitle, DialogContent, DialogActions, Button, Dialog,
TextField, Box, Typography, Alert, Slider, Stack, DialogTitle,
Tooltip, IconButton DialogContent,
} from '@mui/material'; DialogActions,
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; Button,
import { useTranslation } from 'react-i18next'; TextField,
import api from '../api'; Box,
Typography,
Alert,
Slider,
Stack,
Tooltip,
IconButton,
} from "@mui/material";
import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
import { useTranslation } from "react-i18next";
import api from "../api";
interface BannerSettingSchema { interface BannerSettingSchema {
key: string; key: string;
label: string; label: string;
type: 'number' | 'slider' | 'text' | 'boolean'; type: "number" | "slider" | "text" | "boolean";
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
@@ -35,16 +45,21 @@ interface BannerSettingsDialogProps {
onSave: () => void; onSave: () => void;
} }
const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClose, banner, onSave }) => { const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
open,
onClose,
banner,
onSave,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [localSettings, setLocalSettings] = useState<any>({}); const [localSettings, setLocalSettings] = useState<any>({});
const [error, setError] = useState(''); const [error, setError] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
if (banner) { if (banner) {
setLocalSettings(banner.settings || {}); setLocalSettings(banner.settings || {});
setError(''); setError("");
} }
}, [banner]); }, [banner]);
@@ -57,7 +72,7 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClo
onSave(); onSave();
onClose(); onClose();
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.error || 'Failed to save settings'); setError(err.response?.data?.error || "Failed to save settings");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -66,29 +81,29 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClo
const updateSetting = (key: string, value: any) => { const updateSetting = (key: string, value: any) => {
setLocalSettings((prev: any) => ({ setLocalSettings((prev: any) => ({
...prev, ...prev,
[key]: value [key]: value,
})); }));
}; };
const renderField = (schema: BannerSettingSchema) => { const renderField = (schema: BannerSettingSchema) => {
const value = localSettings[schema.key] ?? ''; const value = localSettings[schema.key] ?? "";
return ( return (
<Box key={schema.key} sx={{ mb: 3 }}> <Box key={schema.key} sx={{ mb: 3 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}> <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}> <Typography variant="subtitle2" sx={{ fontWeight: "bold" }}>
{schema.label} {schema.label}
</Typography> </Typography>
{schema.description && ( {schema.description && (
<Tooltip title={schema.description} arrow> <Tooltip title={schema.description} arrow>
<IconButton size="small" sx={{ p: 0.5 }}> <IconButton size="small" sx={{ p: 0.5 }}>
<HelpOutlineIcon sx={{ fontSize: '1rem', opacity: 0.6 }} /> <HelpOutlineIcon sx={{ fontSize: "1rem", opacity: 0.6 }} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
)} )}
</Stack> </Stack>
{schema.type === 'slider' ? ( {schema.type === "slider" ? (
<Box sx={{ px: 1 }}> <Box sx={{ px: 1 }}>
<Slider <Slider
value={Number(value)} value={Number(value)}
@@ -99,7 +114,7 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClo
valueLabelDisplay="auto" valueLabelDisplay="auto"
/> />
</Box> </Box>
) : schema.type === 'number' ? ( ) : schema.type === "number" ? (
<TextField <TextField
type="number" type="number"
fullWidth fullWidth
@@ -118,7 +133,11 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClo
)} )}
{schema.description && ( {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} {schema.description}
</Typography> </Typography>
)} )}
@@ -128,31 +147,41 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClo
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 'bold' }}> <DialogTitle sx={{ fontWeight: "bold" }}>
Banner Settings: {banner?.name} Banner Settings: {banner?.name}
</DialogTitle> </DialogTitle>
<DialogContent dividers> <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 ? ( {banner?.schema && banner.schema.length > 0 ? (
<Box sx={{ mt: 1 }}> <Box sx={{ mt: 1 }}>
{banner.schema.map(field => renderField(field))} {banner.schema.map((field) => renderField(field))}
</Box> </Box>
) : ( ) : (
<Typography color="text.secondary" sx={{ fontStyle: 'italic', py: 2 }}> <Typography
No dynamic schema defined for this banner. Please use the JSON editor or check the banner configuration files. 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> </Typography>
)} )}
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}> <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 <Button
onClick={handleSave} onClick={handleSave}
variant="contained" variant="contained"
disabled={saving} disabled={saving}
sx={{ px: 4 }} sx={{ px: 4 }}
> >
{saving ? t('common.loading') : t('common.save')} {saving ? t("common.loading") : t("common.save")}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
+63 -32
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { import {
Typography, Typography,
Button, Button,
@@ -7,34 +7,37 @@ import {
DialogContent, DialogContent,
DialogTitle, DialogTitle,
DialogActions, DialogActions,
Box Box,
} from '@mui/material'; } from "@mui/material";
import { useTranslation } from 'react-i18next'; import { useTranslation } from "react-i18next";
import type { CatppuccinTheme } from '../theme'; import type { CatppuccinTheme } from "../theme";
interface CookieBannerProps { interface CookieBannerProps {
onThemeChange: (newTheme: CatppuccinTheme) => void; onThemeChange: (newTheme: CatppuccinTheme) => void;
currentTheme: CatppuccinTheme; currentTheme: CatppuccinTheme;
} }
const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme }) => { const CookieBanner: React.FC<CookieBannerProps> = ({
onThemeChange,
currentTheme,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
const consent = localStorage.getItem('cookie-consent'); const consent = localStorage.getItem("cookie-consent");
if (!consent) { if (!consent) {
setOpen(true); setOpen(true);
} }
}, []); }, []);
const handleAccept = () => { const handleAccept = () => {
localStorage.setItem('cookie-consent', 'true'); localStorage.setItem("cookie-consent", "true");
setOpen(false); setOpen(false);
}; };
const handleToggleTheme = () => { const handleToggleTheme = () => {
const newTheme = currentTheme === 'holy_light' ? 'holy_dark' : 'holy_light'; const newTheme = currentTheme === "holy_light" ? "holy_dark" : "holy_light";
onThemeChange(newTheme); onThemeChange(newTheme);
}; };
@@ -48,57 +51,85 @@ const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme
PaperProps={{ PaperProps={{
sx: { sx: {
borderRadius: 4, borderRadius: 4,
border: '1px solid', border: "1px solid",
borderColor: 'primary.main', borderColor: "primary.main",
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.98), bgcolor: (theme) => alpha(theme.palette.background.paper, 0.98),
backdropFilter: 'blur(10px)', backdropFilter: "blur(10px)",
boxShadow: (theme) => `0 20px 50px ${alpha(theme.palette.primary.main, 0.2)}`, boxShadow: (theme) =>
} `0 20px 50px ${alpha(theme.palette.primary.main, 0.2)}`,
},
}} }}
> >
<DialogTitle sx={{ fontWeight: 'bold', fontSize: '1.5rem', pt: 3 }}> <DialogTitle sx={{ fontWeight: "bold", fontSize: "1.5rem", pt: 3 }}>
{t('cookieBanner.title')} 🍪 {t("cookieBanner.title")} 🍪
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<Typography variant="body1" sx={{ mb: 2, lineHeight: 1.7 }}> <Typography variant="body1" sx={{ mb: 2, lineHeight: 1.7 }}>
{t('cookieBanner.description')} {t("cookieBanner.description")}
</Typography> </Typography>
<Box sx={{ <Box
sx={{
p: 2, p: 2,
borderRadius: 2, borderRadius: 2,
bgcolor: 'action.hover', bgcolor: "action.hover",
border: '1px solid', border: "1px solid",
borderColor: 'divider', borderColor: "divider",
mt: 2 mt: 2,
}}> }}
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', display: 'block', mb: 1, textTransform: 'uppercase' }}> >
<Typography
variant="caption"
color="text.secondary"
sx={{
fontWeight: "bold",
display: "block",
mb: 1,
textTransform: "uppercase",
}}
>
Technical Details: Technical Details:
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
• Session ID: Required for login & authentication.<br /> • Session ID: Required for login & authentication.
• CSRF Token: Protects your account from malicious attacks.<br /> <br />
• Preference Store: Remembers your theme and language choices. • CSRF Token: Protects your account from malicious attacks.
<br />• Preference Store: Remembers your theme and language choices.
</Typography> </Typography>
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions sx={{ p: 3, gap: 2, flexDirection: { xs: 'column', sm: 'row' } }}> <DialogActions
sx={{ p: 3, gap: 2, flexDirection: { xs: "column", sm: "row" } }}
>
<Button <Button
fullWidth fullWidth
variant="outlined" variant="outlined"
color="primary" color="primary"
onClick={handleToggleTheme} 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>
<Button <Button
fullWidth fullWidth
variant="contained" variant="contained"
color="primary" color="primary"
onClick={handleAccept} 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> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
+50 -28
View File
@@ -1,11 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import api from '../api'; import api from "../api";
import GenerativeBanner from './GenerativeBanner'; import GenerativeBanner from "./GenerativeBanner";
import NebulaBanner from './NebulaBanner'; import NebulaBanner from "./NebulaBanner";
import FirefliesBanner from './FirefliesBanner'; import FirefliesBanner from "./FirefliesBanner";
import HextechBanner from './HextechBanner'; import HextechBanner from "./HextechBanner";
import HextechCorruptionBanner from './HextechCorruptionBanner'; import HextechCorruptionBanner from "./HextechCorruptionBanner";
import GenerativeFlowBanner from './GenerativeFlowBanner'; import GenerativeFlowBanner from "./GenerativeFlowBanner";
interface Banner { interface Banner {
id: number; id: number;
@@ -24,20 +24,42 @@ interface DynamicBannerProps {
} }
// Future expansion: Add more banner components here // Future expansion: Add more banner components here
const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({ username, settings }) => { const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({
username,
settings,
}) => {
// Placeholder for a second model // Placeholder for a second model
return ( return (
<div style={{ <div
position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', style={{
background: 'rgba(0,0,0,0.8)', color: '#0f0', display: 'flex', position: "absolute",
alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace', top: 0,
zIndex: 1, overflow: 'hidden', opacity: settings.opacity || 0.5 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> <div>
{Array.from({ length: 20 }).map((_, i) => ( {Array.from({ length: 20 }).map((_, i) => (
<div key={i} style={{ animation: `matrixFall ${2 + Math.random() * 3}s linear infinite` }}> <div
{username.split('').map((char, j) => ( key={i}
<span key={j} style={{ display: 'block' }}>{char}</span> style={{
animation: `matrixFall ${2 + Math.random() * 3}s linear infinite`,
}}
>
{username.split("").map((char, j) => (
<span key={j} style={{ display: "block" }}>
{char}
</span>
))} ))}
</div> </div>
))} ))}
@@ -61,12 +83,12 @@ const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
useEffect(() => { useEffect(() => {
const fetchActiveBanner = async () => { const fetchActiveBanner = async () => {
try { try {
const res = await api.get('banners/active/', { const res = await api.get("banners/active/", {
params: { username: props.username } params: { username: props.username },
}); });
setBanner(res.data); setBanner(res.data);
} catch (err) { } catch (err) {
console.error('Failed to fetch active banner:', err); console.error("Failed to fetch active banner:", err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -78,31 +100,31 @@ const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
if (loading) return null; if (loading) return null;
// Default to GenerativeBanner if no active banner or unrecognized slug // Default to GenerativeBanner if no active banner or unrecognized slug
if (!banner || banner.slug === 'generative-web') { if (!banner || banner.slug === "generative-web") {
return <GenerativeBanner {...props} settings={banner?.settings} />; return <GenerativeBanner {...props} settings={banner?.settings} />;
} }
if (banner.slug === 'matrix') { if (banner.slug === "matrix") {
return <MatrixBanner {...props} settings={banner.settings} />; return <MatrixBanner {...props} settings={banner.settings} />;
} }
if (banner.slug === 'nebula') { if (banner.slug === "nebula") {
return <NebulaBanner {...props} settings={banner.settings} />; return <NebulaBanner {...props} settings={banner.settings} />;
} }
if (banner.slug === 'fireflies') { if (banner.slug === "fireflies") {
return <FirefliesBanner {...props} settings={banner.settings} />; return <FirefliesBanner {...props} settings={banner.settings} />;
} }
if (banner.slug === 'hextech') { if (banner.slug === "hextech") {
return <HextechBanner {...props} settings={banner.settings} />; return <HextechBanner {...props} settings={banner.settings} />;
} }
if (banner.slug === 'hextech-corruption') { if (banner.slug === "hextech-corruption") {
return <HextechCorruptionBanner {...props} settings={banner.settings} />; return <HextechCorruptionBanner {...props} settings={banner.settings} />;
} }
if (banner.slug === 'generative-flow') { if (banner.slug === "generative-flow") {
return <GenerativeFlowBanner {...props} settings={banner.settings} />; return <GenerativeFlowBanner {...props} settings={banner.settings} />;
} }
+64 -24
View File
@@ -1,5 +1,5 @@
import React, { useRef, useEffect, useMemo } from 'react'; import React, { useRef, useEffect, useMemo } from "react";
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from "@mui/material";
interface FirefliesBannerProps { interface FirefliesBannerProps {
username: string; username: string;
@@ -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 canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme(); const theme = useTheme();
@@ -38,14 +42,23 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
const seed = useMemo(() => getSeed(username), [username]); const seed = useMemo(() => getSeed(username), [username]);
const brightColors = useMemo(() => { 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 getBrightness = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) || 0; const r = parseInt(hex.slice(1, 3), 16) || 0;
const g = parseInt(hex.slice(3, 5), 16) || 0; const g = parseInt(hex.slice(3, 5), 16) || 0;
const b = parseInt(hex.slice(5, 7), 16) || 0; const b = parseInt(hex.slice(5, 7), 16) || 0;
return (r * 299 + g * 587 + b * 114) / 1000; 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); return sorted.slice(0, 3);
}, [palette, theme]); }, [palette, theme]);
@@ -53,7 +66,7 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true }); const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return; if (!ctx) return;
let animationFrameId: number; let animationFrameId: number;
@@ -89,7 +102,7 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
color: brightColors[i % brightColors.length], color: brightColors[i % brightColors.length],
phase: pseudoRandom(i + 2500) * Math.PI * 2, phase: pseudoRandom(i + 2500) * Math.PI * 2,
noiseOffset: pseudoRandom(i + 3000) * 100, noiseOffset: pseudoRandom(i + 3000) * 100,
isEvading: false isEvading: false,
}); });
} }
return newSwarm; return newSwarm;
@@ -122,8 +135,11 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
const parent = canvas.parentElement; const parent = canvas.parentElement;
if (parent) { if (parent) {
parent.addEventListener('mousemove', handleMouseMove); parent.addEventListener("mousemove", handleMouseMove);
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; }); parent.addEventListener("mouseleave", () => {
mouse.x = -1000;
mouse.y = -1000;
});
} }
let time = 0; let time = 0;
@@ -138,16 +154,23 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
// Vibrant theme-aware night sky background // Vibrant theme-aware night sky background
const isLight = theme.palette.mode === 'light'; const isLight = theme.palette.mode === "light";
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width); const bgGrad = ctx.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
width,
);
if (isLight) { if (isLight) {
// For light themes, we still need a "dusk/night" feel for fireflies to make sense. // 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. // We use a deeper, more saturated version of the theme's colors.
bgGrad.addColorStop(0, alpha(brightColors[2] || '#1a1a2e', 0.85)); bgGrad.addColorStop(0, alpha(brightColors[2] || "#1a1a2e", 0.85));
bgGrad.addColorStop(1, alpha(brightColors[2] || '#16213e', 1.0)); bgGrad.addColorStop(1, alpha(brightColors[2] || "#16213e", 1.0));
} else { } else {
bgGrad.addColorStop(0, '#141a2a'); // Noticeable blue-grey tint bgGrad.addColorStop(0, "#141a2a"); // Noticeable blue-grey tint
bgGrad.addColorStop(1, '#05080f'); bgGrad.addColorStop(1, "#05080f");
} }
ctx.fillStyle = bgGrad; ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
@@ -208,7 +231,14 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
const opacity = flicker > 0 ? flicker : 0; const opacity = flicker > 0 ? flicker : 0;
// Render // 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, alpha(f.color, opacity));
grad.addColorStop(0.4, alpha(f.color, opacity * 0.3)); grad.addColorStop(0.4, alpha(f.color, opacity * 0.3));
grad.addColorStop(1, alpha(f.color, 0)); 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.arc(f.x, f.y, f.size * glowSizeMultiplier, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
ctx.fillStyle = alpha('#fff', opacity * 0.8); ctx.fillStyle = alpha("#fff", opacity * 0.8);
ctx.beginPath(); ctx.beginPath();
ctx.arc(f.x, f.y, f.size * 0.5, 0, Math.PI * 2); ctx.arc(f.x, f.y, f.size * 0.5, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
@@ -232,23 +262,33 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
return () => { return () => {
resizeObserver.disconnect(); resizeObserver.disconnect();
if (parent) { if (parent) {
parent.removeEventListener('mousemove', handleMouseMove); parent.removeEventListener("mousemove", handleMouseMove);
} }
cancelAnimationFrame(animationFrameId); cancelAnimationFrame(animationFrameId);
}; };
}, [seed, brightColors, count, baseSpeed, evasionRadius, evasionStrength, glowSizeMultiplier, flickerSpeed, theme]); }, [
seed,
brightColors,
count,
baseSpeed,
evasionRadius,
evasionStrength,
glowSizeMultiplier,
flickerSpeed,
theme,
]);
return ( return (
<canvas <canvas
ref={canvasRef} ref={canvasRef}
style={{ style={{
position: 'absolute', position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: "100%",
height: '100%', height: "100%",
pointerEvents: 'none', pointerEvents: "none",
zIndex: 1 zIndex: 1,
}} }}
/> />
); );
+64 -12
View File
@@ -1,32 +1,84 @@
import React from 'react'; import React from "react";
import { Box, Container, Typography, Link as MuiLink, Divider } from '@mui/material'; import {
import { Link } from 'react-router-dom'; Box,
import { useTranslation } from 'react-i18next'; Container,
Typography,
Link as MuiLink,
Divider,
} from "@mui/material";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
const Footer: React.FC = () => { const Footer: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const year = new Date().getFullYear(); const year = new Date().getFullYear();
return ( return (
<Box component="footer" sx={{ bgcolor: 'background.paper', pt: 6, pb: 4, mt: 'auto', borderTop: '1px solid', borderColor: 'divider' }}> <Box
component="footer"
sx={{
bgcolor: "background.paper",
pt: 6,
pb: 4,
mt: "auto",
borderTop: "1px solid",
borderColor: "divider",
}}
>
<Container maxWidth="lg"> <Container maxWidth="lg">
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, justifyContent: 'space-between', alignItems: 'center', gap: 2, mb: 2 }}> <Box
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}> sx={{
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 © {year} Holy Flavors Archive
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 3 }}> <Box sx={{ display: "flex", gap: 3 }}>
<MuiLink component={Link} to="/about" color="inherit" sx={{ fontSize: '0.8rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}> <MuiLink
component={Link}
to="/about"
color="inherit"
sx={{
fontSize: "0.8rem",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
Impressum Impressum
</MuiLink> </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 Privacy Policy
</MuiLink> </MuiLink>
</Box> </Box>
</Box> </Box>
<Divider sx={{ my: 2, opacity: 0.5 }} /> <Divider sx={{ my: 2, opacity: 0.5 }} />
<Typography variant="caption" color="text.secondary" align="center" display="block"> <Typography
{t('nav.about')} • Fan Project variant="caption"
color="text.secondary"
align="center"
display="block"
>
{t("nav.about")} • Fan Project
</Typography> </Typography>
</Container> </Container>
</Box> </Box>
+78 -33
View File
@@ -1,5 +1,5 @@
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect } from "react";
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from "@mui/material";
interface GenerativeBannerProps { interface GenerativeBannerProps {
username: string; username: string;
@@ -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 canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme(); const theme = useTheme();
@@ -35,15 +41,25 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true }); const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return; if (!ctx) return;
let animationFrameId: number; let animationFrameId: number;
const seed = getSeed(username); const seed = getSeed(username);
// Configuration - High density for "complex web" feel // Configuration - High density for "complex web" feel
const nodeCount = Math.min(nodeCountBase + Math.floor(ratingsCount * 1.5), 120); const nodeCount = Math.min(
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main]; 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 { interface Node {
x: number; x: number;
@@ -69,11 +85,14 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
const bx = pseudoRandom(i) * width; const bx = pseudoRandom(i) * width;
const by = pseudoRandom(i + 100) * height; const by = pseudoRandom(i + 100) * height;
newNodes.push({ newNodes.push({
x: bx, y: by, x: bx,
baseX: bx, baseY: by, y: by,
vx: 0, vy: 0, baseX: bx,
baseY: by,
vx: 0,
vy: 0,
color: colors[i % colors.length], color: colors[i % colors.length],
size: 1 + pseudoRandom(i + 200) * 3 size: 1 + pseudoRandom(i + 200) * 3,
}); });
} }
return newNodes; return newNodes;
@@ -113,18 +132,29 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
} }
}; };
const handleMouseDown = () => { mouse.down = true; }; const handleMouseDown = () => {
const handleMouseUp = () => { mouse.down = false; }; mouse.down = true;
};
const handleMouseUp = () => {
mouse.down = false;
};
const parent = canvas.parentElement; const parent = canvas.parentElement;
if (parent) { if (parent) {
parent.addEventListener('mousemove', handleMouseMove); parent.addEventListener("mousemove", handleMouseMove);
parent.addEventListener('touchmove', handleTouchMove, { passive: false }); parent.addEventListener("touchmove", handleTouchMove, { passive: false });
parent.addEventListener('mousedown', handleMouseDown); parent.addEventListener("mousedown", handleMouseDown);
parent.addEventListener('mouseup', handleMouseUp); parent.addEventListener("mouseup", handleMouseUp);
parent.addEventListener('touchstart', (e) => { mouse.down = true; handleTouchMove(e); }); parent.addEventListener("touchstart", (e) => {
parent.addEventListener('touchend', handleMouseUp); mouse.down = true;
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; }); handleTouchMove(e);
});
parent.addEventListener("touchend", handleMouseUp);
parent.addEventListener("mouseleave", () => {
mouse.x = -1000;
mouse.y = -1000;
mouse.down = false;
});
} }
const draw = () => { const draw = () => {
@@ -197,7 +227,12 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
ctx.lineTo(nodes[j].x, nodes[j].y); ctx.lineTo(nodes[j].x, nodes[j].y);
// Dynamic gradient for the line // 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(0, alpha(nodes[i].color, opacity));
lineGrad.addColorStop(1, alpha(nodes[j].color, opacity)); lineGrad.addColorStop(1, alpha(nodes[j].color, opacity));
@@ -226,7 +261,7 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
} }
// Draw Nodes // Draw Nodes
nodes.forEach(node => { nodes.forEach((node) => {
ctx.beginPath(); ctx.beginPath();
ctx.arc(node.x, node.y, node.size, 0, Math.PI * 2); ctx.arc(node.x, node.y, node.size, 0, Math.PI * 2);
ctx.fillStyle = node.color; ctx.fillStyle = node.color;
@@ -247,29 +282,39 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
return () => { return () => {
resizeObserver.disconnect(); resizeObserver.disconnect();
if (parent) { if (parent) {
parent.removeEventListener('mousemove', handleMouseMove); parent.removeEventListener("mousemove", handleMouseMove);
parent.removeEventListener('touchmove', handleTouchMove); parent.removeEventListener("touchmove", handleTouchMove);
parent.removeEventListener('mousedown', handleMouseDown); parent.removeEventListener("mousedown", handleMouseDown);
parent.removeEventListener('mouseup', handleMouseUp); parent.removeEventListener("mouseup", handleMouseUp);
parent.removeEventListener('touchstart', handleMouseDown); parent.removeEventListener("touchstart", handleMouseDown);
parent.removeEventListener('touchend', handleMouseUp); parent.removeEventListener("touchend", handleMouseUp);
} }
cancelAnimationFrame(animationFrameId); cancelAnimationFrame(animationFrameId);
}; };
}, [username, palette, ratingsCount, followersCount, theme, nodeCountBase, connectionDist, speed, globalOpacity]); }, [
username,
palette,
ratingsCount,
followersCount,
theme,
nodeCountBase,
connectionDist,
speed,
globalOpacity,
]);
return ( return (
<canvas <canvas
ref={canvasRef} ref={canvasRef}
style={{ style={{
position: 'absolute', position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: "100%",
height: '100%', height: "100%",
pointerEvents: 'none', pointerEvents: "none",
zIndex: 1, 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 React, { useRef, useEffect, useMemo } from "react";
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from "@mui/material";
interface GenerativeFlowProps { interface GenerativeFlowProps {
username: string; username: string;
@@ -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 canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme(); const theme = useTheme();
@@ -36,14 +40,23 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
const seed = useMemo(() => getSeed(username), [username]); const seed = useMemo(() => getSeed(username), [username]);
const brightColors = useMemo(() => { 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 getBrightness = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) || 0; const r = parseInt(hex.slice(1, 3), 16) || 0;
const g = parseInt(hex.slice(3, 5), 16) || 0; const g = parseInt(hex.slice(3, 5), 16) || 0;
const b = parseInt(hex.slice(5, 7), 16) || 0; const b = parseInt(hex.slice(5, 7), 16) || 0;
return (r * 299 + g * 587 + b * 114) / 1000; 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); return sorted.slice(0, 4);
}, [palette, theme]); }, [palette, theme]);
@@ -51,7 +64,7 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true }); const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return; if (!ctx) return;
let animationFrameId: number; let animationFrameId: number;
@@ -83,13 +96,16 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
const rx = pseudoRandom(i) * width; const rx = pseudoRandom(i) * width;
const ry = pseudoRandom(i + 500) * height; const ry = pseudoRandom(i + 500) * height;
newBlobs.push({ newBlobs.push({
x: rx, y: ry, x: rx,
ox: rx, oy: ry, y: ry,
vx: 0, vy: 0, ox: rx,
size: (width / 4) + pseudoRandom(i + 1000) * (width / 3), oy: ry,
vx: 0,
vy: 0,
size: width / 4 + pseudoRandom(i + 1000) * (width / 3),
color: brightColors[i % brightColors.length], color: brightColors[i % brightColors.length],
phase: pseudoRandom(i + 1500) * Math.PI * 2, phase: pseudoRandom(i + 1500) * Math.PI * 2,
speedFactor: 0.5 + pseudoRandom(i + 2000) speedFactor: 0.5 + pseudoRandom(i + 2000),
}); });
} }
return newBlobs; return newBlobs;
@@ -122,8 +138,11 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
const parent = canvas.parentElement; const parent = canvas.parentElement;
if (parent) { if (parent) {
parent.addEventListener('mousemove', handleMouseMove); parent.addEventListener("mousemove", handleMouseMove);
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; }); parent.addEventListener("mouseleave", () => {
mouse.x = -1000;
mouse.y = -1000;
});
} }
const draw = () => { const draw = () => {
@@ -137,15 +156,28 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
// Background Base // Background Base
const isLight = theme.palette.mode === 'light'; const isLight = theme.palette.mode === "light";
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width); const bgGrad = ctx.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
width,
);
if (isLight) { if (isLight) {
// For light themes, use a deeper, more saturated version of the theme color // 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(
bgGrad.addColorStop(1, alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.9)); 0,
alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.6),
);
bgGrad.addColorStop(
1,
alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.9),
);
} else { } else {
bgGrad.addColorStop(0, '#0a0a1a'); bgGrad.addColorStop(0, "#0a0a1a");
bgGrad.addColorStop(1, '#020205'); bgGrad.addColorStop(1, "#020205");
} }
ctx.fillStyle = bgGrad; ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
@@ -154,12 +186,13 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
// Apply filter for "Liquid" look // Apply filter for "Liquid" look
ctx.filter = `blur(${blurAmount}px)`; ctx.filter = `blur(${blurAmount}px)`;
ctx.globalCompositeOperation = 'screen'; ctx.globalCompositeOperation = "screen";
blobs.forEach((b) => { blobs.forEach((b) => {
// Harmonic autonomous drift // Harmonic autonomous drift
const driftX = Math.sin(time * b.speedFactor + b.phase) * (width / 5); 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 // Mouse distortion
const dxMouse = mouse.x - b.ox; 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 pulse = 1 + Math.sin(time + b.phase) * 0.1;
const currentSize = b.size * pulse; 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(0, alpha(b.color, vibrancy));
grad.addColorStop(1, alpha(b.color, 0)); grad.addColorStop(1, alpha(b.color, 0));
@@ -187,8 +227,8 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
ctx.fill(); ctx.fill();
}); });
ctx.filter = 'none'; ctx.filter = "none";
ctx.globalCompositeOperation = 'source-over'; ctx.globalCompositeOperation = "source-over";
animationFrameId = requestAnimationFrame(draw); animationFrameId = requestAnimationFrame(draw);
}; };
@@ -198,23 +238,32 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
return () => { return () => {
resizeObserver.disconnect(); resizeObserver.disconnect();
if (parent) { if (parent) {
parent.removeEventListener('mousemove', handleMouseMove); parent.removeEventListener("mousemove", handleMouseMove);
} }
cancelAnimationFrame(animationFrameId); cancelAnimationFrame(animationFrameId);
}; };
}, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme]); }, [
seed,
brightColors,
blobCount,
flowSpeed,
vibrancy,
blurAmount,
distortionFactor,
theme,
]);
return ( return (
<canvas <canvas
ref={canvasRef} ref={canvasRef}
style={{ style={{
position: 'absolute', position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: "100%",
height: '100%', height: "100%",
pointerEvents: 'none', pointerEvents: "none",
zIndex: 1 zIndex: 1,
}} }}
/> />
); );
+95 -38
View File
@@ -1,5 +1,5 @@
import React, { useRef, useEffect, useMemo } from 'react'; import React, { useRef, useEffect, useMemo } from "react";
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from "@mui/material";
interface HextechBannerProps { interface HextechBannerProps {
username: string; username: string;
@@ -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 canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme(); const theme = useTheme();
@@ -36,14 +40,23 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
const seed = useMemo(() => getSeed(username), [username]); const seed = useMemo(() => getSeed(username), [username]);
const brightColors = useMemo(() => { 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 getBrightness = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) || 0; const r = parseInt(hex.slice(1, 3), 16) || 0;
const g = parseInt(hex.slice(3, 5), 16) || 0; const g = parseInt(hex.slice(3, 5), 16) || 0;
const b = parseInt(hex.slice(5, 7), 16) || 0; const b = parseInt(hex.slice(5, 7), 16) || 0;
return (r * 299 + g * 587 + b * 114) / 1000; 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); return sorted.slice(0, 3);
}, [palette, theme]); }, [palette, theme]);
@@ -51,7 +64,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true }); const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return; if (!ctx) return;
let animationFrameId: number; let animationFrameId: number;
@@ -64,7 +77,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
// Mana Stream Logic // Mana Stream Logic
interface Stream { interface Stream {
segments: { x: number, y: number }[]; segments: { x: number; y: number }[];
color: string; color: string;
life: number; life: number;
speed: number; speed: number;
@@ -73,7 +86,8 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
let streams: Stream[] = []; let streams: Stream[] = [];
const createStream = (width: number, height: number) => { 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 x = pseudoRandom(time + 1) * width;
let y = pseudoRandom(time + 2) * height; let y = pseudoRandom(time + 2) * height;
@@ -81,7 +95,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
segments: [{ x, y }], segments: [{ x, y }],
color, color,
life: 1.0, 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.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top; mouse.y = e.clientY - rect.top;
}; };
const handleMouseDown = () => { mouse.down = true; }; const handleMouseDown = () => {
const handleMouseUp = () => { mouse.down = false; }; mouse.down = true;
};
const handleMouseUp = () => {
mouse.down = false;
};
const parent = canvas.parentElement; const parent = canvas.parentElement;
if (parent) { if (parent) {
parent.addEventListener('mousemove', handleMouseMove); parent.addEventListener("mousemove", handleMouseMove);
parent.addEventListener('mousedown', handleMouseDown); parent.addEventListener("mousedown", handleMouseDown);
parent.addEventListener('mouseup', handleMouseUp); parent.addEventListener("mouseup", handleMouseUp);
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; }); parent.addEventListener("mouseleave", () => {
mouse.x = -1000;
mouse.y = -1000;
mouse.down = false;
});
} }
const draw = () => { const draw = () => {
@@ -142,15 +164,22 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
// Background // Background
const isLight = theme.palette.mode === 'light'; const isLight = theme.palette.mode === "light";
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width); const bgGrad = ctx.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
width,
);
if (isLight) { if (isLight) {
// For light themes, use a deeper, more saturated version of the theme color // 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(0, alpha(brightColors[2] || brightColors[0], 0.65));
bgGrad.addColorStop(1, alpha(brightColors[2] || brightColors[0], 0.95)); bgGrad.addColorStop(1, alpha(brightColors[2] || brightColors[0], 0.95));
} else { } else {
bgGrad.addColorStop(0, '#0a051a'); // Arcane Purple tint bgGrad.addColorStop(0, "#0a051a"); // Arcane Purple tint
bgGrad.addColorStop(1, '#020105'); bgGrad.addColorStop(1, "#020105");
} }
ctx.fillStyle = bgGrad; ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height); 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 y = -gridSize; y < height + gridSize; y += h / 2) {
for (let x = -gridSize; x < width + gridSize; x += w * 0.75) { 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 finalX = x + xOffset;
const finalY = y; const finalY = y;
// Apply Trippiness (Distortion) // Apply Trippiness (Distortion)
const dist = Math.sqrt(Math.pow(finalX - mouse.x, 2) + Math.pow(finalY - mouse.y, 2)); const dist = Math.sqrt(
const mouseImpact = Math.max(0, (200 - dist) / 200) * (mouse.down ? 2 : 1); 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 dx =
const dy = Math.cos(time + (finalY * 0.01)) * gridSize * distortionFactor * (1 + mouseImpact); 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 drawX = finalX + dx;
const drawY = finalY + dy; const drawY = finalY + dy;
// Pulse glow // 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; const opacity = (0.1 + pulseVal + mouseImpact * 0.3) * glowIntensity;
ctx.strokeStyle = alpha(brightColors[0], opacity); ctx.strokeStyle = alpha(brightColors[0], opacity);
@@ -200,7 +241,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
streams.push(createStream(width, height)); streams.push(createStream(width, height));
} }
ctx.globalCompositeOperation = 'screen'; ctx.globalCompositeOperation = "screen";
streams.forEach((s, idx) => { streams.forEach((s, idx) => {
s.life -= 0.005; s.life -= 0.005;
const last = s.segments[s.segments.length - 1]; const last = s.segments[s.segments.length - 1];
@@ -222,7 +263,14 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
ctx.stroke(); ctx.stroke();
const head = s.segments[s.segments.length - 1]; 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(0, alpha(s.color, s.life));
g.addColorStop(1, alpha(s.color, 0)); g.addColorStop(1, alpha(s.color, 0));
ctx.fillStyle = g; 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.arc(head.x, head.y, 15, 0, Math.PI * 2);
ctx.fill(); 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); animationFrameId = requestAnimationFrame(draw);
}; };
@@ -242,25 +290,34 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
return () => { return () => {
resizeObserver.disconnect(); resizeObserver.disconnect();
if (parent) { if (parent) {
parent.removeEventListener('mousemove', handleMouseMove); parent.removeEventListener("mousemove", handleMouseMove);
parent.removeEventListener('mousedown', handleMouseDown); parent.removeEventListener("mousedown", handleMouseDown);
parent.removeEventListener('mouseup', handleMouseUp); parent.removeEventListener("mouseup", handleMouseUp);
} }
cancelAnimationFrame(animationFrameId); cancelAnimationFrame(animationFrameId);
}; };
}, [seed, brightColors, gridSize, pulseSpeed, glowIntensity, streamCount, distortionFactor, theme]); }, [
seed,
brightColors,
gridSize,
pulseSpeed,
glowIntensity,
streamCount,
distortionFactor,
theme,
]);
return ( return (
<canvas <canvas
ref={canvasRef} ref={canvasRef}
style={{ style={{
position: 'absolute', position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: "100%",
height: '100%', height: "100%",
pointerEvents: 'none', pointerEvents: "none",
zIndex: 1 zIndex: 1,
}} }}
/> />
); );
@@ -1,5 +1,5 @@
import React, { useRef, useEffect, useMemo } from 'react'; import React, { useRef, useEffect, useMemo } from "react";
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from "@mui/material";
interface HextechCorruptionBannerProps { interface HextechCorruptionBannerProps {
username: string; username: string;
@@ -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 canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme(); const theme = useTheme();
@@ -36,14 +40,23 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
const seed = useMemo(() => getSeed(username), [username]); const seed = useMemo(() => getSeed(username), [username]);
const brightColors = useMemo(() => { 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 getBrightness = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) || 0; const r = parseInt(hex.slice(1, 3), 16) || 0;
const g = parseInt(hex.slice(3, 5), 16) || 0; const g = parseInt(hex.slice(3, 5), 16) || 0;
const b = parseInt(hex.slice(5, 7), 16) || 0; const b = parseInt(hex.slice(5, 7), 16) || 0;
return (r * 299 + g * 587 + b * 114) / 1000; 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); return sorted.slice(0, 3);
}, [palette, theme]); }, [palette, theme]);
@@ -51,7 +64,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true }); const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return; if (!ctx) return;
let animationFrameId: number; let animationFrameId: number;
@@ -81,11 +94,14 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
const rx = pseudoRandom(i) * width; const rx = pseudoRandom(i) * width;
const ry = pseudoRandom(i + 500) * height; const ry = pseudoRandom(i + 500) * height;
newPoints.push({ newPoints.push({
x: rx, y: ry, x: rx,
ox: rx, oy: ry, y: ry,
vx: 0, vy: 0, ox: rx,
oy: ry,
vx: 0,
vy: 0,
phase: pseudoRandom(i + 1000) * Math.PI * 2, phase: pseudoRandom(i + 1000) * Math.PI * 2,
color: brightColors[i % brightColors.length] color: brightColors[i % brightColors.length],
}); });
} }
return newPoints; return newPoints;
@@ -115,15 +131,23 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
mouse.x = e.clientX - rect.left; mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top; mouse.y = e.clientY - rect.top;
}; };
const handleMouseDown = () => { mouse.down = true; }; const handleMouseDown = () => {
const handleMouseUp = () => { mouse.down = false; }; mouse.down = true;
};
const handleMouseUp = () => {
mouse.down = false;
};
const parent = canvas.parentElement; const parent = canvas.parentElement;
if (parent) { if (parent) {
parent.addEventListener('mousemove', handleMouseMove); parent.addEventListener("mousemove", handleMouseMove);
parent.addEventListener('mousedown', handleMouseDown); parent.addEventListener("mousedown", handleMouseDown);
parent.addEventListener('mouseup', handleMouseUp); parent.addEventListener("mouseup", handleMouseUp);
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; }); parent.addEventListener("mouseleave", () => {
mouse.x = -1000;
mouse.y = -1000;
mouse.down = false;
});
} }
const draw = () => { const draw = () => {
@@ -138,7 +162,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
time += shimmerSpeed; time += shimmerSpeed;
// 1. Draw Under-Glow (Inner Cores) // 1. Draw Under-Glow (Inner Cores)
ctx.globalCompositeOperation = 'source-over'; ctx.globalCompositeOperation = "source-over";
points.forEach((p) => { points.forEach((p) => {
// Harmonic motion + mouse interaction // Harmonic motion + mouse interaction
const dxMouse = mouse.x - p.x; 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); 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 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); const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize);
grad.addColorStop(0, alpha(p.color, 0.8 * glowIntensity * pulse)); grad.addColorStop(0, 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) // 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 // 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 // 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: // 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. // 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.width = canvas.width;
offCanvas.height = canvas.height; offCanvas.height = canvas.height;
const offCtx = offCanvas.getContext('2d'); const offCtx = offCanvas.getContext("2d");
if (offCtx) { if (offCtx) {
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
offCtx.scale(dpr, dpr); offCtx.scale(dpr, dpr);
// Draw full dark shell // 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.fillStyle = shellColor;
offCtx.fillRect(0, 0, width, height); offCtx.fillRect(0, 0, width, height);
// Cut holes // Cut holes
offCtx.globalCompositeOperation = 'destination-out'; offCtx.globalCompositeOperation = "destination-out";
points.forEach(p => { points.forEach((p) => {
const holeSize = 15 + Math.sin(time + p.phase) * 5 + (instability * 10); const holeSize = 15 + Math.sin(time + p.phase) * 5 + instability * 10;
// Main hole // Main hole
offCtx.beginPath(); offCtx.beginPath();
@@ -199,7 +226,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
// Add secondary smaller holes for that "punctured" look // Add secondary smaller holes for that "punctured" look
for (let j = 0; j < 3; j++) { 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 hx = p.x + Math.cos(angle) * (holeSize * 1.5);
const hy = p.y + Math.sin(angle) * (holeSize * 1.5); const hy = p.y + Math.sin(angle) * (holeSize * 1.5);
offCtx.beginPath(); offCtx.beginPath();
@@ -212,9 +239,9 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
} }
// 3. Add Iridescent Edge Highlights // 3. Add Iridescent Edge Highlights
ctx.globalCompositeOperation = 'screen'; ctx.globalCompositeOperation = "screen";
ctx.lineWidth = 2; ctx.lineWidth = 2;
points.forEach(p => { points.forEach((p) => {
const pulse = 0.5 + Math.sin(time * 2 + p.phase) * 0.5; const pulse = 0.5 + Math.sin(time * 2 + p.phase) * 0.5;
ctx.strokeStyle = alpha(p.color, pulse * 0.4); ctx.strokeStyle = alpha(p.color, pulse * 0.4);
ctx.beginPath(); ctx.beginPath();
@@ -222,7 +249,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
ctx.stroke(); ctx.stroke();
}); });
ctx.globalCompositeOperation = 'source-over'; ctx.globalCompositeOperation = "source-over";
animationFrameId = requestAnimationFrame(draw); animationFrameId = requestAnimationFrame(draw);
}; };
@@ -231,25 +258,34 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
return () => { return () => {
resizeObserver.disconnect(); resizeObserver.disconnect();
if (parent) { if (parent) {
parent.removeEventListener('mousemove', handleMouseMove); parent.removeEventListener("mousemove", handleMouseMove);
parent.removeEventListener('mousedown', handleMouseDown); parent.removeEventListener("mousedown", handleMouseDown);
parent.removeEventListener('mouseup', handleMouseUp); parent.removeEventListener("mouseup", handleMouseUp);
} }
cancelAnimationFrame(animationFrameId); cancelAnimationFrame(animationFrameId);
}; };
}, [seed, brightColors, cellCount, shimmerSpeed, glowIntensity, instability, shellDarkness, theme]); }, [
seed,
brightColors,
cellCount,
shimmerSpeed,
glowIntensity,
instability,
shellDarkness,
theme,
]);
return ( return (
<canvas <canvas
ref={canvasRef} ref={canvasRef}
style={{ style={{
position: 'absolute', position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: "100%",
height: '100%', height: "100%",
pointerEvents: 'none', pointerEvents: "none",
zIndex: 1 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 { import {
TextField, TextField,
Popper, Popper,
@@ -9,9 +9,9 @@ import {
ListItemAvatar, ListItemAvatar,
ListItemText, ListItemText,
Box, Box,
alpha alpha,
} from '@mui/material'; } from "@mui/material";
import api from '../api'; import api from "../api";
interface UserSuggestion { interface UserSuggestion {
id: number; id: number;
@@ -26,7 +26,7 @@ interface MentionTextFieldProps {
fullWidth?: boolean; fullWidth?: boolean;
multiline?: boolean; multiline?: boolean;
rows?: number; rows?: number;
size?: 'small' | 'medium'; size?: "small" | "medium";
onKeyDown?: (e: React.KeyboardEvent) => void; onKeyDown?: (e: React.KeyboardEvent) => void;
} }
@@ -37,8 +37,8 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
fullWidth = true, fullWidth = true,
multiline = false, multiline = false,
rows, rows,
size = 'small', size = "small",
onKeyDown onKeyDown,
}) => { }) => {
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]); const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false);
@@ -52,17 +52,17 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
useEffect(() => { useEffect(() => {
const fetchUsers = async () => { const fetchUsers = async () => {
try { try {
const res = await api.get('flavors/search/'); const res = await api.get("flavors/search/");
const users = (res.data || []) const users = (res.data || [])
.filter((item: any) => item.type === 'user') .filter((item: any) => item.type === "user")
.map((item: any) => ({ .map((item: any) => ({
id: item.id, id: item.id,
username: item.name, username: item.name,
avatar: item.image_url avatar: item.image_url,
})); }));
setAllUsers(users); setAllUsers(users);
} catch (err) { } catch (err) {
console.error('Failed to fetch users for mentions'); console.error("Failed to fetch users for mentions");
} }
}; };
fetchUsers(); fetchUsers();
@@ -74,14 +74,14 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
const selectionStart = e.target.selectionStart || 0; const selectionStart = e.target.selectionStart || 0;
const textBeforeCursor = newValue.substring(0, selectionStart); const textBeforeCursor = newValue.substring(0, selectionStart);
const lastAtPos = textBeforeCursor.lastIndexOf('@'); const lastAtPos = textBeforeCursor.lastIndexOf("@");
if (lastAtPos !== -1) { if (lastAtPos !== -1) {
const query = textBeforeCursor.substring(lastAtPos + 1); const query = textBeforeCursor.substring(lastAtPos + 1);
// Ensure there's no space or newline between @ and the cursor // Ensure there's no space or newline between @ and the cursor
if (!query.includes(' ') && !query.includes('\n')) { if (!query.includes(" ") && !query.includes("\n")) {
const filtered = allUsers.filter(u => const filtered = allUsers.filter((u) =>
u.username.toLowerCase().startsWith(query.toLowerCase()) u.username.toLowerCase().startsWith(query.toLowerCase()),
); );
setSuggestions(filtered); setSuggestions(filtered);
setShowSuggestions(filtered.length > 0); setShowSuggestions(filtered.length > 0);
@@ -97,11 +97,13 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
const selectionStart = inputRef.current?.selectionStart || 0; const selectionStart = inputRef.current?.selectionStart || 0;
const textBeforeCursor = value.substring(0, selectionStart); const textBeforeCursor = value.substring(0, selectionStart);
const textAfterCursor = value.substring(selectionStart); const textAfterCursor = value.substring(selectionStart);
const lastAtPos = textBeforeCursor.lastIndexOf('@'); const lastAtPos = textBeforeCursor.lastIndexOf("@");
const newValue = const newValue =
value.substring(0, lastAtPos) + value.substring(0, lastAtPos) +
'@' + user.username + ' ' + "@" +
user.username +
" " +
textAfterCursor; textAfterCursor;
onChange(newValue); onChange(newValue);
@@ -119,22 +121,24 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (showSuggestions) { if (showSuggestions) {
if (e.key === 'Tab' || e.key === 'Enter') { if (e.key === "Tab" || e.key === "Enter") {
e.preventDefault(); e.preventDefault();
handleSelectSuggestion(suggestions[selectedIndex]); handleSelectSuggestion(suggestions[selectedIndex]);
return; return;
} }
if (e.key === 'ArrowDown') { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % suggestions.length); setSelectedIndex((prev) => (prev + 1) % suggestions.length);
return; return;
} }
if (e.key === 'ArrowUp') { if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length); setSelectedIndex(
(prev) => (prev - 1 + suggestions.length) % suggestions.length,
);
return; return;
} }
if (e.key === 'Escape') { if (e.key === "Escape") {
setShowSuggestions(false); setShowSuggestions(false);
return; return;
} }
@@ -143,7 +147,7 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
}; };
return ( return (
<Box sx={{ position: 'relative', width: fullWidth ? '100%' : 'auto' }}> <Box sx={{ position: "relative", width: fullWidth ? "100%" : "auto" }}>
<TextField <TextField
inputRef={inputRef} inputRef={inputRef}
fullWidth={fullWidth} fullWidth={fullWidth}
@@ -163,7 +167,16 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
placement="bottom-start" placement="bottom-start"
style={{ zIndex: 2000, width: 250 }} 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 }}> <MenuList dense sx={{ py: 0 }}>
{suggestions.map((user, index) => ( {suggestions.map((user, index) => (
<MenuItem <MenuItem
@@ -172,20 +185,29 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
onClick={() => handleSelectSuggestion(user)} onClick={() => handleSelectSuggestion(user)}
sx={{ sx={{
py: 1, py: 1,
'&.Mui-selected': { "&.Mui-selected": {
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.15), 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 }}> <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()} {user.username.charAt(0).toUpperCase()}
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary={user.username} primary={user.username}
primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold' }} primaryTypographyProps={{
variant: "body2",
fontWeight: "bold",
}}
/> />
</MenuItem> </MenuItem>
))} ))}
+81 -36
View File
@@ -1,5 +1,5 @@
import React, { useRef, useEffect, useMemo } from 'react'; import React, { useRef, useEffect, useMemo } from "react";
import { alpha, useTheme } from '@mui/material'; import { alpha, useTheme } from "@mui/material";
interface NebulaBannerProps { interface NebulaBannerProps {
username: string; username: string;
@@ -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 canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme(); const theme = useTheme();
@@ -43,11 +49,18 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true }); const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return; if (!ctx) return;
let animationFrameId: number; 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 { interface Particle {
x: number; x: number;
@@ -86,7 +99,7 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
x: pseudoRandom(i + 5000) * width, x: pseudoRandom(i + 5000) * width,
y: pseudoRandom(i + 6000) * height, y: pseudoRandom(i + 6000) * height,
size: pseudoRandom(i + 7000) * 1.5, 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 clusterCount = colors.length * 3; // More clusters
const groupCenters = Array.from({ length: clusterCount }).map((_, i) => ({ const groupCenters = Array.from({ length: clusterCount }).map((_, i) => ({
x: (pseudoRandom(i * 15) * 0.8 + 0.1) * width, 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++) { 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; const by = center.y + Math.sin(angle) * dist;
newParticles.push({ newParticles.push({
x: bx, y: by, x: bx,
baseX: bx, baseY: by, y: by,
vx: 0, vy: 0, baseX: bx,
baseY: by,
vx: 0,
vy: 0,
size: 40 + pseudoRandom(i + 300) * 80, // Larger, softer blobs size: 40 + pseudoRandom(i + 300) * 80, // Larger, softer blobs
color: colors[colorIdx], color: colors[colorIdx],
alpha: 0, alpha: 0,
targetAlpha: 0.05 + pseudoRandom(i + 400) * 0.15, 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 }; 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 }; let mouse = { x: -1000, y: -1000, px: -1000, py: -1000, down: false };
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect(); 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.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top; mouse.y = e.clientY - rect.top;
}; };
const handleMouseDown = () => { mouse.down = true; }; const handleMouseDown = () => {
const handleMouseUp = () => { mouse.down = false; }; mouse.down = true;
};
const handleMouseUp = () => {
mouse.down = false;
};
const parent = canvas.parentElement; const parent = canvas.parentElement;
if (parent) { if (parent) {
parent.addEventListener('mousemove', handleMouseMove); parent.addEventListener("mousemove", handleMouseMove);
parent.addEventListener('mousedown', handleMouseDown); parent.addEventListener("mousedown", handleMouseDown);
parent.addEventListener('mouseup', handleMouseUp); parent.addEventListener("mouseup", handleMouseUp);
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; }); parent.addEventListener("mouseleave", () => {
mouse.x = -1000;
mouse.y = -1000;
mouse.down = false;
});
} }
let time = 0; let time = 0;
@@ -171,8 +196,15 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
// 1. Dynamic Celestial Background // 1. Dynamic Celestial Background
const isLight = theme.palette.mode === 'light'; const isLight = theme.palette.mode === "light";
const skyGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width); const skyGrad = ctx.createRadialGradient(
width / 2,
height / 2,
0,
width / 2,
height / 2,
width,
);
if (isLight) { if (isLight) {
// Vibrant Deep Primary for light mode - but dark enough for glowing gas to pop // 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)); skyGrad.addColorStop(1, alpha(colors[colors.length - 1], 1.0));
} else { } else {
// True Deep Space with visible tints for dark mode // True Deep Space with visible tints for dark mode
skyGrad.addColorStop(0, '#1a1a3a'); // More blue/purple tint skyGrad.addColorStop(0, "#1a1a3a"); // More blue/purple tint
skyGrad.addColorStop(0.5, '#0a0a1a'); skyGrad.addColorStop(0.5, "#0a0a1a");
skyGrad.addColorStop(1, '#050508'); skyGrad.addColorStop(1, "#050508");
} }
ctx.fillStyle = skyGrad; ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
// 2. Draw Distant Stars // 2. Draw Distant Stars
ctx.fillStyle = '#fff'; ctx.fillStyle = "#fff";
stars.forEach(s => { stars.forEach((s) => {
ctx.globalAlpha = s.opacity * (0.7 + Math.sin(time * 2 + s.x) * 0.3); ctx.globalAlpha = s.opacity * (0.7 + Math.sin(time * 2 + s.x) * 0.3);
ctx.beginPath(); ctx.beginPath();
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2); 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)); grad.addColorStop(1, alpha(p.color, 0));
ctx.fillStyle = grad; ctx.fillStyle = grad;
ctx.globalCompositeOperation = 'screen'; ctx.globalCompositeOperation = "screen";
ctx.beginPath(); ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
}); });
ctx.globalCompositeOperation = 'source-over'; ctx.globalCompositeOperation = "source-over";
animationFrameId = requestAnimationFrame(draw); animationFrameId = requestAnimationFrame(draw);
}; };
@@ -267,25 +299,38 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
return () => { return () => {
resizeObserver.disconnect(); resizeObserver.disconnect();
if (parent) { if (parent) {
parent.removeEventListener('mousemove', handleMouseMove); parent.removeEventListener("mousemove", handleMouseMove);
parent.removeEventListener('mousedown', handleMouseDown); parent.removeEventListener("mousedown", handleMouseDown);
parent.removeEventListener('mouseup', handleMouseUp); parent.removeEventListener("mouseup", handleMouseUp);
} }
cancelAnimationFrame(animationFrameId); 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 ( return (
<canvas <canvas
ref={canvasRef} ref={canvasRef}
style={{ style={{
position: 'absolute', position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: "100%",
height: '100%', height: "100%",
pointerEvents: 'none', pointerEvents: "none",
zIndex: 1 zIndex: 1,
}} }}
/> />
); );
+44 -28
View File
@@ -1,49 +1,65 @@
import React from 'react'; import React from "react";
import { Box, Typography, useTheme } from '@mui/material'; import { Box, Typography, useTheme } from "@mui/material";
interface RatingBadgeProps { interface RatingBadgeProps {
score: number; score: number;
size?: 'small' | 'medium' | 'large'; size?: "small" | "medium" | "large";
sx?: any; sx?: any;
} }
const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx }) => { const RatingBadge: React.FC<RatingBadgeProps> = ({
score,
size = "medium",
sx,
}) => {
const theme = useTheme(); const theme = useTheme();
const isLarge = size === 'large'; const isLarge = size === "large";
const isSmall = size === 'small'; const isSmall = size === "small";
return ( return (
<Box sx={{ <Box
display: 'inline-flex', sx={{
alignItems: 'center', display: "inline-flex",
justifyContent: 'center', alignItems: "center",
justifyContent: "center",
gap: 0.5, gap: 0.5,
bgcolor: 'primary.main', bgcolor: "primary.main",
color: 'primary.contrastText', color: "primary.contrastText",
px: isLarge ? 2 : (isSmall ? 1 : 1.5), px: isLarge ? 2 : isSmall ? 1 : 1.5,
py: isLarge ? 0.5 : (isSmall ? 0.2 : 0.4), py: isLarge ? 0.5 : isSmall ? 0.2 : 0.4,
borderRadius: 2, borderRadius: 2,
minWidth: 'fit-content', minWidth: "fit-content",
height: 'fit-content', height: "fit-content",
boxShadow: theme.palette.mode === 'dark' boxShadow:
? '0 4px 12px rgba(0,0,0,0.4)' theme.palette.mode === "dark"
: '0 2px 8px rgba(0,0,0,0.1)', ? "0 4px 12px rgba(0,0,0,0.4)"
...sx : "0 2px 8px rgba(0,0,0,0.1)",
}}> ...sx,
<Typography }}
variant={isLarge ? "h5" : (isSmall ? "caption" : "subtitle1")}
sx={{ fontWeight: '900', lineHeight: 1, display: 'flex', alignItems: 'center' }}
> >
{typeof score === 'number' ? (isLarge ? score.toFixed(1) : score.toFixed(0)) : score} <Typography
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>
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
fontSize: isLarge ? '0.75rem' : (isSmall ? '0.55rem' : '0.65rem'), fontSize: isLarge ? "0.75rem" : isSmall ? "0.55rem" : "0.65rem",
fontWeight: '900', fontWeight: "900",
opacity: 0.9, opacity: 0.9,
lineHeight: 1, lineHeight: 1,
mt: 0.2 mt: 0.2,
}} }}
> >
/ 10 / 10
+8 -8
View File
@@ -1,6 +1,6 @@
import React from 'react'; import React from "react";
import { Link } from 'react-router-dom'; import { Link } from "react-router-dom";
import { useTheme, alpha } from '@mui/material'; import { useTheme, alpha } from "@mui/material";
interface RichTextProps { interface RichTextProps {
text: string; text: string;
@@ -15,7 +15,7 @@ const RichText: React.FC<RichTextProps> = ({ text }) => {
return ( return (
<span> <span>
{parts.map((part, i) => { {parts.map((part, i) => {
if (part.startsWith('@')) { if (part.startsWith("@")) {
const username = part.substring(1); const username = part.substring(1);
return ( return (
<Link <Link
@@ -23,11 +23,11 @@ const RichText: React.FC<RichTextProps> = ({ text }) => {
to={`/profile/${username}`} to={`/profile/${username}`}
style={{ style={{
color: theme.palette.primary.main, color: theme.palette.primary.main,
textDecoration: 'none', textDecoration: "none",
fontWeight: 'bold', fontWeight: "bold",
backgroundColor: alpha(theme.palette.primary.main, 0.1), backgroundColor: alpha(theme.palette.primary.main, 0.1),
padding: '0 4px', padding: "0 4px",
borderRadius: '4px' borderRadius: "4px",
}} }}
> >
{part} {part}
+33 -24
View File
@@ -1,50 +1,59 @@
import React from 'react'; import React from "react";
import { Box, Typography, alpha, useTheme } from '@mui/material'; import { Box, Typography, alpha, useTheme } from "@mui/material";
import HistoryIcon from '@mui/icons-material/History'; import HistoryIcon from "@mui/icons-material/History";
import BlockIcon from '@mui/icons-material/Block'; import BlockIcon from "@mui/icons-material/Block";
interface StatusBadgeProps { interface StatusBadgeProps {
isLegacy?: boolean; isLegacy?: boolean;
isAvailable?: boolean; isAvailable?: boolean;
size?: 'small' | 'medium'; size?: "small" | "medium";
sx?: any; sx?: any;
} }
const StatusBadge: React.FC<StatusBadgeProps> = ({ isLegacy, isAvailable, size = 'medium', sx }) => { const StatusBadge: React.FC<StatusBadgeProps> = ({
isLegacy,
isAvailable,
size = "medium",
sx,
}) => {
const theme = useTheme(); const theme = useTheme();
const isSmall = size === 'small'; const isSmall = size === "small";
if (isAvailable && !isLegacy) return null; if (isAvailable && !isLegacy) return null;
const label = isLegacy ? 'Limited' : 'Out of Stock'; const label = isLegacy ? "Limited" : "Out of Stock";
const color = isLegacy ? theme.palette.warning.main : theme.palette.error.main; const color = isLegacy
? theme.palette.warning.main
: theme.palette.error.main;
const Icon = isLegacy ? HistoryIcon : BlockIcon; const Icon = isLegacy ? HistoryIcon : BlockIcon;
return ( return (
<Box sx={{ <Box
display: 'inline-flex', sx={{
alignItems: 'center', display: "inline-flex",
alignItems: "center",
gap: 0.5, gap: 0.5,
bgcolor: alpha(color, 0.9), bgcolor: alpha(color, 0.9),
color: '#fff', color: "#fff",
px: isSmall ? 0.8 : 1.2, px: isSmall ? 0.8 : 1.2,
py: isSmall ? 0.2 : 0.4, py: isSmall ? 0.2 : 0.4,
borderRadius: 1.5, borderRadius: 1.5,
boxShadow: '0 4px 12px rgba(0,0,0,0.2)', boxShadow: "0 4px 12px rgba(0,0,0,0.2)",
backdropFilter: 'blur(4px)', backdropFilter: "blur(4px)",
border: '1px solid', border: "1px solid",
borderColor: alpha('#fff', 0.2), borderColor: alpha("#fff", 0.2),
...sx ...sx,
}}> }}
<Icon sx={{ fontSize: isSmall ? '0.7rem' : '0.9rem' }} /> >
<Icon sx={{ fontSize: isSmall ? "0.7rem" : "0.9rem" }} />
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
fontWeight: '900', fontWeight: "900",
textTransform: 'uppercase', textTransform: "uppercase",
letterSpacing: 0.5, letterSpacing: 0.5,
fontSize: isSmall ? '0.55rem' : '0.65rem', fontSize: isSmall ? "0.55rem" : "0.65rem",
lineHeight: 1 lineHeight: 1,
}} }}
> >
{label} {label}
+1 -1
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect } from "react";
export const useTitle = (title: string) => { export const useTitle = (title: string) => {
useEffect(() => { useEffect(() => {
+520 -459
View File
File diff suppressed because it is too large. Load diff
+7 -7
View File
@@ -1,13 +1,13 @@
import { StrictMode } from 'react' import { StrictMode } from "react";
import { createRoot } from 'react-dom/client' import { createRoot } from "react-dom/client";
import { BrowserRouter as Router } from 'react-router-dom' import { BrowserRouter as Router } from "react-router-dom";
import './index.css' import "./index.css";
import App from './App.tsx' import App from "./App.tsx";
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<Router> <Router>
<App /> <App />
</Router> </Router>
</StrictMode>, </StrictMode>,
) );
+111 -42
View File
@@ -1,88 +1,157 @@
import React from 'react'; import React from "react";
import { Typography, Box, Container, Paper, Divider, Button, useMediaQuery } from '@mui/material'; import {
import { Link } from 'react-router-dom'; Typography,
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; Box,
import { useTranslation } from 'react-i18next'; Container,
import { useTitle } from '../hooks/useTitle'; Paper,
Divider,
Button,
useMediaQuery,
} from "@mui/material";
import { Link } from "react-router-dom";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { useTranslation } from "react-i18next";
import { useTitle } from "../hooks/useTitle";
const About: React.FC = () => { const About: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
useTitle(t('nav.about')); useTitle(t("nav.about"));
const isMobile = useMediaQuery('(max-width:600px)'); const isMobile = useMediaQuery("(max-width:600px)");
return ( return (
<Container maxWidth="md" sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}> <Container
maxWidth="md"
sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}
>
<Button <Button
variant="outlined" variant="outlined"
component={Link} component={Link}
to="/" to="/"
startIcon={<ArrowBackIcon />} startIcon={<ArrowBackIcon />}
sx={{ mb: 4, textTransform: 'none', borderRadius: 2 }} sx={{ mb: 4, textTransform: "none", borderRadius: 2 }}
> >
{t('common.backToArchive')} {t("common.backToArchive")}
</Button> </Button>
<Paper sx={{ p: { xs: 2.5, sm: 4, md: 6 }, borderRadius: 4, overflow: 'hidden' }}> <Paper
<Typography variant={isMobile ? "h4" : "h3"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.title')}</Typography> sx={{
<Typography variant="body1" paragraph sx={{ fontSize: { xs: '1rem', md: '1.1rem' }, lineHeight: 1.8 }}> p: { xs: 2.5, sm: 4, md: 6 },
{t('about.mission')} borderRadius: 4,
overflow: "hidden",
}}
>
<Typography
variant={isMobile ? "h4" : "h3"}
gutterBottom
sx={{ fontWeight: "bold" }}
>
{t("about.title")}
</Typography>
<Typography
variant="body1"
paragraph
sx={{ fontSize: { xs: "1rem", md: "1.1rem" }, lineHeight: 1.8 }}
>
{t("about.mission")}
</Typography> </Typography>
<Box sx={{ my: 4 }}> <Box sx={{ my: 4 }}>
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.canDoTitle')}</Typography> <Typography
variant={isMobile ? "h6" : "h5"}
gutterBottom
sx={{ fontWeight: "bold" }}
>
{t("about.canDoTitle")}
</Typography>
<Typography component="ul" sx={{ lineHeight: 2, pl: 2 }}> <Typography component="ul" sx={{ lineHeight: 2, pl: 2 }}>
<li>{t('about.featureExplore')}</li> <li>{t("about.featureExplore")}</li>
<li>{t('about.featureRate')}</li> <li>{t("about.featureRate")}</li>
<li>{t('about.featureCommunity')}</li> <li>{t("about.featureCommunity")}</li>
<li>{t('about.featureProfile')}</li> <li>{t("about.featureProfile")}</li>
<li>{t('about.featurePersonalize')}</li> <li>{t("about.featurePersonalize")}</li>
<li>{t('about.featureEngage')}</li> <li>{t("about.featureEngage")}</li>
<li>{t('about.featureSupport')}</li> <li>{t("about.featureSupport")}</li>
</Typography> </Typography>
</Box> </Box>
<Typography variant="body2" color="text.secondary" paragraph> <Typography variant="body2" color="text.secondary" paragraph>
{t('about.disclaimer')} {t("about.disclaimer")}
</Typography> </Typography>
<Divider sx={{ my: { xs: 4, md: 6 } }} /> <Divider sx={{ my: { xs: 4, md: 6 } }} />
<Typography variant={isMobile ? "h5" : "h4"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.impressumTitle')}</Typography> <Typography
variant={isMobile ? "h5" : "h4"}
gutterBottom
sx={{ fontWeight: "bold" }}
>
{t("about.impressumTitle")}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 2 }}> <Typography variant="body2" sx={{ lineHeight: 2 }}>
<strong>{t('about.legalInfo')}</strong><br /> <strong>{t("about.legalInfo")}</strong>
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name or Project Name]'}<br /> <br />
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}<br /> {import.meta.env.VITE_IMPRESSUM_NAME || "[Your Name or Project Name]"}
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}<br /> <br />
{t('about.region')} {import.meta.env.VITE_IMPRESSUM_STREET || "[Your Street Address]"}
<br />
{import.meta.env.VITE_IMPRESSUM_CITY || "[Postal Code and City]"}
<br />
{t("about.region")}
</Typography> </Typography>
<Box sx={{ mt: 3 }}> <Box sx={{ mt: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('about.contact')}:</Typography> <Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
{t("about.contact")}:
</Typography>
<Typography variant="body2"> <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 && ( {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 Internet: holy.narl.io
</Typography> </Typography>
</Box> </Box>
<Box sx={{ mt: 3 }}> <Box sx={{ mt: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('about.responsible')}</Typography> <Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
{t("about.responsible")}
</Typography>
<Typography variant="body2"> <Typography variant="body2">
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}<br /> {import.meta.env.VITE_IMPRESSUM_NAME || "[Your Name]"}
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}<br /> <br />
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'} {import.meta.env.VITE_IMPRESSUM_STREET || "[Your Street Address]"}
<br />
{import.meta.env.VITE_IMPRESSUM_CITY || "[Postal Code and City]"}
</Typography> </Typography>
</Box> </Box>
<Box sx={{ mt: 4 }}> <Box sx={{ mt: 4 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.disclaimerTitle')}</Typography> <Typography variant="h6" gutterBottom sx={{ fontWeight: "bold" }}>
<Typography variant="caption" color="text.secondary" component="p" sx={{ mb: 2, lineHeight: 1.6 }}> {t("about.disclaimerTitle")}
<strong>{t('about.liabilityContentTitle')}</strong> {t('about.liabilityContentDesc')}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary" component="p" sx={{ lineHeight: 1.6 }}> <Typography
<strong>{t('about.copyrightTitle')}</strong> {t('about.copyrightDesc')} variant="caption"
color="text.secondary"
component="p"
sx={{ mb: 2, lineHeight: 1.6 }}
>
<strong>{t("about.liabilityContentTitle")}</strong>{" "}
{t("about.liabilityContentDesc")}
</Typography>
<Typography
variant="caption"
color="text.secondary"
component="p"
sx={{ lineHeight: 1.6 }}
>
<strong>{t("about.copyrightTitle")}</strong>{" "}
{t("about.copyrightDesc")}
</Typography> </Typography>
</Box> </Box>
</Paper> </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 { import {
Container, Typography, Box, Button, Container,
Stack, CircularProgress, TextField, Paper Typography,
} from '@mui/material'; Box,
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; Button,
import DeleteIcon from '@mui/icons-material/Delete'; Stack,
import { useTranslation } from 'react-i18next'; CircularProgress,
import { useNavigate, useParams } from 'react-router-dom'; TextField,
import api from '../api'; Paper,
import { useTitle } from '../hooks/useTitle'; } from "@mui/material";
import MentionTextField from '../components/MentionTextField'; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import DeleteIcon from "@mui/icons-material/Delete";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import api from "../api";
import { useTitle } from "../hooks/useTitle";
import MentionTextField from "../components/MentionTextField";
const AdminRatingDetail: React.FC = () => { const AdminRatingDetail: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [score, setScore] = useState(0); const [score, setScore] = useState(0);
const [comment, setComment] = useState(''); const [comment, setComment] = useState("");
const [rating, setRating] = useState<any>(null); const [rating, setRating] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -25,10 +31,10 @@ const AdminRatingDetail: React.FC = () => {
const res = await api.get(`ratings/${id}/`); const res = await api.get(`ratings/${id}/`);
setRating(res.data); setRating(res.data);
setScore(res.data.score); setScore(res.data.score);
setComment(res.data.comment || ''); setComment(res.data.comment || "");
} catch (err) { } catch (err) {
console.error(err); console.error(err);
navigate('/admin-panel'); navigate("/admin-panel");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -38,25 +44,38 @@ const AdminRatingDetail: React.FC = () => {
fetchRating(); fetchRating();
}, [id]); }, [id]);
useTitle(rating ? `${t('admin.manageRating')}: ${rating.flavor_name}` : 'Rating Management'); useTitle(
rating
? `${t("admin.manageRating")}: ${rating.flavor_name}`
: "Rating Management",
);
const handleUpdate = async () => { const handleUpdate = async () => {
try { try {
await api.patch(`ratings/${id}/`, { score, comment }); await api.patch(`ratings/${id}/`, { score, comment });
fetchRating(); fetchRating();
alert('Rating updated!'); alert("Rating updated!");
} catch (err) { alert('Update failed'); } } catch (err) {
alert("Update failed");
}
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!window.confirm('Delete this rating and all replies?')) return; if (!window.confirm("Delete this rating and all replies?")) return;
try { try {
await api.delete(`ratings/${id}/`); await api.delete(`ratings/${id}/`);
navigate('/admin-panel'); navigate("/admin-panel");
} catch (err) { alert('Delete failed'); } } catch (err) {
alert("Delete failed");
}
}; };
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>; if (loading)
return (
<Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
<CircularProgress />
</Box>
);
return ( return (
<Container maxWidth="sm" sx={{ py: 4 }}> <Container maxWidth="sm" sx={{ py: 4 }}>
@@ -66,28 +85,48 @@ const AdminRatingDetail: React.FC = () => {
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }} sx={{ mb: 4, borderRadius: 2 }}
> >
{t('common.back')} {t("common.back")}
</Button> </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 }}> <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}) {rating.flavor_name} (User: {rating.user})
</Typography> </Typography>
<Stack spacing={3} sx={{ mt: 3 }}> <Stack spacing={3} sx={{ mt: 3 }}>
<TextField <TextField
label="Score" type="number" label="Score"
type="number"
slotProps={{ input: { inputProps: { min: 1, max: 10 } } }} 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 <MentionTextField
multiline rows={4} multiline
value={comment} onChange={setComment} rows={4}
value={comment}
onChange={setComment}
/> />
<Stack direction="row" spacing={2}> <Stack direction="row" spacing={2}>
<Button variant="contained" fullWidth onClick={handleUpdate}>{t('common.save')}</Button> <Button variant="contained" fullWidth onClick={handleUpdate}>
<Button variant="outlined" color="error" fullWidth startIcon={<DeleteIcon />} onClick={handleDelete}>{t('common.delete')}</Button> {t("common.save")}
</Button>
<Button
variant="outlined"
color="error"
fullWidth
startIcon={<DeleteIcon />}
onClick={handleDelete}
>
{t("common.delete")}
</Button>
</Stack> </Stack>
</Stack> </Stack>
</Paper> </Paper>
+59 -27
View File
@@ -1,21 +1,26 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { import {
Container, Typography, Box, Button, Container,
Stack, CircularProgress, Paper Typography,
} from '@mui/material'; Box,
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; Button,
import DeleteIcon from '@mui/icons-material/Delete'; Stack,
import { useTranslation } from 'react-i18next'; CircularProgress,
import { useNavigate, useParams } from 'react-router-dom'; Paper,
import api from '../api'; } from "@mui/material";
import { useTitle } from '../hooks/useTitle'; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import MentionTextField from '../components/MentionTextField'; import DeleteIcon from "@mui/icons-material/Delete";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import api from "../api";
import { useTitle } from "../hooks/useTitle";
import MentionTextField from "../components/MentionTextField";
const AdminReplyDetail: React.FC = () => { const AdminReplyDetail: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [text, setText] = useState(''); const [text, setText] = useState("");
const [reply, setReply] = useState<any>(null); const [reply, setReply] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -27,7 +32,7 @@ const AdminReplyDetail: React.FC = () => {
setText(res.data.text); setText(res.data.text);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
navigate('/admin-panel'); navigate("/admin-panel");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -37,25 +42,34 @@ const AdminReplyDetail: React.FC = () => {
fetchReply(); fetchReply();
}, [id]); }, [id]);
useTitle(reply ? `${t('admin.manageReply')}` : 'Reply Management'); useTitle(reply ? `${t("admin.manageReply")}` : "Reply Management");
const handleUpdate = async () => { const handleUpdate = async () => {
try { try {
await api.patch(`replies/${id}/`, { text }); await api.patch(`replies/${id}/`, { text });
fetchReply(); fetchReply();
alert('Reply updated!'); alert("Reply updated!");
} catch (err) { alert('Update failed'); } } catch (err) {
alert("Update failed");
}
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!window.confirm('Delete this reply?')) return; if (!window.confirm("Delete this reply?")) return;
try { try {
await api.delete(`replies/${id}/`); await api.delete(`replies/${id}/`);
navigate('/admin-panel'); navigate("/admin-panel");
} catch (err) { alert('Delete failed'); } } catch (err) {
alert("Delete failed");
}
}; };
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>; if (loading)
return (
<Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
<CircularProgress />
</Box>
);
return ( return (
<Container maxWidth="sm" sx={{ py: 4 }}> <Container maxWidth="sm" sx={{ py: 4 }}>
@@ -65,23 +79,41 @@ const AdminReplyDetail: React.FC = () => {
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }} sx={{ mb: 4, borderRadius: 2 }}
> >
{t('common.back')} {t("common.back")}
</Button> </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 }}> <Paper sx={{ p: 3, borderRadius: 4 }}>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}> <Typography
variant="subtitle1"
gutterBottom
sx={{ fontWeight: "bold" }}
>
Reply by {reply.user} Reply by {reply.user}
</Typography> </Typography>
<Stack spacing={3} sx={{ mt: 3 }}> <Stack spacing={3} sx={{ mt: 3 }}>
<MentionTextField <MentionTextField
multiline rows={4} multiline
value={text} onChange={setText} rows={4}
value={text}
onChange={setText}
/> />
<Stack direction="row" spacing={2}> <Stack direction="row" spacing={2}>
<Button variant="contained" fullWidth onClick={handleUpdate}>{t('common.save')}</Button> <Button variant="contained" fullWidth onClick={handleUpdate}>
<Button variant="outlined" color="error" fullWidth startIcon={<DeleteIcon />} onClick={handleDelete}>{t('common.delete')}</Button> {t("common.save")}
</Button>
<Button
variant="outlined"
color="error"
fullWidth
startIcon={<DeleteIcon />}
onClick={handleDelete}
>
{t("common.delete")}
</Button>
</Stack> </Stack>
</Stack> </Stack>
</Paper> </Paper>
+393 -93
View File
@@ -1,21 +1,39 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { import {
Container, Typography, Box, Card, CardContent, Button, Container,
Stack, alpha, useTheme, CircularProgress, Chip, Paper, List, ListItem, ListItemText, Typography,
Avatar, Grid, TextField, Tabs, Tab, Divider Box,
} from '@mui/material'; Card,
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; CardContent,
import DeleteIcon from '@mui/icons-material/Delete'; Button,
import SecurityIcon from '@mui/icons-material/Security'; Stack,
import HistoryIcon from '@mui/icons-material/History'; alpha,
import RateReviewIcon from '@mui/icons-material/RateReview'; useTheme,
import { useTranslation } from 'react-i18next'; CircularProgress,
import { useNavigate, useParams } from 'react-router-dom'; Chip,
import api from '../api'; Paper,
import { useTitle } from '../hooks/useTitle'; List,
import { formatDate } from '../utils/date'; ListItem,
import MentionTextField from '../components/MentionTextField'; ListItemText,
import RichText from '../components/RichText'; Avatar,
Grid,
TextField,
Tabs,
Tab,
Divider,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import DeleteIcon from "@mui/icons-material/Delete";
import SecurityIcon from "@mui/icons-material/Security";
import HistoryIcon from "@mui/icons-material/History";
import RateReviewIcon from "@mui/icons-material/RateReview";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import api from "../api";
import { useTitle } from "../hooks/useTitle";
import { formatDate } from "../utils/date";
import MentionTextField from "../components/MentionTextField";
import RichText from "../components/RichText";
interface AdminUser { interface AdminUser {
id: number; id: number;
@@ -41,11 +59,11 @@ const AdminUserDetail: React.FC = () => {
// Edit state for Ratings // Edit state for Ratings
const [editingRatingId, setEditingRatingId] = useState<number | null>(null); const [editingRatingId, setEditingRatingId] = useState<number | null>(null);
const [editScore, setEditScore] = useState(0); const [editScore, setEditScore] = useState(0);
const [editComment, setEditComment] = useState(''); const [editComment, setEditComment] = useState("");
// Edit state for Replies // Edit state for Replies
const [editingReplyId, setEditingReplyId] = useState<number | null>(null); const [editingReplyId, setEditingReplyId] = useState<number | null>(null);
const [editReplyText, setEditReplyText] = useState(''); const [editReplyText, setEditReplyText] = useState("");
const fetchUser = async () => { const fetchUser = async () => {
try { try {
@@ -53,7 +71,7 @@ const AdminUserDetail: React.FC = () => {
setUser(res.data); setUser(res.data);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
navigate('/admin-panel'); navigate("/admin-panel");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -63,30 +81,43 @@ const AdminUserDetail: React.FC = () => {
fetchUser(); fetchUser();
}, [id]); }, [id]);
useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : 'User Management'); useTitle(
user ? `${t("admin.manageUser")}: ${user.username}` : "User Management",
);
const handleToggleActive = async () => { const handleToggleActive = async () => {
if (!user) return; if (!user) return;
try { try {
await api.patch(`admin-custom/${id}/user_detail/`, { is_active: !user.is_active }); await api.patch(`admin-custom/${id}/user_detail/`, {
is_active: !user.is_active,
});
fetchUser(); fetchUser();
} catch (err) { alert('Action failed'); } } catch (err) {
alert("Action failed");
}
}; };
const handleUpdateRating = async (ratingId: number) => { const handleUpdateRating = async (ratingId: number) => {
try { try {
await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment }); await api.patch(`ratings/${ratingId}/`, {
score: editScore,
comment: editComment,
});
setEditingRatingId(null); setEditingRatingId(null);
fetchUser(); fetchUser();
} catch (err) { alert('Update failed'); } } catch (err) {
alert("Update failed");
}
}; };
const handleDeleteRating = async (ratingId: number) => { const handleDeleteRating = async (ratingId: number) => {
if (!confirm('Delete this rating?')) return; if (!confirm("Delete this rating?")) return;
try { try {
await api.delete(`ratings/${ratingId}/`); await api.delete(`ratings/${ratingId}/`);
fetchUser(); fetchUser();
} catch (err) { alert('Delete failed'); } } catch (err) {
alert("Delete failed");
}
}; };
const handleUpdateReply = async (replyId: number) => { const handleUpdateReply = async (replyId: number) => {
@@ -94,26 +125,42 @@ const AdminUserDetail: React.FC = () => {
await api.patch(`replies/${replyId}/`, { text: editReplyText }); await api.patch(`replies/${replyId}/`, { text: editReplyText });
setEditingReplyId(null); setEditingReplyId(null);
fetchUser(); fetchUser();
} catch (err) { alert('Update failed'); } } catch (err) {
alert("Update failed");
}
}; };
const handleDeleteReply = async (replyId: number) => { const handleDeleteReply = async (replyId: number) => {
if (!confirm('Delete this reply?')) return; if (!confirm("Delete this reply?")) return;
try { try {
await api.delete(`replies/${replyId}/`); await api.delete(`replies/${replyId}/`);
fetchUser(); fetchUser();
} catch (err) { alert('Delete failed'); } } catch (err) {
alert("Delete failed");
}
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!window.confirm('PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.')) return; if (
!window.confirm(
"PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.",
)
)
return;
try { try {
await api.delete(`admin-custom/${id}/user_detail/`); await api.delete(`admin-custom/${id}/user_detail/`);
navigate('/admin-panel'); navigate("/admin-panel");
} catch (err) { alert('Delete failed'); } } catch (err) {
alert("Delete failed");
}
}; };
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>; if (loading)
return (
<Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
<CircularProgress />
</Box>
);
if (!user) return <Typography>User not found</Typography>; if (!user) return <Typography>User not found</Typography>;
return ( return (
@@ -124,40 +171,83 @@ const AdminUserDetail: React.FC = () => {
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }} sx={{ mb: 4, borderRadius: 2 }}
> >
{t('common.back')} {t("common.back")}
</Button> </Button>
{/* Profile Header */} {/* 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 } }}> <CardContent sx={{ p: { xs: 2, md: 4 } }}>
<Grid container spacing={3} alignItems="center"> <Grid container spacing={3} alignItems="center">
<Grid size={{ xs: 12, sm: 'auto' }}> <Grid size={{ xs: 12, sm: "auto" }}>
<Avatar sx={{ width: 80, height: 80, fontSize: '2rem', bgcolor: 'primary.main', mx: 'auto' }}> <Avatar
sx={{
width: 80,
height: 80,
fontSize: "2rem",
bgcolor: "primary.main",
mx: "auto",
}}
>
{user.username.charAt(0).toUpperCase()} {user.username.charAt(0).toUpperCase()}
</Avatar> </Avatar>
</Grid> </Grid>
<Grid size={{ xs: 12, sm: 8, md: 9 }}> <Grid size={{ xs: 12, sm: 8, md: 9 }}>
<Box sx={{ textAlign: { xs: 'center', sm: 'left' } }}> <Box sx={{ textAlign: { xs: "center", sm: "left" } }}>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>{user.username}</Typography> <Typography variant="h4" sx={{ fontWeight: "bold" }}>
<Typography variant="body1" color="text.secondary">{user.email}</Typography> {user.username}
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.5 }}>
Joined: {formatDate(user.date_joined)} • Last Login: {formatDate(user.last_login)}
</Typography> </Typography>
<Stack direction="row" spacing={1} sx={{ mt: 1.5, justifyContent: { xs: 'center', sm: 'flex-start' } }}> <Typography variant="body1" color="text.secondary">
<Chip label={user.is_active ? 'Active' : 'Inactive'} color={user.is_active ? 'success' : 'default'} size="small" /> {user.email}
{user.is_superuser && <Chip label="Superuser" color="secondary" size="small" />} </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> </Stack>
</Box> </Box>
</Grid> </Grid>
<Grid size={{ xs: 12, sm: 'auto' }}> <Grid size={{ xs: 12, sm: "auto" }}>
<Button <Button
fullWidth fullWidth
variant="contained" variant="contained"
color={user.is_active ? "warning" : "success"} color={user.is_active ? "warning" : "success"}
onClick={handleToggleActive} 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> </Button>
</Grid> </Grid>
</Grid> </Grid>
@@ -166,10 +256,26 @@ const AdminUserDetail: React.FC = () => {
{/* Content Tabs */} {/* Content Tabs */}
<Box sx={{ mb: 4 }}> <Box sx={{ mb: 4 }}>
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} variant="fullWidth"> <Tabs
<Tab icon={<RateReviewIcon />} label={`Content (${user.ratings.length})`} sx={{ fontWeight: 'bold' }} /> value={activeTab}
<Tab icon={<HistoryIcon />} label="Security & IP" sx={{ fontWeight: 'bold' }} /> onChange={(_, v) => setActiveTab(v)}
<Tab icon={<SecurityIcon />} label="Danger Zone" sx={{ fontWeight: 'bold', color: 'error.main' }} /> 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> </Tabs>
<Divider /> <Divider />
</Box> </Box>
@@ -177,76 +283,223 @@ const AdminUserDetail: React.FC = () => {
{/* TAB 0: USER CONTENT (Ratings & Replies) */} {/* TAB 0: USER CONTENT (Ratings & Replies) */}
{activeTab === 0 && ( {activeTab === 0 && (
<Box> <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}> <Stack spacing={2}>
{user.ratings.map(rating => ( {user.ratings.map((rating) => (
<Card key={rating.id} variant="outlined" sx={{ borderRadius: 3, bgcolor: alpha(theme.palette.background.paper, 0.4) }}> <Card
key={rating.id}
variant="outlined"
sx={{
borderRadius: 3,
bgcolor: alpha(theme.palette.background.paper, 0.4),
}}
>
<CardContent sx={{ p: { xs: 2, md: 3 } }}> <CardContent sx={{ p: { xs: 2, md: 3 } }}>
{editingRatingId === rating.id ? ( {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"> <Stack direction="row" spacing={2} alignItems="center">
<Typography variant="body2">Score:</Typography> <Typography variant="body2">Score:</Typography>
<TextField <TextField
type="number" size="small" type="number"
slotProps={{ input: { inputProps: { min: 1, max: 10 } } }} size="small"
value={editScore} onChange={(e) => setEditScore(Number(e.target.value))} slotProps={{
input: { inputProps: { min: 1, max: 10 } },
}}
value={editScore}
onChange={(e) => setEditScore(Number(e.target.value))}
sx={{ width: 80 }} sx={{ width: 80 }}
/> />
</Stack> </Stack>
<MentionTextField <MentionTextField
multiline rows={3} multiline
rows={3}
value={editComment} value={editComment}
onChange={setEditComment} onChange={setEditComment}
/> />
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
<Button variant="contained" size="small" onClick={() => handleUpdateRating(rating.id)}>{t('common.save')}</Button> <Button
<Button variant="outlined" size="small" onClick={() => setEditingRatingId(null)}>{t('common.cancel')}</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> </Stack>
</Box> </Box>
) : ( ) : (
<> <>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1 }}> <Stack
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{rating.flavor_name}</Typography> direction="row"
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}> justifyContent="space-between"
<Chip label={`${rating.score}/10`} size="small" color="primary" sx={{ fontWeight: '900' }} /> alignItems="flex-start"
<Button size="small" onClick={() => { setEditingRatingId(rating.id); setEditScore(rating.score); setEditComment(rating.comment || ''); }}>Edit</Button> sx={{ mb: 1 }}
<Button size="small" color="error" onClick={() => handleDeleteRating(rating.id)}>Delete</Button> >
<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> </Box>
</Stack> </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 ? ( {rating.comment ? (
<Typography variant="body2" color="text.primary"> <Typography variant="body2" color="text.primary">
<RichText text={rating.comment} /> <RichText text={rating.comment} />
</Typography> </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> </Box>
{rating.replies.length > 0 && ( {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) => ( {rating.replies.map((reply: any) => (
<Box key={reply.id} sx={{ mb: 1.5 }}> <Box key={reply.id} sx={{ mb: 1.5 }}>
{editingReplyId === reply.id ? ( {editingReplyId === reply.id ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Box
<MentionTextField value={editReplyText} onChange={setEditReplyText} /> sx={{
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
<MentionTextField
value={editReplyText}
onChange={setEditReplyText}
/>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
<Button variant="contained" size="small" onClick={() => handleUpdateReply(reply.id)}>{t('common.save')}</Button> <Button
<Button variant="outlined" size="small" onClick={() => setEditingReplyId(null)}>{t('common.cancel')}</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> </Stack>
</Box> </Box>
) : ( ) : (
<> <>
<Stack direction="row" justifyContent="space-between" alignItems="center"> <Stack
<Typography variant="caption" sx={{ fontWeight: 'bold', color: reply.user === user.username ? 'primary.main' : 'text.secondary' }}> direction="row"
justifyContent="space-between"
alignItems="center"
>
<Typography
variant="caption"
sx={{
fontWeight: "bold",
color:
reply.user === user.username
? "primary.main"
: "text.secondary",
}}
>
{reply.user} {reply.user}
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 0.5 }}> <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
<Button size="small" color="error" sx={{ minWidth: 0, p: 0, fontSize: '0.7rem' }} onClick={() => handleDeleteReply(reply.id)}>Delete</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> </Box>
</Stack> </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> </Box>
@@ -259,7 +512,16 @@ const AdminUserDetail: React.FC = () => {
</Card> </Card>
))} ))}
{user.ratings.length === 0 && ( {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> <Typography color="text.secondary">No ratings yet.</Typography>
</Box> </Box>
)} )}
@@ -269,30 +531,68 @@ const AdminUserDetail: React.FC = () => {
{/* TAB 1: SECURITY & IP */} {/* TAB 1: SECURITY & IP */}
{activeTab === 1 && ( {activeTab === 1 && (
<Paper sx={{ p: 3, borderRadius: 3, border: '1px solid', borderColor: 'divider' }}> <Paper
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}> sx={{
<SecurityIcon /> {t('admin.ipHistory')} 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> </Typography>
<List dense> <List dense>
{user.ips.map((ip, idx) => ( {user.ips.map((ip, idx) => (
<ListItem key={idx} divider={idx < user.ips.length - 1}> <ListItem key={idx} divider={idx < user.ips.length - 1}>
<ListItemText <ListItemText
primary={ip} primary={ip}
primaryTypographyProps={{ sx: { fontWeight: 'bold', fontFamily: 'monospace' } }} primaryTypographyProps={{
sx: { fontWeight: "bold", fontFamily: "monospace" },
}}
/> />
</ListItem> </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> </List>
</Paper> </Paper>
)} )}
{/* TAB 2: DANGER ZONE */} {/* TAB 2: DANGER ZONE */}
{activeTab === 2 && ( {activeTab === 2 && (
<Paper sx={{ p: 4, borderRadius: 3, border: '1px solid', borderColor: 'error.main', bgcolor: (theme) => alpha(theme.palette.error.main, 0.05) }}> <Paper
<Typography variant="h5" color="error" gutterBottom sx={{ fontWeight: 'bold' }}>{t('admin.dangerZone')}</Typography> 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 }}> <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> <strong> This action is irreversible.</strong>
</Typography> </Typography>
<Button <Button
@@ -301,9 +601,9 @@ const AdminUserDetail: React.FC = () => {
size="large" size="large"
startIcon={<DeleteIcon />} startIcon={<DeleteIcon />}
onClick={handleDelete} onClick={handleDelete}
sx={{ borderRadius: 2, fontWeight: 'bold', px: 4 }} sx={{ borderRadius: 2, fontWeight: "bold", px: 4 }}
> >
{t('admin.deleteUser')} {t("admin.deleteUser")}
</Button> </Button>
</Paper> </Paper>
)} )}
+481 -157
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from "react";
import { import {
Typography, Typography,
Box, Box,
@@ -19,23 +19,23 @@ import {
alpha, alpha,
IconButton, IconButton,
Collapse, Collapse,
Link as MuiLink Link as MuiLink,
} from '@mui/material'; } from "@mui/material";
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from "@mui/icons-material/Search";
import CommentIcon from '@mui/icons-material/Comment'; import CommentIcon from "@mui/icons-material/Comment";
import SendIcon from '@mui/icons-material/Send'; import SendIcon from "@mui/icons-material/Send";
import WhatshotIcon from '@mui/icons-material/Whatshot'; import WhatshotIcon from "@mui/icons-material/Whatshot";
import NotificationsIcon from '@mui/icons-material/Notifications'; import NotificationsIcon from "@mui/icons-material/Notifications";
import PeopleIcon from '@mui/icons-material/People'; import PeopleIcon from "@mui/icons-material/People";
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from "react-router-dom";
import api from '../api'; import api from "../api";
import { useTitle } from '../hooks/useTitle'; import { useTitle } from "../hooks/useTitle";
import RichText from '../components/RichText'; import RichText from "../components/RichText";
import { formatDate } from '../utils/date'; import { formatDate } from "../utils/date";
import { useTranslation } from 'react-i18next'; import { useTranslation } from "react-i18next";
import MentionTextField from '../components/MentionTextField'; import MentionTextField from "../components/MentionTextField";
import RatingBadge from '../components/RatingBadge'; import RatingBadge from "../components/RatingBadge";
interface Reply { interface Reply {
id: number; id: number;
@@ -70,7 +70,13 @@ interface Notification {
id: number; id: number;
actor_username: string; actor_username: string;
actor_avatar: string | null; 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; rating: number | null;
reply: number | null; reply: number | null;
is_read: boolean; is_read: boolean;
@@ -85,7 +91,7 @@ interface CommunityFeedProps {
const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => { const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
const { t } = useTranslation(); const { t } = useTranslation();
useTitle(t('community.title')); useTitle(t("community.title"));
const navigate = useNavigate(); const navigate = useNavigate();
const [ratings, setRatings] = useState<FeedRating[]>([]); const [ratings, setRatings] = useState<FeedRating[]>([]);
const [following, setFollowing] = useState<FollowedUser[]>([]); const [following, setFollowing] = useState<FollowedUser[]>([]);
@@ -93,65 +99,77 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [followingSearch, setFollowingSearch] = useState(''); const [followingSearch, setFollowingSearch] = useState("");
// Top Rated Flavors by followed users // Top Rated Flavors by followed users
const [topFollowed, setTopFollowed] = useState<any[]>([]); const [topFollowed, setTopFollowed] = useState<any[]>([]);
// UI State // UI State
const [replyInputs, setReplyInputs] = useState<{ [key: number]: string }>({}); 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 => const filteredFollowing = following.filter((user) =>
user.username.toLowerCase().includes(followingSearch.toLowerCase()) user.username.toLowerCase().includes(followingSearch.toLowerCase()),
); );
const handleGoBack = () => { const handleGoBack = () => {
if (window.history.length > 1) { if (window.history.length > 1) {
navigate(-1); navigate(-1);
} else { } else {
navigate('/'); navigate("/");
} }
}; };
const fetchFeedData = async (pageNum: number) => { const fetchFeedData = async (pageNum: number) => {
setLoading(true); setLoading(true);
try { 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(`ratings/feed/?page=${pageNum}`),
api.get('users/following_list/'), api.get("users/following_list/"),
api.get('flavors/followed_top/'), api.get("flavors/followed_top/"),
api.get('notifications/') 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); setRatings(feedData);
const count = feedRes.data.count || 0; const count = feedRes.data.count || 0;
setTotalPages(Math.ceil(count / 10)); 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); 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 setNotifications(notifData.slice(0, 5)); // Just the latest 5 for sidebar
} catch (err: any) { } catch (err: any) {
if (err.response?.status === 401) { 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 { } finally {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (!token) { if (!token) {
navigate('/login'); navigate("/login");
return; return;
} }
fetchFeedData(page); fetchFeedData(page);
@@ -159,11 +177,11 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => { const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => {
setPage(value); setPage(value);
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: "smooth" });
}; };
const handleReplyToggle = (ratingId: number) => { const handleReplyToggle = (ratingId: number) => {
setExpandedReplies(prev => ({ ...prev, [ratingId]: !prev[ratingId] })); setExpandedReplies((prev) => ({ ...prev, [ratingId]: !prev[ratingId] }));
}; };
const handleReplySubmit = async (ratingId: number) => { const handleReplySubmit = async (ratingId: number) => {
@@ -171,15 +189,17 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
if (!text) return; if (!text) return;
try { try {
const res = await api.post(`ratings/${ratingId}/reply/`, { text }); const res = await api.post(`ratings/${ratingId}/reply/`, { text });
setRatings(prevRatings => prevRatings.map(r => { setRatings((prevRatings) =>
prevRatings.map((r) => {
if (r.id === ratingId) { if (r.id === ratingId) {
return { ...r, replies: [...r.replies, res.data] }; return { ...r, replies: [...r.replies, res.data] };
} }
return r; return r;
})); }),
setReplyInputs(prev => ({ ...prev, [ratingId]: '' })); );
setReplyInputs((prev) => ({ ...prev, [ratingId]: "" }));
} catch (err) { } 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); 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 ( return (
<Container maxWidth="lg" sx={{ py: 4 }}> <Container maxWidth="lg" sx={{ py: 4 }}>
@@ -198,25 +223,29 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
startIcon={<ArrowBackIcon />} startIcon={<ArrowBackIcon />}
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
textTransform: 'none', textTransform: "none",
fontWeight: 'bold', fontWeight: "bold",
color: 'text.secondary', color: "text.secondary",
borderColor: 'divider', borderColor: "divider",
'&:hover': { "&:hover": {
borderColor: 'primary.main', borderColor: "primary.main",
color: 'primary.main', color: "primary.main",
bgcolor: 'transparent' bgcolor: "transparent",
} },
}} }}
> >
{window.history.length > 1 ? t('common.back') : t('common.backToHome')} {window.history.length > 1
? t("common.back")
: t("common.backToHome")}
</Button> </Button>
</Box> </Box>
<Box sx={{ mb: 6 }}> <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"> <Typography variant="h6" color="text.secondary">
{t('community.subtitle')} {t("community.subtitle")}
</Typography> </Typography>
</Box> </Box>
@@ -224,53 +253,96 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
{/* Main Feed Column */} {/* Main Feed Column */}
<Grid size={{ xs: 12, lg: 8 }}> <Grid size={{ xs: 12, lg: 8 }}>
{ratings.length === 0 ? ( {ratings.length === 0 ? (
<Box sx={{ <Box
sx={{
p: 6, p: 6,
textAlign: 'center', textAlign: "center",
borderRadius: 4, borderRadius: 4,
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.6), bgcolor: (theme) => alpha(theme.palette.background.paper, 0.6),
backdropFilter: 'blur(10px)', backdropFilter: "blur(10px)",
border: '1px dashed', border: "1px dashed",
borderColor: 'divider' borderColor: "divider",
}}> }}
<Typography variant="h6" gutterBottom color="text.secondary">{t('community.quietFeed')}</Typography> >
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}> <Typography variant="h6" gutterBottom color="text.secondary">
{t('community.followMore')} {t("community.quietFeed")}
</Typography> </Typography>
<Button variant="contained" component={Link} to="/" sx={{ borderRadius: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{t('home.exploreFlavors')} {t("community.followMore")}
</Typography>
<Button
variant="contained"
component={Link}
to="/"
sx={{ borderRadius: 2 }}
>
{t("home.exploreFlavors")}
</Button> </Button>
</Box> </Box>
) : ( ) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
{ratings.map(rating => ( {ratings.map((rating) => (
<Card key={rating.id} elevation={0} sx={{ <Card
key={rating.id}
elevation={0}
sx={{
borderRadius: 4, borderRadius: 4,
border: '1px solid', border: "1px solid",
borderColor: 'divider', borderColor: "divider",
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.8), bgcolor: (theme) =>
transition: 'transform 0.2s', alpha(theme.palette.background.paper, 0.8),
'&:hover': { transition: "transform 0.2s",
transform: 'translateY(-2px)', "&:hover": {
borderColor: (theme) => alpha(theme.palette.primary.main, 0.2) transform: "translateY(-2px)",
} borderColor: (theme) =>
}}> alpha(theme.palette.primary.main, 0.2),
},
}}
>
<CardContent sx={{ p: { xs: 2, sm: 3 } }}> <CardContent sx={{ p: { xs: 2, sm: 3 } }}>
{/* User Info */} {/* User Info */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}> <Box
<Box sx={{ display: 'flex', gap: 2 }}> sx={{
<Link to={`/profile/${rating.user}`} style={{ textDecoration: 'none' }}> display: "flex",
<Avatar src={rating.user_avatar || undefined} sx={{ width: 48, height: 48, border: '2px solid', borderColor: 'divider' }}> justifyContent: "space-between",
{!rating.user_avatar && rating.user.charAt(0).toUpperCase()} 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> </Avatar>
</Link> </Link>
<Box> <Box>
<Link to={`/profile/${rating.user}`} style={{ textDecoration: 'none', color: 'inherit' }}> <Link
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', lineHeight: 1.2 }}> to={`/profile/${rating.user}`}
style={{ textDecoration: "none", color: "inherit" }}
>
<Typography
variant="subtitle1"
sx={{ fontWeight: "bold", lineHeight: 1.2 }}
>
{rating.user} {rating.user}
</Typography> </Typography>
</Link> </Link>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}> <Typography
variant="caption"
color="text.secondary"
sx={{ display: "block" }}
>
{formatTimestamp(rating.created_at)} {formatTimestamp(rating.created_at)}
</Typography> </Typography>
</Box> </Box>
@@ -280,31 +352,89 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
{adminMode && ( {adminMode && (
<Button <Button
size="small" variant="outlined" color="secondary" size="small"
onClick={() => navigate(`/admin-panel/rating/${rating.id}`)} variant="outlined"
sx={{ mb: 2, borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }} 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> </Button>
)} )}
{/* Flavor and Comment */} {/* Flavor and Comment */}
<Box sx={{ display: 'flex', gap: 3 }}> <Box sx={{ display: "flex", gap: 3 }}>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> <Typography
{t('community.rated')} <MuiLink component={Link} to={`/flavor/${rating.flavor}`} sx={{ color: 'primary.main', fontWeight: 'bold', textDecoration: 'none' }}>{rating.flavor_name}</MuiLink> 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> </Typography>
{rating.comment ? ( {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} /> <RichText text={rating.comment} />
</Typography> </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> </Box>
<Link to={`/flavor/${rating.flavor}`} style={{ flexShrink: 0 }}> <Link
<Box sx={{ width: 70, height: 70, borderRadius: 1, overflow: 'hidden', bgcolor: 'background.default', border: '1px solid', borderColor: 'divider' }}> to={`/flavor/${rating.flavor}`}
<Box component="img" src={rating.flavor_image || undefined} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} /> 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> </Box>
</Link> </Link>
</Box> </Box>
@@ -316,44 +446,93 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
size="small" size="small"
startIcon={<CommentIcon fontSize="small" />} startIcon={<CommentIcon fontSize="small" />}
onClick={() => handleReplyToggle(rating.id)} 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> </Button>
<Collapse in={expandedReplies[rating.id]}> <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) => ( {rating.replies.map((reply) => (
<Box key={reply.id} sx={{ mb: 1.5 }}> <Box key={reply.id} sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}> <Box
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', fontSize: '0.8rem' }}>{reply.user}</Typography> sx={{
<Typography variant="caption" color="text.secondary">{formatTimestamp(reply.created_at)}</Typography> 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>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <Box
<Typography variant="body2"><RichText text={reply.text} /></Typography> sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Typography variant="body2">
<RichText text={reply.text} />
</Typography>
{adminMode && ( {adminMode && (
<Button <Button
size="small" sx={{ minWidth: 0, py: 0, fontSize: '0.7rem' }} size="small"
onClick={() => navigate(`/admin-panel/reply/${reply.id}`)} sx={{
minWidth: 0,
py: 0,
fontSize: "0.7rem",
}}
onClick={() =>
navigate(`/admin-panel/reply/${reply.id}`)
}
> >
{t('admin.manageReply')} {t("admin.manageReply")}
</Button> </Button>
)} )}
</Box> </Box>
</Box> </Box>
))} ))}
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}> <Box sx={{ mt: 2, display: "flex", gap: 1 }}>
<MentionTextField <MentionTextField
placeholder={t('community.writeReply')} placeholder={t("community.writeReply")}
value={replyInputs[rating.id] || ''} value={replyInputs[rating.id] || ""}
onChange={(val) => setReplyInputs(prev => ({ ...prev, [rating.id]: val }))} onChange={(val) =>
setReplyInputs((prev) => ({
...prev,
[rating.id]: val,
}))
}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
handleReplySubmit(rating.id); 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 /> <SendIcon />
</IconButton> </IconButton>
</Box> </Box>
@@ -363,8 +542,13 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
</Card> </Card>
))} ))}
{totalPages > 1 && ( {totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}> <Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
<Pagination count={totalPages} page={page} onChange={handlePageChange} color="primary" /> <Pagination
count={totalPages}
page={page}
onChange={handlePageChange}
color="primary"
/>
</Box> </Box>
)} )}
</Box> </Box>
@@ -373,21 +557,43 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
{/* Sidebar Column */} {/* Sidebar Column */}
<Grid size={{ xs: 12, lg: 4 }}> <Grid size={{ xs: 12, lg: 4 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
{/* Notifications Widget */} {/* Notifications Widget */}
<Card elevation={0} sx={{ borderRadius: 1, border: '1px solid', borderColor: 'divider' }}> <Card
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider', bgcolor: alpha('#000', 0.02), display: 'flex', alignItems: 'center', gap: 1 }}> 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" /> <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> </Box>
<List disablePadding> <List disablePadding>
{notifications.length === 0 ? ( {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 = () => { const handleClick = () => {
if (n.notification_type === 'follow') { if (n.notification_type === "follow") {
navigate(`/profile/${n.actor_username}`); navigate(`/profile/${n.actor_username}`);
} else if (n.flavor_id) { } else if (n.flavor_id) {
navigate(`/flavor/${n.flavor_id}`); navigate(`/flavor/${n.flavor_id}`);
@@ -395,23 +601,39 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
}; };
return ( 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 }}> <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> </ListItemAvatar>
<ListItemText <ListItemText
primary={ primary={
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}> <Typography
<strong>{n.actor_username}</strong> { variant="body2"
n.notification_type === 'reply' ? t('community.notifReply') : sx={{ fontSize: "0.8rem" }}
n.notification_type === 'mention' ? t('community.notifMention') : >
n.notification_type === 'follow' ? t('community.notifFollow') : <strong>{n.actor_username}</strong>{" "}
n.notification_type {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> </Typography>
} }
secondary={formatTimestamp(n.created_at)} secondary={formatTimestamp(n.created_at)}
secondaryTypographyProps={{ sx: { fontSize: '0.7rem' } }} secondaryTypographyProps={{
sx: { fontSize: "0.7rem" },
}}
/> />
</ListItemButton> </ListItemButton>
); );
@@ -421,48 +643,150 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
</Card> </Card>
{/* Following Widget */} {/* Following Widget */}
<Card elevation={0} sx={{ borderRadius: 1, border: '1px solid', borderColor: 'divider' }}> <Card
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider', bgcolor: alpha('#000', 0.02), display: 'flex', alignItems: 'center', gap: 1 }}> 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" /> <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>
<Box sx={{ p: 1.5 }}> <Box sx={{ p: 1.5 }}>
<TextField <TextField
fullWidth size="small" placeholder={t('community.searchFriends')} fullWidth
size="small"
placeholder={t("community.searchFriends")}
value={followingSearch} value={followingSearch}
onChange={(e) => setFollowingSearch(e.target.value)} onChange={(e) => setFollowingSearch(e.target.value)}
InputProps={{ startAdornment: <SearchIcon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} /> }} InputProps={{
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 1 } }} startAdornment: (
<SearchIcon
fontSize="small"
sx={{ mr: 1, color: "text.secondary" }}
/>
),
}}
sx={{ "& .MuiOutlinedInput-root": { borderRadius: 1 } }}
/> />
</Box> </Box>
<List disablePadding sx={{ maxHeight: 250, overflowY: 'auto' }}> <List disablePadding sx={{ maxHeight: 250, overflowY: "auto" }}>
{filteredFollowing.map(user => ( {filteredFollowing.map((user) => (
<ListItemButton key={user.id} component={Link} to={`/profile/${user.username}`}> <ListItemButton
<Avatar src={user.avatar || undefined} sx={{ width: 28, height: 28, mr: 2, fontSize: '0.8rem' }}>{user.username.charAt(0)}</Avatar> key={user.id}
<ListItemText primary={user.username} primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold' }} /> 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> </ListItemButton>
))} ))}
</List> </List>
</Card> </Card>
{/* Top Rated by Circle */} {/* Top Rated by Circle */}
<Card elevation={0} sx={{ borderRadius: 1, border: '1px solid', borderColor: 'divider' }}> <Card
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider', bgcolor: alpha('#000', 0.02), display: 'flex', alignItems: 'center', gap: 1 }}> 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" /> <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> </Box>
<List disablePadding> <List disablePadding>
{topFollowed.map((flavor, idx) => ( {topFollowed.map((flavor, idx) => (
<ListItemButton key={flavor.id} component={Link} to={`/flavor/${flavor.id}`}> <ListItemButton
<Box sx={{ mr: 2, fontWeight: 'bold', color: 'text.secondary', width: 15 }}>{idx + 1}</Box> key={flavor.id}
<Box sx={{ width: 32, height: 32, mr: 2, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider', borderRadius: 0.5, overflow: 'hidden', flexShrink: 0 }}> component={Link}
<Box component="img" src={flavor.image_url} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} /> 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> </Box>
<ListItemText <ListItemText
primary={flavor.name} 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> </ListItemButton>
))} ))}
</List> </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 { import {
Typography, Typography,
Card, Card,
@@ -9,11 +9,11 @@ import {
Accordion, Accordion,
AccordionSummary, AccordionSummary,
AccordionDetails, AccordionDetails,
Rating as MuiRating Rating as MuiRating,
} from '@mui/material'; } from "@mui/material";
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import api from '../api'; import api from "../api";
import RatingBadge from '../components/RatingBadge'; import RatingBadge from "../components/RatingBadge";
interface Rating { interface Rating {
id: number; id: number;
@@ -46,14 +46,16 @@ const FlavorList: React.FC = () => {
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [flavors, setFlavors] = useState<Flavor[]>([]); const [flavors, setFlavors] = useState<Flavor[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [ratingInput, setRatingInput] = useState<{[key: number]: {score: number, comment: string}}>({}); const [ratingInput, setRatingInput] = useState<{
[key: number]: { score: number; comment: string };
}>({});
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
const [catRes, flavRes] = await Promise.all([ const [catRes, flavRes] = await Promise.all([
api.get('categories/'), api.get("categories/"),
api.get('flavors/') api.get("flavors/"),
]); ]);
setCategories(catRes.data); setCategories(catRes.data);
setFlavors(flavRes.data); setFlavors(flavRes.data);
@@ -71,17 +73,19 @@ const FlavorList: React.FC = () => {
if (!input) return; if (!input) return;
try { try {
await api.post('ratings/', { await api.post("ratings/", {
flavor: flavorId, flavor: flavorId,
score: input.score, score: input.score,
comment: input.comment comment: input.comment,
}); });
// Refresh flavors // Refresh flavors
const res = await api.get('flavors/'); const res = await api.get("flavors/");
setFlavors(res.data); setFlavors(res.data);
alert('Rating submitted!'); alert("Rating submitted!");
} catch (err) { } catch (err) {
alert("Failed to submit rating. Make sure you are logged in and haven't rated this yet."); alert(
"Failed to submit rating. Make sure you are logged in and haven't rated this yet.",
);
} }
}; };
@@ -89,26 +93,41 @@ const FlavorList: React.FC = () => {
return ( return (
<Box> <Box>
<Typography variant="h4" gutterBottom>Holy Flavors</Typography> <Typography variant="h4" gutterBottom>
{categories.map(category => ( Holy Flavors
</Typography>
{categories.map((category) => (
<Box key={category.id} sx={{ mb: 4 }}> <Box key={category.id} sx={{ mb: 4 }}>
<Typography variant="h5" color="primary" gutterBottom>{category.name}</Typography> <Typography variant="h5" color="primary" gutterBottom>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}> {category.name}
{flavors.filter(f => f.category_name === category.name).map(flavor => ( </Typography>
<Box sx={{ flex: '1 1 30%', minWidth: 280 }} key={flavor.id}> <Box sx={{ display: "flex", flexWrap: "wrap", gap: 3 }}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', position: 'relative' }}> {flavors
.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 && ( {!flavor.is_available && (
<Box sx={{ <Box
position: 'absolute', sx={{
position: "absolute",
top: 10, top: 10,
right: 10, right: 10,
bgcolor: 'error.main', bgcolor: "error.main",
color: 'white', color: "white",
px: 1, px: 1,
borderRadius: 1, borderRadius: 1,
fontSize: '0.75rem', fontSize: "0.75rem",
zIndex: 1 zIndex: 1,
}}> }}
>
Out of Stock Out of Stock
</Box> </Box>
)} )}
@@ -117,17 +136,21 @@ const FlavorList: React.FC = () => {
component="img" component="img"
src={flavor.image_url} src={flavor.image_url}
sx={{ sx={{
width: '100%', width: "100%",
height: 200, height: 200,
objectFit: 'contain', objectFit: "contain",
p: 2, p: 2,
bgcolor: 'rgba(255,255,255,0.05)' bgcolor: "rgba(255,255,255,0.05)",
}} }}
/> />
)} )}
<CardContent sx={{ flexGrow: 1 }}> <CardContent sx={{ flexGrow: 1 }}>
<Typography variant="h6">{flavor.name}</Typography> <Typography variant="h6">{flavor.name}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography
variant="body2"
color="text.secondary"
sx={{ mb: 2 }}
>
{flavor.description} {flavor.description}
</Typography> </Typography>
{flavor.shop_url && ( {flavor.shop_url && (
@@ -137,26 +160,60 @@ const FlavorList: React.FC = () => {
component="a" component="a"
href={flavor.shop_url} href={flavor.shop_url}
target="_blank" target="_blank"
sx={{ mb: 2, fontSize: '0.7rem' }} sx={{ mb: 2, fontSize: "0.7rem" }}
> >
View in Shop View in Shop
</Button> </Button>
)} )}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}> <Box
<Typography variant="body2" sx={{ mr: 1 }}>Avg:</Typography> sx={{ display: "flex", alignItems: "center", mb: 1 }}
<RatingBadge score={flavor.average_rating || 0} size="small" /> >
<Typography variant="body2" sx={{ mr: 1 }}>
Avg:
</Typography>
<RatingBadge
score={flavor.average_rating || 0}
size="small"
/>
</Box> </Box>
{flavor.ratings && flavor.ratings.length > 0 && ( {flavor.ratings && flavor.ratings.length > 0 && (
<Accordion variant="outlined" sx={{ mt: 1, mb: 1 }}> <Accordion variant="outlined" sx={{ mt: 1, mb: 1 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="caption">Comments ({flavor.ratings.filter(r => r.comment).length})</Typography> <Typography variant="caption">
Comments (
{flavor.ratings.filter((r) => r.comment).length})
</Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails sx={{ maxHeight: 200, overflow: 'auto', p: 1 }}> <AccordionDetails
{flavor.ratings.filter(r => r.comment).map(r => ( sx={{ maxHeight: 200, overflow: "auto", p: 1 }}
<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> {flavor.ratings
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>{r.comment}</Typography> .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> </Box>
))} ))}
</AccordionDetails> </AccordionDetails>
@@ -164,28 +221,64 @@ const FlavorList: React.FC = () => {
)} )}
{flavor.user_rating ? ( {flavor.user_rating ? (
<Typography variant="body2" color="secondary">Your Rating: {flavor.user_rating}/10</Typography> <Typography variant="body2" color="secondary">
Your Rating: {flavor.user_rating}/10
</Typography>
) : ( ) : (
<Accordion variant="outlined" sx={{ mt: 2 }}> <Accordion variant="outlined" sx={{ mt: 2 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="body2">Rate this flavor</Typography> <Typography variant="body2">
Rate this flavor
</Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Box
sx={{
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
<MuiRating <MuiRating
max={10} max={10}
value={ratingInput[flavor.id]?.score || 0} 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 <TextField
label="Comment (Optional)" label="Comment (Optional)"
variant="outlined" variant="outlined"
size="small" size="small"
multiline multiline
value={ratingInput[flavor.id]?.comment || ''} value={ratingInput[flavor.id]?.comment || ""}
onChange={(e) => setRatingInput({...ratingInput, [flavor.id]: {...(ratingInput[flavor.id] || {score: 0}), comment: e.target.value}})} 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> </Box>
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
+154 -62
View File
@@ -1,45 +1,61 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { Typography, TextField, Button, Paper, Tab, Tabs, Alert, Container } from '@mui/material'; import {
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; Typography,
import { Link } from 'react-router-dom'; TextField,
import api from '../api'; Button,
import { useTitle } from '../hooks/useTitle'; Paper,
import { useTranslation } from 'react-i18next'; Tab,
Tabs,
Alert,
Container,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { Link } from "react-router-dom";
import api from "../api";
import { useTitle } from "../hooks/useTitle";
import { useTranslation } from "react-i18next";
interface LoginProps { interface LoginProps {}
}
const Login: React.FC<LoginProps> = () => { const Login: React.FC<LoginProps> = () => {
const { t } = useTranslation(); const { t } = useTranslation();
useTitle('Login'); useTitle("Login");
const [tab, setTab] = useState(0); const [tab, setTab] = useState(0);
const [username, setUsername] = useState(''); const [username, setUsername] = useState("");
const [password, setPassword] = useState(''); const [password, setPassword] = useState("");
const [email, setEmail] = useState(''); const [email, setEmail] = useState("");
const [verificationCode, setVerificationCode] = useState(''); const [verificationCode, setVerificationCode] = useState("");
const [isVerifying, setIsVerifying] = useState(false); const [isVerifying, setIsVerifying] = useState(false);
const [showResend, setShowResend] = useState(false); const [showResend, setShowResend] = useState(false);
const [isResetting, setIsResetting] = useState(false); const [isResetting, setIsResetting] = useState(false);
const [resetStep, setResetStep] = useState(1); // 1: email, 2: code/new pass const [resetStep, setResetStep] = useState(1); // 1: email, 2: code/new pass
const [message, setMessage] = useState<{type: 'success' | 'error', text: string} | null>(null); const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setMessage(null); setMessage(null);
try { try {
const res = await api.post('token/', { const res = await api.post("token/", {
username, username,
password, password,
}); });
localStorage.setItem('token', res.data.token); localStorage.setItem("token", res.data.token);
window.location.href = '/'; window.location.href = "/";
} catch (err: any) { } catch (err: any) {
const errorMsg = err.response?.data?.non_field_errors?.[0] || t('auth.loginFailed'); const errorMsg =
if (errorMsg.toLowerCase().includes('inactive') || errorMsg.toLowerCase().includes('verify') || errorMsg.includes('disabled')) { err.response?.data?.non_field_errors?.[0] || t("auth.loginFailed");
setMessage({ type: 'error', text: t('auth.unverifiedError') }); if (
errorMsg.toLowerCase().includes("inactive") ||
errorMsg.toLowerCase().includes("verify") ||
errorMsg.includes("disabled")
) {
setMessage({ type: "error", text: t("auth.unverifiedError") });
setIsVerifying(true); setIsVerifying(true);
} else { } else {
setMessage({ type: 'error', text: errorMsg }); setMessage({ type: "error", text: errorMsg });
} }
} }
}; };
@@ -48,11 +64,14 @@ const Login: React.FC<LoginProps> = () => {
e.preventDefault(); e.preventDefault();
setMessage(null); setMessage(null);
try { try {
await api.post('users/signup/', { username, email, password }); await api.post("users/signup/", { username, email, password });
setIsVerifying(true); setIsVerifying(true);
setMessage({ type: 'success', text: t('auth.signupSuccess') }); setMessage({ type: "success", text: t("auth.signupSuccess") });
} catch (err: any) { } catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Signup failed' }); setMessage({
type: "error",
text: err.response?.data?.error || "Signup failed",
});
} }
}; };
@@ -60,13 +79,19 @@ const Login: React.FC<LoginProps> = () => {
e.preventDefault(); e.preventDefault();
setMessage(null); setMessage(null);
try { try {
await api.post('users/verify_signup/', { username, code: verificationCode }); await api.post("users/verify_signup/", {
setMessage({ type: 'success', text: t('auth.verifySuccess') }); username,
code: verificationCode,
});
setMessage({ type: "success", text: t("auth.verifySuccess") });
setIsVerifying(false); setIsVerifying(false);
setTab(0); setTab(0);
setPassword(''); setPassword("");
} catch (err: any) { } 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); setShowResend(true);
} }
}; };
@@ -74,11 +99,14 @@ const Login: React.FC<LoginProps> = () => {
const handleResendCode = async () => { const handleResendCode = async () => {
setMessage(null); setMessage(null);
try { try {
await api.post('users/resend_verification/', { username }); await api.post("users/resend_verification/", { username });
setMessage({ type: 'success', text: t('auth.resendSuccess') }); setMessage({ type: "success", text: t("auth.resendSuccess") });
setShowResend(false); setShowResend(false);
} catch (err: any) { } catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to resend code' }); setMessage({
type: "error",
text: err.response?.data?.error || "Failed to resend code",
});
} }
}; };
@@ -86,11 +114,17 @@ const Login: React.FC<LoginProps> = () => {
e.preventDefault(); e.preventDefault();
setMessage(null); setMessage(null);
try { try {
await api.post('users/request_password_reset/', { email }); await api.post("users/request_password_reset/", { email });
setResetStep(2); setResetStep(2);
setMessage({ type: 'success', text: 'If an account exists, a reset code has been sent.' }); setMessage({
type: "success",
text: "If an account exists, a reset code has been sent.",
});
} catch (err: any) { } 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(); e.preventDefault();
setMessage(null); setMessage(null);
try { try {
await api.post('users/complete_password_reset/', { email, code: verificationCode, password }); await api.post("users/complete_password_reset/", {
setMessage({ type: 'success', text: 'Password reset successful! You can now login.' }); email,
code: verificationCode,
password,
});
setMessage({
type: "success",
text: "Password reset successful! You can now login.",
});
setIsResetting(false); setIsResetting(false);
setResetStep(1); setResetStep(1);
setTab(0); setTab(0);
setPassword(''); setPassword("");
setVerificationCode(''); setVerificationCode("");
} catch (err: any) { } 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) { if (isVerifying) {
return ( return (
<form onSubmit={handleVerify}> <form onSubmit={handleVerify}>
<Typography variant="h6" gutterBottom>{t('auth.verifyTitle')}</Typography> <Typography variant="h6" gutterBottom>
<Typography variant="body2" sx={{ mb: 2 }} dangerouslySetInnerHTML={{ __html: t('auth.verifyDesc', { username }) }} /> {t("auth.verifyTitle")}
</Typography>
<Typography
variant="body2"
sx={{ mb: 2 }}
dangerouslySetInnerHTML={{
__html: t("auth.verifyDesc", { username }),
}}
/>
<TextField <TextField
fullWidth fullWidth
label="Verification Code" label="Verification Code"
@@ -133,13 +185,20 @@ const Login: React.FC<LoginProps> = () => {
variant={showResend ? "contained" : "outlined"} variant={showResend ? "contained" : "outlined"}
color={showResend ? "secondary" : "primary"} color={showResend ? "secondary" : "primary"}
onClick={handleResendCode} onClick={handleResendCode}
sx={{ mt: 2, textTransform: 'none' }} sx={{ mt: 2, textTransform: "none" }}
> >
{t('auth.resendButton')} {t("auth.resendButton")}
</Button> </Button>
<Button fullWidth onClick={() => { setIsVerifying(false); setMessage(null); }} sx={{ mt: 1 }}> <Button
{t('auth.backToAuth')} fullWidth
onClick={() => {
setIsVerifying(false);
setMessage(null);
}}
sx={{ mt: 1 }}
>
{t("auth.backToAuth")}
</Button> </Button>
</form> </form>
); );
@@ -147,8 +206,12 @@ const Login: React.FC<LoginProps> = () => {
if (isResetting) { if (isResetting) {
return ( return (
<form onSubmit={resetStep === 1 ? handleRequestReset : handleCompleteReset}> <form
<Typography variant="h6" gutterBottom>Reset Password</Typography> onSubmit={resetStep === 1 ? handleRequestReset : handleCompleteReset}
>
<Typography variant="h6" gutterBottom>
Reset Password
</Typography>
<TextField <TextField
fullWidth fullWidth
label="Email" label="Email"
@@ -177,9 +240,17 @@ const Login: React.FC<LoginProps> = () => {
</> </>
)} )}
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}> <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>
<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 Back to Login
</Button> </Button>
</form> </form>
@@ -188,9 +259,16 @@ const Login: React.FC<LoginProps> = () => {
return ( return (
<> <>
<Tabs value={tab} onChange={(_, v) => { setTab(v); setMessage(null); }} sx={{ mb: 2 }}> <Tabs
<Tab label={t('auth.loginTitle')} /> value={tab}
<Tab label={t('auth.signupTitle')} /> onChange={(_, v) => {
setTab(v);
setMessage(null);
}}
sx={{ mb: 2 }}
>
<Tab label={t("auth.loginTitle")} />
<Tab label={t("auth.signupTitle")} />
</Tabs> </Tabs>
{tab === 0 ? ( {tab === 0 ? (
@@ -211,10 +289,18 @@ const Login: React.FC<LoginProps> = () => {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}> <Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
{t('auth.loginTitle')} {t("auth.loginTitle")}
</Button> </Button>
<Button fullWidth onClick={() => { setIsResetting(true); setMessage(null); }} sx={{ mt: 1, textTransform: 'none' }} size="small"> <Button
{t('auth.forgotPassword')} fullWidth
onClick={() => {
setIsResetting(true);
setMessage(null);
}}
sx={{ mt: 1, textTransform: "none" }}
size="small"
>
{t("auth.forgotPassword")}
</Button> </Button>
</form> </form>
) : ( ) : (
@@ -243,7 +329,7 @@ const Login: React.FC<LoginProps> = () => {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}> <Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
{t('auth.signupTitle')} {t("auth.signupTitle")}
</Button> </Button>
</form> </form>
)} )}
@@ -258,16 +344,22 @@ const Login: React.FC<LoginProps> = () => {
component={Link} component={Link}
to="/" to="/"
startIcon={<ArrowBackIcon />} startIcon={<ArrowBackIcon />}
sx={{ mb: 4, textTransform: 'none', borderRadius: 2 }} sx={{ mb: 4, textTransform: "none", borderRadius: 2 }}
> >
Back to Home Back to Home
</Button> </Button>
<Paper sx={{ <Paper
sx={{
p: 3, p: 3,
'& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 } "& .MuiInputLabel-root": { bgcolor: "background.paper", px: 0.5 },
}}> }}
{message && <Alert severity={message.type} sx={{ mb: 2 }}>{message.text}</Alert>} >
{message && (
<Alert severity={message.type} sx={{ mb: 2 }}>
{message.text}
</Alert>
)}
{renderContent()} {renderContent()}
</Paper> </Paper>
</Container> </Container>
File diff suppressed because it is too large. Load diff
+151 -63
View File
@@ -1,15 +1,24 @@
import React from 'react'; import React from "react";
import { Typography, Box, Container, Paper, Button, ToggleButton, ToggleButtonGroup, useMediaQuery } from '@mui/material'; import {
import { Link } from 'react-router-dom'; Typography,
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; Box,
import { useTitle } from '../hooks/useTitle'; Container,
import { useTranslation } from 'react-i18next'; Paper,
Button,
ToggleButton,
ToggleButtonGroup,
useMediaQuery,
} from "@mui/material";
import { Link } from "react-router-dom";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { useTitle } from "../hooks/useTitle";
import { useTranslation } from "react-i18next";
const Privacy: React.FC = () => { const Privacy: React.FC = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language.startsWith('de') ? 'de' : 'en'; const lang = i18n.language.startsWith("de") ? "de" : "en";
useTitle(t('privacy.title')); useTitle(t("privacy.title"));
const isMobile = useMediaQuery('(max-width:600px)'); const isMobile = useMediaQuery("(max-width:600px)");
const handleLangChange = (_: any, newLang: string | null) => { const handleLangChange = (_: any, newLang: string | null) => {
if (newLang) { if (newLang) {
@@ -18,16 +27,28 @@ const Privacy: React.FC = () => {
}; };
return ( return (
<Container maxWidth="md" sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}> <Container
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4, flexWrap: 'wrap', gap: 2 }}> maxWidth="md"
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 <Button
variant="outlined" variant="outlined"
component={Link} component={Link}
to="/" to="/"
startIcon={<ArrowBackIcon />} startIcon={<ArrowBackIcon />}
sx={{ textTransform: 'none', borderRadius: 2 }} sx={{ textTransform: "none", borderRadius: 2 }}
> >
{t('common.back')} {t("common.back")}
</Button> </Button>
<ToggleButtonGroup <ToggleButtonGroup
@@ -41,65 +62,132 @@ const Privacy: React.FC = () => {
</ToggleButtonGroup> </ToggleButtonGroup>
</Box> </Box>
<Paper sx={{ p: { xs: 2.5, sm: 4, md: 6 }, borderRadius: 4, overflow: 'hidden' }}> <Paper
<Typography variant={isMobile ? "h4" : "h3"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('privacy.title')}</Typography> sx={{
p: { xs: 2.5, sm: 4, md: 6 },
borderRadius: 4,
overflow: "hidden",
}}
>
<Typography
variant={isMobile ? "h4" : "h3"}
gutterBottom
sx={{ fontWeight: "bold" }}
>
{t("privacy.title")}
</Typography>
<Typography variant="body1" paragraph sx={{ lineHeight: 1.7 }}> <Typography variant="body1" paragraph sx={{ lineHeight: 1.7 }}>
{t('privacy.intro')} {t("privacy.intro")}
</Typography> </Typography>
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>{t('privacy.controllerTitle')}</Typography> <Typography
variant={isMobile ? "h6" : "h5"}
gutterBottom
sx={{ mt: 4, fontWeight: "bold" }}
>
{t("privacy.controllerTitle")}
</Typography>
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}> <Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
{t('privacy.controllerDesc')}<br /> {t("privacy.controllerDesc")}
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}<br /> <br />
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street]'}<br /> {import.meta.env.VITE_IMPRESSUM_NAME || "[Your Name]"}
{import.meta.env.VITE_IMPRESSUM_CITY || '[Your City]'} <br />
{import.meta.env.VITE_IMPRESSUM_STREET || "[Your Street]"}
<br />
{import.meta.env.VITE_IMPRESSUM_CITY || "[Your City]"}
</Typography> </Typography>
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>{t('privacy.collectionTitle')}</Typography> <Typography
variant={isMobile ? "h6" : "h5"}
<Box sx={{ mb: 2 }}> gutterBottom
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.regTitle')}</Typography> sx={{ mt: 4, fontWeight: "bold" }}
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.regDesc')}</Typography> >
</Box> {t("privacy.collectionTitle")}
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.ipTitle')}</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.ipDesc')}</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.profileTitle')}</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.profileDesc')}</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.socialTitle')}</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.socialDesc')}</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.supportTitle')}</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.supportDesc')}</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.personalizationTitle')}</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.personalizationDesc')}</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.deletionTitle')}</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.deletionDesc')}</Typography>
</Box>
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>{t('privacy.transferTitle')}</Typography>
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
{t('privacy.transferDesc')}
</Typography> </Typography>
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>{t('privacy.rightsTitle')}</Typography> <Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
{t("privacy.regTitle")}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
{t("privacy.regDesc")}
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
{t("privacy.ipTitle")}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
{t("privacy.ipDesc")}
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
{t("privacy.profileTitle")}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
{t("privacy.profileDesc")}
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
{t("privacy.socialTitle")}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
{t("privacy.socialDesc")}
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
{t("privacy.supportTitle")}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
{t("privacy.supportDesc")}
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
{t("privacy.personalizationTitle")}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
{t("privacy.personalizationDesc")}
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
{t("privacy.deletionTitle")}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
{t("privacy.deletionDesc")}
</Typography>
</Box>
<Typography
variant={isMobile ? "h6" : "h5"}
gutterBottom
sx={{ mt: 4, fontWeight: "bold" }}
>
{t("privacy.transferTitle")}
</Typography>
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}> <Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
{t('privacy.rightsDesc')} {import.meta.env.VITE_IMPRESSUM_EMAIL} {t("privacy.transferDesc")}
</Typography>
<Typography
variant={isMobile ? "h6" : "h5"}
gutterBottom
sx={{ mt: 4, fontWeight: "bold" }}
>
{t("privacy.rightsTitle")}
</Typography>
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
{t("privacy.rightsDesc")} {import.meta.env.VITE_IMPRESSUM_EMAIL}
</Typography> </Typography>
</Paper> </Paper>
</Container> </Container>
File diff suppressed because it is too large. Load diff
+272 -118
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { import {
Typography, Typography,
Box, Box,
@@ -14,14 +14,14 @@ import {
MenuItem, MenuItem,
ListSubheader, ListSubheader,
Divider, Divider,
Grid Grid,
} from '@mui/material'; } from "@mui/material";
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import api from '../api'; import api from "../api";
import { useTranslation } from 'react-i18next'; import { useTranslation } from "react-i18next";
import { useTitle } from '../hooks/useTitle'; import { useTitle } from "../hooks/useTitle";
import type { CatppuccinTheme } from '../theme'; import type { CatppuccinTheme } from "../theme";
interface SettingsProps { interface SettingsProps {
themeName: CatppuccinTheme; themeName: CatppuccinTheme;
@@ -31,19 +31,22 @@ interface SettingsProps {
const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => { const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
useTitle(t('nav.settings')); useTitle(t("nav.settings"));
const [username, setUsername] = useState(''); const [username, setUsername] = useState("");
const [email, setEmail] = useState(''); const [email, setEmail] = useState("");
const [currentEmail, setCurrentEmail] = useState(''); const [currentEmail, setCurrentEmail] = useState("");
const [avatar, setAvatar] = useState<string | null>(null); const [avatar, setAvatar] = useState<string | null>(null);
const [language, setLanguage] = useState(i18n.language.split('-')[0]); const [language, setLanguage] = useState(i18n.language.split("-")[0]);
const [confirmationCode, setConfirmationCode] = useState(''); const [confirmationCode, setConfirmationCode] = useState("");
const [pendingConfirmation, setPendingConfirmation] = useState(false); const [pendingConfirmation, setPendingConfirmation] = useState(false);
const [oldPassword, setOldPassword] = useState(''); const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState("");
const [deletionCode, setDeletionCode] = useState(''); const [deletionCode, setDeletionCode] = useState("");
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null); const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const [banners, setBanners] = useState<any[]>([]); const [banners, setBanners] = useState<any[]>([]);
const [selectedBannerId, setSelectedBannerId] = useState<number | string>(""); const [selectedBannerId, setSelectedBannerId] = useState<number | string>("");
@@ -51,7 +54,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
if (window.history.length > 1) { if (window.history.length > 1) {
navigate(-1); navigate(-1);
} else { } else {
navigate('/'); navigate("/");
} }
}; };
@@ -59,8 +62,8 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
const fetchUser = async () => { const fetchUser = async () => {
try { try {
const [userRes, bannersRes] = await Promise.all([ const [userRes, bannersRes] = await Promise.all([
api.get('users/me/'), api.get("users/me/"),
api.get('banners/') api.get("banners/"),
]); ]);
setUsername(userRes.data.username); setUsername(userRes.data.username);
@@ -74,7 +77,9 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
} }
// Handle both paginated and non-paginated // Handle both paginated and non-paginated
const bannersData = Array.isArray(bannersRes.data) ? bannersRes.data : (bannersRes.data.results || []); const bannersData = Array.isArray(bannersRes.data)
? bannersRes.data
: bannersRes.data.results || [];
setBanners(bannersData); setBanners(bannersData);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -88,21 +93,24 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
if (!file) return; if (!file) return;
if (file.size > 2 * 1024 * 1024) { if (file.size > 2 * 1024 * 1024) {
setMessage({ type: 'error', text: 'File size exceeds 2MB limit' }); setMessage({ type: "error", text: "File size exceeds 2MB limit" });
return; return;
} }
const formData = new FormData(); const formData = new FormData();
formData.append('avatar', file); formData.append("avatar", file);
try { try {
const res = await api.post('users/update_avatar/', formData, { const res = await api.post("users/update_avatar/", formData, {
headers: { 'Content-Type': 'multipart/form-data' } headers: { "Content-Type": "multipart/form-data" },
}); });
setAvatar(res.data.avatar); setAvatar(res.data.avatar);
setMessage({ type: 'success', text: t('settings.avatarSuccess') }); setMessage({ type: "success", text: t("settings.avatarSuccess") });
} catch (err: any) { } catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to upload avatar' }); setMessage({
type: "error",
text: err.response?.data?.error || "Failed to upload avatar",
});
} }
}; };
@@ -110,72 +118,90 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
setLanguage(newLang); setLanguage(newLang);
i18n.changeLanguage(newLang); i18n.changeLanguage(newLang);
try { try {
await api.patch('users/update_preferences/', { language: newLang }); await api.patch("users/update_preferences/", { language: newLang });
} catch (err) { } catch (err) {
console.error('Failed to update language on server'); console.error("Failed to update language on server");
} }
}; };
const handleUpdateProfile = async (e: React.FormEvent) => { const handleUpdateProfile = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
const res = await api.patch('users/update_profile/', { username, email }); const res = await api.patch("users/update_profile/", { username, email });
if (res.data.message) { if (res.data.message) {
setPendingConfirmation(true); setPendingConfirmation(true);
setMessage({ type: 'success', text: `${t('settings.updateSuccess')} ${res.data.message}` }); setMessage({
type: "success",
text: `${t("settings.updateSuccess")} ${res.data.message}`,
});
} else { } else {
setMessage({ type: 'success', text: t('settings.updateSuccess') }); setMessage({ type: "success", text: t("settings.updateSuccess") });
setCurrentEmail(email); setCurrentEmail(email);
} }
} catch (err: any) { } catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to update profile' }); setMessage({
type: "error",
text: err.response?.data?.error || "Failed to update profile",
});
} }
}; };
const handleConfirmEmail = async (e: React.FormEvent) => { const handleConfirmEmail = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
const res = await api.post('users/confirm_email/', { code: confirmationCode }); const res = await api.post("users/confirm_email/", {
setMessage({ type: 'success', text: t('settings.updateSuccess') }); code: confirmationCode,
});
setMessage({ type: "success", text: t("settings.updateSuccess") });
setCurrentEmail(res.data.email); setCurrentEmail(res.data.email);
setPendingConfirmation(false); setPendingConfirmation(false);
setConfirmationCode(''); setConfirmationCode("");
} catch (err: any) { } catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Confirmation failed' }); setMessage({
type: "error",
text: err.response?.data?.error || "Confirmation failed",
});
} }
}; };
const handleChangePassword = async (e: React.FormEvent) => { const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
await api.post('users/change_password/', { old_password: oldPassword, new_password: newPassword }); await api.post("users/change_password/", {
setMessage({ type: 'success', text: t('settings.passwordSuccess') }); old_password: oldPassword,
setOldPassword(''); new_password: newPassword,
setNewPassword(''); });
setMessage({ type: "success", text: t("settings.passwordSuccess") });
setOldPassword("");
setNewPassword("");
} catch (err: any) { } catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to change password' }); setMessage({
type: "error",
text: err.response?.data?.error || "Failed to change password",
});
} }
}; };
const handleRequestDeletion = async () => { const handleRequestDeletion = async () => {
try { try {
await api.post('users/request_account_deletion/'); await api.post("users/request_account_deletion/");
setIsDeleting(true); setIsDeleting(true);
setMessage({ type: 'success', text: t('settings.codeSent') }); setMessage({ type: "success", text: t("settings.codeSent") });
} catch (err: any) { } catch (err: any) {
setMessage({ type: 'error', text: 'Failed to request deletion.' }); setMessage({ type: "error", text: "Failed to request deletion." });
} }
}; };
const handleConfirmDeletion = async (e: React.FormEvent) => { const handleConfirmDeletion = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!window.confirm('Are you absolutely sure? This cannot be undone.')) return; if (!window.confirm("Are you absolutely sure? This cannot be undone."))
return;
try { try {
await api.post('users/confirm_account_deletion/', { code: deletionCode }); await api.post("users/confirm_account_deletion/", { code: deletionCode });
localStorage.removeItem('token'); localStorage.removeItem("token");
window.location.href = '/'; window.location.href = "/";
} catch (err: any) { } 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={{ sx={{
mb: 4, mb: 4,
borderRadius: 2, borderRadius: 2,
textTransform: 'none', textTransform: "none",
fontWeight: 'bold', fontWeight: "bold",
color: 'text.secondary', color: "text.secondary",
borderColor: 'divider', borderColor: "divider",
'&:hover': { "&:hover": {
borderColor: 'primary.main', borderColor: "primary.main",
color: 'primary.main', color: "primary.main",
bgcolor: 'transparent' bgcolor: "transparent",
} },
}} }}
> >
{window.history.length > 1 ? t('common.back') : t('common.backToHome')} {window.history.length > 1 ? t("common.back") : t("common.backToHome")}
</Button> </Button>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 6 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 3, mb: 6 }}>
<Avatar src={avatar || undefined} sx={{ width: 80, height: 80, border: '3px solid', borderColor: 'primary.main' }}> <Avatar
src={avatar || undefined}
sx={{
width: 80,
height: 80,
border: "3px solid",
borderColor: "primary.main",
}}
>
{!avatar && username.charAt(0).toUpperCase()} {!avatar && username.charAt(0).toUpperCase()}
</Avatar> </Avatar>
<Box> <Box>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>{username}</Typography> <Typography variant="h4" sx={{ fontWeight: "bold" }}>
<Typography variant="body2" color="text.secondary">{t('settings.subtitle')}</Typography> {username}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("settings.subtitle")}
</Typography>
</Box> </Box>
</Box> </Box>
{message && ( {message && (
<Alert severity={message.type} sx={{ mb: 4 }} onClose={() => setMessage(null)}> <Alert
severity={message.type}
sx={{ mb: 4 }}
onClose={() => setMessage(null)}
>
{message.text} {message.text}
</Alert> </Alert>
)} )}
@@ -221,14 +263,25 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
<Grid container spacing={4}> <Grid container spacing={4}>
{/* Left Column: Visuals & Language */} {/* Left Column: Visuals & Language */}
<Grid size={{ xs: 12, lg: 5 }}> <Grid size={{ xs: 12, lg: 5 }}>
<Paper sx={{ p: 3, mb: 4, borderRadius: 3, '& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 } }}> <Paper
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('settings.appearanceTitle')}</Typography> sx={{
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"> <FormControl fullWidth margin="normal">
<InputLabel>{t('settings.themeLabel')}</InputLabel> <InputLabel>{t("settings.themeLabel")}</InputLabel>
<Select <Select
value={themeName} value={themeName}
label={t('settings.themeLabel')} label={t("settings.themeLabel")}
onChange={(e) => onThemeChange(e.target.value as CatppuccinTheme)} onChange={(e) =>
onThemeChange(e.target.value as CatppuccinTheme)
}
> >
<ListSubheader>Holy Archive</ListSubheader> <ListSubheader>Holy Archive</ListSubheader>
<MenuItem value="holy_light">Holy Light</MenuItem> <MenuItem value="holy_light">Holy Light</MenuItem>
@@ -258,32 +311,36 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
</FormControl> </FormControl>
<FormControl fullWidth margin="normal"> <FormControl fullWidth margin="normal">
<InputLabel>{t('settings.bannerLabel')}</InputLabel> <InputLabel>{t("settings.bannerLabel")}</InputLabel>
<Select <Select
value={selectedBannerId} value={selectedBannerId}
label={t('settings.bannerLabel')} label={t("settings.bannerLabel")}
onChange={async (e) => { onChange={async (e) => {
const newId = e.target.value; const newId = e.target.value;
setSelectedBannerId(newId); setSelectedBannerId(newId);
try { try {
await api.patch('users/update_preferences/', { selected_banner: newId }); await api.patch("users/update_preferences/", {
selected_banner: newId,
});
} catch (err) { } catch (err) {
console.error('Failed to update banner preference'); console.error("Failed to update banner preference");
} }
}} }}
> >
<MenuItem value="">{t('settings.bannerDefault')}</MenuItem> <MenuItem value="">{t("settings.bannerDefault")}</MenuItem>
{banners.map(b => ( {banners.map((b) => (
<MenuItem key={b.id} value={b.id}>{b.name}</MenuItem> <MenuItem key={b.id} value={b.id}>
{b.name}
</MenuItem>
))} ))}
</Select> </Select>
</FormControl> </FormControl>
<FormControl fullWidth margin="normal"> <FormControl fullWidth margin="normal">
<InputLabel>{t('settings.langLabel')}</InputLabel> <InputLabel>{t("settings.langLabel")}</InputLabel>
<Select <Select
value={language} value={language}
label={t('settings.langLabel')} label={t("settings.langLabel")}
onChange={(e) => handleLanguageChange(e.target.value)} onChange={(e) => handleLanguageChange(e.target.value)}
> >
<MenuItem value="en">English</MenuItem> <MenuItem value="en">English</MenuItem>
@@ -293,18 +350,33 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
</Paper> </Paper>
<Paper sx={{ p: 3, borderRadius: 3 }}> <Paper sx={{ p: 3, borderRadius: 3 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('settings.avatarTitle')}</Typography> <Typography variant="h6" gutterBottom sx={{ fontWeight: "bold" }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, my: 2 }}> {t("settings.avatarTitle")}
<Avatar src={avatar || undefined} sx={{ width: 100, height: 100 }}> </Typography>
<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 && username.charAt(0).toUpperCase()}
</Avatar> </Avatar>
<Box> <Box>
<Button variant="outlined" component="label" size="small"> <Button variant="outlined" component="label" size="small">
{t('settings.avatarButton')} {t("settings.avatarButton")}
<input type="file" hidden accept="image/*" onChange={handleAvatarUpload} /> <input
type="file"
hidden
accept="image/*"
onChange={handleAvatarUpload}
/>
</Button> </Button>
<Typography variant="caption" display="block" sx={{ mt: 1 }} color="text.secondary"> <Typography
{t('settings.avatarHint')} variant="caption"
display="block"
sx={{ mt: 1 }}
color="text.secondary"
>
{t("settings.avatarHint")}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
@@ -313,12 +385,21 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
{/* Right Column: Account Info & Security */} {/* Right Column: Account Info & Security */}
<Grid size={{ xs: 12, lg: 7 }}> <Grid size={{ xs: 12, lg: 7 }}>
<Paper sx={{ p: 3, mb: 4, borderRadius: 3, '& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 } }}> <Paper
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('settings.infoTitle')}</Typography> 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}> <form onSubmit={handleUpdateProfile}>
<TextField <TextField
fullWidth fullWidth
label={t('settings.usernameLabel')} label={t("settings.usernameLabel")}
margin="normal" margin="normal"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
@@ -326,45 +407,76 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
/> />
<TextField <TextField
fullWidth fullWidth
label={t('settings.emailLabel')} label={t("settings.emailLabel")}
margin="normal" margin="normal"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
helperText={email !== currentEmail ? t('settings.emailHint') : ""} helperText={
email !== currentEmail ? t("settings.emailHint") : ""
}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
/> />
<Button variant="contained" type="submit" sx={{ mt: 2, borderRadius: 2 }}> <Button
{t('settings.updateButton')} variant="contained"
type="submit"
sx={{ mt: 2, borderRadius: 2 }}
>
{t("settings.updateButton")}
</Button> </Button>
</form> </form>
{pendingConfirmation && ( {pendingConfirmation && (
<Box sx={{ mt: 4, pt: 3, borderTop: '1px solid', borderColor: 'divider' }}> <Box
<Typography variant="h6" color="primary" gutterBottom>{t('settings.confirmEmailTitle')}</Typography> sx={{
<Typography variant="body2" sx={{ mb: 2 }}>{t('settings.confirmEmailHint', { email })}</Typography> 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}> <form onSubmit={handleConfirmEmail}>
<TextField <TextField
fullWidth fullWidth
label={t('settings.deletionCodeLabel')} label={t("settings.deletionCodeLabel")}
margin="normal" margin="normal"
value={confirmationCode} value={confirmationCode}
onChange={(e) => setConfirmationCode(e.target.value)} onChange={(e) => setConfirmationCode(e.target.value)}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
/> />
<Button variant="contained" type="submit" sx={{ mt: 1, borderRadius: 2 }} color="secondary"> <Button
{t('settings.confirmButton')} variant="contained"
type="submit"
sx={{ mt: 1, borderRadius: 2 }}
color="secondary"
>
{t("settings.confirmButton")}
</Button> </Button>
</form> </form>
</Box> </Box>
)} )}
</Paper> </Paper>
<Paper sx={{ p: 3, mb: 4, borderRadius: 3, '& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 } }}> <Paper
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('settings.passwordTitle')}</Typography> 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}> <form onSubmit={handleChangePassword}>
<TextField <TextField
fullWidth fullWidth
label={t('settings.oldPasswordLabel')} label={t("settings.oldPasswordLabel")}
type="password" type="password"
margin="normal" margin="normal"
value={oldPassword} value={oldPassword}
@@ -373,46 +485,88 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
/> />
<TextField <TextField
fullWidth fullWidth
label={t('settings.newPasswordLabel')} label={t("settings.newPasswordLabel")}
type="password" type="password"
margin="normal" margin="normal"
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
/> />
<Button variant="contained" type="submit" sx={{ mt: 2, borderRadius: 2 }} color="warning"> <Button
{t('settings.passwordButton')} variant="contained"
type="submit"
sx={{ mt: 2, borderRadius: 2 }}
color="warning"
>
{t("settings.passwordButton")}
</Button> </Button>
</form> </form>
</Paper> </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 } }}> <Paper
<Typography variant="h6" gutterBottom color="error" sx={{ fontWeight: 'bold' }}>{t('settings.dangerTitle')}</Typography> 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 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t('settings.dangerDesc')} {t("settings.dangerDesc")}
</Typography> </Typography>
{!isDeleting ? ( {!isDeleting ? (
<Button variant="outlined" color="error" onClick={handleRequestDeletion} sx={{ borderRadius: 2 }}> <Button
{t('settings.deleteButton')} variant="outlined"
color="error"
onClick={handleRequestDeletion}
sx={{ borderRadius: 2 }}
>
{t("settings.deleteButton")}
</Button> </Button>
) : ( ) : (
<form onSubmit={handleConfirmDeletion}> <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 <TextField
fullWidth fullWidth
size="small" size="small"
label={t('settings.deletionCodeLabel')} label={t("settings.deletionCodeLabel")}
value={deletionCode} value={deletionCode}
onChange={(e) => setDeletionCode(e.target.value)} onChange={(e) => setDeletionCode(e.target.value)}
sx={{ mb: 2 }} sx={{ mb: 2 }}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
/> />
<Box sx={{ display: 'flex', gap: 1 }}> <Box sx={{ display: "flex", gap: 1 }}>
<Button variant="contained" color="error" type="submit" disabled={!deletionCode} sx={{ borderRadius: 2 }}> <Button
{t('settings.confirmDeleteButton')} 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>
<Button onClick={() => setIsDeleting(false)} sx={{ borderRadius: 2 }}>{t('settings.cancelButton')}</Button>
</Box> </Box>
</form> </form>
)} )}
+335 -101
View File
@@ -1,18 +1,32 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { import {
Container, Typography, Box, Card, CardContent, Button, Container,
TextField, Stack, Chip, Divider, Collapse, Paper, alpha, CircularProgress, Typography,
IconButton, Avatar, useTheme Box,
} from '@mui/material'; Card,
import SendIcon from '@mui/icons-material/Send'; CardContent,
import AddIcon from '@mui/icons-material/Add'; Button,
import DeleteIcon from '@mui/icons-material/Delete'; TextField,
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; Stack,
import { useTranslation } from 'react-i18next'; Chip,
import { useNavigate } from 'react-router-dom'; Divider,
import api from '../api'; Collapse,
import { useTitle } from '../hooks/useTitle'; Paper,
import { formatDate } from '../utils/date'; alpha,
CircularProgress,
IconButton,
Avatar,
useTheme,
} from "@mui/material";
import SendIcon from "@mui/icons-material/Send";
import AddIcon from "@mui/icons-material/Add";
import DeleteIcon from "@mui/icons-material/Delete";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import api from "../api";
import { useTitle } from "../hooks/useTitle";
import { formatDate } from "../utils/date";
interface Message { interface Message {
id: number; id: number;
@@ -30,7 +44,7 @@ interface Ticket {
user_avatar: string | null; user_avatar: string | null;
subject: string; subject: string;
description: string; description: string;
status: 'open' | 'in_progress' | 'resolved' | 'closed'; status: "open" | "in_progress" | "resolved" | "closed";
created_at: string; created_at: string;
messages: Message[]; messages: Message[];
} }
@@ -39,27 +53,29 @@ const Support: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
const navigate = useNavigate(); const navigate = useNavigate();
useTitle(t('support.title')); useTitle(t("support.title"));
const [tickets, setTickets] = useState<Ticket[]>([]); const [tickets, setTickets] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
// Create form // Create form
const [subject, setSubject] = useState(''); const [subject, setSubject] = useState("");
const [description, setDescription] = useState(''); const [description, setDescription] = useState("");
// Reply state // Reply state
const [replyText, setReplyText] = useState<{ [key: number]: string }>({}); const [replyText, setReplyText] = useState<{ [key: number]: string }>({});
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
const [currentUser, setCurrentUser] = useState<any>(null); const [currentUser, setCurrentUser] = useState<any>(null);
const [unreadTicketIds, setUnreadTicketIds] = useState<Set<number>>(new Set()); const [unreadTicketIds, setUnreadTicketIds] = useState<Set<number>>(
new Set(),
);
const fetchTickets = async () => { const fetchTickets = async () => {
try { try {
const [ticketRes, userRes, notifRes] = await Promise.all([ const [ticketRes, userRes, notifRes] = await Promise.all([
api.get('tickets/'), api.get("tickets/"),
api.get('users/me/'), api.get("users/me/"),
api.get('notifications/') api.get("notifications/"),
]); ]);
setTickets(ticketRes.data.results || ticketRes.data); setTickets(ticketRes.data.results || ticketRes.data);
setCurrentUser(userRes.data); setCurrentUser(userRes.data);
@@ -67,8 +83,13 @@ const Support: React.FC = () => {
const notifs = notifRes.data.results || notifRes.data; const notifs = notifRes.data.results || notifRes.data;
const unreadIds = new Set<number>( const unreadIds = new Set<number>(
notifs notifs
.filter((n: any) => !n.is_read && n.notification_type.startsWith('ticket') && n.ticket) .filter(
.map((n: any) => n.ticket) (n: any) =>
!n.is_read &&
n.notification_type.startsWith("ticket") &&
n.ticket,
)
.map((n: any) => n.ticket),
); );
setUnreadTicketIds(unreadIds); setUnreadTicketIds(unreadIds);
} catch (err) { } catch (err) {
@@ -105,13 +126,13 @@ const Support: React.FC = () => {
const handleCreate = async (e: React.FormEvent) => { const handleCreate = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
await api.post('tickets/', { subject, description }); await api.post("tickets/", { subject, description });
setSubject(''); setSubject("");
setDescription(''); setDescription("");
setShowCreate(false); setShowCreate(false);
fetchTickets(); fetchTickets();
} catch (err) { } catch (err) {
alert('Failed to create ticket'); alert("Failed to create ticket");
} }
}; };
@@ -120,10 +141,10 @@ const Support: React.FC = () => {
if (!text) return; if (!text) return;
try { try {
await api.post(`tickets/${ticketId}/add_message/`, { text }); await api.post(`tickets/${ticketId}/add_message/`, { text });
setReplyText({ ...replyText, [ticketId]: '' }); setReplyText({ ...replyText, [ticketId]: "" });
fetchTickets(); fetchTickets();
} catch (err) { } catch (err) {
alert('Failed to send message'); alert("Failed to send message");
} }
}; };
@@ -132,30 +153,39 @@ const Support: React.FC = () => {
await api.post(`tickets/${ticketId}/update_status/`, { status }); await api.post(`tickets/${ticketId}/update_status/`, { status });
fetchTickets(); fetchTickets();
} catch (err) { } catch (err) {
alert('Failed to update status'); alert("Failed to update status");
} }
}; };
const handleDeleteTicket = async (ticketId: number) => { const handleDeleteTicket = async (ticketId: number) => {
if (!confirm('Delete this ticket?')) return; if (!confirm("Delete this ticket?")) return;
try { try {
await api.delete(`tickets/${ticketId}/`); await api.delete(`tickets/${ticketId}/`);
fetchTickets(); fetchTickets();
} catch (err) { } catch (err) {
alert('Failed to delete ticket'); alert("Failed to delete ticket");
} }
}; };
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'open': return 'error'; case "open":
case 'in_progress': return 'warning'; return "error";
case 'resolved': return 'success'; case "in_progress":
default: return 'default'; return "warning";
case "resolved":
return "success";
default:
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 ( return (
<Container maxWidth="md" sx={{ py: 4 }}> <Container maxWidth="md" sx={{ py: 4 }}>
@@ -165,84 +195,181 @@ const Support: React.FC = () => {
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }} sx={{ mb: 4, borderRadius: 2 }}
> >
{t('common.back')} {t("common.back")}
</Button> </Button>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 4 }}> <Stack
<Typography variant="h3" sx={{ fontWeight: 'bold' }}>{t('support.title')}</Typography> direction="row"
justifyContent="space-between"
alignItems="center"
sx={{ mb: 4 }}
>
<Typography variant="h3" sx={{ fontWeight: "bold" }}>
{t("support.title")}
</Typography>
<Button <Button
variant="contained" variant="contained"
startIcon={<AddIcon />} startIcon={<AddIcon />}
onClick={() => setShowCreate(!showCreate)} onClick={() => setShowCreate(!showCreate)}
sx={{ borderRadius: 2 }} sx={{ borderRadius: 2 }}
> >
{t('support.createTicket')} {t("support.createTicket")}
</Button> </Button>
</Stack> </Stack>
<Collapse in={showCreate}> <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}> <form onSubmit={handleCreate}>
<TextField <TextField
fullWidth label={t('support.subject')} margin="normal" fullWidth
value={subject} onChange={(e) => setSubject(e.target.value)} required label={t("support.subject")}
margin="normal"
value={subject}
onChange={(e) => setSubject(e.target.value)}
required
/> />
<TextField <TextField
fullWidth multiline rows={4} label={t('support.description')} margin="normal" fullWidth
value={description} onChange={(e) => setDescription(e.target.value)} required 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 }}> <Button
{t('support.createTicket')} type="submit"
variant="contained"
sx={{ mt: 2, borderRadius: 2 }}
>
{t("support.createTicket")}
</Button> </Button>
</form> </form>
</Paper> </Paper>
</Collapse> </Collapse>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 'bold' }}> <Typography variant="h5" sx={{ mb: 2, fontWeight: "bold" }}>
{currentUser?.is_superuser ? t('admin.users') + ' Tickets' : t('support.myTickets')} {currentUser?.is_superuser
? t("admin.users") + " Tickets"
: t("support.myTickets")}
</Typography> </Typography>
{tickets.length === 0 ? ( {tickets.length === 0 ? (
<Box sx={{ py: 8, textAlign: 'center', bgcolor: 'action.hover', borderRadius: 4 }}> <Box
<Typography color="text.secondary">{t('support.noTickets')}</Typography> sx={{
py: 8,
textAlign: "center",
bgcolor: "action.hover",
borderRadius: 4,
}}
>
<Typography color="text.secondary">
{t("support.noTickets")}
</Typography>
</Box> </Box>
) : ( ) : (
<Stack spacing={2}> <Stack spacing={2}>
{tickets.map((ticket: Ticket) => ( {tickets.map((ticket: Ticket) => (
<Card key={ticket.id} sx={{ <Card
key={ticket.id}
sx={{
borderRadius: 3, borderRadius: 3,
border: '1px solid', border: "1px solid",
borderColor: unreadTicketIds.has(ticket.id) ? 'primary.main' : 'divider', borderColor: unreadTicketIds.has(ticket.id)
bgcolor: unreadTicketIds.has(ticket.id) ? alpha(theme.palette.primary.main, 0.02) : 'inherit' ? "primary.main"
}}> : "divider",
bgcolor: unreadTicketIds.has(ticket.id)
? alpha(theme.palette.primary.main, 0.02)
: "inherit",
}}
>
<CardContent sx={{ p: 0 }}> <CardContent sx={{ p: 0 }}>
<Box <Box
onClick={() => handleExpand(ticket.id)} 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) && ( {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> <Box>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}> <Typography
variant="subtitle1"
sx={{ fontWeight: "bold" }}
>
{ticket.subject} {ticket.subject}
{currentUser?.is_superuser && ( {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}) ({ticket.username})
</Typography> </Typography>
)} )}
</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>
</Box> </Box>
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
{unreadTicketIds.has(ticket.id) && ( {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 && ( {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" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
)} )}
@@ -250,28 +377,68 @@ const Support: React.FC = () => {
</Box> </Box>
{currentUser?.is_superuser && ( {currentUser?.is_superuser && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 1, p: 1, borderRadius: 2, bgcolor: alpha('#000', 0.03) }}> <Box
<Avatar src={ticket.user_avatar || undefined} sx={{ width: 24, height: 24, fontSize: '0.7rem' }}> 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()} {ticket.username.charAt(0).toUpperCase()}
</Avatar> </Avatar>
<Box sx={{ flex: 1, minWidth: 0 }}> <Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis' }}> <Typography
{ticket.username} • <span style={{ opacity: 0.7 }}>{ticket.user_email}</span> variant="caption"
sx={{
fontWeight: "bold",
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{ticket.username} •{" "}
<span style={{ opacity: 0.7 }}>
{ticket.user_email}
</span>
</Typography> </Typography>
</Box> </Box>
<Button <Button
size="small" size="small"
onClick={(e) => { e.stopPropagation(); navigate(`/profile/${ticket.username}`); }} onClick={(e) => {
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }} e.stopPropagation();
navigate(`/profile/${ticket.username}`);
}}
sx={{
minWidth: 0,
py: 0,
textTransform: "none",
fontSize: "0.7rem",
}}
> >
View Profile View Profile
</Button> </Button>
<Button <Button
size="small" size="small"
onClick={(e) => { e.stopPropagation(); navigate(`/admin-panel/user/${ticket.user}`); }} onClick={(e) => {
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }} 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> </Button>
</Box> </Box>
)} )}
@@ -279,55 +446,122 @@ const Support: React.FC = () => {
<Collapse in={expandedId === ticket.id}> <Collapse in={expandedId === ticket.id}>
<Divider /> <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 && ( {currentUser?.is_superuser && (
<Stack direction="row" spacing={1} sx={{ mb: 2, overflowX: 'auto', pb: 1 }}> <Stack
{['open', 'in_progress', 'resolved', 'closed'].map((s) => ( direction="row"
spacing={1}
sx={{ mb: 2, overflowX: "auto", pb: 1 }}
>
{["open", "in_progress", "resolved", "closed"].map(
(s) => (
<Button <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)} onClick={() => handleUpdateStatus(ticket.id, s)}
color={getStatusColor(s) as any} 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}`)} {t(`support.${s}`)}
</Button> </Button>
))} ),
)}
</Stack> </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} {ticket.description}
</Typography> </Typography>
<Stack spacing={2} sx={{ mb: 3 }}> <Stack spacing={2} sx={{ mb: 3 }}>
{ticket.messages.map((msg: Message) => ( {ticket.messages.map((msg: Message) => (
<Box key={msg.id} sx={{ <Box
alignSelf: msg.is_admin ? 'flex-start' : 'flex-end', key={msg.id}
maxWidth: '85%', sx={{
alignSelf: msg.is_admin ? "flex-start" : "flex-end",
maxWidth: "85%",
p: 1.5, p: 1.5,
borderRadius: 2, borderRadius: 2,
bgcolor: msg.is_admin ? 'primary.main' : 'action.selected', bgcolor: msg.is_admin
color: msg.is_admin ? 'white' : 'inherit', ? "primary.main"
boxShadow: msg.is_admin ? 2 : 0 : "action.selected",
}}> color: msg.is_admin ? "white" : "inherit",
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block' }}> boxShadow: msg.is_admin ? 2 : 0,
{msg.username} {msg.is_admin && '(Support)'} }}
>
<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>
<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> </Box>
))} ))}
</Stack> </Stack>
{ticket.status !== 'closed' && ( {ticket.status !== "closed" && (
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
<TextField <TextField
fullWidth size="small" placeholder={t('community.writeReply')} fullWidth
value={replyText[ticket.id] || ''} size="small"
onChange={(e) => setReplyText({ ...replyText, [ticket.id]: e.target.value })} placeholder={t("community.writeReply")}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleReply(ticket.id)} 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 /> <SendIcon />
</IconButton> </IconButton>
</Stack> </Stack>
+57 -32
View File
@@ -1,43 +1,53 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from "react";
import { useLocation, useNavigate, Link } from 'react-router-dom'; import { useLocation, useNavigate, Link } from "react-router-dom";
import { Container, Paper, Typography, Box, CircularProgress, Button, Alert } from '@mui/material'; import {
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; Container,
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; Paper,
import { useTranslation } from 'react-i18next'; Typography,
import api from '../api'; Box,
import { useTitle } from '../hooks/useTitle'; CircularProgress,
Button,
Alert,
} from "@mui/material";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import { useTranslation } from "react-i18next";
import api from "../api";
import { useTitle } from "../hooks/useTitle";
const VerifyEmail: React.FC = () => { const VerifyEmail: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); const [status, setStatus] = useState<"loading" | "success" | "error">(
const [errorMsg, setErrorMsg] = useState(''); "loading",
);
const [errorMsg, setErrorMsg] = useState("");
useTitle(t('auth.verifyTitle')); useTitle(t("auth.verifyTitle"));
useEffect(() => { useEffect(() => {
const query = new URLSearchParams(location.search); const query = new URLSearchParams(location.search);
const username = query.get('username'); const username = query.get("username");
const code = query.get('code'); const code = query.get("code");
if (!username || !code) { if (!username || !code) {
setStatus('error'); setStatus("error");
setErrorMsg('Invalid verification link.'); setErrorMsg("Invalid verification link.");
return; return;
} }
const verify = async () => { const verify = async () => {
try { try {
await api.post('users/verify_signup/', { username, code }); await api.post("users/verify_signup/", { username, code });
setStatus('success'); setStatus("success");
// Optional: Auto redirect after 3 seconds // Optional: Auto redirect after 3 seconds
setTimeout(() => { setTimeout(() => {
navigate('/login'); navigate("/login");
}, 3000); }, 3000);
} catch (err: any) { } catch (err: any) {
setStatus('error'); setStatus("error");
setErrorMsg(err.response?.data?.error || 'Verification failed.'); setErrorMsg(err.response?.data?.error || "Verification failed.");
} }
}; };
@@ -46,11 +56,11 @@ const VerifyEmail: React.FC = () => {
return ( return (
<Container maxWidth="sm" sx={{ py: 12 }}> <Container maxWidth="sm" sx={{ py: 12 }}>
<Paper sx={{ p: 6, textAlign: 'center', borderRadius: 4 }}> <Paper sx={{ p: 6, textAlign: "center", borderRadius: 4 }}>
{status === 'loading' && ( {status === "loading" && (
<Box> <Box>
<CircularProgress size={60} sx={{ mb: 4 }} /> <CircularProgress size={60} sx={{ mb: 4 }} />
<Typography variant="h5" sx={{ fontWeight: 'bold' }}> <Typography variant="h5" sx={{ fontWeight: "bold" }}>
Verifying your account... Verifying your account...
</Typography> </Typography>
<Typography color="text.secondary" sx={{ mt: 1 }}> <Typography color="text.secondary" sx={{ mt: 1 }}>
@@ -59,34 +69,49 @@ const VerifyEmail: React.FC = () => {
</Box> </Box>
)} )}
{status === 'success' && ( {status === "success" && (
<Box> <Box>
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 80, mb: 2 }} /> <CheckCircleOutlineIcon
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 2 }}> color="success"
{t('auth.verifySuccess')} sx={{ fontSize: 80, mb: 2 }}
/>
<Typography variant="h4" sx={{ fontWeight: "bold", mb: 2 }}>
{t("auth.verifySuccess")}
</Typography> </Typography>
<Typography color="text.secondary" sx={{ mb: 4 }}> <Typography color="text.secondary" sx={{ mb: 4 }}>
You will be redirected to the login page shortly. You will be redirected to the login page shortly.
</Typography> </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 Go to Login Now
</Button> </Button>
</Box> </Box>
)} )}
{status === 'error' && ( {status === "error" && (
<Box> <Box>
<ErrorOutlineIcon color="error" sx={{ fontSize: 80, mb: 2 }} /> <ErrorOutlineIcon color="error" sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 2 }}> <Typography variant="h4" sx={{ fontWeight: "bold", mb: 2 }}>
Verification Failed Verification Failed
</Typography> </Typography>
<Alert severity="error" sx={{ mb: 4, textAlign: 'left' }}> <Alert severity="error" sx={{ mb: 4, textAlign: "left" }}>
{errorMsg} {errorMsg}
</Alert> </Alert>
<Typography color="text.secondary" sx={{ mb: 4 }}> <Typography color="text.secondary" sx={{ mb: 4 }}>
The link might be expired or already used. The link might be expired or already used.
</Typography> </Typography>
<Button variant="outlined" component={Link} to="/login" size="large" sx={{ borderRadius: 2 }}> <Button
variant="outlined"
component={Link}
to="/login"
size="large"
sx={{ borderRadius: 2 }}
>
Back to Login / Signup Back to Login / Signup
</Button> </Button>
</Box> </Box>
+101 -56
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from "react";
import { useParams, Link, useNavigate } from 'react-router-dom'; import { useParams, Link, useNavigate } from "react-router-dom";
import { import {
Typography, Typography,
Box, Box,
@@ -7,14 +7,14 @@ import {
CardContent, CardContent,
CircularProgress, CircularProgress,
Container, Container,
Button Button,
} from '@mui/material'; } from "@mui/material";
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import api from '../../api'; import api from "../../api";
import { useTitle } from '../../hooks/useTitle'; import { useTitle } from "../../hooks/useTitle";
import RatingBadge from '../../components/RatingBadge'; import RatingBadge from "../../components/RatingBadge";
import StatusBadge from '../../components/StatusBadge'; import StatusBadge from "../../components/StatusBadge";
import { useTranslation } from 'react-i18next'; import { useTranslation } from "react-i18next";
interface Rating { interface Rating {
id: number; id: number;
@@ -43,14 +43,14 @@ const CategoryFlavors: React.FC = () => {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [flavors, setFlavors] = useState<Flavor[]>([]); const [flavors, setFlavors] = useState<Flavor[]>([]);
const [categoryName, setCategoryName] = useState(''); const [categoryName, setCategoryName] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const handleGoBack = () => { const handleGoBack = () => {
if (window.history.length > 1) { if (window.history.length > 1) {
navigate(-1); navigate(-1);
} else { } else {
navigate('/'); navigate("/");
} }
}; };
@@ -59,7 +59,9 @@ const CategoryFlavors: React.FC = () => {
setLoading(true); setLoading(true);
try { try {
const res = await api.get(`flavors/?category__slug=${slug}`); const res = await api.get(`flavors/?category__slug=${slug}`);
const data = Array.isArray(res.data) ? res.data : (res.data.results || []); const data = Array.isArray(res.data)
? res.data
: res.data.results || [];
setFlavors(data); setFlavors(data);
if (data.length > 0) { if (data.length > 0) {
setCategoryName(data[0].category_name); setCategoryName(data[0].category_name);
@@ -73,85 +75,128 @@ const CategoryFlavors: React.FC = () => {
fetchFlavors(); fetchFlavors();
}, [slug]); }, [slug]);
useTitle(categoryName || 'Flavors'); useTitle(categoryName || "Flavors");
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>; if (loading)
return (
<Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
<CircularProgress />
</Box>
);
return ( return (
<Container maxWidth={false} sx={{ px: { xs: 2, sm: 4, md: 6 }, py: 4 }}> <Container maxWidth={false} sx={{ px: { xs: 2, sm: 4, md: 6 }, py: 4 }}>
<Box sx={{ mb: 4 }}> <Box sx={{ mb: 4 }}>
<Typography variant="h4" gutterBottom>{categoryName || 'Flavors'}</Typography> <Typography variant="h4" gutterBottom>
{categoryName || "Flavors"}
</Typography>
<Button <Button
variant="outlined" variant="outlined"
onClick={handleGoBack} onClick={handleGoBack}
startIcon={<ArrowBackIcon />} startIcon={<ArrowBackIcon />}
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
textTransform: 'none', textTransform: "none",
fontWeight: 'bold', fontWeight: "bold",
color: 'text.secondary', color: "text.secondary",
borderColor: 'divider', borderColor: "divider",
'&:hover': { "&:hover": {
borderColor: 'primary.main', borderColor: "primary.main",
color: 'primary.main', color: "primary.main",
bgcolor: 'transparent' bgcolor: "transparent",
} },
}} }}
> >
{window.history.length > 1 ? t('common.back') : t('common.backToHome')} {window.history.length > 1
? t("common.back")
: t("common.backToHome")}
</Button> </Button>
</Box> </Box>
<Box sx={{ <Box
display: 'grid', sx={{
display: "grid",
gridTemplateColumns: { gridTemplateColumns: {
xs: '1fr', xs: "1fr",
sm: 'repeat(auto-fill, minmax(280px, 1fr))', sm: "repeat(auto-fill, minmax(280px, 1fr))",
lg: 'repeat(auto-fill, minmax(240px, 1fr))' lg: "repeat(auto-fill, minmax(240px, 1fr))",
}, },
gap: 3 gap: 3,
}}> }}
{flavors.map(flavor => ( >
{flavors.map((flavor) => (
<Box key={flavor.id}> <Box key={flavor.id}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', position: 'relative', transition: 'transform 0.2s', '&:hover': { transform: 'scale(1.02)' } }}> <Card
<Box component={Link} to={`/flavor/${flavor.id}`} sx={{ textDecoration: 'none', color: 'inherit', flexGrow: 1, display: 'flex', flexDirection: 'column' }}> sx={{
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 1 }}> height: "100%",
<StatusBadge isLegacy={flavor.is_legacy} isAvailable={flavor.is_available} size="small" /> display: "flex",
flexDirection: "column",
position: "relative",
transition: "transform 0.2s",
"&:hover": { transform: "scale(1.02)" },
}}
>
<Box
component={Link}
to={`/flavor/${flavor.id}`}
sx={{
textDecoration: "none",
color: "inherit",
flexGrow: 1,
display: "flex",
flexDirection: "column",
}}
>
<Box
sx={{ position: "absolute", top: 10, right: 10, zIndex: 1 }}
>
<StatusBadge
isLegacy={flavor.is_legacy}
isAvailable={flavor.is_available}
size="small"
/>
</Box> </Box>
{flavor.image_url && ( {flavor.image_url && (
<Box <Box
sx={{ sx={{
width: '100%', width: "100%",
aspectRatio: '1/1', aspectRatio: "1/1",
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
bgcolor: 'background.default', bgcolor: "background.default",
borderBottom: '1px solid', borderBottom: "1px solid",
borderColor: 'divider', borderColor: "divider",
overflow: 'hidden' overflow: "hidden",
}} }}
> >
<Box <Box
component="img" component="img"
src={flavor.image_url} src={flavor.image_url}
sx={{ sx={{
width: '100%', width: "100%",
height: '100%', height: "100%",
objectFit: 'cover', objectFit: "cover",
transition: 'transform 0.5s ease', transition: "transform 0.5s ease",
'&:hover': { transform: 'scale(1.1)' } "&:hover": { transform: "scale(1.1)" },
}} }}
/> />
</Box> </Box>
)} )}
<CardContent sx={{ flexGrow: 1 }}> <CardContent sx={{ flexGrow: 1 }}>
<Typography variant="h6" sx={{ fontSize: '1.1rem', mb: 1 }}>{flavor.name}</Typography> <Typography variant="h6" sx={{ fontSize: "1.1rem", mb: 1 }}>
{flavor.name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}> <Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
<RatingBadge score={flavor.average_rating || 0} size="small" /> <RatingBadge
score={flavor.average_rating || 0}
size="small"
/>
</Box> </Box>
<Typography variant="caption" color="text.secondary">{flavor.ratings.length} reviews</Typography> <Typography variant="caption" color="text.secondary">
{flavor.ratings.length} reviews
</Typography>
</CardContent> </CardContent>
</Box> </Box>
</Card> </Card>
File diff suppressed because it is too large. Load diff
+183 -159
View File
@@ -1,152 +1,172 @@
import { createTheme, type ThemeOptions, alpha } from '@mui/material/styles'; import { createTheme, type ThemeOptions, alpha } from "@mui/material/styles";
import { flavors } from '@catppuccin/palette'; import { flavors } from "@catppuccin/palette";
export type CatppuccinTheme = export type CatppuccinTheme =
| 'holy_light' | 'holy_dark' | "holy_light"
| 'latte' | 'frappe' | 'macchiato' | 'mocha' | "holy_dark"
| 'pink_pastel' | 'mint_pastel' | 'lavender_pastel' | 'dracula' | 'nord' | 'gruvbox' | 'oceanic' | "latte"
| 't0p_sai' | 't0p_trench' | 't0p_blurryface' | 't0p_clancy'; | "frappe"
| "macchiato"
| "mocha"
| "pink_pastel"
| "mint_pastel"
| "lavender_pastel"
| "dracula"
| "nord"
| "gruvbox"
| "oceanic"
| "t0p_sai"
| "t0p_trench"
| "t0p_blurryface"
| "t0p_clancy";
export const isLightTheme = (mode: CatppuccinTheme): boolean => { export const isLightTheme = (mode: CatppuccinTheme): boolean => {
return ['holy_light', 'latte', 'pink_pastel', 'mint_pastel', 'lavender_pastel', 't0p_sai'].includes(mode); return [
"holy_light",
"latte",
"pink_pastel",
"mint_pastel",
"lavender_pastel",
"t0p_sai",
].includes(mode);
}; };
export const getTheme = (mode: CatppuccinTheme) => { export const getTheme = (mode: CatppuccinTheme) => {
let palette: any; let palette: any;
// Define Holy branding colors // Define Holy branding colors
const holyPurple = { light: '#7c3aed', dark: '#a78bfa' }; const holyPurple = { light: "#7c3aed", dark: "#a78bfa" };
const holyPink = { light: '#db2777', dark: '#f472b6' }; const holyPink = { light: "#db2777", dark: "#f472b6" };
if (mode === 'holy_light') { if (mode === "holy_light") {
palette = { palette = {
base: { hex: '#f8fafc' }, base: { hex: "#f8fafc" },
surface0: { hex: '#ffffff' }, surface0: { hex: "#ffffff" },
crust: { hex: '#f1f5f9' }, crust: { hex: "#f1f5f9" },
text: { hex: '#0f172a' }, text: { hex: "#0f172a" },
subtext0: { hex: '#64748b' }, subtext0: { hex: "#64748b" },
primary: { hex: holyPurple.light }, primary: { hex: holyPurple.light },
secondary: { hex: holyPink.light }, secondary: { hex: holyPink.light },
}; };
} else if (mode === 'holy_dark') { } else if (mode === "holy_dark") {
palette = { palette = {
base: { hex: '#020617' }, base: { hex: "#020617" },
surface0: { hex: '#0f172a' }, surface0: { hex: "#0f172a" },
crust: { hex: '#000000' }, crust: { hex: "#000000" },
text: { hex: '#f8fafc' }, text: { hex: "#f8fafc" },
subtext0: { hex: '#94a3b8' }, subtext0: { hex: "#94a3b8" },
primary: { hex: holyPurple.dark }, primary: { hex: holyPurple.dark },
secondary: { hex: holyPink.dark }, secondary: { hex: holyPink.dark },
}; };
} else if (mode === 'pink_pastel') { } else if (mode === "pink_pastel") {
palette = { palette = {
base: { hex: '#fff1f2' }, base: { hex: "#fff1f2" },
surface0: { hex: '#ffffff' }, surface0: { hex: "#ffffff" },
crust: { hex: '#ffe4e6' }, crust: { hex: "#ffe4e6" },
text: { hex: '#881337' }, text: { hex: "#881337" },
subtext0: { hex: '#be123c' }, subtext0: { hex: "#be123c" },
primary: { hex: '#fb7185' }, primary: { hex: "#fb7185" },
secondary: { hex: '#2dd4bf' }, secondary: { hex: "#2dd4bf" },
}; };
} else if (mode === 'mint_pastel') { } else if (mode === "mint_pastel") {
palette = { palette = {
base: { hex: '#f0fdf4' }, base: { hex: "#f0fdf4" },
surface0: { hex: '#ffffff' }, surface0: { hex: "#ffffff" },
crust: { hex: '#dcfce7' }, crust: { hex: "#dcfce7" },
text: { hex: '#14532d' }, text: { hex: "#14532d" },
subtext0: { hex: '#16a34a' }, subtext0: { hex: "#16a34a" },
primary: { hex: '#4ade80' }, primary: { hex: "#4ade80" },
secondary: { hex: '#fb7185' }, secondary: { hex: "#fb7185" },
}; };
} else if (mode === 'lavender_pastel') { } else if (mode === "lavender_pastel") {
palette = { palette = {
base: { hex: '#f5f3ff' }, base: { hex: "#f5f3ff" },
surface0: { hex: '#ffffff' }, surface0: { hex: "#ffffff" },
crust: { hex: '#ede9fe' }, crust: { hex: "#ede9fe" },
text: { hex: '#4c1d95' }, text: { hex: "#4c1d95" },
subtext0: { hex: '#7c3aed' }, subtext0: { hex: "#7c3aed" },
primary: { hex: '#8b5cf6' }, primary: { hex: "#8b5cf6" },
secondary: { hex: '#facc15' }, secondary: { hex: "#facc15" },
}; };
} else if (mode === 't0p_sai') { } else if (mode === "t0p_sai") {
palette = { palette = {
base: { hex: '#fdf2f8' }, // Very light SAI Pink base: { hex: "#fdf2f8" }, // Very light SAI Pink
surface0: { hex: '#ffffff' }, surface0: { hex: "#ffffff" },
crust: { hex: '#fce7f3' }, // SAI Pink accent crust: { hex: "#fce7f3" }, // SAI Pink accent
text: { hex: '#1e1b4b' }, text: { hex: "#1e1b4b" },
subtext0: { hex: '#4b5563' }, subtext0: { hex: "#4b5563" },
primary: { hex: '#00d7ff' }, // SAI Cyan primary: { hex: "#00d7ff" }, // SAI Cyan
secondary: { hex: '#ff8ad8' }, // SAI Pink secondary: { hex: "#ff8ad8" }, // SAI Pink
}; };
} else if (mode === 't0p_trench') { } else if (mode === "t0p_trench") {
palette = { palette = {
base: { hex: '#1a1a1a' }, base: { hex: "#1a1a1a" },
surface0: { hex: '#242424' }, surface0: { hex: "#242424" },
crust: { hex: '#000000' }, crust: { hex: "#000000" },
text: { hex: '#fce300' }, // Trench Yellow text: { hex: "#fce300" }, // Trench Yellow
subtext0: { hex: '#a0a0a0' }, subtext0: { hex: "#a0a0a0" },
primary: { hex: '#fce300' }, primary: { hex: "#fce300" },
secondary: { hex: '#4b5320' }, // Olive secondary: { hex: "#4b5320" }, // Olive
}; };
} else if (mode === 't0p_blurryface') { } else if (mode === "t0p_blurryface") {
palette = { palette = {
base: { hex: '#000000' }, base: { hex: "#000000" },
surface0: { hex: '#1a1a1a' }, surface0: { hex: "#1a1a1a" },
crust: { hex: '#000000' }, crust: { hex: "#000000" },
text: { hex: '#ffffff' }, text: { hex: "#ffffff" },
subtext0: { hex: '#e31e24' }, subtext0: { hex: "#e31e24" },
primary: { hex: '#e31e24' }, // BF Red primary: { hex: "#e31e24" }, // BF Red
secondary: { hex: '#ffffff' }, secondary: { hex: "#ffffff" },
}; };
} else if (mode === 't0p_clancy') { } else if (mode === "t0p_clancy") {
palette = { palette = {
base: { hex: '#000000' }, base: { hex: "#000000" },
surface0: { hex: '#121212' }, surface0: { hex: "#121212" },
crust: { hex: '#000000' }, crust: { hex: "#000000" },
text: { hex: '#ffffff' }, text: { hex: "#ffffff" },
subtext0: { hex: '#fce300' }, subtext0: { hex: "#fce300" },
primary: { hex: '#e31e24' }, // Clancy Red primary: { hex: "#e31e24" }, // Clancy Red
secondary: { hex: '#fce300' }, // Clancy Yellow secondary: { hex: "#fce300" }, // Clancy Yellow
}; };
} else if (mode === 'dracula') { } else if (mode === "dracula") {
palette = { palette = {
base: { hex: '#282a36' }, base: { hex: "#282a36" },
surface0: { hex: '#44475a' }, surface0: { hex: "#44475a" },
crust: { hex: '#191a21' }, crust: { hex: "#191a21" },
text: { hex: '#f8f8f2' }, text: { hex: "#f8f8f2" },
subtext0: { hex: '#6272a4' }, subtext0: { hex: "#6272a4" },
primary: { hex: '#bd93f9' }, primary: { hex: "#bd93f9" },
secondary: { hex: '#ff79c6' }, secondary: { hex: "#ff79c6" },
}; };
} else if (mode === 'nord') { } else if (mode === "nord") {
palette = { palette = {
base: { hex: '#2e3440' }, base: { hex: "#2e3440" },
surface0: { hex: '#3b4252' }, surface0: { hex: "#3b4252" },
crust: { hex: '#242933' }, crust: { hex: "#242933" },
text: { hex: '#eceff4' }, text: { hex: "#eceff4" },
subtext0: { hex: '#d8dee9' }, subtext0: { hex: "#d8dee9" },
primary: { hex: '#88c0d0' }, primary: { hex: "#88c0d0" },
secondary: { hex: '#b48ead' }, secondary: { hex: "#b48ead" },
}; };
} else if (mode === 'gruvbox') { } else if (mode === "gruvbox") {
palette = { palette = {
base: { hex: '#282828' }, base: { hex: "#282828" },
surface0: { hex: '#3c3836' }, surface0: { hex: "#3c3836" },
crust: { hex: '#1d2021' }, crust: { hex: "#1d2021" },
text: { hex: '#ebdbb2' }, text: { hex: "#ebdbb2" },
subtext0: { hex: '#928374' }, subtext0: { hex: "#928374" },
primary: { hex: '#fabd2f' }, primary: { hex: "#fabd2f" },
secondary: { hex: '#fb4934' }, secondary: { hex: "#fb4934" },
}; };
} else if (mode === 'oceanic') { } else if (mode === "oceanic") {
palette = { palette = {
base: { hex: '#1b262c' }, base: { hex: "#1b262c" },
surface0: { hex: '#0f4c75' }, surface0: { hex: "#0f4c75" },
crust: { hex: '#0f161c' }, crust: { hex: "#0f161c" },
text: { hex: '#bbe1fa' }, text: { hex: "#bbe1fa" },
subtext0: { hex: '#3282b8' }, subtext0: { hex: "#3282b8" },
primary: { hex: '#3282b8' }, primary: { hex: "#3282b8" },
secondary: { hex: '#bbe1fa' }, secondary: { hex: "#bbe1fa" },
}; };
} else { } else {
// Handle Catppuccin flavors // Handle Catppuccin flavors
@@ -165,15 +185,15 @@ export const getTheme = (mode: CatppuccinTheme) => {
} else { } else {
// Fallback to holy_light // Fallback to holy_light
palette = { palette = {
base: { hex: '#f8fafc' }, base: { hex: "#f8fafc" },
surface0: { hex: '#ffffff' }, surface0: { hex: "#ffffff" },
crust: { hex: '#f1f5f9' }, crust: { hex: "#f1f5f9" },
text: { hex: '#0f172a' }, text: { hex: "#0f172a" },
subtext0: { hex: '#64748b' }, subtext0: { hex: "#64748b" },
primary: { hex: holyPurple.light }, primary: { hex: holyPurple.light },
secondary: { hex: holyPink.light }, secondary: { hex: holyPink.light },
}; };
mode = 'holy_light'; mode = "holy_light";
} }
} }
@@ -181,10 +201,10 @@ export const getTheme = (mode: CatppuccinTheme) => {
const themeOptions: ThemeOptions = { const themeOptions: ThemeOptions = {
palette: { palette: {
mode: isLight ? 'light' : 'dark', mode: isLight ? "light" : "dark",
primary: { primary: {
main: palette.primary.hex, main: palette.primary.hex,
contrastText: isLight ? '#ffffff' : '#000000', contrastText: isLight ? "#ffffff" : "#000000",
}, },
secondary: { secondary: {
main: palette.secondary.hex, main: palette.secondary.hex,
@@ -201,7 +221,7 @@ export const getTheme = (mode: CatppuccinTheme) => {
action: { action: {
hover: alpha(palette.primary.hex, 0.08), hover: alpha(palette.primary.hex, 0.08),
selected: alpha(palette.primary.hex, 0.12), selected: alpha(palette.primary.hex, 0.12),
} },
}, },
shape: { shape: {
borderRadius: 8, borderRadius: 8,
@@ -214,35 +234,37 @@ export const getTheme = (mode: CatppuccinTheme) => {
h4: { fontWeight: 700 }, h4: { fontWeight: 700 },
h5: { fontWeight: 600 }, h5: { fontWeight: 600 },
h6: { fontWeight: 600 }, h6: { fontWeight: 600 },
button: { textTransform: 'none', fontWeight: 600 }, button: { textTransform: "none", fontWeight: 600 },
}, },
components: { components: {
MuiCssBaseline: { MuiCssBaseline: {
styleOverrides: { styleOverrides: {
body: { body: {
scrollbarColor: `${alpha(palette.text.hex, 0.2)} transparent`, scrollbarColor: `${alpha(palette.text.hex, 0.2)} transparent`,
'&::-webkit-scrollbar': { width: '8px', height: '8px' }, "&::-webkit-scrollbar": { width: "8px", height: "8px" },
'&::-webkit-scrollbar-thumb': { "&::-webkit-scrollbar-thumb": {
backgroundColor: alpha(palette.text.hex, 0.2), backgroundColor: alpha(palette.text.hex, 0.2),
borderRadius: '10px', borderRadius: "10px",
'&:hover': { backgroundColor: alpha(palette.text.hex, 0.3) } "&:hover": { backgroundColor: alpha(palette.text.hex, 0.3) },
},
},
}, },
}
}
}, },
MuiPaper: { MuiPaper: {
styleOverrides: { styleOverrides: {
root: { root: {
backgroundColor: isLight ? palette.surface0.hex : alpha(palette.surface0.hex, 0.8), backgroundColor: isLight
backgroundImage: 'none', ? palette.surface0.hex
backdropFilter: isLight ? 'none' : 'blur(16px)', : alpha(palette.surface0.hex, 0.8),
border: '1px solid', backgroundImage: "none",
backdropFilter: isLight ? "none" : "blur(16px)",
border: "1px solid",
borderColor: alpha(palette.text.hex, 0.08), borderColor: alpha(palette.text.hex, 0.08),
boxShadow: isLight boxShadow: isLight
? '0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.1)' ? "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 4px 20px rgba(0,0,0,0.2)",
} },
} },
}, },
MuiCard: { MuiCard: {
defaultProps: { defaultProps: {
@@ -250,57 +272,59 @@ export const getTheme = (mode: CatppuccinTheme) => {
}, },
styleOverrides: { styleOverrides: {
root: { root: {
backgroundColor: isLight ? palette.surface0.hex : alpha(palette.surface0.hex, 0.6), backgroundColor: isLight
backdropFilter: 'blur(12px)', ? palette.surface0.hex
border: '1px solid', : alpha(palette.surface0.hex, 0.6),
backdropFilter: "blur(12px)",
border: "1px solid",
borderColor: alpha(palette.text.hex, 0.1), borderColor: alpha(palette.text.hex, 0.1),
borderRadius: '12px', borderRadius: "12px",
transition: 'all 0.2s ease-in-out', transition: "all 0.2s ease-in-out",
} },
} },
}, },
MuiAppBar: { MuiAppBar: {
styleOverrides: { styleOverrides: {
root: { root: {
backgroundColor: alpha(palette.base.hex, 0.8), backgroundColor: alpha(palette.base.hex, 0.8),
backdropFilter: 'blur(20px)', backdropFilter: "blur(20px)",
borderBottom: '1px solid', borderBottom: "1px solid",
borderColor: alpha(palette.text.hex, 0.08), borderColor: alpha(palette.text.hex, 0.08),
color: palette.text.hex, color: palette.text.hex,
} },
} },
}, },
MuiButton: { MuiButton: {
styleOverrides: { styleOverrides: {
root: { root: {
borderRadius: '8px', borderRadius: "8px",
padding: '8px 20px', padding: "8px 20px",
}, },
containedPrimary: { containedPrimary: {
boxShadow: `0 4px 14px 0 ${alpha(palette.primary.hex, 0.39)}`, boxShadow: `0 4px 14px 0 ${alpha(palette.primary.hex, 0.39)}`,
'&:hover': { "&:hover": {
boxShadow: `0 6px 20px rgba(0,0,0,0.23)`, boxShadow: `0 6px 20px rgba(0,0,0,0.23)`,
} },
} },
} },
}, },
MuiTab: { MuiTab: {
styleOverrides: { styleOverrides: {
root: { root: {
minHeight: '48px', minHeight: "48px",
fontWeight: 700, fontWeight: 700,
borderRadius: '8px', borderRadius: "8px",
} },
} },
}, },
MuiOutlinedInput: { MuiOutlinedInput: {
styleOverrides: { styleOverrides: {
root: { root: {
borderRadius: '8px', borderRadius: "8px",
} },
} },
} },
} },
}; };
return createTheme(themeOptions); return createTheme(themeOptions);
+5 -5
View File
@@ -1,10 +1,10 @@
export const formatDate = (dateStr: string) => { export const formatDate = (dateStr: string) => {
if (!dateStr) return ''; if (!dateStr) return "";
const d = new Date(dateStr); const d = new Date(dateStr);
const day = String(d.getDate()).padStart(2, '0'); const day = String(d.getDate()).padStart(2, "0");
const month = String(d.getMonth() + 1).padStart(2, '0'); const month = String(d.getMonth() + 1).padStart(2, "0");
const year = d.getFullYear(); const year = d.getFullYear();
const hours = String(d.getHours()).padStart(2, '0'); const hours = String(d.getHours()).padStart(2, "0");
const minutes = String(d.getMinutes()).padStart(2, '0'); const minutes = String(d.getMinutes()).padStart(2, "0");
return `${day}/${month}/${year} ${hours}:${minutes}`; return `${day}/${month}/${year} ${hours}:${minutes}`;
}; };