mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
drawer fix for desktop
This commit is contained in:
1 parent
5e3474dd59
commit
aa702ad823
38 files changed
+7893
-3088
No files matched your search
+538
-199
File diff suppressed because it is too large.
Load diff
+13
-9
@@ -1,13 +1,13 @@
|
||||
import axios from 'axios';
|
||||
import axios from "axios";
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
if (document.cookie && document.cookie !== "") {
|
||||
const cookies = document.cookie.split(";");
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
// Does this cookie string begin with the name we want?
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
if (cookie.substring(0, name.length + 1) === name + "=") {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
@@ -17,22 +17,26 @@ function getCookie(name: string): string | null {
|
||||
}
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/',
|
||||
baseURL: import.meta.env.VITE_API_URL || "http://localhost:8000/api/",
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
// Set DRF Auth Token if available from localStorage
|
||||
const token = localStorage.getItem('token');
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Token ${token}`;
|
||||
}
|
||||
|
||||
// Set CSRF token for state-changing methods
|
||||
const csrfToken = getCookie('csrftoken');
|
||||
const csrfToken = getCookie("csrftoken");
|
||||
const method = config.method?.toUpperCase();
|
||||
if (csrfToken && method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
||||
if (
|
||||
csrfToken &&
|
||||
method &&
|
||||
["POST", "PUT", "PATCH", "DELETE"].includes(method)
|
||||
) {
|
||||
if (config.headers) {
|
||||
config.headers['X-CSRFToken'] = csrfToken;
|
||||
config.headers["X-CSRFToken"] = csrfToken;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||
TextField, Box, Typography, Alert, Slider, Stack,
|
||||
Tooltip, IconButton
|
||||
} from '@mui/material';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import api from '../api';
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Box,
|
||||
Typography,
|
||||
Alert,
|
||||
Slider,
|
||||
Stack,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import api from "../api";
|
||||
|
||||
interface BannerSettingSchema {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'number' | 'slider' | 'text' | 'boolean';
|
||||
type: "number" | "slider" | "text" | "boolean";
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
@@ -35,16 +45,21 @@ interface BannerSettingsDialogProps {
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClose, banner, onSave }) => {
|
||||
const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
banner,
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [localSettings, setLocalSettings] = useState<any>({});
|
||||
const [error, setError] = useState('');
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (banner) {
|
||||
setLocalSettings(banner.settings || {});
|
||||
setError('');
|
||||
setError("");
|
||||
}
|
||||
}, [banner]);
|
||||
|
||||
@@ -57,7 +72,7 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClo
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to save settings');
|
||||
setError(err.response?.data?.error || "Failed to save settings");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -66,29 +81,29 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClo
|
||||
const updateSetting = (key: string, value: any) => {
|
||||
setLocalSettings((prev: any) => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const renderField = (schema: BannerSettingSchema) => {
|
||||
const value = localSettings[schema.key] ?? '';
|
||||
const value = localSettings[schema.key] ?? "";
|
||||
|
||||
return (
|
||||
<Box key={schema.key} sx={{ mb: 3 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: "bold" }}>
|
||||
{schema.label}
|
||||
</Typography>
|
||||
{schema.description && (
|
||||
<Tooltip title={schema.description} arrow>
|
||||
<IconButton size="small" sx={{ p: 0.5 }}>
|
||||
<HelpOutlineIcon sx={{ fontSize: '1rem', opacity: 0.6 }} />
|
||||
<HelpOutlineIcon sx={{ fontSize: "1rem", opacity: 0.6 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{schema.type === 'slider' ? (
|
||||
{schema.type === "slider" ? (
|
||||
<Box sx={{ px: 1 }}>
|
||||
<Slider
|
||||
value={Number(value)}
|
||||
@@ -99,7 +114,7 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClo
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
) : schema.type === 'number' ? (
|
||||
) : schema.type === "number" ? (
|
||||
<TextField
|
||||
type="number"
|
||||
fullWidth
|
||||
@@ -118,7 +133,11 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClo
|
||||
)}
|
||||
|
||||
{schema.description && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: "block", mt: 0.5 }}
|
||||
>
|
||||
{schema.description}
|
||||
</Typography>
|
||||
)}
|
||||
@@ -128,31 +147,41 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClo
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 'bold' }}>
|
||||
<DialogTitle sx={{ fontWeight: "bold" }}>
|
||||
Banner Settings: {banner?.name}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{banner?.schema && banner.schema.length > 0 ? (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{banner.schema.map(field => renderField(field))}
|
||||
{banner.schema.map((field) => renderField(field))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography color="text.secondary" sx={{ fontStyle: 'italic', py: 2 }}>
|
||||
No dynamic schema defined for this banner. Please use the JSON editor or check the banner configuration files.
|
||||
<Typography
|
||||
color="text.secondary"
|
||||
sx={{ fontStyle: "italic", py: 2 }}
|
||||
>
|
||||
No dynamic schema defined for this banner. Please use the JSON
|
||||
editor or check the banner configuration files.
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={onClose} color="inherit">{t('common.cancel')}</Button>
|
||||
<Button onClick={onClose} color="inherit">
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="contained"
|
||||
disabled={saving}
|
||||
sx={{ px: 4 }}
|
||||
>
|
||||
{saving ? t('common.loading') : t('common.save')}
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Typography,
|
||||
Button,
|
||||
@@ -7,34 +7,37 @@ import {
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
Box
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CatppuccinTheme } from '../theme';
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { CatppuccinTheme } from "../theme";
|
||||
|
||||
interface CookieBannerProps {
|
||||
onThemeChange: (newTheme: CatppuccinTheme) => void;
|
||||
currentTheme: CatppuccinTheme;
|
||||
}
|
||||
|
||||
const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme }) => {
|
||||
const CookieBanner: React.FC<CookieBannerProps> = ({
|
||||
onThemeChange,
|
||||
currentTheme,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const consent = localStorage.getItem('cookie-consent');
|
||||
const consent = localStorage.getItem("cookie-consent");
|
||||
if (!consent) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAccept = () => {
|
||||
localStorage.setItem('cookie-consent', 'true');
|
||||
localStorage.setItem("cookie-consent", "true");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleToggleTheme = () => {
|
||||
const newTheme = currentTheme === 'holy_light' ? 'holy_dark' : 'holy_light';
|
||||
const newTheme = currentTheme === "holy_light" ? "holy_dark" : "holy_light";
|
||||
onThemeChange(newTheme);
|
||||
};
|
||||
|
||||
@@ -48,57 +51,85 @@ const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.main',
|
||||
border: "1px solid",
|
||||
borderColor: "primary.main",
|
||||
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.98),
|
||||
backdropFilter: 'blur(10px)',
|
||||
boxShadow: (theme) => `0 20px 50px ${alpha(theme.palette.primary.main, 0.2)}`,
|
||||
}
|
||||
backdropFilter: "blur(10px)",
|
||||
boxShadow: (theme) =>
|
||||
`0 20px 50px ${alpha(theme.palette.primary.main, 0.2)}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ fontWeight: 'bold', fontSize: '1.5rem', pt: 3 }}>
|
||||
{t('cookieBanner.title')} 🍪
|
||||
<DialogTitle sx={{ fontWeight: "bold", fontSize: "1.5rem", pt: 3 }}>
|
||||
{t("cookieBanner.title")} 🍪
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body1" sx={{ mb: 2, lineHeight: 1.7 }}>
|
||||
{t('cookieBanner.description')}
|
||||
{t("cookieBanner.description")}
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'action.hover',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
mt: 2
|
||||
}}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', display: 'block', mb: 1, textTransform: 'uppercase' }}>
|
||||
bgcolor: "action.hover",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
display: "block",
|
||||
mb: 1,
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
Technical Details:
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
• Session ID: Required for login & authentication.<br />
|
||||
• CSRF Token: Protects your account from malicious attacks.<br />
|
||||
• Preference Store: Remembers your theme and language choices.
|
||||
• Session ID: Required for login & authentication.
|
||||
<br />
|
||||
• CSRF Token: Protects your account from malicious attacks.
|
||||
<br />• Preference Store: Remembers your theme and language choices.
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 3, gap: 2, flexDirection: { xs: 'column', sm: 'row' } }}>
|
||||
<DialogActions
|
||||
sx={{ p: 3, gap: 2, flexDirection: { xs: "column", sm: "row" } }}
|
||||
>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleToggleTheme}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', m: '0 !important' }}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: "none",
|
||||
fontWeight: "bold",
|
||||
m: "0 !important",
|
||||
}}
|
||||
>
|
||||
{currentTheme === 'holy_light' ? t('cookieBanner.switchToDark') : t('cookieBanner.switchToLight')}
|
||||
{currentTheme === "holy_light"
|
||||
? t("cookieBanner.switchToDark")
|
||||
: t("cookieBanner.switchToLight")}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleAccept}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', px: 4, m: '0 !important' }}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: "none",
|
||||
fontWeight: "bold",
|
||||
px: 4,
|
||||
m: "0 !important",
|
||||
}}
|
||||
>
|
||||
{t('cookieBanner.accept')}
|
||||
{t("cookieBanner.accept")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
import GenerativeBanner from './GenerativeBanner';
|
||||
import NebulaBanner from './NebulaBanner';
|
||||
import FirefliesBanner from './FirefliesBanner';
|
||||
import HextechBanner from './HextechBanner';
|
||||
import HextechCorruptionBanner from './HextechCorruptionBanner';
|
||||
import GenerativeFlowBanner from './GenerativeFlowBanner';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import api from "../api";
|
||||
import GenerativeBanner from "./GenerativeBanner";
|
||||
import NebulaBanner from "./NebulaBanner";
|
||||
import FirefliesBanner from "./FirefliesBanner";
|
||||
import HextechBanner from "./HextechBanner";
|
||||
import HextechCorruptionBanner from "./HextechCorruptionBanner";
|
||||
import GenerativeFlowBanner from "./GenerativeFlowBanner";
|
||||
|
||||
interface Banner {
|
||||
id: number;
|
||||
@@ -24,20 +24,42 @@ interface DynamicBannerProps {
|
||||
}
|
||||
|
||||
// Future expansion: Add more banner components here
|
||||
const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({ username, settings }) => {
|
||||
const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({
|
||||
username,
|
||||
settings,
|
||||
}) => {
|
||||
// Placeholder for a second model
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, width: '100%', height: '100%',
|
||||
background: 'rgba(0,0,0,0.8)', color: '#0f0', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace',
|
||||
zIndex: 1, overflow: 'hidden', opacity: settings.opacity || 0.5
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "rgba(0,0,0,0.8)",
|
||||
color: "#0f0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "monospace",
|
||||
zIndex: 1,
|
||||
overflow: "hidden",
|
||||
opacity: settings.opacity || 0.5,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<div key={i} style={{ animation: `matrixFall ${2 + Math.random() * 3}s linear infinite` }}>
|
||||
{username.split('').map((char, j) => (
|
||||
<span key={j} style={{ display: 'block' }}>{char}</span>
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
animation: `matrixFall ${2 + Math.random() * 3}s linear infinite`,
|
||||
}}
|
||||
>
|
||||
{username.split("").map((char, j) => (
|
||||
<span key={j} style={{ display: "block" }}>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
@@ -61,12 +83,12 @@ const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
|
||||
useEffect(() => {
|
||||
const fetchActiveBanner = async () => {
|
||||
try {
|
||||
const res = await api.get('banners/active/', {
|
||||
params: { username: props.username }
|
||||
const res = await api.get("banners/active/", {
|
||||
params: { username: props.username },
|
||||
});
|
||||
setBanner(res.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch active banner:', err);
|
||||
console.error("Failed to fetch active banner:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -78,31 +100,31 @@ const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
|
||||
if (loading) return null;
|
||||
|
||||
// Default to GenerativeBanner if no active banner or unrecognized slug
|
||||
if (!banner || banner.slug === 'generative-web') {
|
||||
if (!banner || banner.slug === "generative-web") {
|
||||
return <GenerativeBanner {...props} settings={banner?.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'matrix') {
|
||||
if (banner.slug === "matrix") {
|
||||
return <MatrixBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'nebula') {
|
||||
if (banner.slug === "nebula") {
|
||||
return <NebulaBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'fireflies') {
|
||||
if (banner.slug === "fireflies") {
|
||||
return <FirefliesBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'hextech') {
|
||||
if (banner.slug === "hextech") {
|
||||
return <HextechBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'hextech-corruption') {
|
||||
if (banner.slug === "hextech-corruption") {
|
||||
return <HextechCorruptionBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'generative-flow') {
|
||||
if (banner.slug === "generative-flow") {
|
||||
return <GenerativeFlowBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
import React, { useRef, useEffect, useMemo } from "react";
|
||||
import { alpha, useTheme } from "@mui/material";
|
||||
|
||||
interface FirefliesBannerProps {
|
||||
username: string;
|
||||
@@ -16,7 +16,11 @@ interface FirefliesBannerProps {
|
||||
};
|
||||
}
|
||||
|
||||
const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, settings }) => {
|
||||
const FirefliesBanner: React.FC<FirefliesBannerProps> = ({
|
||||
username,
|
||||
palette,
|
||||
settings,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -38,14 +42,23 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
const seed = useMemo(() => getSeed(username), [username]);
|
||||
|
||||
const brightColors = useMemo(() => {
|
||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const colors =
|
||||
palette.length >= 2
|
||||
? palette
|
||||
: [
|
||||
theme.palette.primary.main,
|
||||
theme.palette.secondary.main,
|
||||
theme.palette.info.main,
|
||||
];
|
||||
const getBrightness = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
||||
return (r * 299 + g * 587 + b * 114) / 1000;
|
||||
};
|
||||
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
||||
const sorted = [...colors].sort(
|
||||
(a, b) => getBrightness(b) - getBrightness(a),
|
||||
);
|
||||
return sorted.slice(0, 3);
|
||||
}, [palette, theme]);
|
||||
|
||||
@@ -53,7 +66,7 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
const ctx = canvas.getContext("2d", { alpha: true });
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
@@ -89,7 +102,7 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
color: brightColors[i % brightColors.length],
|
||||
phase: pseudoRandom(i + 2500) * Math.PI * 2,
|
||||
noiseOffset: pseudoRandom(i + 3000) * 100,
|
||||
isEvading: false
|
||||
isEvading: false,
|
||||
});
|
||||
}
|
||||
return newSwarm;
|
||||
@@ -122,8 +135,11 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; });
|
||||
parent.addEventListener("mousemove", handleMouseMove);
|
||||
parent.addEventListener("mouseleave", () => {
|
||||
mouse.x = -1000;
|
||||
mouse.y = -1000;
|
||||
});
|
||||
}
|
||||
|
||||
let time = 0;
|
||||
@@ -138,16 +154,23 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Vibrant theme-aware night sky background
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
|
||||
const isLight = theme.palette.mode === "light";
|
||||
const bgGrad = ctx.createRadialGradient(
|
||||
width / 2,
|
||||
height / 2,
|
||||
0,
|
||||
width / 2,
|
||||
height / 2,
|
||||
width,
|
||||
);
|
||||
if (isLight) {
|
||||
// For light themes, we still need a "dusk/night" feel for fireflies to make sense.
|
||||
// We use a deeper, more saturated version of the theme's colors.
|
||||
bgGrad.addColorStop(0, alpha(brightColors[2] || '#1a1a2e', 0.85));
|
||||
bgGrad.addColorStop(1, alpha(brightColors[2] || '#16213e', 1.0));
|
||||
bgGrad.addColorStop(0, alpha(brightColors[2] || "#1a1a2e", 0.85));
|
||||
bgGrad.addColorStop(1, alpha(brightColors[2] || "#16213e", 1.0));
|
||||
} else {
|
||||
bgGrad.addColorStop(0, '#141a2a'); // Noticeable blue-grey tint
|
||||
bgGrad.addColorStop(1, '#05080f');
|
||||
bgGrad.addColorStop(0, "#141a2a"); // Noticeable blue-grey tint
|
||||
bgGrad.addColorStop(1, "#05080f");
|
||||
}
|
||||
ctx.fillStyle = bgGrad;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
@@ -208,7 +231,14 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
const opacity = flicker > 0 ? flicker : 0;
|
||||
|
||||
// Render
|
||||
const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.size * glowSizeMultiplier);
|
||||
const grad = ctx.createRadialGradient(
|
||||
f.x,
|
||||
f.y,
|
||||
0,
|
||||
f.x,
|
||||
f.y,
|
||||
f.size * glowSizeMultiplier,
|
||||
);
|
||||
grad.addColorStop(0, alpha(f.color, opacity));
|
||||
grad.addColorStop(0.4, alpha(f.color, opacity * 0.3));
|
||||
grad.addColorStop(1, alpha(f.color, 0));
|
||||
@@ -218,7 +248,7 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
ctx.arc(f.x, f.y, f.size * glowSizeMultiplier, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = alpha('#fff', opacity * 0.8);
|
||||
ctx.fillStyle = alpha("#fff", opacity * 0.8);
|
||||
ctx.beginPath();
|
||||
ctx.arc(f.x, f.y, f.size * 0.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
@@ -232,23 +262,33 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMouseMove);
|
||||
parent.removeEventListener("mousemove", handleMouseMove);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [seed, brightColors, count, baseSpeed, evasionRadius, evasionStrength, glowSizeMultiplier, flickerSpeed, theme]);
|
||||
}, [
|
||||
seed,
|
||||
brightColors,
|
||||
count,
|
||||
baseSpeed,
|
||||
evasionRadius,
|
||||
evasionStrength,
|
||||
glowSizeMultiplier,
|
||||
flickerSpeed,
|
||||
theme,
|
||||
]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,32 +1,84 @@
|
||||
import React from 'react';
|
||||
import { Box, Container, Typography, Link as MuiLink, Divider } from '@mui/material';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Link as MuiLink,
|
||||
Divider,
|
||||
} from "@mui/material";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<Box component="footer" sx={{ bgcolor: 'background.paper', pt: 6, pb: 4, mt: 'auto', borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
bgcolor: "background.paper",
|
||||
pt: 6,
|
||||
pb: 4,
|
||||
mt: "auto",
|
||||
borderTop: "1px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, justifyContent: 'space-between', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: "bold" }}
|
||||
>
|
||||
© {year} Holy Flavors Archive
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||
<MuiLink component={Link} to="/about" color="inherit" sx={{ fontSize: '0.8rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
|
||||
<Box sx={{ display: "flex", gap: 3 }}>
|
||||
<MuiLink
|
||||
component={Link}
|
||||
to="/about"
|
||||
color="inherit"
|
||||
sx={{
|
||||
fontSize: "0.8rem",
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
Impressum
|
||||
</MuiLink>
|
||||
<MuiLink component={Link} to="/privacy" color="inherit" sx={{ fontSize: '0.8rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
|
||||
<MuiLink
|
||||
component={Link}
|
||||
to="/privacy"
|
||||
color="inherit"
|
||||
sx={{
|
||||
fontSize: "0.8rem",
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
Privacy Policy
|
||||
</MuiLink>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider sx={{ my: 2, opacity: 0.5 }} />
|
||||
<Typography variant="caption" color="text.secondary" align="center" display="block">
|
||||
{t('nav.about')} • Fan Project
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
align="center"
|
||||
display="block"
|
||||
>
|
||||
{t("nav.about")} • Fan Project
|
||||
</Typography>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import { alpha, useTheme } from "@mui/material";
|
||||
|
||||
interface GenerativeBannerProps {
|
||||
username: string;
|
||||
@@ -14,7 +14,13 @@ interface GenerativeBannerProps {
|
||||
};
|
||||
}
|
||||
|
||||
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => {
|
||||
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({
|
||||
username,
|
||||
palette,
|
||||
ratingsCount,
|
||||
followersCount,
|
||||
settings,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -35,15 +41,25 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
const ctx = canvas.getContext("2d", { alpha: true });
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
const seed = getSeed(username);
|
||||
|
||||
// Configuration - High density for "complex web" feel
|
||||
const nodeCount = Math.min(nodeCountBase + Math.floor(ratingsCount * 1.5), 120);
|
||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const nodeCount = Math.min(
|
||||
nodeCountBase + Math.floor(ratingsCount * 1.5),
|
||||
120,
|
||||
);
|
||||
const colors =
|
||||
palette.length >= 2
|
||||
? palette
|
||||
: [
|
||||
theme.palette.primary.main,
|
||||
theme.palette.secondary.main,
|
||||
theme.palette.info.main,
|
||||
];
|
||||
|
||||
interface Node {
|
||||
x: number;
|
||||
@@ -69,11 +85,14 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
|
||||
const bx = pseudoRandom(i) * width;
|
||||
const by = pseudoRandom(i + 100) * height;
|
||||
newNodes.push({
|
||||
x: bx, y: by,
|
||||
baseX: bx, baseY: by,
|
||||
vx: 0, vy: 0,
|
||||
x: bx,
|
||||
y: by,
|
||||
baseX: bx,
|
||||
baseY: by,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
color: colors[i % colors.length],
|
||||
size: 1 + pseudoRandom(i + 200) * 3
|
||||
size: 1 + pseudoRandom(i + 200) * 3,
|
||||
});
|
||||
}
|
||||
return newNodes;
|
||||
@@ -113,18 +132,29 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = () => { mouse.down = true; };
|
||||
const handleMouseUp = () => { mouse.down = false; };
|
||||
const handleMouseDown = () => {
|
||||
mouse.down = true;
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
mouse.down = false;
|
||||
};
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
parent.addEventListener('mousedown', handleMouseDown);
|
||||
parent.addEventListener('mouseup', handleMouseUp);
|
||||
parent.addEventListener('touchstart', (e) => { mouse.down = true; handleTouchMove(e); });
|
||||
parent.addEventListener('touchend', handleMouseUp);
|
||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
|
||||
parent.addEventListener("mousemove", handleMouseMove);
|
||||
parent.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
parent.addEventListener("mousedown", handleMouseDown);
|
||||
parent.addEventListener("mouseup", handleMouseUp);
|
||||
parent.addEventListener("touchstart", (e) => {
|
||||
mouse.down = true;
|
||||
handleTouchMove(e);
|
||||
});
|
||||
parent.addEventListener("touchend", handleMouseUp);
|
||||
parent.addEventListener("mouseleave", () => {
|
||||
mouse.x = -1000;
|
||||
mouse.y = -1000;
|
||||
mouse.down = false;
|
||||
});
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
@@ -197,7 +227,12 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
|
||||
ctx.lineTo(nodes[j].x, nodes[j].y);
|
||||
|
||||
// Dynamic gradient for the line
|
||||
const lineGrad = ctx.createLinearGradient(nodes[i].x, nodes[i].y, nodes[j].x, nodes[j].y);
|
||||
const lineGrad = ctx.createLinearGradient(
|
||||
nodes[i].x,
|
||||
nodes[i].y,
|
||||
nodes[j].x,
|
||||
nodes[j].y,
|
||||
);
|
||||
lineGrad.addColorStop(0, alpha(nodes[i].color, opacity));
|
||||
lineGrad.addColorStop(1, alpha(nodes[j].color, opacity));
|
||||
|
||||
@@ -226,7 +261,7 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
|
||||
}
|
||||
|
||||
// Draw Nodes
|
||||
nodes.forEach(node => {
|
||||
nodes.forEach((node) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, node.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = node.color;
|
||||
@@ -247,29 +282,39 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMouseMove);
|
||||
parent.removeEventListener('touchmove', handleTouchMove);
|
||||
parent.removeEventListener('mousedown', handleMouseDown);
|
||||
parent.removeEventListener('mouseup', handleMouseUp);
|
||||
parent.removeEventListener('touchstart', handleMouseDown);
|
||||
parent.removeEventListener('touchend', handleMouseUp);
|
||||
parent.removeEventListener("mousemove", handleMouseMove);
|
||||
parent.removeEventListener("touchmove", handleTouchMove);
|
||||
parent.removeEventListener("mousedown", handleMouseDown);
|
||||
parent.removeEventListener("mouseup", handleMouseUp);
|
||||
parent.removeEventListener("touchstart", handleMouseDown);
|
||||
parent.removeEventListener("touchend", handleMouseUp);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [username, palette, ratingsCount, followersCount, theme, nodeCountBase, connectionDist, speed, globalOpacity]);
|
||||
}, [
|
||||
username,
|
||||
palette,
|
||||
ratingsCount,
|
||||
followersCount,
|
||||
theme,
|
||||
nodeCountBase,
|
||||
connectionDist,
|
||||
speed,
|
||||
globalOpacity,
|
||||
]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1,
|
||||
mixBlendMode: theme.palette.mode === 'light' ? 'multiply' : 'screen'
|
||||
mixBlendMode: theme.palette.mode === "light" ? "multiply" : "screen",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
import React, { useRef, useEffect, useMemo } from "react";
|
||||
import { alpha, useTheme } from "@mui/material";
|
||||
|
||||
interface GenerativeFlowProps {
|
||||
username: string;
|
||||
@@ -15,7 +15,11 @@ interface GenerativeFlowProps {
|
||||
};
|
||||
}
|
||||
|
||||
const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette, settings }) => {
|
||||
const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({
|
||||
username,
|
||||
palette,
|
||||
settings,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -36,14 +40,23 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
const seed = useMemo(() => getSeed(username), [username]);
|
||||
|
||||
const brightColors = useMemo(() => {
|
||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const colors =
|
||||
palette.length >= 2
|
||||
? palette
|
||||
: [
|
||||
theme.palette.primary.main,
|
||||
theme.palette.secondary.main,
|
||||
theme.palette.info.main,
|
||||
];
|
||||
const getBrightness = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
||||
return (r * 299 + g * 587 + b * 114) / 1000;
|
||||
};
|
||||
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
||||
const sorted = [...colors].sort(
|
||||
(a, b) => getBrightness(b) - getBrightness(a),
|
||||
);
|
||||
return sorted.slice(0, 4);
|
||||
}, [palette, theme]);
|
||||
|
||||
@@ -51,7 +64,7 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
const ctx = canvas.getContext("2d", { alpha: true });
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
@@ -83,13 +96,16 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
const rx = pseudoRandom(i) * width;
|
||||
const ry = pseudoRandom(i + 500) * height;
|
||||
newBlobs.push({
|
||||
x: rx, y: ry,
|
||||
ox: rx, oy: ry,
|
||||
vx: 0, vy: 0,
|
||||
size: (width / 4) + pseudoRandom(i + 1000) * (width / 3),
|
||||
x: rx,
|
||||
y: ry,
|
||||
ox: rx,
|
||||
oy: ry,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
size: width / 4 + pseudoRandom(i + 1000) * (width / 3),
|
||||
color: brightColors[i % brightColors.length],
|
||||
phase: pseudoRandom(i + 1500) * Math.PI * 2,
|
||||
speedFactor: 0.5 + pseudoRandom(i + 2000)
|
||||
speedFactor: 0.5 + pseudoRandom(i + 2000),
|
||||
});
|
||||
}
|
||||
return newBlobs;
|
||||
@@ -122,8 +138,11 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; });
|
||||
parent.addEventListener("mousemove", handleMouseMove);
|
||||
parent.addEventListener("mouseleave", () => {
|
||||
mouse.x = -1000;
|
||||
mouse.y = -1000;
|
||||
});
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
@@ -137,15 +156,28 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Background Base
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
|
||||
const isLight = theme.palette.mode === "light";
|
||||
const bgGrad = ctx.createRadialGradient(
|
||||
width / 2,
|
||||
height / 2,
|
||||
0,
|
||||
width / 2,
|
||||
height / 2,
|
||||
width,
|
||||
);
|
||||
if (isLight) {
|
||||
// For light themes, use a deeper, more saturated version of the theme color
|
||||
bgGrad.addColorStop(0, alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.6));
|
||||
bgGrad.addColorStop(1, alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.9));
|
||||
bgGrad.addColorStop(
|
||||
0,
|
||||
alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.6),
|
||||
);
|
||||
bgGrad.addColorStop(
|
||||
1,
|
||||
alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.9),
|
||||
);
|
||||
} else {
|
||||
bgGrad.addColorStop(0, '#0a0a1a');
|
||||
bgGrad.addColorStop(1, '#020205');
|
||||
bgGrad.addColorStop(0, "#0a0a1a");
|
||||
bgGrad.addColorStop(1, "#020205");
|
||||
}
|
||||
ctx.fillStyle = bgGrad;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
@@ -154,12 +186,13 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
|
||||
// Apply filter for "Liquid" look
|
||||
ctx.filter = `blur(${blurAmount}px)`;
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
ctx.globalCompositeOperation = "screen";
|
||||
|
||||
blobs.forEach((b) => {
|
||||
// Harmonic autonomous drift
|
||||
const driftX = Math.sin(time * b.speedFactor + b.phase) * (width / 5);
|
||||
const driftY = Math.cos(time * 0.8 * b.speedFactor + b.phase) * (height / 5);
|
||||
const driftY =
|
||||
Math.cos(time * 0.8 * b.speedFactor + b.phase) * (height / 5);
|
||||
|
||||
// Mouse distortion
|
||||
const dxMouse = mouse.x - b.ox;
|
||||
@@ -177,7 +210,14 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
const pulse = 1 + Math.sin(time + b.phase) * 0.1;
|
||||
const currentSize = b.size * pulse;
|
||||
|
||||
const grad = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, currentSize);
|
||||
const grad = ctx.createRadialGradient(
|
||||
b.x,
|
||||
b.y,
|
||||
0,
|
||||
b.x,
|
||||
b.y,
|
||||
currentSize,
|
||||
);
|
||||
grad.addColorStop(0, alpha(b.color, vibrancy));
|
||||
grad.addColorStop(1, alpha(b.color, 0));
|
||||
|
||||
@@ -187,8 +227,8 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
ctx.filter = 'none';
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.filter = "none";
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
@@ -198,23 +238,32 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMouseMove);
|
||||
parent.removeEventListener("mousemove", handleMouseMove);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme]);
|
||||
}, [
|
||||
seed,
|
||||
brightColors,
|
||||
blobCount,
|
||||
flowSpeed,
|
||||
vibrancy,
|
||||
blurAmount,
|
||||
distortionFactor,
|
||||
theme,
|
||||
]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
import React, { useRef, useEffect, useMemo } from "react";
|
||||
import { alpha, useTheme } from "@mui/material";
|
||||
|
||||
interface HextechBannerProps {
|
||||
username: string;
|
||||
@@ -15,7 +15,11 @@ interface HextechBannerProps {
|
||||
};
|
||||
}
|
||||
|
||||
const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settings }) => {
|
||||
const HextechBanner: React.FC<HextechBannerProps> = ({
|
||||
username,
|
||||
palette,
|
||||
settings,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -36,14 +40,23 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
const seed = useMemo(() => getSeed(username), [username]);
|
||||
|
||||
const brightColors = useMemo(() => {
|
||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const colors =
|
||||
palette.length >= 2
|
||||
? palette
|
||||
: [
|
||||
theme.palette.primary.main,
|
||||
theme.palette.secondary.main,
|
||||
theme.palette.info.main,
|
||||
];
|
||||
const getBrightness = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
||||
return (r * 299 + g * 587 + b * 114) / 1000;
|
||||
};
|
||||
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
||||
const sorted = [...colors].sort(
|
||||
(a, b) => getBrightness(b) - getBrightness(a),
|
||||
);
|
||||
return sorted.slice(0, 3);
|
||||
}, [palette, theme]);
|
||||
|
||||
@@ -51,7 +64,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
const ctx = canvas.getContext("2d", { alpha: true });
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
@@ -64,7 +77,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
|
||||
// Mana Stream Logic
|
||||
interface Stream {
|
||||
segments: { x: number, y: number }[];
|
||||
segments: { x: number; y: number }[];
|
||||
color: string;
|
||||
life: number;
|
||||
speed: number;
|
||||
@@ -73,7 +86,8 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
let streams: Stream[] = [];
|
||||
|
||||
const createStream = (width: number, height: number) => {
|
||||
const color = brightColors[Math.floor(pseudoRandom(time) * brightColors.length)];
|
||||
const color =
|
||||
brightColors[Math.floor(pseudoRandom(time) * brightColors.length)];
|
||||
let x = pseudoRandom(time + 1) * width;
|
||||
let y = pseudoRandom(time + 2) * height;
|
||||
|
||||
@@ -81,7 +95,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
segments: [{ x, y }],
|
||||
color,
|
||||
life: 1.0,
|
||||
speed: 2 + pseudoRandom(time + 3) * 4
|
||||
speed: 2 + pseudoRandom(time + 3) * 4,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -120,15 +134,23 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
const handleMouseDown = () => { mouse.down = true; };
|
||||
const handleMouseUp = () => { mouse.down = false; };
|
||||
const handleMouseDown = () => {
|
||||
mouse.down = true;
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
mouse.down = false;
|
||||
};
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mousedown', handleMouseDown);
|
||||
parent.addEventListener('mouseup', handleMouseUp);
|
||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
|
||||
parent.addEventListener("mousemove", handleMouseMove);
|
||||
parent.addEventListener("mousedown", handleMouseDown);
|
||||
parent.addEventListener("mouseup", handleMouseUp);
|
||||
parent.addEventListener("mouseleave", () => {
|
||||
mouse.x = -1000;
|
||||
mouse.y = -1000;
|
||||
mouse.down = false;
|
||||
});
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
@@ -142,15 +164,22 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Background
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
|
||||
const isLight = theme.palette.mode === "light";
|
||||
const bgGrad = ctx.createRadialGradient(
|
||||
width / 2,
|
||||
height / 2,
|
||||
0,
|
||||
width / 2,
|
||||
height / 2,
|
||||
width,
|
||||
);
|
||||
if (isLight) {
|
||||
// For light themes, use a deeper, more saturated version of the theme color
|
||||
bgGrad.addColorStop(0, alpha(brightColors[2] || brightColors[0], 0.65));
|
||||
bgGrad.addColorStop(1, alpha(brightColors[2] || brightColors[0], 0.95));
|
||||
} else {
|
||||
bgGrad.addColorStop(0, '#0a051a'); // Arcane Purple tint
|
||||
bgGrad.addColorStop(1, '#020105');
|
||||
bgGrad.addColorStop(0, "#0a051a"); // Arcane Purple tint
|
||||
bgGrad.addColorStop(1, "#020105");
|
||||
}
|
||||
ctx.fillStyle = bgGrad;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
@@ -164,22 +193,34 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
|
||||
for (let y = -gridSize; y < height + gridSize; y += h / 2) {
|
||||
for (let x = -gridSize; x < width + gridSize; x += w * 0.75) {
|
||||
const xOffset = (Math.floor(y / (h/2)) % 2 === 0) ? 0 : w * 0.375;
|
||||
const xOffset = Math.floor(y / (h / 2)) % 2 === 0 ? 0 : w * 0.375;
|
||||
const finalX = x + xOffset;
|
||||
const finalY = y;
|
||||
|
||||
// Apply Trippiness (Distortion)
|
||||
const dist = Math.sqrt(Math.pow(finalX - mouse.x, 2) + Math.pow(finalY - mouse.y, 2));
|
||||
const mouseImpact = Math.max(0, (200 - dist) / 200) * (mouse.down ? 2 : 1);
|
||||
const dist = Math.sqrt(
|
||||
Math.pow(finalX - mouse.x, 2) + Math.pow(finalY - mouse.y, 2),
|
||||
);
|
||||
const mouseImpact =
|
||||
Math.max(0, (200 - dist) / 200) * (mouse.down ? 2 : 1);
|
||||
|
||||
const dx = Math.sin(time + (finalX * 0.01)) * gridSize * distortionFactor * (1 + mouseImpact);
|
||||
const dy = Math.cos(time + (finalY * 0.01)) * gridSize * distortionFactor * (1 + mouseImpact);
|
||||
const dx =
|
||||
Math.sin(time + finalX * 0.01) *
|
||||
gridSize *
|
||||
distortionFactor *
|
||||
(1 + mouseImpact);
|
||||
const dy =
|
||||
Math.cos(time + finalY * 0.01) *
|
||||
gridSize *
|
||||
distortionFactor *
|
||||
(1 + mouseImpact);
|
||||
|
||||
const drawX = finalX + dx;
|
||||
const drawY = finalY + dy;
|
||||
|
||||
// Pulse glow
|
||||
const pulseVal = 0.1 + Math.sin(time * 2 + (finalX + finalY) * 0.005) * 0.1;
|
||||
const pulseVal =
|
||||
0.1 + Math.sin(time * 2 + (finalX + finalY) * 0.005) * 0.1;
|
||||
const opacity = (0.1 + pulseVal + mouseImpact * 0.3) * glowIntensity;
|
||||
|
||||
ctx.strokeStyle = alpha(brightColors[0], opacity);
|
||||
@@ -200,7 +241,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
streams.push(createStream(width, height));
|
||||
}
|
||||
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
ctx.globalCompositeOperation = "screen";
|
||||
streams.forEach((s, idx) => {
|
||||
s.life -= 0.005;
|
||||
const last = s.segments[s.segments.length - 1];
|
||||
@@ -222,7 +263,14 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
ctx.stroke();
|
||||
|
||||
const head = s.segments[s.segments.length - 1];
|
||||
const g = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 15);
|
||||
const g = ctx.createRadialGradient(
|
||||
head.x,
|
||||
head.y,
|
||||
0,
|
||||
head.x,
|
||||
head.y,
|
||||
15,
|
||||
);
|
||||
g.addColorStop(0, alpha(s.color, s.life));
|
||||
g.addColorStop(1, alpha(s.color, 0));
|
||||
ctx.fillStyle = g;
|
||||
@@ -230,9 +278,9 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
ctx.arc(head.x, head.y, 15, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
|
||||
streams = streams.filter(s => s.life > 0);
|
||||
streams = streams.filter((s) => s.life > 0);
|
||||
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
@@ -242,25 +290,34 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMouseMove);
|
||||
parent.removeEventListener('mousedown', handleMouseDown);
|
||||
parent.removeEventListener('mouseup', handleMouseUp);
|
||||
parent.removeEventListener("mousemove", handleMouseMove);
|
||||
parent.removeEventListener("mousedown", handleMouseDown);
|
||||
parent.removeEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [seed, brightColors, gridSize, pulseSpeed, glowIntensity, streamCount, distortionFactor, theme]);
|
||||
}, [
|
||||
seed,
|
||||
brightColors,
|
||||
gridSize,
|
||||
pulseSpeed,
|
||||
glowIntensity,
|
||||
streamCount,
|
||||
distortionFactor,
|
||||
theme,
|
||||
]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
import React, { useRef, useEffect, useMemo } from "react";
|
||||
import { alpha, useTheme } from "@mui/material";
|
||||
|
||||
interface HextechCorruptionBannerProps {
|
||||
username: string;
|
||||
@@ -15,7 +15,11 @@ interface HextechCorruptionBannerProps {
|
||||
};
|
||||
}
|
||||
|
||||
const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ username, palette, settings }) => {
|
||||
const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({
|
||||
username,
|
||||
palette,
|
||||
settings,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -36,14 +40,23 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
const seed = useMemo(() => getSeed(username), [username]);
|
||||
|
||||
const brightColors = useMemo(() => {
|
||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const colors =
|
||||
palette.length >= 2
|
||||
? palette
|
||||
: [
|
||||
theme.palette.primary.main,
|
||||
theme.palette.secondary.main,
|
||||
theme.palette.info.main,
|
||||
];
|
||||
const getBrightness = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
||||
return (r * 299 + g * 587 + b * 114) / 1000;
|
||||
};
|
||||
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
||||
const sorted = [...colors].sort(
|
||||
(a, b) => getBrightness(b) - getBrightness(a),
|
||||
);
|
||||
return sorted.slice(0, 3);
|
||||
}, [palette, theme]);
|
||||
|
||||
@@ -51,7 +64,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
const ctx = canvas.getContext("2d", { alpha: true });
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
@@ -81,11 +94,14 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
const rx = pseudoRandom(i) * width;
|
||||
const ry = pseudoRandom(i + 500) * height;
|
||||
newPoints.push({
|
||||
x: rx, y: ry,
|
||||
ox: rx, oy: ry,
|
||||
vx: 0, vy: 0,
|
||||
x: rx,
|
||||
y: ry,
|
||||
ox: rx,
|
||||
oy: ry,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
phase: pseudoRandom(i + 1000) * Math.PI * 2,
|
||||
color: brightColors[i % brightColors.length]
|
||||
color: brightColors[i % brightColors.length],
|
||||
});
|
||||
}
|
||||
return newPoints;
|
||||
@@ -115,15 +131,23 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
const handleMouseDown = () => { mouse.down = true; };
|
||||
const handleMouseUp = () => { mouse.down = false; };
|
||||
const handleMouseDown = () => {
|
||||
mouse.down = true;
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
mouse.down = false;
|
||||
};
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mousedown', handleMouseDown);
|
||||
parent.addEventListener('mouseup', handleMouseUp);
|
||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
|
||||
parent.addEventListener("mousemove", handleMouseMove);
|
||||
parent.addEventListener("mousedown", handleMouseDown);
|
||||
parent.addEventListener("mouseup", handleMouseUp);
|
||||
parent.addEventListener("mouseleave", () => {
|
||||
mouse.x = -1000;
|
||||
mouse.y = -1000;
|
||||
mouse.down = false;
|
||||
});
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
@@ -138,7 +162,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
time += shimmerSpeed;
|
||||
|
||||
// 1. Draw Under-Glow (Inner Cores)
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
points.forEach((p) => {
|
||||
// Harmonic motion + mouse interaction
|
||||
const dxMouse = mouse.x - p.x;
|
||||
@@ -153,7 +177,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
p.y = p.oy + driftY + (mouse.down ? -dyMouse * mouseForce * 0.5 : 0);
|
||||
|
||||
const pulse = 0.7 + Math.sin(time * 3 + p.phase) * 0.3;
|
||||
const glowSize = 60 + (mouseForce * 40);
|
||||
const glowSize = 60 + mouseForce * 40;
|
||||
|
||||
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize);
|
||||
grad.addColorStop(0, alpha(p.color, 0.8 * glowIntensity * pulse));
|
||||
@@ -168,29 +192,32 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
|
||||
// 2. Draw Dark "Void" Shell (Voronoi-based cutout)
|
||||
// We use a temporary canvas to draw the shell then mask it, or just use a heavy overlay
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
|
||||
// Create the "holes" effect by using a pattern of circles that are actually transparent
|
||||
// Actually, to get the specific look from the image, we can use a simpler approach:
|
||||
// Layer a dark color over everything, but with 'destination-out' circles at the point locations.
|
||||
|
||||
const offCanvas = document.createElement('canvas');
|
||||
const offCanvas = document.createElement("canvas");
|
||||
offCanvas.width = canvas.width;
|
||||
offCanvas.height = canvas.height;
|
||||
const offCtx = offCanvas.getContext('2d');
|
||||
const offCtx = offCanvas.getContext("2d");
|
||||
if (offCtx) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
offCtx.scale(dpr, dpr);
|
||||
|
||||
// Draw full dark shell
|
||||
const shellColor = theme.palette.mode === 'light' ? alpha('#1a0a2a', shellDarkness) : alpha('#05020a', shellDarkness);
|
||||
const shellColor =
|
||||
theme.palette.mode === "light"
|
||||
? alpha("#1a0a2a", shellDarkness)
|
||||
: alpha("#05020a", shellDarkness);
|
||||
offCtx.fillStyle = shellColor;
|
||||
offCtx.fillRect(0, 0, width, height);
|
||||
|
||||
// Cut holes
|
||||
offCtx.globalCompositeOperation = 'destination-out';
|
||||
points.forEach(p => {
|
||||
const holeSize = 15 + Math.sin(time + p.phase) * 5 + (instability * 10);
|
||||
offCtx.globalCompositeOperation = "destination-out";
|
||||
points.forEach((p) => {
|
||||
const holeSize = 15 + Math.sin(time + p.phase) * 5 + instability * 10;
|
||||
|
||||
// Main hole
|
||||
offCtx.beginPath();
|
||||
@@ -199,7 +226,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
|
||||
// Add secondary smaller holes for that "punctured" look
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const angle = p.phase + (j * Math.PI * 0.6);
|
||||
const angle = p.phase + j * Math.PI * 0.6;
|
||||
const hx = p.x + Math.cos(angle) * (holeSize * 1.5);
|
||||
const hy = p.y + Math.sin(angle) * (holeSize * 1.5);
|
||||
offCtx.beginPath();
|
||||
@@ -212,9 +239,9 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
}
|
||||
|
||||
// 3. Add Iridescent Edge Highlights
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
ctx.globalCompositeOperation = "screen";
|
||||
ctx.lineWidth = 2;
|
||||
points.forEach(p => {
|
||||
points.forEach((p) => {
|
||||
const pulse = 0.5 + Math.sin(time * 2 + p.phase) * 0.5;
|
||||
ctx.strokeStyle = alpha(p.color, pulse * 0.4);
|
||||
ctx.beginPath();
|
||||
@@ -222,7 +249,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
@@ -231,25 +258,34 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ usern
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMouseMove);
|
||||
parent.removeEventListener('mousedown', handleMouseDown);
|
||||
parent.removeEventListener('mouseup', handleMouseUp);
|
||||
parent.removeEventListener("mousemove", handleMouseMove);
|
||||
parent.removeEventListener("mousedown", handleMouseDown);
|
||||
parent.removeEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [seed, brightColors, cellCount, shimmerSpeed, glowIntensity, instability, shellDarkness, theme]);
|
||||
}, [
|
||||
seed,
|
||||
brightColors,
|
||||
cellCount,
|
||||
shimmerSpeed,
|
||||
glowIntensity,
|
||||
instability,
|
||||
shellDarkness,
|
||||
theme,
|
||||
]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
TextField,
|
||||
Popper,
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
Box,
|
||||
alpha
|
||||
} from '@mui/material';
|
||||
import api from '../api';
|
||||
alpha,
|
||||
} from "@mui/material";
|
||||
import api from "../api";
|
||||
|
||||
interface UserSuggestion {
|
||||
id: number;
|
||||
@@ -26,7 +26,7 @@ interface MentionTextFieldProps {
|
||||
fullWidth?: boolean;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
size?: 'small' | 'medium';
|
||||
size?: "small" | "medium";
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
||||
fullWidth = true,
|
||||
multiline = false,
|
||||
rows,
|
||||
size = 'small',
|
||||
onKeyDown
|
||||
size = "small",
|
||||
onKeyDown,
|
||||
}) => {
|
||||
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
@@ -52,17 +52,17 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await api.get('flavors/search/');
|
||||
const res = await api.get("flavors/search/");
|
||||
const users = (res.data || [])
|
||||
.filter((item: any) => item.type === 'user')
|
||||
.filter((item: any) => item.type === "user")
|
||||
.map((item: any) => ({
|
||||
id: item.id,
|
||||
username: item.name,
|
||||
avatar: item.image_url
|
||||
avatar: item.image_url,
|
||||
}));
|
||||
setAllUsers(users);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch users for mentions');
|
||||
console.error("Failed to fetch users for mentions");
|
||||
}
|
||||
};
|
||||
fetchUsers();
|
||||
@@ -74,14 +74,14 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
||||
|
||||
const selectionStart = e.target.selectionStart || 0;
|
||||
const textBeforeCursor = newValue.substring(0, selectionStart);
|
||||
const lastAtPos = textBeforeCursor.lastIndexOf('@');
|
||||
const lastAtPos = textBeforeCursor.lastIndexOf("@");
|
||||
|
||||
if (lastAtPos !== -1) {
|
||||
const query = textBeforeCursor.substring(lastAtPos + 1);
|
||||
// Ensure there's no space or newline between @ and the cursor
|
||||
if (!query.includes(' ') && !query.includes('\n')) {
|
||||
const filtered = allUsers.filter(u =>
|
||||
u.username.toLowerCase().startsWith(query.toLowerCase())
|
||||
if (!query.includes(" ") && !query.includes("\n")) {
|
||||
const filtered = allUsers.filter((u) =>
|
||||
u.username.toLowerCase().startsWith(query.toLowerCase()),
|
||||
);
|
||||
setSuggestions(filtered);
|
||||
setShowSuggestions(filtered.length > 0);
|
||||
@@ -97,11 +97,13 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
||||
const selectionStart = inputRef.current?.selectionStart || 0;
|
||||
const textBeforeCursor = value.substring(0, selectionStart);
|
||||
const textAfterCursor = value.substring(selectionStart);
|
||||
const lastAtPos = textBeforeCursor.lastIndexOf('@');
|
||||
const lastAtPos = textBeforeCursor.lastIndexOf("@");
|
||||
|
||||
const newValue =
|
||||
value.substring(0, lastAtPos) +
|
||||
'@' + user.username + ' ' +
|
||||
"@" +
|
||||
user.username +
|
||||
" " +
|
||||
textAfterCursor;
|
||||
|
||||
onChange(newValue);
|
||||
@@ -119,22 +121,24 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (showSuggestions) {
|
||||
if (e.key === 'Tab' || e.key === 'Enter') {
|
||||
if (e.key === "Tab" || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSelectSuggestion(suggestions[selectedIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % suggestions.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length);
|
||||
setSelectedIndex(
|
||||
(prev) => (prev - 1 + suggestions.length) % suggestions.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
if (e.key === "Escape") {
|
||||
setShowSuggestions(false);
|
||||
return;
|
||||
}
|
||||
@@ -143,7 +147,7 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', width: fullWidth ? '100%' : 'auto' }}>
|
||||
<Box sx={{ position: "relative", width: fullWidth ? "100%" : "auto" }}>
|
||||
<TextField
|
||||
inputRef={inputRef}
|
||||
fullWidth={fullWidth}
|
||||
@@ -163,7 +167,16 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
||||
placement="bottom-start"
|
||||
style={{ zIndex: 2000, width: 250 }}
|
||||
>
|
||||
<Paper elevation={8} sx={{ mt: 1, borderRadius: 2, overflow: 'hidden', border: '1px solid', borderColor: 'divider' }}>
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{
|
||||
mt: 1,
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<MenuList dense sx={{ py: 0 }}>
|
||||
{suggestions.map((user, index) => (
|
||||
<MenuItem
|
||||
@@ -172,20 +185,29 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
||||
onClick={() => handleSelectSuggestion(user)}
|
||||
sx={{
|
||||
py: 1,
|
||||
'&.Mui-selected': {
|
||||
"&.Mui-selected": {
|
||||
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.15),
|
||||
'&:hover': { bgcolor: (theme) => alpha(theme.palette.primary.main, 0.25) }
|
||||
}
|
||||
"&:hover": {
|
||||
bgcolor: (theme) =>
|
||||
alpha(theme.palette.primary.main, 0.25),
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemAvatar sx={{ minWidth: 40 }}>
|
||||
<Avatar src={user.avatar || undefined} sx={{ width: 28, height: 28, fontSize: '0.8rem' }}>
|
||||
<Avatar
|
||||
src={user.avatar || undefined}
|
||||
sx={{ width: 28, height: 28, fontSize: "0.8rem" }}
|
||||
>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={user.username}
|
||||
primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold' }}
|
||||
primaryTypographyProps={{
|
||||
variant: "body2",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
import React, { useRef, useEffect, useMemo } from "react";
|
||||
import { alpha, useTheme } from "@mui/material";
|
||||
|
||||
interface NebulaBannerProps {
|
||||
username: string;
|
||||
@@ -17,7 +17,13 @@ interface NebulaBannerProps {
|
||||
};
|
||||
}
|
||||
|
||||
const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => {
|
||||
const NebulaBanner: React.FC<NebulaBannerProps> = ({
|
||||
username,
|
||||
palette,
|
||||
ratingsCount,
|
||||
followersCount,
|
||||
settings,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -43,11 +49,18 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
const ctx = canvas.getContext("2d", { alpha: true });
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const colors =
|
||||
palette.length >= 2
|
||||
? palette
|
||||
: [
|
||||
theme.palette.primary.main,
|
||||
theme.palette.secondary.main,
|
||||
theme.palette.info.main,
|
||||
];
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
@@ -86,7 +99,7 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
|
||||
x: pseudoRandom(i + 5000) * width,
|
||||
y: pseudoRandom(i + 6000) * height,
|
||||
size: pseudoRandom(i + 7000) * 1.5,
|
||||
opacity: 0.1 + pseudoRandom(i + 8000) * 0.5
|
||||
opacity: 0.1 + pseudoRandom(i + 8000) * 0.5,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -95,7 +108,7 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
|
||||
const clusterCount = colors.length * 3; // More clusters
|
||||
const groupCenters = Array.from({ length: clusterCount }).map((_, i) => ({
|
||||
x: (pseudoRandom(i * 15) * 0.8 + 0.1) * width,
|
||||
y: (pseudoRandom(i * 25 + 7) * 0.8 + 0.1) * height
|
||||
y: (pseudoRandom(i * 25 + 7) * 0.8 + 0.1) * height,
|
||||
}));
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
@@ -108,14 +121,17 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
|
||||
const by = center.y + Math.sin(angle) * dist;
|
||||
|
||||
newParticles.push({
|
||||
x: bx, y: by,
|
||||
baseX: bx, baseY: by,
|
||||
vx: 0, vy: 0,
|
||||
x: bx,
|
||||
y: by,
|
||||
baseX: bx,
|
||||
baseY: by,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
size: 40 + pseudoRandom(i + 300) * 80, // Larger, softer blobs
|
||||
color: colors[colorIdx],
|
||||
alpha: 0,
|
||||
targetAlpha: 0.05 + pseudoRandom(i + 400) * 0.15,
|
||||
phase: pseudoRandom(i + 500) * Math.PI * 2
|
||||
phase: pseudoRandom(i + 500) * Math.PI * 2,
|
||||
});
|
||||
}
|
||||
return { stars: newStars, particles: newParticles };
|
||||
@@ -144,19 +160,28 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
|
||||
let mouse = { x: -1000, y: -1000, px: -1000, py: -1000, down: false };
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.px = mouse.x; mouse.py = mouse.y;
|
||||
mouse.px = mouse.x;
|
||||
mouse.py = mouse.y;
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
const handleMouseDown = () => { mouse.down = true; };
|
||||
const handleMouseUp = () => { mouse.down = false; };
|
||||
const handleMouseDown = () => {
|
||||
mouse.down = true;
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
mouse.down = false;
|
||||
};
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mousedown', handleMouseDown);
|
||||
parent.addEventListener('mouseup', handleMouseUp);
|
||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
|
||||
parent.addEventListener("mousemove", handleMouseMove);
|
||||
parent.addEventListener("mousedown", handleMouseDown);
|
||||
parent.addEventListener("mouseup", handleMouseUp);
|
||||
parent.addEventListener("mouseleave", () => {
|
||||
mouse.x = -1000;
|
||||
mouse.y = -1000;
|
||||
mouse.down = false;
|
||||
});
|
||||
}
|
||||
|
||||
let time = 0;
|
||||
@@ -171,8 +196,15 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// 1. Dynamic Celestial Background
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const skyGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
|
||||
const isLight = theme.palette.mode === "light";
|
||||
const skyGrad = ctx.createRadialGradient(
|
||||
width / 2,
|
||||
height / 2,
|
||||
0,
|
||||
width / 2,
|
||||
height / 2,
|
||||
width,
|
||||
);
|
||||
|
||||
if (isLight) {
|
||||
// Vibrant Deep Primary for light mode - but dark enough for glowing gas to pop
|
||||
@@ -181,17 +213,17 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
|
||||
skyGrad.addColorStop(1, alpha(colors[colors.length - 1], 1.0));
|
||||
} else {
|
||||
// True Deep Space with visible tints for dark mode
|
||||
skyGrad.addColorStop(0, '#1a1a3a'); // More blue/purple tint
|
||||
skyGrad.addColorStop(0.5, '#0a0a1a');
|
||||
skyGrad.addColorStop(1, '#050508');
|
||||
skyGrad.addColorStop(0, "#1a1a3a"); // More blue/purple tint
|
||||
skyGrad.addColorStop(0.5, "#0a0a1a");
|
||||
skyGrad.addColorStop(1, "#050508");
|
||||
}
|
||||
|
||||
ctx.fillStyle = skyGrad;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// 2. Draw Distant Stars
|
||||
ctx.fillStyle = '#fff';
|
||||
stars.forEach(s => {
|
||||
ctx.fillStyle = "#fff";
|
||||
stars.forEach((s) => {
|
||||
ctx.globalAlpha = s.opacity * (0.7 + Math.sin(time * 2 + s.x) * 0.3);
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
|
||||
@@ -252,13 +284,13 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
|
||||
grad.addColorStop(1, alpha(p.color, 0));
|
||||
|
||||
ctx.fillStyle = grad;
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
ctx.globalCompositeOperation = "screen";
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
@@ -267,25 +299,38 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsC
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMouseMove);
|
||||
parent.removeEventListener('mousedown', handleMouseDown);
|
||||
parent.removeEventListener('mouseup', handleMouseUp);
|
||||
parent.removeEventListener("mousemove", handleMouseMove);
|
||||
parent.removeEventListener("mousedown", handleMouseDown);
|
||||
parent.removeEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [seed, palette, ratingsCount, followersCount, theme, particleCount, speed, globalOpacity, smearRadius, turbulenceStrength, returnSpeed, starDensity]);
|
||||
}, [
|
||||
seed,
|
||||
palette,
|
||||
ratingsCount,
|
||||
followersCount,
|
||||
theme,
|
||||
particleCount,
|
||||
speed,
|
||||
globalOpacity,
|
||||
smearRadius,
|
||||
turbulenceStrength,
|
||||
returnSpeed,
|
||||
starDensity,
|
||||
]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,49 +1,65 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, useTheme } from '@mui/material';
|
||||
import React from "react";
|
||||
import { Box, Typography, useTheme } from "@mui/material";
|
||||
|
||||
interface RatingBadgeProps {
|
||||
score: number;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
size?: "small" | "medium" | "large";
|
||||
sx?: any;
|
||||
}
|
||||
|
||||
const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx }) => {
|
||||
const RatingBadge: React.FC<RatingBadgeProps> = ({
|
||||
score,
|
||||
size = "medium",
|
||||
sx,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isLarge = size === 'large';
|
||||
const isSmall = size === 'small';
|
||||
const isLarge = size === "large";
|
||||
const isSmall = size === "small";
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
<Box
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 0.5,
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
px: isLarge ? 2 : (isSmall ? 1 : 1.5),
|
||||
py: isLarge ? 0.5 : (isSmall ? 0.2 : 0.4),
|
||||
bgcolor: "primary.main",
|
||||
color: "primary.contrastText",
|
||||
px: isLarge ? 2 : isSmall ? 1 : 1.5,
|
||||
py: isLarge ? 0.5 : isSmall ? 0.2 : 0.4,
|
||||
borderRadius: 2,
|
||||
minWidth: 'fit-content',
|
||||
height: 'fit-content',
|
||||
boxShadow: theme.palette.mode === 'dark'
|
||||
? '0 4px 12px rgba(0,0,0,0.4)'
|
||||
: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
...sx
|
||||
}}>
|
||||
<Typography
|
||||
variant={isLarge ? "h5" : (isSmall ? "caption" : "subtitle1")}
|
||||
sx={{ fontWeight: '900', lineHeight: 1, display: 'flex', alignItems: 'center' }}
|
||||
minWidth: "fit-content",
|
||||
height: "fit-content",
|
||||
boxShadow:
|
||||
theme.palette.mode === "dark"
|
||||
? "0 4px 12px rgba(0,0,0,0.4)"
|
||||
: "0 2px 8px rgba(0,0,0,0.1)",
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{typeof score === 'number' ? (isLarge ? score.toFixed(1) : score.toFixed(0)) : score}
|
||||
<Typography
|
||||
variant={isLarge ? "h5" : isSmall ? "caption" : "subtitle1"}
|
||||
sx={{
|
||||
fontWeight: "900",
|
||||
lineHeight: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{typeof score === "number"
|
||||
? isLarge
|
||||
? score.toFixed(1)
|
||||
: score.toFixed(0)
|
||||
: score}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: isLarge ? '0.75rem' : (isSmall ? '0.55rem' : '0.65rem'),
|
||||
fontWeight: '900',
|
||||
fontSize: isLarge ? "0.75rem" : isSmall ? "0.55rem" : "0.65rem",
|
||||
fontWeight: "900",
|
||||
opacity: 0.9,
|
||||
lineHeight: 1,
|
||||
mt: 0.2
|
||||
mt: 0.2,
|
||||
}}
|
||||
>
|
||||
/ 10
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTheme, alpha } from '@mui/material';
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTheme, alpha } from "@mui/material";
|
||||
|
||||
interface RichTextProps {
|
||||
text: string;
|
||||
@@ -15,7 +15,7 @@ const RichText: React.FC<RichTextProps> = ({ text }) => {
|
||||
return (
|
||||
<span>
|
||||
{parts.map((part, i) => {
|
||||
if (part.startsWith('@')) {
|
||||
if (part.startsWith("@")) {
|
||||
const username = part.substring(1);
|
||||
return (
|
||||
<Link
|
||||
@@ -23,11 +23,11 @@ const RichText: React.FC<RichTextProps> = ({ text }) => {
|
||||
to={`/profile/${username}`}
|
||||
style={{
|
||||
color: theme.palette.primary.main,
|
||||
textDecoration: 'none',
|
||||
fontWeight: 'bold',
|
||||
textDecoration: "none",
|
||||
fontWeight: "bold",
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||
padding: '0 4px',
|
||||
borderRadius: '4px'
|
||||
padding: "0 4px",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
|
||||
@@ -1,50 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, alpha, useTheme } from '@mui/material';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import BlockIcon from '@mui/icons-material/Block';
|
||||
import React from "react";
|
||||
import { Box, Typography, alpha, useTheme } from "@mui/material";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import BlockIcon from "@mui/icons-material/Block";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
isLegacy?: boolean;
|
||||
isAvailable?: boolean;
|
||||
size?: 'small' | 'medium';
|
||||
size?: "small" | "medium";
|
||||
sx?: any;
|
||||
}
|
||||
|
||||
const StatusBadge: React.FC<StatusBadgeProps> = ({ isLegacy, isAvailable, size = 'medium', sx }) => {
|
||||
const StatusBadge: React.FC<StatusBadgeProps> = ({
|
||||
isLegacy,
|
||||
isAvailable,
|
||||
size = "medium",
|
||||
sx,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = size === 'small';
|
||||
const isSmall = size === "small";
|
||||
|
||||
if (isAvailable && !isLegacy) return null;
|
||||
|
||||
const label = isLegacy ? 'Limited' : 'Out of Stock';
|
||||
const color = isLegacy ? theme.palette.warning.main : theme.palette.error.main;
|
||||
const label = isLegacy ? "Limited" : "Out of Stock";
|
||||
const color = isLegacy
|
||||
? theme.palette.warning.main
|
||||
: theme.palette.error.main;
|
||||
const Icon = isLegacy ? HistoryIcon : BlockIcon;
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
<Box
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
bgcolor: alpha(color, 0.9),
|
||||
color: '#fff',
|
||||
color: "#fff",
|
||||
px: isSmall ? 0.8 : 1.2,
|
||||
py: isSmall ? 0.2 : 0.4,
|
||||
borderRadius: 1.5,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: '1px solid',
|
||||
borderColor: alpha('#fff', 0.2),
|
||||
...sx
|
||||
}}>
|
||||
<Icon sx={{ fontSize: isSmall ? '0.7rem' : '0.9rem' }} />
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.2)",
|
||||
backdropFilter: "blur(4px)",
|
||||
border: "1px solid",
|
||||
borderColor: alpha("#fff", 0.2),
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Icon sx={{ fontSize: isSmall ? "0.7rem" : "0.9rem" }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: '900',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: "900",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
fontSize: isSmall ? '0.55rem' : '0.65rem',
|
||||
lineHeight: 1
|
||||
fontSize: isSmall ? "0.55rem" : "0.65rem",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const useTitle = (title: string) => {
|
||||
useEffect(() => {
|
||||
|
||||
+520
-459
File diff suppressed because it is too large.
Load diff
@@ -1,13 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter as Router } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter as Router } from "react-router-dom";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<Router>
|
||||
<App />
|
||||
</Router>
|
||||
</StrictMode>,
|
||||
)
|
||||
);
|
||||
+111
-42
@@ -1,88 +1,157 @@
|
||||
import React from 'react';
|
||||
import { Typography, Box, Container, Paper, Divider, Button, useMediaQuery } from '@mui/material';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import React from "react";
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Container,
|
||||
Paper,
|
||||
Divider,
|
||||
Button,
|
||||
useMediaQuery,
|
||||
} from "@mui/material";
|
||||
import { Link } from "react-router-dom";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTitle } from "../hooks/useTitle";
|
||||
|
||||
const About: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
useTitle(t('nav.about'));
|
||||
const isMobile = useMediaQuery('(max-width:600px)');
|
||||
useTitle(t("nav.about"));
|
||||
const isMobile = useMediaQuery("(max-width:600px)");
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}>
|
||||
<Container
|
||||
maxWidth="md"
|
||||
sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component={Link}
|
||||
to="/"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ mb: 4, textTransform: 'none', borderRadius: 2 }}
|
||||
sx={{ mb: 4, textTransform: "none", borderRadius: 2 }}
|
||||
>
|
||||
{t('common.backToArchive')}
|
||||
{t("common.backToArchive")}
|
||||
</Button>
|
||||
|
||||
<Paper sx={{ p: { xs: 2.5, sm: 4, md: 6 }, borderRadius: 4, overflow: 'hidden' }}>
|
||||
<Typography variant={isMobile ? "h4" : "h3"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.title')}</Typography>
|
||||
<Typography variant="body1" paragraph sx={{ fontSize: { xs: '1rem', md: '1.1rem' }, lineHeight: 1.8 }}>
|
||||
{t('about.mission')}
|
||||
<Paper
|
||||
sx={{
|
||||
p: { xs: 2.5, sm: 4, md: 6 },
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant={isMobile ? "h4" : "h3"}
|
||||
gutterBottom
|
||||
sx={{ fontWeight: "bold" }}
|
||||
>
|
||||
{t("about.title")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
paragraph
|
||||
sx={{ fontSize: { xs: "1rem", md: "1.1rem" }, lineHeight: 1.8 }}
|
||||
>
|
||||
{t("about.mission")}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ my: 4 }}>
|
||||
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.canDoTitle')}</Typography>
|
||||
<Typography
|
||||
variant={isMobile ? "h6" : "h5"}
|
||||
gutterBottom
|
||||
sx={{ fontWeight: "bold" }}
|
||||
>
|
||||
{t("about.canDoTitle")}
|
||||
</Typography>
|
||||
<Typography component="ul" sx={{ lineHeight: 2, pl: 2 }}>
|
||||
<li>{t('about.featureExplore')}</li>
|
||||
<li>{t('about.featureRate')}</li>
|
||||
<li>{t('about.featureCommunity')}</li>
|
||||
<li>{t('about.featureProfile')}</li>
|
||||
<li>{t('about.featurePersonalize')}</li>
|
||||
<li>{t('about.featureEngage')}</li>
|
||||
<li>{t('about.featureSupport')}</li>
|
||||
<li>{t("about.featureExplore")}</li>
|
||||
<li>{t("about.featureRate")}</li>
|
||||
<li>{t("about.featureCommunity")}</li>
|
||||
<li>{t("about.featureProfile")}</li>
|
||||
<li>{t("about.featurePersonalize")}</li>
|
||||
<li>{t("about.featureEngage")}</li>
|
||||
<li>{t("about.featureSupport")}</li>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
{t('about.disclaimer')}
|
||||
{t("about.disclaimer")}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: { xs: 4, md: 6 } }} />
|
||||
|
||||
<Typography variant={isMobile ? "h5" : "h4"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.impressumTitle')}</Typography>
|
||||
<Typography
|
||||
variant={isMobile ? "h5" : "h4"}
|
||||
gutterBottom
|
||||
sx={{ fontWeight: "bold" }}
|
||||
>
|
||||
{t("about.impressumTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 2 }}>
|
||||
<strong>{t('about.legalInfo')}</strong><br />
|
||||
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name or Project Name]'}<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}<br />
|
||||
{t('about.region')}
|
||||
<strong>{t("about.legalInfo")}</strong>
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_NAME || "[Your Name or Project Name]"}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_STREET || "[Your Street Address]"}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_CITY || "[Postal Code and City]"}
|
||||
<br />
|
||||
{t("about.region")}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('about.contact')}:</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
|
||||
{t("about.contact")}:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
E-Mail: {import.meta.env.VITE_IMPRESSUM_EMAIL || '[Your Support Email Address]'}<br />
|
||||
E-Mail:{" "}
|
||||
{import.meta.env.VITE_IMPRESSUM_EMAIL ||
|
||||
"[Your Support Email Address]"}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_PHONE && (
|
||||
<>Telefon: {import.meta.env.VITE_IMPRESSUM_PHONE}<br /></>
|
||||
<>
|
||||
Telefon: {import.meta.env.VITE_IMPRESSUM_PHONE}
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
Internet: holy.narl.io
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('about.responsible')}</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
|
||||
{t("about.responsible")}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}
|
||||
{import.meta.env.VITE_IMPRESSUM_NAME || "[Your Name]"}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_STREET || "[Your Street Address]"}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_CITY || "[Postal Code and City]"}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.disclaimerTitle')}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" component="p" sx={{ mb: 2, lineHeight: 1.6 }}>
|
||||
<strong>{t('about.liabilityContentTitle')}</strong> {t('about.liabilityContentDesc')}
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: "bold" }}>
|
||||
{t("about.disclaimerTitle")}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" component="p" sx={{ lineHeight: 1.6 }}>
|
||||
<strong>{t('about.copyrightTitle')}</strong> {t('about.copyrightDesc')}
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
component="p"
|
||||
sx={{ mb: 2, lineHeight: 1.6 }}
|
||||
>
|
||||
<strong>{t("about.liabilityContentTitle")}</strong>{" "}
|
||||
{t("about.liabilityContentDesc")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
component="p"
|
||||
sx={{ lineHeight: 1.6 }}
|
||||
>
|
||||
<strong>{t("about.copyrightTitle")}</strong>{" "}
|
||||
{t("about.copyrightDesc")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,22 +1,28 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Container, Typography, Box, Button,
|
||||
Stack, CircularProgress, TextField, Paper
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import MentionTextField from '../components/MentionTextField';
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Button,
|
||||
Stack,
|
||||
CircularProgress,
|
||||
TextField,
|
||||
Paper,
|
||||
} from "@mui/material";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import api from "../api";
|
||||
import { useTitle } from "../hooks/useTitle";
|
||||
import MentionTextField from "../components/MentionTextField";
|
||||
|
||||
const AdminRatingDetail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [score, setScore] = useState(0);
|
||||
const [comment, setComment] = useState('');
|
||||
const [comment, setComment] = useState("");
|
||||
const [rating, setRating] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -25,10 +31,10 @@ const AdminRatingDetail: React.FC = () => {
|
||||
const res = await api.get(`ratings/${id}/`);
|
||||
setRating(res.data);
|
||||
setScore(res.data.score);
|
||||
setComment(res.data.comment || '');
|
||||
setComment(res.data.comment || "");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
navigate('/admin-panel');
|
||||
navigate("/admin-panel");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -38,25 +44,38 @@ const AdminRatingDetail: React.FC = () => {
|
||||
fetchRating();
|
||||
}, [id]);
|
||||
|
||||
useTitle(rating ? `${t('admin.manageRating')}: ${rating.flavor_name}` : 'Rating Management');
|
||||
useTitle(
|
||||
rating
|
||||
? `${t("admin.manageRating")}: ${rating.flavor_name}`
|
||||
: "Rating Management",
|
||||
);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
await api.patch(`ratings/${id}/`, { score, comment });
|
||||
fetchRating();
|
||||
alert('Rating updated!');
|
||||
} catch (err) { alert('Update failed'); }
|
||||
alert("Rating updated!");
|
||||
} catch (err) {
|
||||
alert("Update failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Delete this rating and all replies?')) return;
|
||||
if (!window.confirm("Delete this rating and all replies?")) return;
|
||||
try {
|
||||
await api.delete(`ratings/${id}/`);
|
||||
navigate('/admin-panel');
|
||||
} catch (err) { alert('Delete failed'); }
|
||||
navigate("/admin-panel");
|
||||
} catch (err) {
|
||||
alert("Delete failed");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||
if (loading)
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ py: 4 }}>
|
||||
@@ -66,28 +85,48 @@ const AdminRatingDetail: React.FC = () => {
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 4 }}>{t('admin.manageRating')}</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: "bold", mb: 4 }}>
|
||||
{t("admin.manageRating")}
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ p: 3, borderRadius: 4 }}>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: "bold" }}
|
||||
>
|
||||
{rating.flavor_name} (User: {rating.user})
|
||||
</Typography>
|
||||
<Stack spacing={3} sx={{ mt: 3 }}>
|
||||
<TextField
|
||||
label="Score" type="number"
|
||||
label="Score"
|
||||
type="number"
|
||||
slotProps={{ input: { inputProps: { min: 1, max: 10 } } }}
|
||||
value={score} onChange={(e) => setScore(Number(e.target.value))}
|
||||
value={score}
|
||||
onChange={(e) => setScore(Number(e.target.value))}
|
||||
/>
|
||||
<MentionTextField
|
||||
multiline rows={4}
|
||||
value={comment} onChange={setComment}
|
||||
multiline
|
||||
rows={4}
|
||||
value={comment}
|
||||
onChange={setComment}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button variant="contained" fullWidth onClick={handleUpdate}>{t('common.save')}</Button>
|
||||
<Button variant="outlined" color="error" fullWidth startIcon={<DeleteIcon />} onClick={handleDelete}>{t('common.delete')}</Button>
|
||||
<Button variant="contained" fullWidth onClick={handleUpdate}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
fullWidth
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Container, Typography, Box, Button,
|
||||
Stack, CircularProgress, Paper
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import MentionTextField from '../components/MentionTextField';
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Button,
|
||||
Stack,
|
||||
CircularProgress,
|
||||
Paper,
|
||||
} from "@mui/material";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import api from "../api";
|
||||
import { useTitle } from "../hooks/useTitle";
|
||||
import MentionTextField from "../components/MentionTextField";
|
||||
|
||||
const AdminReplyDetail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [text, setText] = useState('');
|
||||
const [text, setText] = useState("");
|
||||
const [reply, setReply] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -27,7 +32,7 @@ const AdminReplyDetail: React.FC = () => {
|
||||
setText(res.data.text);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
navigate('/admin-panel');
|
||||
navigate("/admin-panel");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -37,25 +42,34 @@ const AdminReplyDetail: React.FC = () => {
|
||||
fetchReply();
|
||||
}, [id]);
|
||||
|
||||
useTitle(reply ? `${t('admin.manageReply')}` : 'Reply Management');
|
||||
useTitle(reply ? `${t("admin.manageReply")}` : "Reply Management");
|
||||
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
await api.patch(`replies/${id}/`, { text });
|
||||
fetchReply();
|
||||
alert('Reply updated!');
|
||||
} catch (err) { alert('Update failed'); }
|
||||
alert("Reply updated!");
|
||||
} catch (err) {
|
||||
alert("Update failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Delete this reply?')) return;
|
||||
if (!window.confirm("Delete this reply?")) return;
|
||||
try {
|
||||
await api.delete(`replies/${id}/`);
|
||||
navigate('/admin-panel');
|
||||
} catch (err) { alert('Delete failed'); }
|
||||
navigate("/admin-panel");
|
||||
} catch (err) {
|
||||
alert("Delete failed");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||
if (loading)
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ py: 4 }}>
|
||||
@@ -65,23 +79,41 @@ const AdminReplyDetail: React.FC = () => {
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 4 }}>{t('admin.manageReply')}</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: "bold", mb: 4 }}>
|
||||
{t("admin.manageReply")}
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ p: 3, borderRadius: 4 }}>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: "bold" }}
|
||||
>
|
||||
Reply by {reply.user}
|
||||
</Typography>
|
||||
<Stack spacing={3} sx={{ mt: 3 }}>
|
||||
<MentionTextField
|
||||
multiline rows={4}
|
||||
value={text} onChange={setText}
|
||||
multiline
|
||||
rows={4}
|
||||
value={text}
|
||||
onChange={setText}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button variant="contained" fullWidth onClick={handleUpdate}>{t('common.save')}</Button>
|
||||
<Button variant="outlined" color="error" fullWidth startIcon={<DeleteIcon />} onClick={handleDelete}>{t('common.delete')}</Button>
|
||||
<Button variant="contained" fullWidth onClick={handleUpdate}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
fullWidth
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Container, Typography, Box, Card, CardContent, Button,
|
||||
Stack, alpha, useTheme, CircularProgress, Chip, Paper, List, ListItem, ListItemText,
|
||||
Avatar, Grid, TextField, Tabs, Tab, Divider
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import SecurityIcon from '@mui/icons-material/Security';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import RateReviewIcon from '@mui/icons-material/RateReview';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { formatDate } from '../utils/date';
|
||||
import MentionTextField from '../components/MentionTextField';
|
||||
import RichText from '../components/RichText';
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Stack,
|
||||
alpha,
|
||||
useTheme,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Avatar,
|
||||
Grid,
|
||||
TextField,
|
||||
Tabs,
|
||||
Tab,
|
||||
Divider,
|
||||
} from "@mui/material";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import SecurityIcon from "@mui/icons-material/Security";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import RateReviewIcon from "@mui/icons-material/RateReview";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import api from "../api";
|
||||
import { useTitle } from "../hooks/useTitle";
|
||||
import { formatDate } from "../utils/date";
|
||||
import MentionTextField from "../components/MentionTextField";
|
||||
import RichText from "../components/RichText";
|
||||
|
||||
interface AdminUser {
|
||||
id: number;
|
||||
@@ -41,11 +59,11 @@ const AdminUserDetail: React.FC = () => {
|
||||
// Edit state for Ratings
|
||||
const [editingRatingId, setEditingRatingId] = useState<number | null>(null);
|
||||
const [editScore, setEditScore] = useState(0);
|
||||
const [editComment, setEditComment] = useState('');
|
||||
const [editComment, setEditComment] = useState("");
|
||||
|
||||
// Edit state for Replies
|
||||
const [editingReplyId, setEditingReplyId] = useState<number | null>(null);
|
||||
const [editReplyText, setEditReplyText] = useState('');
|
||||
const [editReplyText, setEditReplyText] = useState("");
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
@@ -53,7 +71,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
setUser(res.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
navigate('/admin-panel');
|
||||
navigate("/admin-panel");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -63,30 +81,43 @@ const AdminUserDetail: React.FC = () => {
|
||||
fetchUser();
|
||||
}, [id]);
|
||||
|
||||
useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : 'User Management');
|
||||
useTitle(
|
||||
user ? `${t("admin.manageUser")}: ${user.username}` : "User Management",
|
||||
);
|
||||
|
||||
const handleToggleActive = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
await api.patch(`admin-custom/${id}/user_detail/`, { is_active: !user.is_active });
|
||||
await api.patch(`admin-custom/${id}/user_detail/`, {
|
||||
is_active: !user.is_active,
|
||||
});
|
||||
fetchUser();
|
||||
} catch (err) { alert('Action failed'); }
|
||||
} catch (err) {
|
||||
alert("Action failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRating = async (ratingId: number) => {
|
||||
try {
|
||||
await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment });
|
||||
await api.patch(`ratings/${ratingId}/`, {
|
||||
score: editScore,
|
||||
comment: editComment,
|
||||
});
|
||||
setEditingRatingId(null);
|
||||
fetchUser();
|
||||
} catch (err) { alert('Update failed'); }
|
||||
} catch (err) {
|
||||
alert("Update failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRating = async (ratingId: number) => {
|
||||
if (!confirm('Delete this rating?')) return;
|
||||
if (!confirm("Delete this rating?")) return;
|
||||
try {
|
||||
await api.delete(`ratings/${ratingId}/`);
|
||||
fetchUser();
|
||||
} catch (err) { alert('Delete failed'); }
|
||||
} catch (err) {
|
||||
alert("Delete failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateReply = async (replyId: number) => {
|
||||
@@ -94,26 +125,42 @@ const AdminUserDetail: React.FC = () => {
|
||||
await api.patch(`replies/${replyId}/`, { text: editReplyText });
|
||||
setEditingReplyId(null);
|
||||
fetchUser();
|
||||
} catch (err) { alert('Update failed'); }
|
||||
} catch (err) {
|
||||
alert("Update failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteReply = async (replyId: number) => {
|
||||
if (!confirm('Delete this reply?')) return;
|
||||
if (!confirm("Delete this reply?")) return;
|
||||
try {
|
||||
await api.delete(`replies/${replyId}/`);
|
||||
fetchUser();
|
||||
} catch (err) { alert('Delete failed'); }
|
||||
} catch (err) {
|
||||
alert("Delete failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.')) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
"PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api.delete(`admin-custom/${id}/user_detail/`);
|
||||
navigate('/admin-panel');
|
||||
} catch (err) { alert('Delete failed'); }
|
||||
navigate("/admin-panel");
|
||||
} catch (err) {
|
||||
alert("Delete failed");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||
if (loading)
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
if (!user) return <Typography>User not found</Typography>;
|
||||
|
||||
return (
|
||||
@@ -124,40 +171,83 @@ const AdminUserDetail: React.FC = () => {
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
|
||||
{/* Profile Header */}
|
||||
<Card elevation={0} sx={{ borderRadius: 4, border: '1px solid', borderColor: 'divider', mb: 4 }}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 4,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
mb: 4,
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: { xs: 2, md: 4 } }}>
|
||||
<Grid container spacing={3} alignItems="center">
|
||||
<Grid size={{ xs: 12, sm: 'auto' }}>
|
||||
<Avatar sx={{ width: 80, height: 80, fontSize: '2rem', bgcolor: 'primary.main', mx: 'auto' }}>
|
||||
<Grid size={{ xs: 12, sm: "auto" }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
fontSize: "2rem",
|
||||
bgcolor: "primary.main",
|
||||
mx: "auto",
|
||||
}}
|
||||
>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 8, md: 9 }}>
|
||||
<Box sx={{ textAlign: { xs: 'center', sm: 'left' } }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>{user.username}</Typography>
|
||||
<Typography variant="body1" color="text.secondary">{user.email}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.5 }}>
|
||||
Joined: {formatDate(user.date_joined)} • Last Login: {formatDate(user.last_login)}
|
||||
<Box sx={{ textAlign: { xs: "center", sm: "left" } }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: "bold" }}>
|
||||
{user.username}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1.5, justifyContent: { xs: 'center', sm: 'flex-start' } }}>
|
||||
<Chip label={user.is_active ? 'Active' : 'Inactive'} color={user.is_active ? 'success' : 'default'} size="small" />
|
||||
{user.is_superuser && <Chip label="Superuser" color="secondary" size="small" />}
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{user.email}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
display="block"
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Joined: {formatDate(user.date_joined)} • Last Login:{" "}
|
||||
{formatDate(user.last_login)}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
mt: 1.5,
|
||||
justifyContent: { xs: "center", sm: "flex-start" },
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
label={user.is_active ? "Active" : "Inactive"}
|
||||
color={user.is_active ? "success" : "default"}
|
||||
size="small"
|
||||
/>
|
||||
{user.is_superuser && (
|
||||
<Chip label="Superuser" color="secondary" size="small" />
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 'auto' }}>
|
||||
<Grid size={{ xs: 12, sm: "auto" }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color={user.is_active ? "warning" : "success"}
|
||||
onClick={handleToggleActive}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: "none",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{user.is_active ? t('admin.deactivate') : t('admin.activate')}
|
||||
{user.is_active ? t("admin.deactivate") : t("admin.activate")}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -166,10 +256,26 @@ const AdminUserDetail: React.FC = () => {
|
||||
|
||||
{/* Content Tabs */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} variant="fullWidth">
|
||||
<Tab icon={<RateReviewIcon />} label={`Content (${user.ratings.length})`} sx={{ fontWeight: 'bold' }} />
|
||||
<Tab icon={<HistoryIcon />} label="Security & IP" sx={{ fontWeight: 'bold' }} />
|
||||
<Tab icon={<SecurityIcon />} label="Danger Zone" sx={{ fontWeight: 'bold', color: 'error.main' }} />
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, v) => setActiveTab(v)}
|
||||
variant="fullWidth"
|
||||
>
|
||||
<Tab
|
||||
icon={<RateReviewIcon />}
|
||||
label={`Content (${user.ratings.length})`}
|
||||
sx={{ fontWeight: "bold" }}
|
||||
/>
|
||||
<Tab
|
||||
icon={<HistoryIcon />}
|
||||
label="Security & IP"
|
||||
sx={{ fontWeight: "bold" }}
|
||||
/>
|
||||
<Tab
|
||||
icon={<SecurityIcon />}
|
||||
label="Danger Zone"
|
||||
sx={{ fontWeight: "bold", color: "error.main" }}
|
||||
/>
|
||||
</Tabs>
|
||||
<Divider />
|
||||
</Box>
|
||||
@@ -177,76 +283,223 @@ const AdminUserDetail: React.FC = () => {
|
||||
{/* TAB 0: USER CONTENT (Ratings & Replies) */}
|
||||
{activeTab === 0 && (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold' }}>User Ratings & Discussion</Typography>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: "bold" }}>
|
||||
User Ratings & Discussion
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
{user.ratings.map(rating => (
|
||||
<Card key={rating.id} variant="outlined" sx={{ borderRadius: 3, bgcolor: alpha(theme.palette.background.paper, 0.4) }}>
|
||||
{user.ratings.map((rating) => (
|
||||
<Card
|
||||
key={rating.id}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
bgcolor: alpha(theme.palette.background.paper, 0.4),
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: { xs: 2, md: 3 } }}>
|
||||
{editingRatingId === rating.id ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box
|
||||
sx={{ display: "flex", flexDirection: "column", gap: 2 }}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<Typography variant="body2">Score:</Typography>
|
||||
<TextField
|
||||
type="number" size="small"
|
||||
slotProps={{ input: { inputProps: { min: 1, max: 10 } } }}
|
||||
value={editScore} onChange={(e) => setEditScore(Number(e.target.value))}
|
||||
type="number"
|
||||
size="small"
|
||||
slotProps={{
|
||||
input: { inputProps: { min: 1, max: 10 } },
|
||||
}}
|
||||
value={editScore}
|
||||
onChange={(e) => setEditScore(Number(e.target.value))}
|
||||
sx={{ width: 80 }}
|
||||
/>
|
||||
</Stack>
|
||||
<MentionTextField
|
||||
multiline rows={3}
|
||||
multiline
|
||||
rows={3}
|
||||
value={editComment}
|
||||
onChange={setEditComment}
|
||||
/>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button variant="contained" size="small" onClick={() => handleUpdateRating(rating.id)}>{t('common.save')}</Button>
|
||||
<Button variant="outlined" size="small" onClick={() => setEditingRatingId(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => handleUpdateRating(rating.id)}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => setEditingRatingId(null)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{rating.flavor_name}</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Chip label={`${rating.score}/10`} size="small" color="primary" sx={{ fontWeight: '900' }} />
|
||||
<Button size="small" onClick={() => { setEditingRatingId(rating.id); setEditScore(rating.score); setEditComment(rating.comment || ''); }}>Edit</Button>
|
||||
<Button size="small" color="error" onClick={() => handleDeleteRating(rating.id)}>Delete</Button>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-start"
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: "bold" }}
|
||||
>
|
||||
{rating.flavor_name}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{ display: "flex", gap: 1, alignItems: "center" }}
|
||||
>
|
||||
<Chip
|
||||
label={`${rating.score}/10`}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ fontWeight: "900" }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditingRatingId(rating.id);
|
||||
setEditScore(rating.score);
|
||||
setEditComment(rating.comment || "");
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteRating(rating.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box sx={{ bgcolor: 'action.hover', p: 1.5, borderRadius: 2, mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: "action.hover",
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
{rating.comment ? (
|
||||
<Typography variant="body2" color="text.primary">
|
||||
<RichText text={rating.comment} />
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>No comment</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ fontStyle: "italic" }}
|
||||
>
|
||||
No comment
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{rating.replies.length > 0 && (
|
||||
<Box sx={{ pl: 2, borderLeft: '2px solid', borderColor: 'divider' }}>
|
||||
<Box
|
||||
sx={{
|
||||
pl: 2,
|
||||
borderLeft: "2px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
{rating.replies.map((reply: any) => (
|
||||
<Box key={reply.id} sx={{ mb: 1.5 }}>
|
||||
{editingReplyId === reply.id ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<MentionTextField value={editReplyText} onChange={setEditReplyText} />
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<MentionTextField
|
||||
value={editReplyText}
|
||||
onChange={setEditReplyText}
|
||||
/>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button variant="contained" size="small" onClick={() => handleUpdateReply(reply.id)}>{t('common.save')}</Button>
|
||||
<Button variant="outlined" size="small" onClick={() => setEditingReplyId(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
handleUpdateReply(reply.id)
|
||||
}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => setEditingReplyId(null)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', color: reply.user === user.username ? 'primary.main' : 'text.secondary' }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
color:
|
||||
reply.user === user.username
|
||||
? "primary.main"
|
||||
: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{reply.user}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Button size="small" sx={{ minWidth: 0, p: 0, fontSize: '0.7rem' }} onClick={() => { setEditingReplyId(reply.id); setEditReplyText(reply.text); }}>Edit</Button>
|
||||
<Button size="small" color="error" sx={{ minWidth: 0, p: 0, fontSize: '0.7rem' }} onClick={() => handleDeleteReply(reply.id)}>Delete</Button>
|
||||
<Box sx={{ display: "flex", gap: 0.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
p: 0,
|
||||
fontSize: "0.7rem",
|
||||
}}
|
||||
onClick={() => {
|
||||
setEditingReplyId(reply.id);
|
||||
setEditReplyText(reply.text);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
p: 0,
|
||||
fontSize: "0.7rem",
|
||||
}}
|
||||
onClick={() =>
|
||||
handleDeleteReply(reply.id)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Typography variant="caption" display="block" sx={{ mt: 0.2 }}><RichText text={reply.text} /></Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
display="block"
|
||||
sx={{ mt: 0.2 }}
|
||||
>
|
||||
<RichText text={reply.text} />
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
@@ -259,7 +512,16 @@ const AdminUserDetail: React.FC = () => {
|
||||
</Card>
|
||||
))}
|
||||
{user.ratings.length === 0 && (
|
||||
<Box sx={{ py: 6, textAlign: 'center', bgcolor: 'action.hover', borderRadius: 3, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Box
|
||||
sx={{
|
||||
py: 6,
|
||||
textAlign: "center",
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 3,
|
||||
border: "1px dashed",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary">No ratings yet.</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -269,30 +531,68 @@ const AdminUserDetail: React.FC = () => {
|
||||
|
||||
{/* TAB 1: SECURITY & IP */}
|
||||
{activeTab === 1 && (
|
||||
<Paper sx={{ p: 3, borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SecurityIcon /> {t('admin.ipHistory')}
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 3,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<SecurityIcon /> {t("admin.ipHistory")}
|
||||
</Typography>
|
||||
<List dense>
|
||||
{user.ips.map((ip, idx) => (
|
||||
<ListItem key={idx} divider={idx < user.ips.length - 1}>
|
||||
<ListItemText
|
||||
primary={ip}
|
||||
primaryTypographyProps={{ sx: { fontWeight: 'bold', fontFamily: 'monospace' } }}
|
||||
primaryTypographyProps={{
|
||||
sx: { fontWeight: "bold", fontFamily: "monospace" },
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
{user.ips.length === 0 && <Typography variant="caption">No IP history available.</Typography>}
|
||||
{user.ips.length === 0 && (
|
||||
<Typography variant="caption">
|
||||
No IP history available.
|
||||
</Typography>
|
||||
)}
|
||||
</List>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* TAB 2: DANGER ZONE */}
|
||||
{activeTab === 2 && (
|
||||
<Paper sx={{ p: 4, borderRadius: 3, border: '1px solid', borderColor: 'error.main', bgcolor: (theme) => alpha(theme.palette.error.main, 0.05) }}>
|
||||
<Typography variant="h5" color="error" gutterBottom sx={{ fontWeight: 'bold' }}>{t('admin.dangerZone')}</Typography>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
borderRadius: 3,
|
||||
border: "1px solid",
|
||||
borderColor: "error.main",
|
||||
bgcolor: (theme) => alpha(theme.palette.error.main, 0.05),
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h5"
|
||||
color="error"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: "bold" }}
|
||||
>
|
||||
{t("admin.dangerZone")}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 4, maxWidth: 600 }}>
|
||||
Deleting this user will permanently remove their profile, all their ratings, replies, and IP history.
|
||||
Deleting this user will permanently remove their profile, all their
|
||||
ratings, replies, and IP history.
|
||||
<strong> This action is irreversible.</strong>
|
||||
</Typography>
|
||||
<Button
|
||||
@@ -301,9 +601,9 @@ const AdminUserDetail: React.FC = () => {
|
||||
size="large"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
sx={{ borderRadius: 2, fontWeight: 'bold', px: 4 }}
|
||||
sx={{ borderRadius: 2, fontWeight: "bold", px: 4 }}
|
||||
>
|
||||
{t('admin.deleteUser')}
|
||||
{t("admin.deleteUser")}
|
||||
</Button>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
@@ -19,23 +19,23 @@ import {
|
||||
alpha,
|
||||
IconButton,
|
||||
Collapse,
|
||||
Link as MuiLink
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import CommentIcon from '@mui/icons-material/Comment';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import WhatshotIcon from '@mui/icons-material/Whatshot';
|
||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import PeopleIcon from '@mui/icons-material/People';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import RichText from '../components/RichText';
|
||||
import { formatDate } from '../utils/date';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MentionTextField from '../components/MentionTextField';
|
||||
import RatingBadge from '../components/RatingBadge';
|
||||
Link as MuiLink,
|
||||
} from "@mui/material";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import CommentIcon from "@mui/icons-material/Comment";
|
||||
import SendIcon from "@mui/icons-material/Send";
|
||||
import WhatshotIcon from "@mui/icons-material/Whatshot";
|
||||
import NotificationsIcon from "@mui/icons-material/Notifications";
|
||||
import PeopleIcon from "@mui/icons-material/People";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import api from "../api";
|
||||
import { useTitle } from "../hooks/useTitle";
|
||||
import RichText from "../components/RichText";
|
||||
import { formatDate } from "../utils/date";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import MentionTextField from "../components/MentionTextField";
|
||||
import RatingBadge from "../components/RatingBadge";
|
||||
|
||||
interface Reply {
|
||||
id: number;
|
||||
@@ -70,7 +70,13 @@ interface Notification {
|
||||
id: number;
|
||||
actor_username: string;
|
||||
actor_avatar: string | null;
|
||||
notification_type: 'reply' | 'mention' | 'follow' | 'ticket_new' | 'ticket_reply' | 'profile_comment';
|
||||
notification_type:
|
||||
| "reply"
|
||||
| "mention"
|
||||
| "follow"
|
||||
| "ticket_new"
|
||||
| "ticket_reply"
|
||||
| "profile_comment";
|
||||
rating: number | null;
|
||||
reply: number | null;
|
||||
is_read: boolean;
|
||||
@@ -85,7 +91,7 @@ interface CommunityFeedProps {
|
||||
|
||||
const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
const { t } = useTranslation();
|
||||
useTitle(t('community.title'));
|
||||
useTitle(t("community.title"));
|
||||
const navigate = useNavigate();
|
||||
const [ratings, setRatings] = useState<FeedRating[]>([]);
|
||||
const [following, setFollowing] = useState<FollowedUser[]>([]);
|
||||
@@ -93,65 +99,77 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [followingSearch, setFollowingSearch] = useState('');
|
||||
const [followingSearch, setFollowingSearch] = useState("");
|
||||
|
||||
// Top Rated Flavors by followed users
|
||||
const [topFollowed, setTopFollowed] = useState<any[]>([]);
|
||||
|
||||
// UI State
|
||||
const [replyInputs, setReplyInputs] = useState<{ [key: number]: string }>({});
|
||||
const [expandedReplies, setExpandedReplies] = useState<{[key: number]: boolean}>({});
|
||||
const [expandedReplies, setExpandedReplies] = useState<{
|
||||
[key: number]: boolean;
|
||||
}>({});
|
||||
|
||||
const filteredFollowing = following.filter(user =>
|
||||
user.username.toLowerCase().includes(followingSearch.toLowerCase())
|
||||
const filteredFollowing = following.filter((user) =>
|
||||
user.username.toLowerCase().includes(followingSearch.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
navigate("/");
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFeedData = async (pageNum: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [feedRes, followingRes, topFollowedRes, notifRes] = await Promise.all([
|
||||
const [feedRes, followingRes, topFollowedRes, notifRes] =
|
||||
await Promise.all([
|
||||
api.get(`ratings/feed/?page=${pageNum}`),
|
||||
api.get('users/following_list/'),
|
||||
api.get('flavors/followed_top/'),
|
||||
api.get('notifications/')
|
||||
api.get("users/following_list/"),
|
||||
api.get("flavors/followed_top/"),
|
||||
api.get("notifications/"),
|
||||
]);
|
||||
|
||||
const feedData = feedRes.data.results || (Array.isArray(feedRes.data) ? feedRes.data : []);
|
||||
const feedData =
|
||||
feedRes.data.results ||
|
||||
(Array.isArray(feedRes.data) ? feedRes.data : []);
|
||||
setRatings(feedData);
|
||||
|
||||
const count = feedRes.data.count || 0;
|
||||
setTotalPages(Math.ceil(count / 10));
|
||||
|
||||
const followData = Array.isArray(followingRes.data) ? followingRes.data : (followingRes.data.results || []);
|
||||
const followData = Array.isArray(followingRes.data)
|
||||
? followingRes.data
|
||||
: followingRes.data.results || [];
|
||||
setFollowing(followData);
|
||||
|
||||
setTopFollowed(Array.isArray(topFollowedRes.data) ? topFollowedRes.data.slice(0, 5) : []);
|
||||
setTopFollowed(
|
||||
Array.isArray(topFollowedRes.data)
|
||||
? topFollowedRes.data.slice(0, 5)
|
||||
: [],
|
||||
);
|
||||
|
||||
const notifData = Array.isArray(notifRes.data) ? notifRes.data : (notifRes.data.results || []);
|
||||
const notifData = Array.isArray(notifRes.data)
|
||||
? notifRes.data
|
||||
: notifRes.data.results || [];
|
||||
setNotifications(notifData.slice(0, 5)); // Just the latest 5 for sidebar
|
||||
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 401) {
|
||||
navigate('/login');
|
||||
navigate("/login");
|
||||
}
|
||||
console.error('Failed to fetch feed data', err);
|
||||
console.error("Failed to fetch feed data", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
navigate('/login');
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
fetchFeedData(page);
|
||||
@@ -159,11 +177,11 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
|
||||
const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => {
|
||||
setPage(value);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const handleReplyToggle = (ratingId: number) => {
|
||||
setExpandedReplies(prev => ({ ...prev, [ratingId]: !prev[ratingId] }));
|
||||
setExpandedReplies((prev) => ({ ...prev, [ratingId]: !prev[ratingId] }));
|
||||
};
|
||||
|
||||
const handleReplySubmit = async (ratingId: number) => {
|
||||
@@ -171,15 +189,17 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
if (!text) return;
|
||||
try {
|
||||
const res = await api.post(`ratings/${ratingId}/reply/`, { text });
|
||||
setRatings(prevRatings => prevRatings.map(r => {
|
||||
setRatings((prevRatings) =>
|
||||
prevRatings.map((r) => {
|
||||
if (r.id === ratingId) {
|
||||
return { ...r, replies: [...r.replies, res.data] };
|
||||
}
|
||||
return r;
|
||||
}));
|
||||
setReplyInputs(prev => ({ ...prev, [ratingId]: '' }));
|
||||
}),
|
||||
);
|
||||
setReplyInputs((prev) => ({ ...prev, [ratingId]: "" }));
|
||||
} catch (err) {
|
||||
alert('Failed to send reply');
|
||||
alert("Failed to send reply");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -187,7 +207,12 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
return formatDate(dateStr);
|
||||
};
|
||||
|
||||
if (loading && ratings.length === 0) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||
if (loading && ratings.length === 0)
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
@@ -198,25 +223,29 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
color: 'primary.main',
|
||||
bgcolor: 'transparent'
|
||||
}
|
||||
textTransform: "none",
|
||||
fontWeight: "bold",
|
||||
color: "text.secondary",
|
||||
borderColor: "divider",
|
||||
"&:hover": {
|
||||
borderColor: "primary.main",
|
||||
color: "primary.main",
|
||||
bgcolor: "transparent",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
{window.history.length > 1
|
||||
? t("common.back")
|
||||
: t("common.backToHome")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 6 }}>
|
||||
<Typography variant="h3" sx={{ fontWeight: '800', mb: 1 }}>{t('community.title')}</Typography>
|
||||
<Typography variant="h3" sx={{ fontWeight: "800", mb: 1 }}>
|
||||
{t("community.title")}
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
{t('community.subtitle')}
|
||||
{t("community.subtitle")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -224,53 +253,96 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
{/* Main Feed Column */}
|
||||
<Grid size={{ xs: 12, lg: 8 }}>
|
||||
{ratings.length === 0 ? (
|
||||
<Box sx={{
|
||||
<Box
|
||||
sx={{
|
||||
p: 6,
|
||||
textAlign: 'center',
|
||||
textAlign: "center",
|
||||
borderRadius: 4,
|
||||
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.6),
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider'
|
||||
}}>
|
||||
<Typography variant="h6" gutterBottom color="text.secondary">{t('community.quietFeed')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{t('community.followMore')}
|
||||
backdropFilter: "blur(10px)",
|
||||
border: "1px dashed",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom color="text.secondary">
|
||||
{t("community.quietFeed")}
|
||||
</Typography>
|
||||
<Button variant="contained" component={Link} to="/" sx={{ borderRadius: 2 }}>
|
||||
{t('home.exploreFlavors')}
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{t("community.followMore")}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
component={Link}
|
||||
to="/"
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{t("home.exploreFlavors")}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{ratings.map(rating => (
|
||||
<Card key={rating.id} elevation={0} sx={{
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
|
||||
{ratings.map((rating) => (
|
||||
<Card
|
||||
key={rating.id}
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.8),
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: (theme) => alpha(theme.palette.primary.main, 0.2)
|
||||
}
|
||||
}}>
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
bgcolor: (theme) =>
|
||||
alpha(theme.palette.background.paper, 0.8),
|
||||
transition: "transform 0.2s",
|
||||
"&:hover": {
|
||||
transform: "translateY(-2px)",
|
||||
borderColor: (theme) =>
|
||||
alpha(theme.palette.primary.main, 0.2),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
{/* User Info */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Link to={`/profile/${rating.user}`} style={{ textDecoration: 'none' }}>
|
||||
<Avatar src={rating.user_avatar || undefined} sx={{ width: 48, height: 48, border: '2px solid', borderColor: 'divider' }}>
|
||||
{!rating.user_avatar && rating.user.charAt(0).toUpperCase()}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Link
|
||||
to={`/profile/${rating.user}`}
|
||||
style={{ textDecoration: "none" }}
|
||||
>
|
||||
<Avatar
|
||||
src={rating.user_avatar || undefined}
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
border: "2px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
{!rating.user_avatar &&
|
||||
rating.user.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</Link>
|
||||
<Box>
|
||||
<Link to={`/profile/${rating.user}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', lineHeight: 1.2 }}>
|
||||
<Link
|
||||
to={`/profile/${rating.user}`}
|
||||
style={{ textDecoration: "none", color: "inherit" }}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{ fontWeight: "bold", lineHeight: 1.2 }}
|
||||
>
|
||||
{rating.user}
|
||||
</Typography>
|
||||
</Link>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: "block" }}
|
||||
>
|
||||
{formatTimestamp(rating.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -280,31 +352,89 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
|
||||
{adminMode && (
|
||||
<Button
|
||||
size="small" variant="outlined" color="secondary"
|
||||
onClick={() => navigate(`/admin-panel/rating/${rating.id}`)}
|
||||
sx={{ mb: 2, borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() =>
|
||||
navigate(`/admin-panel/rating/${rating.id}`)
|
||||
}
|
||||
sx={{
|
||||
mb: 2,
|
||||
borderRadius: 2,
|
||||
textTransform: "none",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{t('admin.manageRating')}
|
||||
{t("admin.manageRating")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Flavor and Comment */}
|
||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||
<Box sx={{ display: "flex", gap: 3 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('community.rated')} <MuiLink component={Link} to={`/flavor/${rating.flavor}`} sx={{ color: 'primary.main', fontWeight: 'bold', textDecoration: 'none' }}>{rating.flavor_name}</MuiLink>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
{t("community.rated")}{" "}
|
||||
<MuiLink
|
||||
component={Link}
|
||||
to={`/flavor/${rating.flavor}`}
|
||||
sx={{
|
||||
color: "primary.main",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
{rating.flavor_name}
|
||||
</MuiLink>
|
||||
</Typography>
|
||||
{rating.comment ? (
|
||||
<Typography variant="body1" sx={{ lineHeight: 1.6, fontStyle: 'italic', color: 'text.primary' }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
lineHeight: 1.6,
|
||||
fontStyle: "italic",
|
||||
color: "text.primary",
|
||||
}}
|
||||
>
|
||||
<RichText text={rating.comment} />
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>No comment provided.</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ fontStyle: "italic" }}
|
||||
>
|
||||
No comment provided.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Link to={`/flavor/${rating.flavor}`} style={{ flexShrink: 0 }}>
|
||||
<Box sx={{ width: 70, height: 70, borderRadius: 1, overflow: 'hidden', bgcolor: 'background.default', border: '1px solid', borderColor: 'divider' }}>
|
||||
<Box component="img" src={rating.flavor_image || undefined} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
<Link
|
||||
to={`/flavor/${rating.flavor}`}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 70,
|
||||
height: 70,
|
||||
borderRadius: 1,
|
||||
overflow: "hidden",
|
||||
bgcolor: "background.default",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={rating.flavor_image || undefined}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Link>
|
||||
</Box>
|
||||
@@ -316,44 +446,93 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
size="small"
|
||||
startIcon={<CommentIcon fontSize="small" />}
|
||||
onClick={() => handleReplyToggle(rating.id)}
|
||||
sx={{ textTransform: 'none', color: 'text.secondary' }}
|
||||
sx={{ textTransform: "none", color: "text.secondary" }}
|
||||
>
|
||||
{rating.replies.length > 0 ? `${rating.replies.length} ${t('common.replies')}` : t('common.reply')}
|
||||
{rating.replies.length > 0
|
||||
? `${rating.replies.length} ${t("common.replies")}`
|
||||
: t("common.reply")}
|
||||
</Button>
|
||||
|
||||
<Collapse in={expandedReplies[rating.id]}>
|
||||
<Box sx={{ mt: 2, pl: 2, borderLeft: '2px solid', borderColor: 'divider' }}>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
pl: 2,
|
||||
borderLeft: "2px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
{rating.replies.map((reply) => (
|
||||
<Box key={reply.id} sx={{ mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', fontSize: '0.8rem' }}>{reply.user}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{formatTimestamp(reply.created_at)}</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: "bold", fontSize: "0.8rem" }}
|
||||
>
|
||||
{reply.user}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
>
|
||||
{formatTimestamp(reply.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2"><RichText text={reply.text} /></Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
<RichText text={reply.text} />
|
||||
</Typography>
|
||||
{adminMode && (
|
||||
<Button
|
||||
size="small" sx={{ minWidth: 0, py: 0, fontSize: '0.7rem' }}
|
||||
onClick={() => navigate(`/admin-panel/reply/${reply.id}`)}
|
||||
size="small"
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
py: 0,
|
||||
fontSize: "0.7rem",
|
||||
}}
|
||||
onClick={() =>
|
||||
navigate(`/admin-panel/reply/${reply.id}`)
|
||||
}
|
||||
>
|
||||
{t('admin.manageReply')}
|
||||
{t("admin.manageReply")}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
|
||||
<Box sx={{ mt: 2, display: "flex", gap: 1 }}>
|
||||
<MentionTextField
|
||||
placeholder={t('community.writeReply')}
|
||||
value={replyInputs[rating.id] || ''}
|
||||
onChange={(val) => setReplyInputs(prev => ({ ...prev, [rating.id]: val }))}
|
||||
placeholder={t("community.writeReply")}
|
||||
value={replyInputs[rating.id] || ""}
|
||||
onChange={(val) =>
|
||||
setReplyInputs((prev) => ({
|
||||
...prev,
|
||||
[rating.id]: val,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
handleReplySubmit(rating.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IconButton color="primary" onClick={() => handleReplySubmit(rating.id)} disabled={!replyInputs[rating.id]}>
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => handleReplySubmit(rating.id)}
|
||||
disabled={!replyInputs[rating.id]}
|
||||
>
|
||||
<SendIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
@@ -363,8 +542,13 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
</Card>
|
||||
))}
|
||||
{totalPages > 1 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
|
||||
<Pagination count={totalPages} page={page} onChange={handlePageChange} color="primary" />
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={page}
|
||||
onChange={handlePageChange}
|
||||
color="primary"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
@@ -373,21 +557,43 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
|
||||
{/* Sidebar Column */}
|
||||
<Grid size={{ xs: 12, lg: 4 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
{/* Notifications Widget */}
|
||||
<Card elevation={0} sx={{ borderRadius: 1, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider', bgcolor: alpha('#000', 0.02), display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderBottom: "1px solid",
|
||||
borderColor: "divider",
|
||||
bgcolor: alpha("#000", 0.02),
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<NotificationsIcon color="primary" fontSize="small" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('community.notifications')}</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
|
||||
{t("community.notifications")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<List disablePadding>
|
||||
{notifications.length === 0 ? (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}><Typography variant="body2" color="text.secondary">{t('community.noNotifications')}</Typography></Box>
|
||||
<Box sx={{ p: 3, textAlign: "center" }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("community.noNotifications")}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
notifications.map(n => {
|
||||
notifications.map((n) => {
|
||||
const handleClick = () => {
|
||||
if (n.notification_type === 'follow') {
|
||||
if (n.notification_type === "follow") {
|
||||
navigate(`/profile/${n.actor_username}`);
|
||||
} else if (n.flavor_id) {
|
||||
navigate(`/flavor/${n.flavor_id}`);
|
||||
@@ -395,23 +601,39 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItemButton key={n.id} onClick={handleClick} sx={{ py: 1.5 }}>
|
||||
<ListItemButton
|
||||
key={n.id}
|
||||
onClick={handleClick}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
<ListItemAvatar sx={{ minWidth: 40 }}>
|
||||
<Avatar src={n.actor_avatar || undefined} sx={{ width: 30, height: 30, fontSize: '0.8rem' }}>{n.actor_username.charAt(0)}</Avatar>
|
||||
<Avatar
|
||||
src={n.actor_avatar || undefined}
|
||||
sx={{ width: 30, height: 30, fontSize: "0.8rem" }}
|
||||
>
|
||||
{n.actor_username.charAt(0)}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
|
||||
<strong>{n.actor_username}</strong> {
|
||||
n.notification_type === 'reply' ? t('community.notifReply') :
|
||||
n.notification_type === 'mention' ? t('community.notifMention') :
|
||||
n.notification_type === 'follow' ? t('community.notifFollow') :
|
||||
n.notification_type
|
||||
}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontSize: "0.8rem" }}
|
||||
>
|
||||
<strong>{n.actor_username}</strong>{" "}
|
||||
{n.notification_type === "reply"
|
||||
? t("community.notifReply")
|
||||
: n.notification_type === "mention"
|
||||
? t("community.notifMention")
|
||||
: n.notification_type === "follow"
|
||||
? t("community.notifFollow")
|
||||
: n.notification_type}
|
||||
</Typography>
|
||||
}
|
||||
secondary={formatTimestamp(n.created_at)}
|
||||
secondaryTypographyProps={{ sx: { fontSize: '0.7rem' } }}
|
||||
secondaryTypographyProps={{
|
||||
sx: { fontSize: "0.7rem" },
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
);
|
||||
@@ -421,48 +643,150 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
</Card>
|
||||
|
||||
{/* Following Widget */}
|
||||
<Card elevation={0} sx={{ borderRadius: 1, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider', bgcolor: alpha('#000', 0.02), display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderBottom: "1px solid",
|
||||
borderColor: "divider",
|
||||
bgcolor: alpha("#000", 0.02),
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<PeopleIcon color="primary" fontSize="small" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('nav.following')}</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
|
||||
{t("nav.following")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ p: 1.5 }}>
|
||||
<TextField
|
||||
fullWidth size="small" placeholder={t('community.searchFriends')}
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder={t("community.searchFriends")}
|
||||
value={followingSearch}
|
||||
onChange={(e) => setFollowingSearch(e.target.value)}
|
||||
InputProps={{ startAdornment: <SearchIcon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} /> }}
|
||||
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 1 } }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<SearchIcon
|
||||
fontSize="small"
|
||||
sx={{ mr: 1, color: "text.secondary" }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
sx={{ "& .MuiOutlinedInput-root": { borderRadius: 1 } }}
|
||||
/>
|
||||
</Box>
|
||||
<List disablePadding sx={{ maxHeight: 250, overflowY: 'auto' }}>
|
||||
{filteredFollowing.map(user => (
|
||||
<ListItemButton key={user.id} component={Link} to={`/profile/${user.username}`}>
|
||||
<Avatar src={user.avatar || undefined} sx={{ width: 28, height: 28, mr: 2, fontSize: '0.8rem' }}>{user.username.charAt(0)}</Avatar>
|
||||
<ListItemText primary={user.username} primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold' }} />
|
||||
<List disablePadding sx={{ maxHeight: 250, overflowY: "auto" }}>
|
||||
{filteredFollowing.map((user) => (
|
||||
<ListItemButton
|
||||
key={user.id}
|
||||
component={Link}
|
||||
to={`/profile/${user.username}`}
|
||||
>
|
||||
<Avatar
|
||||
src={user.avatar || undefined}
|
||||
sx={{ width: 28, height: 28, mr: 2, fontSize: "0.8rem" }}
|
||||
>
|
||||
{user.username.charAt(0)}
|
||||
</Avatar>
|
||||
<ListItemText
|
||||
primary={user.username}
|
||||
primaryTypographyProps={{
|
||||
variant: "body2",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
|
||||
{/* Top Rated by Circle */}
|
||||
<Card elevation={0} sx={{ borderRadius: 1, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider', bgcolor: alpha('#000', 0.02), display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderBottom: "1px solid",
|
||||
borderColor: "divider",
|
||||
bgcolor: alpha("#000", 0.02),
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<WhatshotIcon color="error" fontSize="small" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('community.topRated')}</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
|
||||
{t("community.topRated")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<List disablePadding>
|
||||
{topFollowed.map((flavor, idx) => (
|
||||
<ListItemButton key={flavor.id} component={Link} to={`/flavor/${flavor.id}`}>
|
||||
<Box sx={{ mr: 2, fontWeight: 'bold', color: 'text.secondary', width: 15 }}>{idx + 1}</Box>
|
||||
<Box sx={{ width: 32, height: 32, mr: 2, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider', borderRadius: 0.5, overflow: 'hidden', flexShrink: 0 }}>
|
||||
<Box component="img" src={flavor.image_url} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
<ListItemButton
|
||||
key={flavor.id}
|
||||
component={Link}
|
||||
to={`/flavor/${flavor.id}`}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
mr: 2,
|
||||
fontWeight: "bold",
|
||||
color: "text.secondary",
|
||||
width: 15,
|
||||
}}
|
||||
>
|
||||
{idx + 1}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
mr: 2,
|
||||
bgcolor: "background.default",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
borderRadius: 0.5,
|
||||
overflow: "hidden",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={flavor.image_url}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<ListItemText
|
||||
primary={flavor.name}
|
||||
primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold', noWrap: true }}
|
||||
primaryTypographyProps={{
|
||||
variant: "body2",
|
||||
fontWeight: "bold",
|
||||
noWrap: true,
|
||||
}}
|
||||
/>
|
||||
<RatingBadge
|
||||
score={flavor.average_rating || 0}
|
||||
size="small"
|
||||
/>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="small" />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
|
||||
+804
-251
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Rating as MuiRating
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import api from '../api';
|
||||
import RatingBadge from '../components/RatingBadge';
|
||||
Rating as MuiRating,
|
||||
} from "@mui/material";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import api from "../api";
|
||||
import RatingBadge from "../components/RatingBadge";
|
||||
|
||||
interface Rating {
|
||||
id: number;
|
||||
@@ -46,14 +46,16 @@ const FlavorList: React.FC = () => {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [flavors, setFlavors] = useState<Flavor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [ratingInput, setRatingInput] = useState<{[key: number]: {score: number, comment: string}}>({});
|
||||
const [ratingInput, setRatingInput] = useState<{
|
||||
[key: number]: { score: number; comment: string };
|
||||
}>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [catRes, flavRes] = await Promise.all([
|
||||
api.get('categories/'),
|
||||
api.get('flavors/')
|
||||
api.get("categories/"),
|
||||
api.get("flavors/"),
|
||||
]);
|
||||
setCategories(catRes.data);
|
||||
setFlavors(flavRes.data);
|
||||
@@ -71,17 +73,19 @@ const FlavorList: React.FC = () => {
|
||||
if (!input) return;
|
||||
|
||||
try {
|
||||
await api.post('ratings/', {
|
||||
await api.post("ratings/", {
|
||||
flavor: flavorId,
|
||||
score: input.score,
|
||||
comment: input.comment
|
||||
comment: input.comment,
|
||||
});
|
||||
// Refresh flavors
|
||||
const res = await api.get('flavors/');
|
||||
const res = await api.get("flavors/");
|
||||
setFlavors(res.data);
|
||||
alert('Rating submitted!');
|
||||
alert("Rating submitted!");
|
||||
} catch (err) {
|
||||
alert("Failed to submit rating. Make sure you are logged in and haven't rated this yet.");
|
||||
alert(
|
||||
"Failed to submit rating. Make sure you are logged in and haven't rated this yet.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -89,26 +93,41 @@ const FlavorList: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>Holy Flavors</Typography>
|
||||
{categories.map(category => (
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Holy Flavors
|
||||
</Typography>
|
||||
{categories.map((category) => (
|
||||
<Box key={category.id} sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" color="primary" gutterBottom>{category.name}</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
{flavors.filter(f => f.category_name === category.name).map(flavor => (
|
||||
<Box sx={{ flex: '1 1 30%', minWidth: 280 }} key={flavor.id}>
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', position: 'relative' }}>
|
||||
<Typography variant="h5" color="primary" gutterBottom>
|
||||
{category.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 3 }}>
|
||||
{flavors
|
||||
.filter((f) => f.category_name === category.name)
|
||||
.map((flavor) => (
|
||||
<Box sx={{ flex: "1 1 30%", minWidth: 280 }} key={flavor.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{!flavor.is_available && (
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
right: 10,
|
||||
bgcolor: 'error.main',
|
||||
color: 'white',
|
||||
bgcolor: "error.main",
|
||||
color: "white",
|
||||
px: 1,
|
||||
borderRadius: 1,
|
||||
fontSize: '0.75rem',
|
||||
zIndex: 1
|
||||
}}>
|
||||
fontSize: "0.75rem",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
Out of Stock
|
||||
</Box>
|
||||
)}
|
||||
@@ -117,17 +136,21 @@ const FlavorList: React.FC = () => {
|
||||
component="img"
|
||||
src={flavor.image_url}
|
||||
sx={{
|
||||
width: '100%',
|
||||
width: "100%",
|
||||
height: 200,
|
||||
objectFit: 'contain',
|
||||
objectFit: "contain",
|
||||
p: 2,
|
||||
bgcolor: 'rgba(255,255,255,0.05)'
|
||||
bgcolor: "rgba(255,255,255,0.05)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6">{flavor.name}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{flavor.description}
|
||||
</Typography>
|
||||
{flavor.shop_url && (
|
||||
@@ -137,26 +160,60 @@ const FlavorList: React.FC = () => {
|
||||
component="a"
|
||||
href={flavor.shop_url}
|
||||
target="_blank"
|
||||
sx={{ mb: 2, fontSize: '0.7rem' }}
|
||||
sx={{ mb: 2, fontSize: "0.7rem" }}
|
||||
>
|
||||
View in Shop
|
||||
</Button>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ mr: 1 }}>Avg:</Typography>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="small" />
|
||||
<Box
|
||||
sx={{ display: "flex", alignItems: "center", mb: 1 }}
|
||||
>
|
||||
<Typography variant="body2" sx={{ mr: 1 }}>
|
||||
Avg:
|
||||
</Typography>
|
||||
<RatingBadge
|
||||
score={flavor.average_rating || 0}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{flavor.ratings && flavor.ratings.length > 0 && (
|
||||
<Accordion variant="outlined" sx={{ mt: 1, mb: 1 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="caption">Comments ({flavor.ratings.filter(r => r.comment).length})</Typography>
|
||||
<Typography variant="caption">
|
||||
Comments (
|
||||
{flavor.ratings.filter((r) => r.comment).length})
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ maxHeight: 200, overflow: 'auto', p: 1 }}>
|
||||
{flavor.ratings.filter(r => r.comment).map(r => (
|
||||
<Box key={r.id} sx={{ mb: 1, borderBottom: '1px solid rgba(0,0,0,0.1)', pb: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block' }}>{r.user} ({r.score}/10):</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>{r.comment}</Typography>
|
||||
<AccordionDetails
|
||||
sx={{ maxHeight: 200, overflow: "auto", p: 1 }}
|
||||
>
|
||||
{flavor.ratings
|
||||
.filter((r) => r.comment)
|
||||
.map((r) => (
|
||||
<Box
|
||||
key={r.id}
|
||||
sx={{
|
||||
mb: 1,
|
||||
borderBottom: "1px solid rgba(0,0,0,0.1)",
|
||||
pb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{r.user} ({r.score}/10):
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontSize: "0.8rem" }}
|
||||
>
|
||||
{r.comment}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</AccordionDetails>
|
||||
@@ -164,28 +221,64 @@ const FlavorList: React.FC = () => {
|
||||
)}
|
||||
|
||||
{flavor.user_rating ? (
|
||||
<Typography variant="body2" color="secondary">Your Rating: {flavor.user_rating}/10</Typography>
|
||||
<Typography variant="body2" color="secondary">
|
||||
Your Rating: {flavor.user_rating}/10
|
||||
</Typography>
|
||||
) : (
|
||||
<Accordion variant="outlined" sx={{ mt: 2 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="body2">Rate this flavor</Typography>
|
||||
<Typography variant="body2">
|
||||
Rate this flavor
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<MuiRating
|
||||
max={10}
|
||||
value={ratingInput[flavor.id]?.score || 0}
|
||||
onChange={(_: any, val: number | null) => setRatingInput({...ratingInput, [flavor.id]: {...(ratingInput[flavor.id] || {comment: ''}), score: val || 0}})}
|
||||
onChange={(_: any, val: number | null) =>
|
||||
setRatingInput({
|
||||
...ratingInput,
|
||||
[flavor.id]: {
|
||||
...(ratingInput[flavor.id] || {
|
||||
comment: "",
|
||||
}),
|
||||
score: val || 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="Comment (Optional)"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
multiline
|
||||
value={ratingInput[flavor.id]?.comment || ''}
|
||||
onChange={(e) => setRatingInput({...ratingInput, [flavor.id]: {...(ratingInput[flavor.id] || {score: 0}), comment: e.target.value}})}
|
||||
value={ratingInput[flavor.id]?.comment || ""}
|
||||
onChange={(e) =>
|
||||
setRatingInput({
|
||||
...ratingInput,
|
||||
[flavor.id]: {
|
||||
...(ratingInput[flavor.id] || {
|
||||
score: 0,
|
||||
}),
|
||||
comment: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button variant="contained" size="small" onClick={() => handleRate(flavor.id)}>Submit</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => handleRate(flavor.id)}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
+154
-62
@@ -1,45 +1,61 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Typography, TextField, Button, Paper, Tab, Tabs, Alert, Container } from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { Link } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Paper,
|
||||
Tab,
|
||||
Tabs,
|
||||
Alert,
|
||||
Container,
|
||||
} from "@mui/material";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import { Link } from "react-router-dom";
|
||||
import api from "../api";
|
||||
import { useTitle } from "../hooks/useTitle";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface LoginProps {
|
||||
}
|
||||
interface LoginProps {}
|
||||
|
||||
const Login: React.FC<LoginProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
useTitle('Login');
|
||||
useTitle("Login");
|
||||
const [tab, setTab] = useState(0);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [verificationCode, setVerificationCode] = useState("");
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [showResend, setShowResend] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [resetStep, setResetStep] = useState(1); // 1: email, 2: code/new pass
|
||||
const [message, setMessage] = useState<{type: 'success' | 'error', text: string} | null>(null);
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await api.post('token/', {
|
||||
const res = await api.post("token/", {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
localStorage.setItem('token', res.data.token);
|
||||
window.location.href = '/';
|
||||
localStorage.setItem("token", res.data.token);
|
||||
window.location.href = "/";
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.non_field_errors?.[0] || t('auth.loginFailed');
|
||||
if (errorMsg.toLowerCase().includes('inactive') || errorMsg.toLowerCase().includes('verify') || errorMsg.includes('disabled')) {
|
||||
setMessage({ type: 'error', text: t('auth.unverifiedError') });
|
||||
const errorMsg =
|
||||
err.response?.data?.non_field_errors?.[0] || t("auth.loginFailed");
|
||||
if (
|
||||
errorMsg.toLowerCase().includes("inactive") ||
|
||||
errorMsg.toLowerCase().includes("verify") ||
|
||||
errorMsg.includes("disabled")
|
||||
) {
|
||||
setMessage({ type: "error", text: t("auth.unverifiedError") });
|
||||
setIsVerifying(true);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: errorMsg });
|
||||
setMessage({ type: "error", text: errorMsg });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -48,11 +64,14 @@ const Login: React.FC<LoginProps> = () => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/signup/', { username, email, password });
|
||||
await api.post("users/signup/", { username, email, password });
|
||||
setIsVerifying(true);
|
||||
setMessage({ type: 'success', text: t('auth.signupSuccess') });
|
||||
setMessage({ type: "success", text: t("auth.signupSuccess") });
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Signup failed' });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err.response?.data?.error || "Signup failed",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -60,13 +79,19 @@ const Login: React.FC<LoginProps> = () => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/verify_signup/', { username, code: verificationCode });
|
||||
setMessage({ type: 'success', text: t('auth.verifySuccess') });
|
||||
await api.post("users/verify_signup/", {
|
||||
username,
|
||||
code: verificationCode,
|
||||
});
|
||||
setMessage({ type: "success", text: t("auth.verifySuccess") });
|
||||
setIsVerifying(false);
|
||||
setTab(0);
|
||||
setPassword('');
|
||||
setPassword("");
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Verification failed' });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err.response?.data?.error || "Verification failed",
|
||||
});
|
||||
setShowResend(true);
|
||||
}
|
||||
};
|
||||
@@ -74,11 +99,14 @@ const Login: React.FC<LoginProps> = () => {
|
||||
const handleResendCode = async () => {
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/resend_verification/', { username });
|
||||
setMessage({ type: 'success', text: t('auth.resendSuccess') });
|
||||
await api.post("users/resend_verification/", { username });
|
||||
setMessage({ type: "success", text: t("auth.resendSuccess") });
|
||||
setShowResend(false);
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to resend code' });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err.response?.data?.error || "Failed to resend code",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,11 +114,17 @@ const Login: React.FC<LoginProps> = () => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/request_password_reset/', { email });
|
||||
await api.post("users/request_password_reset/", { email });
|
||||
setResetStep(2);
|
||||
setMessage({ type: 'success', text: 'If an account exists, a reset code has been sent.' });
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: "If an account exists, a reset code has been sent.",
|
||||
});
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: 'An error occurred. Please try again later.' });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: "An error occurred. Please try again later.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,15 +132,25 @@ const Login: React.FC<LoginProps> = () => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/complete_password_reset/', { email, code: verificationCode, password });
|
||||
setMessage({ type: 'success', text: 'Password reset successful! You can now login.' });
|
||||
await api.post("users/complete_password_reset/", {
|
||||
email,
|
||||
code: verificationCode,
|
||||
password,
|
||||
});
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: "Password reset successful! You can now login.",
|
||||
});
|
||||
setIsResetting(false);
|
||||
setResetStep(1);
|
||||
setTab(0);
|
||||
setPassword('');
|
||||
setVerificationCode('');
|
||||
setPassword("");
|
||||
setVerificationCode("");
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Reset failed' });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err.response?.data?.error || "Reset failed",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -114,8 +158,16 @@ const Login: React.FC<LoginProps> = () => {
|
||||
if (isVerifying) {
|
||||
return (
|
||||
<form onSubmit={handleVerify}>
|
||||
<Typography variant="h6" gutterBottom>{t('auth.verifyTitle')}</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }} dangerouslySetInnerHTML={{ __html: t('auth.verifyDesc', { username }) }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("auth.verifyTitle")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ mb: 2 }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t("auth.verifyDesc", { username }),
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Verification Code"
|
||||
@@ -133,13 +185,20 @@ const Login: React.FC<LoginProps> = () => {
|
||||
variant={showResend ? "contained" : "outlined"}
|
||||
color={showResend ? "secondary" : "primary"}
|
||||
onClick={handleResendCode}
|
||||
sx={{ mt: 2, textTransform: 'none' }}
|
||||
sx={{ mt: 2, textTransform: "none" }}
|
||||
>
|
||||
{t('auth.resendButton')}
|
||||
{t("auth.resendButton")}
|
||||
</Button>
|
||||
|
||||
<Button fullWidth onClick={() => { setIsVerifying(false); setMessage(null); }} sx={{ mt: 1 }}>
|
||||
{t('auth.backToAuth')}
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
setIsVerifying(false);
|
||||
setMessage(null);
|
||||
}}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
{t("auth.backToAuth")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
@@ -147,8 +206,12 @@ const Login: React.FC<LoginProps> = () => {
|
||||
|
||||
if (isResetting) {
|
||||
return (
|
||||
<form onSubmit={resetStep === 1 ? handleRequestReset : handleCompleteReset}>
|
||||
<Typography variant="h6" gutterBottom>Reset Password</Typography>
|
||||
<form
|
||||
onSubmit={resetStep === 1 ? handleRequestReset : handleCompleteReset}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Reset Password
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
@@ -177,9 +240,17 @@ const Login: React.FC<LoginProps> = () => {
|
||||
</>
|
||||
)}
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
{resetStep === 1 ? 'Send Reset Code' : 'Reset Password'}
|
||||
{resetStep === 1 ? "Send Reset Code" : "Reset Password"}
|
||||
</Button>
|
||||
<Button fullWidth onClick={() => { setIsResetting(false); setResetStep(1); setMessage(null); }} sx={{ mt: 1 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
setIsResetting(false);
|
||||
setResetStep(1);
|
||||
setMessage(null);
|
||||
}}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Back to Login
|
||||
</Button>
|
||||
</form>
|
||||
@@ -188,9 +259,16 @@ const Login: React.FC<LoginProps> = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs value={tab} onChange={(_, v) => { setTab(v); setMessage(null); }} sx={{ mb: 2 }}>
|
||||
<Tab label={t('auth.loginTitle')} />
|
||||
<Tab label={t('auth.signupTitle')} />
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_, v) => {
|
||||
setTab(v);
|
||||
setMessage(null);
|
||||
}}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
<Tab label={t("auth.loginTitle")} />
|
||||
<Tab label={t("auth.signupTitle")} />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 ? (
|
||||
@@ -211,10 +289,18 @@ const Login: React.FC<LoginProps> = () => {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
{t('auth.loginTitle')}
|
||||
{t("auth.loginTitle")}
|
||||
</Button>
|
||||
<Button fullWidth onClick={() => { setIsResetting(true); setMessage(null); }} sx={{ mt: 1, textTransform: 'none' }} size="small">
|
||||
{t('auth.forgotPassword')}
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
setIsResetting(true);
|
||||
setMessage(null);
|
||||
}}
|
||||
sx={{ mt: 1, textTransform: "none" }}
|
||||
size="small"
|
||||
>
|
||||
{t("auth.forgotPassword")}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
@@ -243,7 +329,7 @@ const Login: React.FC<LoginProps> = () => {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
{t('auth.signupTitle')}
|
||||
{t("auth.signupTitle")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
@@ -258,16 +344,22 @@ const Login: React.FC<LoginProps> = () => {
|
||||
component={Link}
|
||||
to="/"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ mb: 4, textTransform: 'none', borderRadius: 2 }}
|
||||
sx={{ mb: 4, textTransform: "none", borderRadius: 2 }}
|
||||
>
|
||||
Back to Home
|
||||
</Button>
|
||||
|
||||
<Paper sx={{
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
'& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 }
|
||||
}}>
|
||||
{message && <Alert severity={message.type} sx={{ mb: 2 }}>{message.text}</Alert>}
|
||||
"& .MuiInputLabel-root": { bgcolor: "background.paper", px: 0.5 },
|
||||
}}
|
||||
>
|
||||
{message && (
|
||||
<Alert severity={message.type} sx={{ mb: 2 }}>
|
||||
{message.text}
|
||||
</Alert>
|
||||
)}
|
||||
{renderContent()}
|
||||
</Paper>
|
||||
</Container>
|
||||
|
||||
+775
-251
File diff suppressed because it is too large.
Load diff
+151
-63
@@ -1,15 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Typography, Box, Container, Paper, Button, ToggleButton, ToggleButtonGroup, useMediaQuery } from '@mui/material';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React from "react";
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Container,
|
||||
Paper,
|
||||
Button,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
useMediaQuery,
|
||||
} from "@mui/material";
|
||||
import { Link } from "react-router-dom";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import { useTitle } from "../hooks/useTitle";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const Privacy: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language.startsWith('de') ? 'de' : 'en';
|
||||
useTitle(t('privacy.title'));
|
||||
const isMobile = useMediaQuery('(max-width:600px)');
|
||||
const lang = i18n.language.startsWith("de") ? "de" : "en";
|
||||
useTitle(t("privacy.title"));
|
||||
const isMobile = useMediaQuery("(max-width:600px)");
|
||||
|
||||
const handleLangChange = (_: any, newLang: string | null) => {
|
||||
if (newLang) {
|
||||
@@ -18,16 +27,28 @@ const Privacy: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4, flexWrap: 'wrap', gap: 2 }}>
|
||||
<Container
|
||||
maxWidth="md"
|
||||
sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 4,
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component={Link}
|
||||
to="/"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ textTransform: 'none', borderRadius: 2 }}
|
||||
sx={{ textTransform: "none", borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
|
||||
<ToggleButtonGroup
|
||||
@@ -41,65 +62,132 @@ const Privacy: React.FC = () => {
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: { xs: 2.5, sm: 4, md: 6 }, borderRadius: 4, overflow: 'hidden' }}>
|
||||
<Typography variant={isMobile ? "h4" : "h3"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('privacy.title')}</Typography>
|
||||
<Paper
|
||||
sx={{
|
||||
p: { xs: 2.5, sm: 4, md: 6 },
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant={isMobile ? "h4" : "h3"}
|
||||
gutterBottom
|
||||
sx={{ fontWeight: "bold" }}
|
||||
>
|
||||
{t("privacy.title")}
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph sx={{ lineHeight: 1.7 }}>
|
||||
{t('privacy.intro')}
|
||||
{t("privacy.intro")}
|
||||
</Typography>
|
||||
|
||||
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>{t('privacy.controllerTitle')}</Typography>
|
||||
<Typography
|
||||
variant={isMobile ? "h6" : "h5"}
|
||||
gutterBottom
|
||||
sx={{ mt: 4, fontWeight: "bold" }}
|
||||
>
|
||||
{t("privacy.controllerTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
|
||||
{t('privacy.controllerDesc')}<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street]'}<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_CITY || '[Your City]'}
|
||||
{t("privacy.controllerDesc")}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_NAME || "[Your Name]"}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_STREET || "[Your Street]"}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_CITY || "[Your City]"}
|
||||
</Typography>
|
||||
|
||||
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>{t('privacy.collectionTitle')}</Typography>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.regTitle')}</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.regDesc')}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.ipTitle')}</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.ipDesc')}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.profileTitle')}</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.profileDesc')}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.socialTitle')}</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.socialDesc')}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.supportTitle')}</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.supportDesc')}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.personalizationTitle')}</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.personalizationDesc')}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{t('privacy.deletionTitle')}</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>{t('privacy.deletionDesc')}</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>{t('privacy.transferTitle')}</Typography>
|
||||
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
|
||||
{t('privacy.transferDesc')}
|
||||
<Typography
|
||||
variant={isMobile ? "h6" : "h5"}
|
||||
gutterBottom
|
||||
sx={{ mt: 4, fontWeight: "bold" }}
|
||||
>
|
||||
{t("privacy.collectionTitle")}
|
||||
</Typography>
|
||||
|
||||
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>{t('privacy.rightsTitle')}</Typography>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
|
||||
{t("privacy.regTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
|
||||
{t("privacy.regDesc")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
|
||||
{t("privacy.ipTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
|
||||
{t("privacy.ipDesc")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
|
||||
{t("privacy.profileTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
|
||||
{t("privacy.profileDesc")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
|
||||
{t("privacy.socialTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
|
||||
{t("privacy.socialDesc")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
|
||||
{t("privacy.supportTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
|
||||
{t("privacy.supportDesc")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
|
||||
{t("privacy.personalizationTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
|
||||
{t("privacy.personalizationDesc")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: "bold" }}>
|
||||
{t("privacy.deletionTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
|
||||
{t("privacy.deletionDesc")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant={isMobile ? "h6" : "h5"}
|
||||
gutterBottom
|
||||
sx={{ mt: 4, fontWeight: "bold" }}
|
||||
>
|
||||
{t("privacy.transferTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
|
||||
{t('privacy.rightsDesc')} {import.meta.env.VITE_IMPRESSUM_EMAIL}
|
||||
{t("privacy.transferDesc")}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant={isMobile ? "h6" : "h5"}
|
||||
gutterBottom
|
||||
sx={{ mt: 4, fontWeight: "bold" }}
|
||||
>
|
||||
{t("privacy.rightsTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
|
||||
{t("privacy.rightsDesc")} {import.meta.env.VITE_IMPRESSUM_EMAIL}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Container>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+272
-118
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
@@ -14,14 +14,14 @@ import {
|
||||
MenuItem,
|
||||
ListSubheader,
|
||||
Divider,
|
||||
Grid
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import type { CatppuccinTheme } from '../theme';
|
||||
Grid,
|
||||
} from "@mui/material";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../api";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTitle } from "../hooks/useTitle";
|
||||
import type { CatppuccinTheme } from "../theme";
|
||||
|
||||
interface SettingsProps {
|
||||
themeName: CatppuccinTheme;
|
||||
@@ -31,19 +31,22 @@ interface SettingsProps {
|
||||
const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
useTitle(t('nav.settings'));
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [currentEmail, setCurrentEmail] = useState('');
|
||||
useTitle(t("nav.settings"));
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [currentEmail, setCurrentEmail] = useState("");
|
||||
const [avatar, setAvatar] = useState<string | null>(null);
|
||||
const [language, setLanguage] = useState(i18n.language.split('-')[0]);
|
||||
const [confirmationCode, setConfirmationCode] = useState('');
|
||||
const [language, setLanguage] = useState(i18n.language.split("-")[0]);
|
||||
const [confirmationCode, setConfirmationCode] = useState("");
|
||||
const [pendingConfirmation, setPendingConfirmation] = useState(false);
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [deletionCode, setDeletionCode] = useState('');
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [deletionCode, setDeletionCode] = useState("");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const [banners, setBanners] = useState<any[]>([]);
|
||||
const [selectedBannerId, setSelectedBannerId] = useState<number | string>("");
|
||||
|
||||
@@ -51,7 +54,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
navigate("/");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,8 +62,8 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const [userRes, bannersRes] = await Promise.all([
|
||||
api.get('users/me/'),
|
||||
api.get('banners/')
|
||||
api.get("users/me/"),
|
||||
api.get("banners/"),
|
||||
]);
|
||||
|
||||
setUsername(userRes.data.username);
|
||||
@@ -74,7 +77,9 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
}
|
||||
|
||||
// Handle both paginated and non-paginated
|
||||
const bannersData = Array.isArray(bannersRes.data) ? bannersRes.data : (bannersRes.data.results || []);
|
||||
const bannersData = Array.isArray(bannersRes.data)
|
||||
? bannersRes.data
|
||||
: bannersRes.data.results || [];
|
||||
setBanners(bannersData);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -88,21 +93,24 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
setMessage({ type: 'error', text: 'File size exceeds 2MB limit' });
|
||||
setMessage({ type: "error", text: "File size exceeds 2MB limit" });
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
formData.append("avatar", file);
|
||||
|
||||
try {
|
||||
const res = await api.post('users/update_avatar/', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
const res = await api.post("users/update_avatar/", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
setAvatar(res.data.avatar);
|
||||
setMessage({ type: 'success', text: t('settings.avatarSuccess') });
|
||||
setMessage({ type: "success", text: t("settings.avatarSuccess") });
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to upload avatar' });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err.response?.data?.error || "Failed to upload avatar",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -110,72 +118,90 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
setLanguage(newLang);
|
||||
i18n.changeLanguage(newLang);
|
||||
try {
|
||||
await api.patch('users/update_preferences/', { language: newLang });
|
||||
await api.patch("users/update_preferences/", { language: newLang });
|
||||
} catch (err) {
|
||||
console.error('Failed to update language on server');
|
||||
console.error("Failed to update language on server");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await api.patch('users/update_profile/', { username, email });
|
||||
const res = await api.patch("users/update_profile/", { username, email });
|
||||
if (res.data.message) {
|
||||
setPendingConfirmation(true);
|
||||
setMessage({ type: 'success', text: `${t('settings.updateSuccess')} ${res.data.message}` });
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: `${t("settings.updateSuccess")} ${res.data.message}`,
|
||||
});
|
||||
} else {
|
||||
setMessage({ type: 'success', text: t('settings.updateSuccess') });
|
||||
setMessage({ type: "success", text: t("settings.updateSuccess") });
|
||||
setCurrentEmail(email);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to update profile' });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err.response?.data?.error || "Failed to update profile",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmEmail = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await api.post('users/confirm_email/', { code: confirmationCode });
|
||||
setMessage({ type: 'success', text: t('settings.updateSuccess') });
|
||||
const res = await api.post("users/confirm_email/", {
|
||||
code: confirmationCode,
|
||||
});
|
||||
setMessage({ type: "success", text: t("settings.updateSuccess") });
|
||||
setCurrentEmail(res.data.email);
|
||||
setPendingConfirmation(false);
|
||||
setConfirmationCode('');
|
||||
setConfirmationCode("");
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Confirmation failed' });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err.response?.data?.error || "Confirmation failed",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.post('users/change_password/', { old_password: oldPassword, new_password: newPassword });
|
||||
setMessage({ type: 'success', text: t('settings.passwordSuccess') });
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
await api.post("users/change_password/", {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
setMessage({ type: "success", text: t("settings.passwordSuccess") });
|
||||
setOldPassword("");
|
||||
setNewPassword("");
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to change password' });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err.response?.data?.error || "Failed to change password",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestDeletion = async () => {
|
||||
try {
|
||||
await api.post('users/request_account_deletion/');
|
||||
await api.post("users/request_account_deletion/");
|
||||
setIsDeleting(true);
|
||||
setMessage({ type: 'success', text: t('settings.codeSent') });
|
||||
setMessage({ type: "success", text: t("settings.codeSent") });
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: 'Failed to request deletion.' });
|
||||
setMessage({ type: "error", text: "Failed to request deletion." });
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDeletion = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!window.confirm('Are you absolutely sure? This cannot be undone.')) return;
|
||||
if (!window.confirm("Are you absolutely sure? This cannot be undone."))
|
||||
return;
|
||||
try {
|
||||
await api.post('users/confirm_account_deletion/', { code: deletionCode });
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/';
|
||||
await api.post("users/confirm_account_deletion/", { code: deletionCode });
|
||||
localStorage.removeItem("token");
|
||||
window.location.href = "/";
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: 'Invalid code. Deletion failed.' });
|
||||
setMessage({ type: "error", text: "Invalid code. Deletion failed." });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -188,32 +214,48 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
sx={{
|
||||
mb: 4,
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
color: 'primary.main',
|
||||
bgcolor: 'transparent'
|
||||
}
|
||||
textTransform: "none",
|
||||
fontWeight: "bold",
|
||||
color: "text.secondary",
|
||||
borderColor: "divider",
|
||||
"&:hover": {
|
||||
borderColor: "primary.main",
|
||||
color: "primary.main",
|
||||
bgcolor: "transparent",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
{window.history.length > 1 ? t("common.back") : t("common.backToHome")}
|
||||
</Button>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 6 }}>
|
||||
<Avatar src={avatar || undefined} sx={{ width: 80, height: 80, border: '3px solid', borderColor: 'primary.main' }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 3, mb: 6 }}>
|
||||
<Avatar
|
||||
src={avatar || undefined}
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
border: "3px solid",
|
||||
borderColor: "primary.main",
|
||||
}}
|
||||
>
|
||||
{!avatar && username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>{username}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t('settings.subtitle')}</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: "bold" }}>
|
||||
{username}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("settings.subtitle")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{message && (
|
||||
<Alert severity={message.type} sx={{ mb: 4 }} onClose={() => setMessage(null)}>
|
||||
<Alert
|
||||
severity={message.type}
|
||||
sx={{ mb: 4 }}
|
||||
onClose={() => setMessage(null)}
|
||||
>
|
||||
{message.text}
|
||||
</Alert>
|
||||
)}
|
||||
@@ -221,14 +263,25 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
<Grid container spacing={4}>
|
||||
{/* Left Column: Visuals & Language */}
|
||||
<Grid size={{ xs: 12, lg: 5 }}>
|
||||
<Paper sx={{ p: 3, mb: 4, borderRadius: 3, '& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 } }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('settings.appearanceTitle')}</Typography>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
borderRadius: 3,
|
||||
"& .MuiInputLabel-root": { bgcolor: "background.paper", px: 0.5 },
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: "bold" }}>
|
||||
{t("settings.appearanceTitle")}
|
||||
</Typography>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('settings.themeLabel')}</InputLabel>
|
||||
<InputLabel>{t("settings.themeLabel")}</InputLabel>
|
||||
<Select
|
||||
value={themeName}
|
||||
label={t('settings.themeLabel')}
|
||||
onChange={(e) => onThemeChange(e.target.value as CatppuccinTheme)}
|
||||
label={t("settings.themeLabel")}
|
||||
onChange={(e) =>
|
||||
onThemeChange(e.target.value as CatppuccinTheme)
|
||||
}
|
||||
>
|
||||
<ListSubheader>Holy Archive</ListSubheader>
|
||||
<MenuItem value="holy_light">Holy Light</MenuItem>
|
||||
@@ -258,32 +311,36 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('settings.bannerLabel')}</InputLabel>
|
||||
<InputLabel>{t("settings.bannerLabel")}</InputLabel>
|
||||
<Select
|
||||
value={selectedBannerId}
|
||||
label={t('settings.bannerLabel')}
|
||||
label={t("settings.bannerLabel")}
|
||||
onChange={async (e) => {
|
||||
const newId = e.target.value;
|
||||
setSelectedBannerId(newId);
|
||||
try {
|
||||
await api.patch('users/update_preferences/', { selected_banner: newId });
|
||||
await api.patch("users/update_preferences/", {
|
||||
selected_banner: newId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to update banner preference');
|
||||
console.error("Failed to update banner preference");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">{t('settings.bannerDefault')}</MenuItem>
|
||||
{banners.map(b => (
|
||||
<MenuItem key={b.id} value={b.id}>{b.name}</MenuItem>
|
||||
<MenuItem value="">{t("settings.bannerDefault")}</MenuItem>
|
||||
{banners.map((b) => (
|
||||
<MenuItem key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('settings.langLabel')}</InputLabel>
|
||||
<InputLabel>{t("settings.langLabel")}</InputLabel>
|
||||
<Select
|
||||
value={language}
|
||||
label={t('settings.langLabel')}
|
||||
label={t("settings.langLabel")}
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
@@ -293,18 +350,33 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, borderRadius: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('settings.avatarTitle')}</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, my: 2 }}>
|
||||
<Avatar src={avatar || undefined} sx={{ width: 100, height: 100 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: "bold" }}>
|
||||
{t("settings.avatarTitle")}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 3, my: 2 }}>
|
||||
<Avatar
|
||||
src={avatar || undefined}
|
||||
sx={{ width: 100, height: 100 }}
|
||||
>
|
||||
{!avatar && username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Button variant="outlined" component="label" size="small">
|
||||
{t('settings.avatarButton')}
|
||||
<input type="file" hidden accept="image/*" onChange={handleAvatarUpload} />
|
||||
{t("settings.avatarButton")}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept="image/*"
|
||||
onChange={handleAvatarUpload}
|
||||
/>
|
||||
</Button>
|
||||
<Typography variant="caption" display="block" sx={{ mt: 1 }} color="text.secondary">
|
||||
{t('settings.avatarHint')}
|
||||
<Typography
|
||||
variant="caption"
|
||||
display="block"
|
||||
sx={{ mt: 1 }}
|
||||
color="text.secondary"
|
||||
>
|
||||
{t("settings.avatarHint")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -313,12 +385,21 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
|
||||
{/* Right Column: Account Info & Security */}
|
||||
<Grid size={{ xs: 12, lg: 7 }}>
|
||||
<Paper sx={{ p: 3, mb: 4, borderRadius: 3, '& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 } }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('settings.infoTitle')}</Typography>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
borderRadius: 3,
|
||||
"& .MuiInputLabel-root": { bgcolor: "background.paper", px: 0.5 },
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: "bold" }}>
|
||||
{t("settings.infoTitle")}
|
||||
</Typography>
|
||||
<form onSubmit={handleUpdateProfile}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.usernameLabel')}
|
||||
label={t("settings.usernameLabel")}
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
@@ -326,45 +407,76 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.emailLabel')}
|
||||
label={t("settings.emailLabel")}
|
||||
margin="normal"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
helperText={email !== currentEmail ? t('settings.emailHint') : ""}
|
||||
helperText={
|
||||
email !== currentEmail ? t("settings.emailHint") : ""
|
||||
}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Button variant="contained" type="submit" sx={{ mt: 2, borderRadius: 2 }}>
|
||||
{t('settings.updateButton')}
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
sx={{ mt: 2, borderRadius: 2 }}
|
||||
>
|
||||
{t("settings.updateButton")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{pendingConfirmation && (
|
||||
<Box sx={{ mt: 4, pt: 3, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="h6" color="primary" gutterBottom>{t('settings.confirmEmailTitle')}</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>{t('settings.confirmEmailHint', { email })}</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 4,
|
||||
pt: 3,
|
||||
borderTop: "1px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" color="primary" gutterBottom>
|
||||
{t("settings.confirmEmailTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
{t("settings.confirmEmailHint", { email })}
|
||||
</Typography>
|
||||
<form onSubmit={handleConfirmEmail}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.deletionCodeLabel')}
|
||||
label={t("settings.deletionCodeLabel")}
|
||||
margin="normal"
|
||||
value={confirmationCode}
|
||||
onChange={(e) => setConfirmationCode(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Button variant="contained" type="submit" sx={{ mt: 1, borderRadius: 2 }} color="secondary">
|
||||
{t('settings.confirmButton')}
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
sx={{ mt: 1, borderRadius: 2 }}
|
||||
color="secondary"
|
||||
>
|
||||
{t("settings.confirmButton")}
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 4, borderRadius: 3, '& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 } }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('settings.passwordTitle')}</Typography>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
borderRadius: 3,
|
||||
"& .MuiInputLabel-root": { bgcolor: "background.paper", px: 0.5 },
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: "bold" }}>
|
||||
{t("settings.passwordTitle")}
|
||||
</Typography>
|
||||
<form onSubmit={handleChangePassword}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.oldPasswordLabel')}
|
||||
label={t("settings.oldPasswordLabel")}
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={oldPassword}
|
||||
@@ -373,46 +485,88 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.newPasswordLabel')}
|
||||
label={t("settings.newPasswordLabel")}
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Button variant="contained" type="submit" sx={{ mt: 2, borderRadius: 2 }} color="warning">
|
||||
{t('settings.passwordButton')}
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
sx={{ mt: 2, borderRadius: 2 }}
|
||||
color="warning"
|
||||
>
|
||||
{t("settings.passwordButton")}
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, borderRadius: 3, border: '1px solid', borderColor: 'error.main', bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255,0,0,0.05)' : 'rgba(255,0,0,0.02)', '& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 } }}>
|
||||
<Typography variant="h6" gutterBottom color="error" sx={{ fontWeight: 'bold' }}>{t('settings.dangerTitle')}</Typography>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 3,
|
||||
border: "1px solid",
|
||||
borderColor: "error.main",
|
||||
bgcolor: (theme) =>
|
||||
theme.palette.mode === "dark"
|
||||
? "rgba(255,0,0,0.05)"
|
||||
: "rgba(255,0,0,0.02)",
|
||||
"& .MuiInputLabel-root": { bgcolor: "background.paper", px: 0.5 },
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
gutterBottom
|
||||
color="error"
|
||||
sx={{ fontWeight: "bold" }}
|
||||
>
|
||||
{t("settings.dangerTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{t('settings.dangerDesc')}
|
||||
{t("settings.dangerDesc")}
|
||||
</Typography>
|
||||
|
||||
{!isDeleting ? (
|
||||
<Button variant="outlined" color="error" onClick={handleRequestDeletion} sx={{ borderRadius: 2 }}>
|
||||
{t('settings.deleteButton')}
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={handleRequestDeletion}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{t("settings.deleteButton")}
|
||||
</Button>
|
||||
) : (
|
||||
<form onSubmit={handleConfirmDeletion}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>{t('settings.deletionCodeHint')}</Typography>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
|
||||
{t("settings.deletionCodeHint")}
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('settings.deletionCodeLabel')}
|
||||
label={t("settings.deletionCodeLabel")}
|
||||
value={deletionCode}
|
||||
onChange={(e) => setDeletionCode(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button variant="contained" color="error" type="submit" disabled={!deletionCode} sx={{ borderRadius: 2 }}>
|
||||
{t('settings.confirmDeleteButton')}
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
type="submit"
|
||||
disabled={!deletionCode}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{t("settings.confirmDeleteButton")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsDeleting(false)}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{t("settings.cancelButton")}
|
||||
</Button>
|
||||
<Button onClick={() => setIsDeleting(false)} sx={{ borderRadius: 2 }}>{t('settings.cancelButton')}</Button>
|
||||
</Box>
|
||||
</form>
|
||||
)}
|
||||
|
||||
+335
-101
@@ -1,18 +1,32 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Container, Typography, Box, Card, CardContent, Button,
|
||||
TextField, Stack, Chip, Divider, Collapse, Paper, alpha, CircularProgress,
|
||||
IconButton, Avatar, useTheme
|
||||
} from '@mui/material';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { formatDate } from '../utils/date';
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
TextField,
|
||||
Stack,
|
||||
Chip,
|
||||
Divider,
|
||||
Collapse,
|
||||
Paper,
|
||||
alpha,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Avatar,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import SendIcon from "@mui/icons-material/Send";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../api";
|
||||
import { useTitle } from "../hooks/useTitle";
|
||||
import { formatDate } from "../utils/date";
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
@@ -30,7 +44,7 @@ interface Ticket {
|
||||
user_avatar: string | null;
|
||||
subject: string;
|
||||
description: string;
|
||||
status: 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||
status: "open" | "in_progress" | "resolved" | "closed";
|
||||
created_at: string;
|
||||
messages: Message[];
|
||||
}
|
||||
@@ -39,27 +53,29 @@ const Support: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
useTitle(t('support.title'));
|
||||
useTitle(t("support.title"));
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
// Create form
|
||||
const [subject, setSubject] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [subject, setSubject] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// Reply state
|
||||
const [replyText, setReplyText] = useState<{ [key: number]: string }>({});
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [currentUser, setCurrentUser] = useState<any>(null);
|
||||
const [unreadTicketIds, setUnreadTicketIds] = useState<Set<number>>(new Set());
|
||||
const [unreadTicketIds, setUnreadTicketIds] = useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const fetchTickets = async () => {
|
||||
try {
|
||||
const [ticketRes, userRes, notifRes] = await Promise.all([
|
||||
api.get('tickets/'),
|
||||
api.get('users/me/'),
|
||||
api.get('notifications/')
|
||||
api.get("tickets/"),
|
||||
api.get("users/me/"),
|
||||
api.get("notifications/"),
|
||||
]);
|
||||
setTickets(ticketRes.data.results || ticketRes.data);
|
||||
setCurrentUser(userRes.data);
|
||||
@@ -67,8 +83,13 @@ const Support: React.FC = () => {
|
||||
const notifs = notifRes.data.results || notifRes.data;
|
||||
const unreadIds = new Set<number>(
|
||||
notifs
|
||||
.filter((n: any) => !n.is_read && n.notification_type.startsWith('ticket') && n.ticket)
|
||||
.map((n: any) => n.ticket)
|
||||
.filter(
|
||||
(n: any) =>
|
||||
!n.is_read &&
|
||||
n.notification_type.startsWith("ticket") &&
|
||||
n.ticket,
|
||||
)
|
||||
.map((n: any) => n.ticket),
|
||||
);
|
||||
setUnreadTicketIds(unreadIds);
|
||||
} catch (err) {
|
||||
@@ -105,13 +126,13 @@ const Support: React.FC = () => {
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.post('tickets/', { subject, description });
|
||||
setSubject('');
|
||||
setDescription('');
|
||||
await api.post("tickets/", { subject, description });
|
||||
setSubject("");
|
||||
setDescription("");
|
||||
setShowCreate(false);
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
alert('Failed to create ticket');
|
||||
alert("Failed to create ticket");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,10 +141,10 @@ const Support: React.FC = () => {
|
||||
if (!text) return;
|
||||
try {
|
||||
await api.post(`tickets/${ticketId}/add_message/`, { text });
|
||||
setReplyText({ ...replyText, [ticketId]: '' });
|
||||
setReplyText({ ...replyText, [ticketId]: "" });
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
alert('Failed to send message');
|
||||
alert("Failed to send message");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -132,30 +153,39 @@ const Support: React.FC = () => {
|
||||
await api.post(`tickets/${ticketId}/update_status/`, { status });
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
alert('Failed to update status');
|
||||
alert("Failed to update status");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTicket = async (ticketId: number) => {
|
||||
if (!confirm('Delete this ticket?')) return;
|
||||
if (!confirm("Delete this ticket?")) return;
|
||||
try {
|
||||
await api.delete(`tickets/${ticketId}/`);
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
alert('Failed to delete ticket');
|
||||
alert("Failed to delete ticket");
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'open': return 'error';
|
||||
case 'in_progress': return 'warning';
|
||||
case 'resolved': return 'success';
|
||||
default: return 'default';
|
||||
case "open":
|
||||
return "error";
|
||||
case "in_progress":
|
||||
return "warning";
|
||||
case "resolved":
|
||||
return "success";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||
if (loading)
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||
@@ -165,84 +195,181 @@ const Support: React.FC = () => {
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 4 }}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 'bold' }}>{t('support.title')}</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
sx={{ mb: 4 }}
|
||||
>
|
||||
<Typography variant="h3" sx={{ fontWeight: "bold" }}>
|
||||
{t("support.title")}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{t('support.createTicket')}
|
||||
{t("support.createTicket")}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Collapse in={showCreate}>
|
||||
<Paper sx={{ p: 3, mb: 4, borderRadius: 3, border: '1px solid', borderColor: 'primary.main' }}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
borderRadius: 3,
|
||||
border: "1px solid",
|
||||
borderColor: "primary.main",
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleCreate}>
|
||||
<TextField
|
||||
fullWidth label={t('support.subject')} margin="normal"
|
||||
value={subject} onChange={(e) => setSubject(e.target.value)} required
|
||||
fullWidth
|
||||
label={t("support.subject")}
|
||||
margin="normal"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
fullWidth multiline rows={4} label={t('support.description')} margin="normal"
|
||||
value={description} onChange={(e) => setDescription(e.target.value)} required
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
label={t("support.description")}
|
||||
margin="normal"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="contained" sx={{ mt: 2, borderRadius: 2 }}>
|
||||
{t('support.createTicket')}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
sx={{ mt: 2, borderRadius: 2 }}
|
||||
>
|
||||
{t("support.createTicket")}
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
|
||||
<Typography variant="h5" sx={{ mb: 2, fontWeight: 'bold' }}>
|
||||
{currentUser?.is_superuser ? t('admin.users') + ' Tickets' : t('support.myTickets')}
|
||||
<Typography variant="h5" sx={{ mb: 2, fontWeight: "bold" }}>
|
||||
{currentUser?.is_superuser
|
||||
? t("admin.users") + " Tickets"
|
||||
: t("support.myTickets")}
|
||||
</Typography>
|
||||
|
||||
{tickets.length === 0 ? (
|
||||
<Box sx={{ py: 8, textAlign: 'center', bgcolor: 'action.hover', borderRadius: 4 }}>
|
||||
<Typography color="text.secondary">{t('support.noTickets')}</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
py: 8,
|
||||
textAlign: "center",
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary">
|
||||
{t("support.noTickets")}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{tickets.map((ticket: Ticket) => (
|
||||
<Card key={ticket.id} sx={{
|
||||
<Card
|
||||
key={ticket.id}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: unreadTicketIds.has(ticket.id) ? 'primary.main' : 'divider',
|
||||
bgcolor: unreadTicketIds.has(ticket.id) ? alpha(theme.palette.primary.main, 0.02) : 'inherit'
|
||||
}}>
|
||||
border: "1px solid",
|
||||
borderColor: unreadTicketIds.has(ticket.id)
|
||||
? "primary.main"
|
||||
: "divider",
|
||||
bgcolor: unreadTicketIds.has(ticket.id)
|
||||
? alpha(theme.palette.primary.main, 0.02)
|
||||
: "inherit",
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
<Box
|
||||
onClick={() => handleExpand(ticket.id)}
|
||||
sx={{ p: 2, cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
|
||||
sx={{
|
||||
p: 2,
|
||||
cursor: "pointer",
|
||||
"&:hover": { bgcolor: "action.hover" },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{ display: "flex", alignItems: "center", gap: 1.5 }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
{unreadTicketIds.has(ticket.id) && (
|
||||
<Box sx={{ width: 10, height: 10, bgcolor: 'primary.main', borderRadius: '50%' }} />
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
bgcolor: "primary.main",
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{ fontWeight: "bold" }}
|
||||
>
|
||||
{ticket.subject}
|
||||
{currentUser?.is_superuser && (
|
||||
<Typography component="span" variant="caption" sx={{ ml: 1, color: 'primary.main' }}>
|
||||
<Typography
|
||||
component="span"
|
||||
variant="caption"
|
||||
sx={{ ml: 1, color: "primary.main" }}
|
||||
>
|
||||
({ticket.username})
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{formatDate(ticket.created_at)}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(ticket.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
{unreadTicketIds.has(ticket.id) && (
|
||||
<Chip label={t('support.unread')} color="primary" size="small" sx={{ fontWeight: 'bold', height: 20, fontSize: '0.6rem' }} />
|
||||
<Chip
|
||||
label={t("support.unread")}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
height: 20,
|
||||
fontSize: "0.6rem",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Chip label={t(`support.${ticket.status}`)} color={getStatusColor(ticket.status) as any} size="small" sx={{ fontWeight: 'bold' }} />
|
||||
<Chip
|
||||
label={t(`support.${ticket.status}`)}
|
||||
color={getStatusColor(ticket.status) as any}
|
||||
size="small"
|
||||
sx={{ fontWeight: "bold" }}
|
||||
/>
|
||||
{currentUser?.is_superuser && (
|
||||
<IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); handleDeleteTicket(ticket.id); }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteTicket(ticket.id);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
@@ -250,28 +377,68 @@ const Support: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
{currentUser?.is_superuser && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 1, p: 1, borderRadius: 2, bgcolor: alpha('#000', 0.03) }}>
|
||||
<Avatar src={ticket.user_avatar || undefined} sx={{ width: 24, height: 24, fontSize: '0.7rem' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
mt: 1,
|
||||
p: 1,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha("#000", 0.03),
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
src={ticket.user_avatar || undefined}
|
||||
sx={{ width: 24, height: 24, fontSize: "0.7rem" }}
|
||||
>
|
||||
{ticket.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{ticket.username} • <span style={{ opacity: 0.7 }}>{ticket.user_email}</span>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
display: "block",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{ticket.username} •{" "}
|
||||
<span style={{ opacity: 0.7 }}>
|
||||
{ticket.user_email}
|
||||
</span>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/profile/${ticket.username}`); }}
|
||||
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/profile/${ticket.username}`);
|
||||
}}
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
py: 0,
|
||||
textTransform: "none",
|
||||
fontSize: "0.7rem",
|
||||
}}
|
||||
>
|
||||
View Profile
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/admin-panel/user/${ticket.user}`); }}
|
||||
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/admin-panel/user/${ticket.user}`);
|
||||
}}
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
py: 0,
|
||||
textTransform: "none",
|
||||
fontSize: "0.7rem",
|
||||
}}
|
||||
>
|
||||
{t('admin.manageUser')}
|
||||
{t("admin.manageUser")}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
@@ -279,55 +446,122 @@ const Support: React.FC = () => {
|
||||
|
||||
<Collapse in={expandedId === ticket.id}>
|
||||
<Divider />
|
||||
<Box sx={{ p: 2, bgcolor: (theme) => alpha(theme.palette.background.default, 0.5) }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: (theme) =>
|
||||
alpha(theme.palette.background.default, 0.5),
|
||||
}}
|
||||
>
|
||||
{currentUser?.is_superuser && (
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2, overflowX: 'auto', pb: 1 }}>
|
||||
{['open', 'in_progress', 'resolved', 'closed'].map((s) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ mb: 2, overflowX: "auto", pb: 1 }}
|
||||
>
|
||||
{["open", "in_progress", "resolved", "closed"].map(
|
||||
(s) => (
|
||||
<Button
|
||||
key={s} size="small" variant={ticket.status === s ? "contained" : "outlined"}
|
||||
key={s}
|
||||
size="small"
|
||||
variant={
|
||||
ticket.status === s ? "contained" : "outlined"
|
||||
}
|
||||
onClick={() => handleUpdateStatus(ticket.id, s)}
|
||||
color={getStatusColor(s) as any}
|
||||
sx={{ textTransform: 'none', borderRadius: 4, fontSize: '0.7rem', flexShrink: 0 }}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
borderRadius: 4,
|
||||
fontSize: "0.7rem",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{t(`support.${s}`)}
|
||||
</Button>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" paragraph sx={{ whiteSpace: 'pre-wrap', mb: 3, bgcolor: 'background.paper', p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
paragraph
|
||||
sx={{
|
||||
whiteSpace: "pre-wrap",
|
||||
mb: 3,
|
||||
bgcolor: "background.paper",
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
{ticket.description}
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||
{ticket.messages.map((msg: Message) => (
|
||||
<Box key={msg.id} sx={{
|
||||
alignSelf: msg.is_admin ? 'flex-start' : 'flex-end',
|
||||
maxWidth: '85%',
|
||||
<Box
|
||||
key={msg.id}
|
||||
sx={{
|
||||
alignSelf: msg.is_admin ? "flex-start" : "flex-end",
|
||||
maxWidth: "85%",
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: msg.is_admin ? 'primary.main' : 'action.selected',
|
||||
color: msg.is_admin ? 'white' : 'inherit',
|
||||
boxShadow: msg.is_admin ? 2 : 0
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block' }}>
|
||||
{msg.username} {msg.is_admin && '(Support)'}
|
||||
bgcolor: msg.is_admin
|
||||
? "primary.main"
|
||||
: "action.selected",
|
||||
color: msg.is_admin ? "white" : "inherit",
|
||||
boxShadow: msg.is_admin ? 2 : 0,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: "bold", display: "block" }}
|
||||
>
|
||||
{msg.username} {msg.is_admin && "(Support)"}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ my: 0.5 }}>
|
||||
{msg.text}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
opacity: 0.7,
|
||||
display: "block",
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{formatDate(msg.created_at)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ my: 0.5 }}>{msg.text}</Typography>
|
||||
<Typography variant="caption" sx={{ opacity: 0.7, display: 'block', textAlign: 'right' }}>{formatDate(msg.created_at)}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{ticket.status !== 'closed' && (
|
||||
{ticket.status !== "closed" && (
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextField
|
||||
fullWidth size="small" placeholder={t('community.writeReply')}
|
||||
value={replyText[ticket.id] || ''}
|
||||
onChange={(e) => setReplyText({ ...replyText, [ticket.id]: e.target.value })}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleReply(ticket.id)}
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder={t("community.writeReply")}
|
||||
value={replyText[ticket.id] || ""}
|
||||
onChange={(e) =>
|
||||
setReplyText({
|
||||
...replyText,
|
||||
[ticket.id]: e.target.value,
|
||||
})
|
||||
}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" &&
|
||||
!e.shiftKey &&
|
||||
handleReply(ticket.id)
|
||||
}
|
||||
/>
|
||||
<IconButton color="primary" onClick={() => handleReply(ticket.id)} disabled={!replyText[ticket.id]}>
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => handleReply(ticket.id)}
|
||||
disabled={!replyText[ticket.id]}
|
||||
>
|
||||
<SendIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
@@ -1,43 +1,53 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate, Link } from 'react-router-dom';
|
||||
import { Container, Paper, Typography, Box, CircularProgress, Button, Alert } from '@mui/material';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useLocation, useNavigate, Link } from "react-router-dom";
|
||||
import {
|
||||
Container,
|
||||
Paper,
|
||||
Typography,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Button,
|
||||
Alert,
|
||||
} from "@mui/material";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import api from "../api";
|
||||
import { useTitle } from "../hooks/useTitle";
|
||||
|
||||
const VerifyEmail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [status, setStatus] = useState<"loading" | "success" | "error">(
|
||||
"loading",
|
||||
);
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
|
||||
useTitle(t('auth.verifyTitle'));
|
||||
useTitle(t("auth.verifyTitle"));
|
||||
|
||||
useEffect(() => {
|
||||
const query = new URLSearchParams(location.search);
|
||||
const username = query.get('username');
|
||||
const code = query.get('code');
|
||||
const username = query.get("username");
|
||||
const code = query.get("code");
|
||||
|
||||
if (!username || !code) {
|
||||
setStatus('error');
|
||||
setErrorMsg('Invalid verification link.');
|
||||
setStatus("error");
|
||||
setErrorMsg("Invalid verification link.");
|
||||
return;
|
||||
}
|
||||
|
||||
const verify = async () => {
|
||||
try {
|
||||
await api.post('users/verify_signup/', { username, code });
|
||||
setStatus('success');
|
||||
await api.post("users/verify_signup/", { username, code });
|
||||
setStatus("success");
|
||||
// Optional: Auto redirect after 3 seconds
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
navigate("/login");
|
||||
}, 3000);
|
||||
} catch (err: any) {
|
||||
setStatus('error');
|
||||
setErrorMsg(err.response?.data?.error || 'Verification failed.');
|
||||
setStatus("error");
|
||||
setErrorMsg(err.response?.data?.error || "Verification failed.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -46,11 +56,11 @@ const VerifyEmail: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ py: 12 }}>
|
||||
<Paper sx={{ p: 6, textAlign: 'center', borderRadius: 4 }}>
|
||||
{status === 'loading' && (
|
||||
<Paper sx={{ p: 6, textAlign: "center", borderRadius: 4 }}>
|
||||
{status === "loading" && (
|
||||
<Box>
|
||||
<CircularProgress size={60} sx={{ mb: 4 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: "bold" }}>
|
||||
Verifying your account...
|
||||
</Typography>
|
||||
<Typography color="text.secondary" sx={{ mt: 1 }}>
|
||||
@@ -59,34 +69,49 @@ const VerifyEmail: React.FC = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
{status === "success" && (
|
||||
<Box>
|
||||
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 80, mb: 2 }} />
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 2 }}>
|
||||
{t('auth.verifySuccess')}
|
||||
<CheckCircleOutlineIcon
|
||||
color="success"
|
||||
sx={{ fontSize: 80, mb: 2 }}
|
||||
/>
|
||||
<Typography variant="h4" sx={{ fontWeight: "bold", mb: 2 }}>
|
||||
{t("auth.verifySuccess")}
|
||||
</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 4 }}>
|
||||
You will be redirected to the login page shortly.
|
||||
</Typography>
|
||||
<Button variant="contained" component={Link} to="/login" size="large" sx={{ borderRadius: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
component={Link}
|
||||
to="/login"
|
||||
size="large"
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
Go to Login Now
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
{status === "error" && (
|
||||
<Box>
|
||||
<ErrorOutlineIcon color="error" sx={{ fontSize: 80, mb: 2 }} />
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 2 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: "bold", mb: 2 }}>
|
||||
Verification Failed
|
||||
</Typography>
|
||||
<Alert severity="error" sx={{ mb: 4, textAlign: 'left' }}>
|
||||
<Alert severity="error" sx={{ mb: 4, textAlign: "left" }}>
|
||||
{errorMsg}
|
||||
</Alert>
|
||||
<Typography color="text.secondary" sx={{ mb: 4 }}>
|
||||
The link might be expired or already used.
|
||||
</Typography>
|
||||
<Button variant="outlined" component={Link} to="/login" size="large" sx={{ borderRadius: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component={Link}
|
||||
to="/login"
|
||||
size="large"
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
Back to Login / Signup
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
@@ -7,14 +7,14 @@ import {
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import api from '../../api';
|
||||
import { useTitle } from '../../hooks/useTitle';
|
||||
import RatingBadge from '../../components/RatingBadge';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
Button,
|
||||
} from "@mui/material";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import api from "../../api";
|
||||
import { useTitle } from "../../hooks/useTitle";
|
||||
import RatingBadge from "../../components/RatingBadge";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Rating {
|
||||
id: number;
|
||||
@@ -43,14 +43,14 @@ const CategoryFlavors: React.FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [flavors, setFlavors] = useState<Flavor[]>([]);
|
||||
const [categoryName, setCategoryName] = useState('');
|
||||
const [categoryName, setCategoryName] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
navigate("/");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,7 +59,9 @@ const CategoryFlavors: React.FC = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get(`flavors/?category__slug=${slug}`);
|
||||
const data = Array.isArray(res.data) ? res.data : (res.data.results || []);
|
||||
const data = Array.isArray(res.data)
|
||||
? res.data
|
||||
: res.data.results || [];
|
||||
setFlavors(data);
|
||||
if (data.length > 0) {
|
||||
setCategoryName(data[0].category_name);
|
||||
@@ -73,85 +75,128 @@ const CategoryFlavors: React.FC = () => {
|
||||
fetchFlavors();
|
||||
}, [slug]);
|
||||
|
||||
useTitle(categoryName || 'Flavors');
|
||||
useTitle(categoryName || "Flavors");
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||
if (loading)
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth={false} sx={{ px: { xs: 2, sm: 4, md: 6 }, py: 4 }}>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>{categoryName || 'Flavors'}</Typography>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{categoryName || "Flavors"}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
color: 'primary.main',
|
||||
bgcolor: 'transparent'
|
||||
}
|
||||
textTransform: "none",
|
||||
fontWeight: "bold",
|
||||
color: "text.secondary",
|
||||
borderColor: "divider",
|
||||
"&:hover": {
|
||||
borderColor: "primary.main",
|
||||
color: "primary.main",
|
||||
bgcolor: "transparent",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
{window.history.length > 1
|
||||
? t("common.back")
|
||||
: t("common.backToHome")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
lg: 'repeat(auto-fill, minmax(240px, 1fr))'
|
||||
xs: "1fr",
|
||||
sm: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||
lg: "repeat(auto-fill, minmax(240px, 1fr))",
|
||||
},
|
||||
gap: 3
|
||||
}}>
|
||||
{flavors.map(flavor => (
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
{flavors.map((flavor) => (
|
||||
<Box key={flavor.id}>
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', position: 'relative', transition: 'transform 0.2s', '&:hover': { transform: 'scale(1.02)' } }}>
|
||||
<Box component={Link} to={`/flavor/${flavor.id}`} sx={{ textDecoration: 'none', color: 'inherit', flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 1 }}>
|
||||
<StatusBadge isLegacy={flavor.is_legacy} isAvailable={flavor.is_available} size="small" />
|
||||
<Card
|
||||
sx={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
transition: "transform 0.2s",
|
||||
"&:hover": { transform: "scale(1.02)" },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component={Link}
|
||||
to={`/flavor/${flavor.id}`}
|
||||
sx={{
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
flexGrow: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{ position: "absolute", top: 10, right: 10, zIndex: 1 }}
|
||||
>
|
||||
<StatusBadge
|
||||
isLegacy={flavor.is_legacy}
|
||||
isAvailable={flavor.is_available}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
{flavor.image_url && (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '1/1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'background.default',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
overflow: 'hidden'
|
||||
width: "100%",
|
||||
aspectRatio: "1/1",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
bgcolor: "background.default",
|
||||
borderBottom: "1px solid",
|
||||
borderColor: "divider",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={flavor.image_url}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
transition: 'transform 0.5s ease',
|
||||
'&:hover': { transform: 'scale(1.1)' }
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
transition: "transform 0.5s ease",
|
||||
"&:hover": { transform: "scale(1.1)" },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontSize: '1.1rem', mb: 1 }}>{flavor.name}</Typography>
|
||||
<Typography variant="h6" sx={{ fontSize: "1.1rem", mb: 1 }}>
|
||||
{flavor.name}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="small" />
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||
<RatingBadge
|
||||
score={flavor.average_rating || 0}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">{flavor.ratings.length} reviews</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{flavor.ratings.length} reviews
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+183
-159
@@ -1,152 +1,172 @@
|
||||
import { createTheme, type ThemeOptions, alpha } from '@mui/material/styles';
|
||||
import { flavors } from '@catppuccin/palette';
|
||||
import { createTheme, type ThemeOptions, alpha } from "@mui/material/styles";
|
||||
import { flavors } from "@catppuccin/palette";
|
||||
|
||||
export type CatppuccinTheme =
|
||||
| 'holy_light' | 'holy_dark'
|
||||
| 'latte' | 'frappe' | 'macchiato' | 'mocha'
|
||||
| 'pink_pastel' | 'mint_pastel' | 'lavender_pastel' | 'dracula' | 'nord' | 'gruvbox' | 'oceanic'
|
||||
| 't0p_sai' | 't0p_trench' | 't0p_blurryface' | 't0p_clancy';
|
||||
| "holy_light"
|
||||
| "holy_dark"
|
||||
| "latte"
|
||||
| "frappe"
|
||||
| "macchiato"
|
||||
| "mocha"
|
||||
| "pink_pastel"
|
||||
| "mint_pastel"
|
||||
| "lavender_pastel"
|
||||
| "dracula"
|
||||
| "nord"
|
||||
| "gruvbox"
|
||||
| "oceanic"
|
||||
| "t0p_sai"
|
||||
| "t0p_trench"
|
||||
| "t0p_blurryface"
|
||||
| "t0p_clancy";
|
||||
|
||||
export const isLightTheme = (mode: CatppuccinTheme): boolean => {
|
||||
return ['holy_light', 'latte', 'pink_pastel', 'mint_pastel', 'lavender_pastel', 't0p_sai'].includes(mode);
|
||||
return [
|
||||
"holy_light",
|
||||
"latte",
|
||||
"pink_pastel",
|
||||
"mint_pastel",
|
||||
"lavender_pastel",
|
||||
"t0p_sai",
|
||||
].includes(mode);
|
||||
};
|
||||
|
||||
export const getTheme = (mode: CatppuccinTheme) => {
|
||||
let palette: any;
|
||||
|
||||
// Define Holy branding colors
|
||||
const holyPurple = { light: '#7c3aed', dark: '#a78bfa' };
|
||||
const holyPink = { light: '#db2777', dark: '#f472b6' };
|
||||
const holyPurple = { light: "#7c3aed", dark: "#a78bfa" };
|
||||
const holyPink = { light: "#db2777", dark: "#f472b6" };
|
||||
|
||||
if (mode === 'holy_light') {
|
||||
if (mode === "holy_light") {
|
||||
palette = {
|
||||
base: { hex: '#f8fafc' },
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#f1f5f9' },
|
||||
text: { hex: '#0f172a' },
|
||||
subtext0: { hex: '#64748b' },
|
||||
base: { hex: "#f8fafc" },
|
||||
surface0: { hex: "#ffffff" },
|
||||
crust: { hex: "#f1f5f9" },
|
||||
text: { hex: "#0f172a" },
|
||||
subtext0: { hex: "#64748b" },
|
||||
primary: { hex: holyPurple.light },
|
||||
secondary: { hex: holyPink.light },
|
||||
};
|
||||
} else if (mode === 'holy_dark') {
|
||||
} else if (mode === "holy_dark") {
|
||||
palette = {
|
||||
base: { hex: '#020617' },
|
||||
surface0: { hex: '#0f172a' },
|
||||
crust: { hex: '#000000' },
|
||||
text: { hex: '#f8fafc' },
|
||||
subtext0: { hex: '#94a3b8' },
|
||||
base: { hex: "#020617" },
|
||||
surface0: { hex: "#0f172a" },
|
||||
crust: { hex: "#000000" },
|
||||
text: { hex: "#f8fafc" },
|
||||
subtext0: { hex: "#94a3b8" },
|
||||
primary: { hex: holyPurple.dark },
|
||||
secondary: { hex: holyPink.dark },
|
||||
};
|
||||
} else if (mode === 'pink_pastel') {
|
||||
} else if (mode === "pink_pastel") {
|
||||
palette = {
|
||||
base: { hex: '#fff1f2' },
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#ffe4e6' },
|
||||
text: { hex: '#881337' },
|
||||
subtext0: { hex: '#be123c' },
|
||||
primary: { hex: '#fb7185' },
|
||||
secondary: { hex: '#2dd4bf' },
|
||||
base: { hex: "#fff1f2" },
|
||||
surface0: { hex: "#ffffff" },
|
||||
crust: { hex: "#ffe4e6" },
|
||||
text: { hex: "#881337" },
|
||||
subtext0: { hex: "#be123c" },
|
||||
primary: { hex: "#fb7185" },
|
||||
secondary: { hex: "#2dd4bf" },
|
||||
};
|
||||
} else if (mode === 'mint_pastel') {
|
||||
} else if (mode === "mint_pastel") {
|
||||
palette = {
|
||||
base: { hex: '#f0fdf4' },
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#dcfce7' },
|
||||
text: { hex: '#14532d' },
|
||||
subtext0: { hex: '#16a34a' },
|
||||
primary: { hex: '#4ade80' },
|
||||
secondary: { hex: '#fb7185' },
|
||||
base: { hex: "#f0fdf4" },
|
||||
surface0: { hex: "#ffffff" },
|
||||
crust: { hex: "#dcfce7" },
|
||||
text: { hex: "#14532d" },
|
||||
subtext0: { hex: "#16a34a" },
|
||||
primary: { hex: "#4ade80" },
|
||||
secondary: { hex: "#fb7185" },
|
||||
};
|
||||
} else if (mode === 'lavender_pastel') {
|
||||
} else if (mode === "lavender_pastel") {
|
||||
palette = {
|
||||
base: { hex: '#f5f3ff' },
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#ede9fe' },
|
||||
text: { hex: '#4c1d95' },
|
||||
subtext0: { hex: '#7c3aed' },
|
||||
primary: { hex: '#8b5cf6' },
|
||||
secondary: { hex: '#facc15' },
|
||||
base: { hex: "#f5f3ff" },
|
||||
surface0: { hex: "#ffffff" },
|
||||
crust: { hex: "#ede9fe" },
|
||||
text: { hex: "#4c1d95" },
|
||||
subtext0: { hex: "#7c3aed" },
|
||||
primary: { hex: "#8b5cf6" },
|
||||
secondary: { hex: "#facc15" },
|
||||
};
|
||||
} else if (mode === 't0p_sai') {
|
||||
} else if (mode === "t0p_sai") {
|
||||
palette = {
|
||||
base: { hex: '#fdf2f8' }, // Very light SAI Pink
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#fce7f3' }, // SAI Pink accent
|
||||
text: { hex: '#1e1b4b' },
|
||||
subtext0: { hex: '#4b5563' },
|
||||
primary: { hex: '#00d7ff' }, // SAI Cyan
|
||||
secondary: { hex: '#ff8ad8' }, // SAI Pink
|
||||
base: { hex: "#fdf2f8" }, // Very light SAI Pink
|
||||
surface0: { hex: "#ffffff" },
|
||||
crust: { hex: "#fce7f3" }, // SAI Pink accent
|
||||
text: { hex: "#1e1b4b" },
|
||||
subtext0: { hex: "#4b5563" },
|
||||
primary: { hex: "#00d7ff" }, // SAI Cyan
|
||||
secondary: { hex: "#ff8ad8" }, // SAI Pink
|
||||
};
|
||||
} else if (mode === 't0p_trench') {
|
||||
} else if (mode === "t0p_trench") {
|
||||
palette = {
|
||||
base: { hex: '#1a1a1a' },
|
||||
surface0: { hex: '#242424' },
|
||||
crust: { hex: '#000000' },
|
||||
text: { hex: '#fce300' }, // Trench Yellow
|
||||
subtext0: { hex: '#a0a0a0' },
|
||||
primary: { hex: '#fce300' },
|
||||
secondary: { hex: '#4b5320' }, // Olive
|
||||
base: { hex: "#1a1a1a" },
|
||||
surface0: { hex: "#242424" },
|
||||
crust: { hex: "#000000" },
|
||||
text: { hex: "#fce300" }, // Trench Yellow
|
||||
subtext0: { hex: "#a0a0a0" },
|
||||
primary: { hex: "#fce300" },
|
||||
secondary: { hex: "#4b5320" }, // Olive
|
||||
};
|
||||
} else if (mode === 't0p_blurryface') {
|
||||
} else if (mode === "t0p_blurryface") {
|
||||
palette = {
|
||||
base: { hex: '#000000' },
|
||||
surface0: { hex: '#1a1a1a' },
|
||||
crust: { hex: '#000000' },
|
||||
text: { hex: '#ffffff' },
|
||||
subtext0: { hex: '#e31e24' },
|
||||
primary: { hex: '#e31e24' }, // BF Red
|
||||
secondary: { hex: '#ffffff' },
|
||||
base: { hex: "#000000" },
|
||||
surface0: { hex: "#1a1a1a" },
|
||||
crust: { hex: "#000000" },
|
||||
text: { hex: "#ffffff" },
|
||||
subtext0: { hex: "#e31e24" },
|
||||
primary: { hex: "#e31e24" }, // BF Red
|
||||
secondary: { hex: "#ffffff" },
|
||||
};
|
||||
} else if (mode === 't0p_clancy') {
|
||||
} else if (mode === "t0p_clancy") {
|
||||
palette = {
|
||||
base: { hex: '#000000' },
|
||||
surface0: { hex: '#121212' },
|
||||
crust: { hex: '#000000' },
|
||||
text: { hex: '#ffffff' },
|
||||
subtext0: { hex: '#fce300' },
|
||||
primary: { hex: '#e31e24' }, // Clancy Red
|
||||
secondary: { hex: '#fce300' }, // Clancy Yellow
|
||||
base: { hex: "#000000" },
|
||||
surface0: { hex: "#121212" },
|
||||
crust: { hex: "#000000" },
|
||||
text: { hex: "#ffffff" },
|
||||
subtext0: { hex: "#fce300" },
|
||||
primary: { hex: "#e31e24" }, // Clancy Red
|
||||
secondary: { hex: "#fce300" }, // Clancy Yellow
|
||||
};
|
||||
} else if (mode === 'dracula') {
|
||||
} else if (mode === "dracula") {
|
||||
palette = {
|
||||
base: { hex: '#282a36' },
|
||||
surface0: { hex: '#44475a' },
|
||||
crust: { hex: '#191a21' },
|
||||
text: { hex: '#f8f8f2' },
|
||||
subtext0: { hex: '#6272a4' },
|
||||
primary: { hex: '#bd93f9' },
|
||||
secondary: { hex: '#ff79c6' },
|
||||
base: { hex: "#282a36" },
|
||||
surface0: { hex: "#44475a" },
|
||||
crust: { hex: "#191a21" },
|
||||
text: { hex: "#f8f8f2" },
|
||||
subtext0: { hex: "#6272a4" },
|
||||
primary: { hex: "#bd93f9" },
|
||||
secondary: { hex: "#ff79c6" },
|
||||
};
|
||||
} else if (mode === 'nord') {
|
||||
} else if (mode === "nord") {
|
||||
palette = {
|
||||
base: { hex: '#2e3440' },
|
||||
surface0: { hex: '#3b4252' },
|
||||
crust: { hex: '#242933' },
|
||||
text: { hex: '#eceff4' },
|
||||
subtext0: { hex: '#d8dee9' },
|
||||
primary: { hex: '#88c0d0' },
|
||||
secondary: { hex: '#b48ead' },
|
||||
base: { hex: "#2e3440" },
|
||||
surface0: { hex: "#3b4252" },
|
||||
crust: { hex: "#242933" },
|
||||
text: { hex: "#eceff4" },
|
||||
subtext0: { hex: "#d8dee9" },
|
||||
primary: { hex: "#88c0d0" },
|
||||
secondary: { hex: "#b48ead" },
|
||||
};
|
||||
} else if (mode === 'gruvbox') {
|
||||
} else if (mode === "gruvbox") {
|
||||
palette = {
|
||||
base: { hex: '#282828' },
|
||||
surface0: { hex: '#3c3836' },
|
||||
crust: { hex: '#1d2021' },
|
||||
text: { hex: '#ebdbb2' },
|
||||
subtext0: { hex: '#928374' },
|
||||
primary: { hex: '#fabd2f' },
|
||||
secondary: { hex: '#fb4934' },
|
||||
base: { hex: "#282828" },
|
||||
surface0: { hex: "#3c3836" },
|
||||
crust: { hex: "#1d2021" },
|
||||
text: { hex: "#ebdbb2" },
|
||||
subtext0: { hex: "#928374" },
|
||||
primary: { hex: "#fabd2f" },
|
||||
secondary: { hex: "#fb4934" },
|
||||
};
|
||||
} else if (mode === 'oceanic') {
|
||||
} else if (mode === "oceanic") {
|
||||
palette = {
|
||||
base: { hex: '#1b262c' },
|
||||
surface0: { hex: '#0f4c75' },
|
||||
crust: { hex: '#0f161c' },
|
||||
text: { hex: '#bbe1fa' },
|
||||
subtext0: { hex: '#3282b8' },
|
||||
primary: { hex: '#3282b8' },
|
||||
secondary: { hex: '#bbe1fa' },
|
||||
base: { hex: "#1b262c" },
|
||||
surface0: { hex: "#0f4c75" },
|
||||
crust: { hex: "#0f161c" },
|
||||
text: { hex: "#bbe1fa" },
|
||||
subtext0: { hex: "#3282b8" },
|
||||
primary: { hex: "#3282b8" },
|
||||
secondary: { hex: "#bbe1fa" },
|
||||
};
|
||||
} else {
|
||||
// Handle Catppuccin flavors
|
||||
@@ -165,15 +185,15 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
} else {
|
||||
// Fallback to holy_light
|
||||
palette = {
|
||||
base: { hex: '#f8fafc' },
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#f1f5f9' },
|
||||
text: { hex: '#0f172a' },
|
||||
subtext0: { hex: '#64748b' },
|
||||
base: { hex: "#f8fafc" },
|
||||
surface0: { hex: "#ffffff" },
|
||||
crust: { hex: "#f1f5f9" },
|
||||
text: { hex: "#0f172a" },
|
||||
subtext0: { hex: "#64748b" },
|
||||
primary: { hex: holyPurple.light },
|
||||
secondary: { hex: holyPink.light },
|
||||
};
|
||||
mode = 'holy_light';
|
||||
mode = "holy_light";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,10 +201,10 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
|
||||
const themeOptions: ThemeOptions = {
|
||||
palette: {
|
||||
mode: isLight ? 'light' : 'dark',
|
||||
mode: isLight ? "light" : "dark",
|
||||
primary: {
|
||||
main: palette.primary.hex,
|
||||
contrastText: isLight ? '#ffffff' : '#000000',
|
||||
contrastText: isLight ? "#ffffff" : "#000000",
|
||||
},
|
||||
secondary: {
|
||||
main: palette.secondary.hex,
|
||||
@@ -201,7 +221,7 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
action: {
|
||||
hover: alpha(palette.primary.hex, 0.08),
|
||||
selected: alpha(palette.primary.hex, 0.12),
|
||||
}
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 8,
|
||||
@@ -214,35 +234,37 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
h4: { fontWeight: 700 },
|
||||
h5: { fontWeight: 600 },
|
||||
h6: { fontWeight: 600 },
|
||||
button: { textTransform: 'none', fontWeight: 600 },
|
||||
button: { textTransform: "none", fontWeight: 600 },
|
||||
},
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
body: {
|
||||
scrollbarColor: `${alpha(palette.text.hex, 0.2)} transparent`,
|
||||
'&::-webkit-scrollbar': { width: '8px', height: '8px' },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
"&::-webkit-scrollbar": { width: "8px", height: "8px" },
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
backgroundColor: alpha(palette.text.hex, 0.2),
|
||||
borderRadius: '10px',
|
||||
'&:hover': { backgroundColor: alpha(palette.text.hex, 0.3) }
|
||||
borderRadius: "10px",
|
||||
"&:hover": { backgroundColor: alpha(palette.text.hex, 0.3) },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: isLight ? palette.surface0.hex : alpha(palette.surface0.hex, 0.8),
|
||||
backgroundImage: 'none',
|
||||
backdropFilter: isLight ? 'none' : 'blur(16px)',
|
||||
border: '1px solid',
|
||||
backgroundColor: isLight
|
||||
? palette.surface0.hex
|
||||
: alpha(palette.surface0.hex, 0.8),
|
||||
backgroundImage: "none",
|
||||
backdropFilter: isLight ? "none" : "blur(16px)",
|
||||
border: "1px solid",
|
||||
borderColor: alpha(palette.text.hex, 0.08),
|
||||
boxShadow: isLight
|
||||
? '0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.1)'
|
||||
: '0 4px 20px rgba(0,0,0,0.2)',
|
||||
}
|
||||
}
|
||||
? "0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.1)"
|
||||
: "0 4px 20px rgba(0,0,0,0.2)",
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
defaultProps: {
|
||||
@@ -250,57 +272,59 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: isLight ? palette.surface0.hex : alpha(palette.surface0.hex, 0.6),
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '1px solid',
|
||||
backgroundColor: isLight
|
||||
? palette.surface0.hex
|
||||
: alpha(palette.surface0.hex, 0.6),
|
||||
backdropFilter: "blur(12px)",
|
||||
border: "1px solid",
|
||||
borderColor: alpha(palette.text.hex, 0.1),
|
||||
borderRadius: '12px',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
}
|
||||
}
|
||||
borderRadius: "12px",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: alpha(palette.base.hex, 0.8),
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderBottom: '1px solid',
|
||||
backdropFilter: "blur(20px)",
|
||||
borderBottom: "1px solid",
|
||||
borderColor: alpha(palette.text.hex, 0.08),
|
||||
color: palette.text.hex,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '8px',
|
||||
padding: '8px 20px',
|
||||
borderRadius: "8px",
|
||||
padding: "8px 20px",
|
||||
},
|
||||
containedPrimary: {
|
||||
boxShadow: `0 4px 14px 0 ${alpha(palette.primary.hex, 0.39)}`,
|
||||
'&:hover': {
|
||||
"&:hover": {
|
||||
boxShadow: `0 6px 20px rgba(0,0,0,0.23)`,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTab: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
minHeight: '48px',
|
||||
minHeight: "48px",
|
||||
fontWeight: 700,
|
||||
borderRadius: '8px',
|
||||
}
|
||||
}
|
||||
borderRadius: "8px",
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '8px',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
borderRadius: "8px",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return createTheme(themeOptions);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const year = d.getFullYear();
|
||||
const hours = String(d.getHours()).padStart(2, '0');
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||
const hours = String(d.getHours()).padStart(2, "0");
|
||||
const minutes = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
};
|
||||
Reference in new issue
Block a user