mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
refactor
This commit is contained in:
1 parent
5e3474dd59
commit
e4d59a13ba
119 files changed
+15490
-9975
No files matched your search
@@ -0,0 +1,5 @@
|
||||
dist
|
||||
node_modules
|
||||
public
|
||||
*.lock
|
||||
package-lock.json
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
@@ -19,5 +19,18 @@ export default defineConfig([
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-empty-object-type': 'warn',
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-hooks/immutability': 'warn',
|
||||
'react-hooks/purity': 'warn',
|
||||
'react-refresh/only-export-components': 'warn',
|
||||
'no-empty': 'warn',
|
||||
},
|
||||
},
|
||||
])
|
||||
Generated
+1240
-4
File diff suppressed because it is too large.
Load diff
+14
-1
@@ -7,6 +7,11 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,js,json,css}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,json,css}\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -26,17 +31,25 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/colorthief": "^2.6.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/ui": "^4.1.4",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"prettier": "^3.8.2",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
+495
-621
File diff suppressed because it is too large.
Load diff
@@ -1,42 +0,0 @@
|
||||
import axios from 'axios';
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
// Does this cookie string begin with the name we want?
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
const api = axios.create({
|
||||
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');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Token ${token}`;
|
||||
}
|
||||
|
||||
// Set CSRF token for state-changing methods
|
||||
const csrfToken = getCookie('csrftoken');
|
||||
const method = config.method?.toUpperCase();
|
||||
if (csrfToken && method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
||||
if (config.headers) {
|
||||
config.headers['X-CSRFToken'] = csrfToken;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
CircularProgress,
|
||||
InputAdornment,
|
||||
TextField,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGlobalSearch } from '../hooks/useGlobalSearch';
|
||||
|
||||
export const GlobalSearch = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useMediaQuery('(max-width:1150px)');
|
||||
const { query, setQuery, options, loading } = useGlobalSearch();
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) {
|
||||
navigate(`/?q=${encodeURIComponent(query.trim())}`);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1, mx: isMobile ? 1 : 4 }}>
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
freeSolo
|
||||
size="small"
|
||||
loading={loading}
|
||||
options={options}
|
||||
filterOptions={(x) => x}
|
||||
getOptionLabel={(option) => (typeof option === 'string' ? option : option.name || '')}
|
||||
inputValue={query}
|
||||
onInputChange={(_, newValue) => setQuery(typeof newValue === 'string' ? newValue : '')}
|
||||
onChange={(_, newValue) => {
|
||||
if (newValue && typeof newValue !== 'string' && newValue.type === 'flavor') {
|
||||
navigate(`/flavor/${newValue.id}`);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSearch(e as unknown as React.FormEvent);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
fullWidth
|
||||
placeholder={t('common.search')}
|
||||
sx={{
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
color: 'inherit',
|
||||
'& fieldset': { border: 'none' },
|
||||
'&:hover fieldset': { border: 'none' },
|
||||
'&.Mui-focused fieldset': { border: 'none' },
|
||||
outline: 'none',
|
||||
},
|
||||
'& .MuiInputBase-input': { outline: 'none' },
|
||||
}}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon sx={{ color: 'inherit', opacity: 0.7 }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<React.Fragment>
|
||||
{loading ? <CircularProgress color="inherit" size={20} /> : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</React.Fragment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
renderOption={(props, option) => {
|
||||
const { key, ...optionProps } = props as React.HTMLAttributes<HTMLLIElement> & {
|
||||
key: React.Key;
|
||||
};
|
||||
return (
|
||||
<Box
|
||||
component="li"
|
||||
key={key}
|
||||
{...optionProps}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={option.image_url || undefined}
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 1,
|
||||
objectFit: 'contain',
|
||||
bgcolor: 'action.hover',
|
||||
p: 0.5,
|
||||
}}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{option.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{option.subtitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ThemeProvider } from '../contexts/ThemeContext';
|
||||
import { AuthProvider } from '../contexts/AuthContext';
|
||||
import { NotificationProvider } from '../contexts/NotificationContext';
|
||||
import '../i18n';
|
||||
|
||||
export const AppProviders = ({ children }: { children: ReactNode }) => (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<NotificationProvider>{children}</NotificationProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
@@ -1,162 +1,175 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||
TextField, Box, Typography, Alert, Slider, Stack,
|
||||
Tooltip, IconButton
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Box,
|
||||
Typography,
|
||||
Alert,
|
||||
Slider,
|
||||
Stack,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import api from '../api';
|
||||
import api from '../lib/api';
|
||||
|
||||
interface BannerSettingSchema {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'number' | 'slider' | 'text' | 'boolean';
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
description?: string;
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'number' | 'slider' | 'text' | 'boolean';
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Banner {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
settings: any;
|
||||
schema: BannerSettingSchema[];
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
settings: any;
|
||||
schema: BannerSettingSchema[];
|
||||
}
|
||||
|
||||
interface BannerSettingsDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
banner: Banner | null;
|
||||
onSave: () => void;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
banner: Banner | null;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClose, banner, onSave }) => {
|
||||
const { t } = useTranslation();
|
||||
const [localSettings, setLocalSettings] = useState<any>({});
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
banner,
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [localSettings, setLocalSettings] = useState<any>({});
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (banner) {
|
||||
setLocalSettings(banner.settings || {});
|
||||
setError('');
|
||||
}
|
||||
}, [banner]);
|
||||
useEffect(() => {
|
||||
if (banner) {
|
||||
setLocalSettings(banner.settings || {});
|
||||
setError('');
|
||||
}
|
||||
}, [banner]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!banner) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await api.patch(`banners/${banner.id}/`, { settings: localSettings });
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to save settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
const handleSave = async () => {
|
||||
if (!banner) return;
|
||||
|
||||
const updateSetting = (key: string, value: any) => {
|
||||
setLocalSettings((prev: any) => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
try {
|
||||
setSaving(true);
|
||||
await api.patch(`banners/${banner.id}/`, { settings: localSettings });
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to save settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (schema: BannerSettingSchema) => {
|
||||
const value = localSettings[schema.key] ?? '';
|
||||
const updateSetting = (key: string, value: any) => {
|
||||
setLocalSettings((prev: any) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box key={schema.key} sx={{ mb: 3 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
{schema.label}
|
||||
</Typography>
|
||||
{schema.description && (
|
||||
<Tooltip title={schema.description} arrow>
|
||||
<IconButton size="small" sx={{ p: 0.5 }}>
|
||||
<HelpOutlineIcon sx={{ fontSize: '1rem', opacity: 0.6 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{schema.type === 'slider' ? (
|
||||
<Box sx={{ px: 1 }}>
|
||||
<Slider
|
||||
value={Number(value)}
|
||||
min={schema.min ?? 0}
|
||||
max={schema.max ?? 100}
|
||||
step={schema.step ?? 1}
|
||||
onChange={(_, val) => updateSetting(schema.key, val)}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
) : schema.type === 'number' ? (
|
||||
<TextField
|
||||
type="number"
|
||||
fullWidth
|
||||
size="small"
|
||||
value={value}
|
||||
inputProps={{ min: schema.min, max: schema.max, step: schema.step }}
|
||||
onChange={(e) => updateSetting(schema.key, Number(e.target.value))}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={value}
|
||||
onChange={(e) => updateSetting(schema.key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{schema.description && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
{schema.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
const renderField = (schema: BannerSettingSchema) => {
|
||||
const value = localSettings[schema.key] ?? '';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 'bold' }}>
|
||||
Banner Settings: {banner?.name}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
|
||||
|
||||
{banner?.schema && banner.schema.length > 0 ? (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{banner.schema.map(field => renderField(field))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography color="text.secondary" sx={{ fontStyle: 'italic', py: 2 }}>
|
||||
No dynamic schema defined for this banner. Please use the JSON editor or check the banner configuration files.
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={onClose} color="inherit">{t('common.cancel')}</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="contained"
|
||||
disabled={saving}
|
||||
sx={{ px: 4 }}
|
||||
>
|
||||
{saving ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Box key={schema.key} sx={{ mb: 3 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
{schema.label}
|
||||
</Typography>
|
||||
{schema.description && (
|
||||
<Tooltip title={schema.description} arrow>
|
||||
<IconButton size="small" sx={{ p: 0.5 }}>
|
||||
<HelpOutlineIcon sx={{ fontSize: '1rem', opacity: 0.6 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{schema.type === 'slider' ? (
|
||||
<Box sx={{ px: 1 }}>
|
||||
<Slider
|
||||
value={Number(value)}
|
||||
min={schema.min ?? 0}
|
||||
max={schema.max ?? 100}
|
||||
step={schema.step ?? 1}
|
||||
onChange={(_, val) => updateSetting(schema.key, val)}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
) : schema.type === 'number' ? (
|
||||
<TextField
|
||||
type="number"
|
||||
fullWidth
|
||||
size="small"
|
||||
value={value}
|
||||
inputProps={{ min: schema.min, max: schema.max, step: schema.step }}
|
||||
onChange={(e) => updateSetting(schema.key, Number(e.target.value))}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={value}
|
||||
onChange={(e) => updateSetting(schema.key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{schema.description && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
{schema.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 'bold' }}>Banner Settings: {banner?.name}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{banner?.schema && banner.schema.length > 0 ? (
|
||||
<Box sx={{ mt: 1 }}>{banner.schema.map((field) => renderField(field))}</Box>
|
||||
) : (
|
||||
<Typography color="text.secondary" sx={{ fontStyle: 'italic', py: 2 }}>
|
||||
No dynamic schema defined for this banner. Please use the JSON editor or check the
|
||||
banner configuration files.
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={onClose} color="inherit">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={saving} sx={{ px: 4 }}>
|
||||
{saving ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default BannerSettingsDialog;
|
||||
@@ -1,108 +1,123 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Button,
|
||||
alpha,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
Box
|
||||
import {
|
||||
Typography,
|
||||
Button,
|
||||
alpha,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
Box,
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CatppuccinTheme } from '../theme';
|
||||
|
||||
interface CookieBannerProps {
|
||||
onThemeChange: (newTheme: CatppuccinTheme) => void;
|
||||
currentTheme: CatppuccinTheme;
|
||||
onThemeChange: (newTheme: CatppuccinTheme) => void;
|
||||
currentTheme: CatppuccinTheme;
|
||||
}
|
||||
|
||||
const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme }) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const consent = localStorage.getItem('cookie-consent');
|
||||
if (!consent) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const consent = localStorage.getItem('cookie-consent');
|
||||
if (!consent) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAccept = () => {
|
||||
localStorage.setItem('cookie-consent', 'true');
|
||||
setOpen(false);
|
||||
};
|
||||
const handleAccept = () => {
|
||||
localStorage.setItem('cookie-consent', 'true');
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleToggleTheme = () => {
|
||||
const newTheme = currentTheme === 'holy_light' ? 'holy_dark' : 'holy_light';
|
||||
onThemeChange(newTheme);
|
||||
};
|
||||
const handleToggleTheme = () => {
|
||||
const newTheme = currentTheme === 'holy_light' ? 'holy_dark' : 'holy_light';
|
||||
onThemeChange(newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {}} // Disable closing by clicking backdrop
|
||||
disableEscapeKeyDown
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.main',
|
||||
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.98),
|
||||
backdropFilter: 'blur(10px)',
|
||||
boxShadow: (theme) => `0 20px 50px ${alpha(theme.palette.primary.main, 0.2)}`,
|
||||
}
|
||||
}}
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {}} // Disable closing by clicking backdrop
|
||||
disableEscapeKeyDown
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.main',
|
||||
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.98),
|
||||
backdropFilter: 'blur(10px)',
|
||||
boxShadow: (theme) => `0 20px 50px ${alpha(theme.palette.primary.main, 0.2)}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ fontWeight: 'bold', fontSize: '1.5rem', pt: 3 }}>
|
||||
{t('cookieBanner.title')} 🍪
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body1" sx={{ mb: 2, lineHeight: 1.7 }}>
|
||||
{t('cookieBanner.description')}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'action.hover',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ fontWeight: 'bold', fontSize: '1.5rem', pt: 3 }}>
|
||||
{t('cookieBanner.title')} 🍪
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body1" sx={{ mb: 2, lineHeight: 1.7 }}>
|
||||
{t('cookieBanner.description')}
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'action.hover',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
mt: 2
|
||||
}}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', display: 'block', mb: 1, textTransform: 'uppercase' }}>
|
||||
Technical Details:
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
• Session ID: Required for login & authentication.<br />
|
||||
• CSRF Token: Protects your account from malicious attacks.<br />
|
||||
• Preference Store: Remembers your theme and language choices.
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 3, gap: 2, flexDirection: { xs: 'column', sm: 'row' } }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleToggleTheme}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', m: '0 !important' }}
|
||||
>
|
||||
{currentTheme === 'holy_light' ? t('cookieBanner.switchToDark') : t('cookieBanner.switchToLight')}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleAccept}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', px: 4, m: '0 !important' }}
|
||||
>
|
||||
{t('cookieBanner.accept')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 'bold', display: 'block', mb: 1, textTransform: 'uppercase' }}
|
||||
>
|
||||
Technical Details:
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
• Session ID: Required for login & authentication.
|
||||
<br />
|
||||
• CSRF Token: Protects your account from malicious attacks.
|
||||
<br />• Preference Store: Remembers your theme and language choices.
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 3, gap: 2, flexDirection: { xs: 'column', sm: 'row' } }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleToggleTheme}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', m: '0 !important' }}
|
||||
>
|
||||
{currentTheme === 'holy_light'
|
||||
? t('cookieBanner.switchToDark')
|
||||
: t('cookieBanner.switchToLight')}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleAccept}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
px: 4,
|
||||
m: '0 !important',
|
||||
}}
|
||||
>
|
||||
{t('cookieBanner.accept')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CookieBanner;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
import api from '../lib/api';
|
||||
import GenerativeBanner from './GenerativeBanner';
|
||||
import NebulaBanner from './NebulaBanner';
|
||||
import FirefliesBanner from './FirefliesBanner';
|
||||
@@ -8,106 +8,123 @@ import HextechCorruptionBanner from './HextechCorruptionBanner';
|
||||
import GenerativeFlowBanner from './GenerativeFlowBanner';
|
||||
|
||||
interface Banner {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
settings: any;
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
settings: any;
|
||||
}
|
||||
|
||||
interface DynamicBannerProps {
|
||||
username: string;
|
||||
palette: string[];
|
||||
ratingsCount: number;
|
||||
followersCount: number;
|
||||
username: string;
|
||||
palette: string[];
|
||||
ratingsCount: number;
|
||||
followersCount: number;
|
||||
}
|
||||
|
||||
// Future expansion: Add more banner components here
|
||||
const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({ username, settings }) => {
|
||||
// Placeholder for a second model
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, width: '100%', height: '100%',
|
||||
background: 'rgba(0,0,0,0.8)', color: '#0f0', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace',
|
||||
zIndex: 1, overflow: 'hidden', opacity: settings.opacity || 0.5
|
||||
}}>
|
||||
<div>
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<div key={i} style={{ animation: `matrixFall ${2 + Math.random() * 3}s linear infinite` }}>
|
||||
{username.split('').map((char, j) => (
|
||||
<span key={j} style={{ display: 'block' }}>{char}</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<style>
|
||||
{`
|
||||
// Placeholder for a second model
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'rgba(0,0,0,0.8)',
|
||||
color: '#0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'monospace',
|
||||
zIndex: 1,
|
||||
overflow: 'hidden',
|
||||
opacity: settings.opacity || 0.5,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ animation: `matrixFall ${2 + Math.random() * 3}s linear infinite` }}
|
||||
>
|
||||
{username.split('').map((char, j) => (
|
||||
<span key={j} style={{ display: 'block' }}>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<style>
|
||||
{`
|
||||
@keyframes matrixFall {
|
||||
0% { transform: translateY(-100%); }
|
||||
100% { transform: translateY(100%); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</div>
|
||||
);
|
||||
</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
|
||||
const [banner, setBanner] = useState<Banner | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [banner, setBanner] = useState<Banner | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchActiveBanner = async () => {
|
||||
try {
|
||||
const res = await api.get('banners/active/', {
|
||||
params: { username: props.username }
|
||||
});
|
||||
setBanner(res.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch active banner:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
const fetchActiveBanner = async () => {
|
||||
try {
|
||||
const res = await api.get('banners/active/', {
|
||||
params: { username: props.username },
|
||||
});
|
||||
setBanner(res.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch active banner:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchActiveBanner();
|
||||
}, [props.username]);
|
||||
fetchActiveBanner();
|
||||
}, [props.username]);
|
||||
|
||||
if (loading) return null;
|
||||
if (loading) return null;
|
||||
|
||||
// Default to GenerativeBanner if no active banner or unrecognized slug
|
||||
if (!banner || banner.slug === 'generative-web') {
|
||||
return <GenerativeBanner {...props} settings={banner?.settings} />;
|
||||
}
|
||||
// Default to GenerativeBanner if no active banner or unrecognized slug
|
||||
if (!banner || banner.slug === 'generative-web') {
|
||||
return <GenerativeBanner {...props} settings={banner?.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'matrix') {
|
||||
return <MatrixBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
if (banner.slug === 'matrix') {
|
||||
return <MatrixBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'nebula') {
|
||||
return <NebulaBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
if (banner.slug === 'nebula') {
|
||||
return <NebulaBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'fireflies') {
|
||||
return <FirefliesBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
if (banner.slug === 'fireflies') {
|
||||
return <FirefliesBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'hextech') {
|
||||
return <HextechBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
if (banner.slug === 'hextech') {
|
||||
return <HextechBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'hextech-corruption') {
|
||||
return <HextechCorruptionBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
if (banner.slug === 'hextech-corruption') {
|
||||
return <HextechCorruptionBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'generative-flow') {
|
||||
return <GenerativeFlowBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
if (banner.slug === 'generative-flow') {
|
||||
return <GenerativeFlowBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return <GenerativeBanner {...props} />;
|
||||
// Fallback
|
||||
return <GenerativeBanner {...props} />;
|
||||
};
|
||||
|
||||
export default DynamicBanner;
|
||||
@@ -2,256 +2,279 @@ import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
|
||||
interface FirefliesBannerProps {
|
||||
username: string;
|
||||
palette: string[];
|
||||
ratingsCount: number;
|
||||
followersCount: number;
|
||||
settings?: {
|
||||
count?: number;
|
||||
speed?: number;
|
||||
evasionRadius?: number;
|
||||
evasionStrength?: number;
|
||||
glowSize?: number;
|
||||
flickerSpeed?: number;
|
||||
};
|
||||
username: string;
|
||||
palette: string[];
|
||||
ratingsCount: number;
|
||||
followersCount: number;
|
||||
settings?: {
|
||||
count?: number;
|
||||
speed?: number;
|
||||
evasionRadius?: number;
|
||||
evasionStrength?: number;
|
||||
glowSize?: number;
|
||||
flickerSpeed?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, settings }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
const count = settings?.count ?? 150;
|
||||
const baseSpeed = settings?.speed ?? 0.8; // Slower default cruise
|
||||
const evasionRadius = settings?.evasionRadius ?? 180;
|
||||
const evasionStrength = settings?.evasionStrength ?? 0.6; // High evasion
|
||||
const glowSizeMultiplier = settings?.glowSize ?? 4;
|
||||
const flickerSpeed = settings?.flickerSpeed ?? 0.05;
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
const getSeed = (str: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return hash;
|
||||
const count = settings?.count ?? 150;
|
||||
const baseSpeed = settings?.speed ?? 0.8; // Slower default cruise
|
||||
const evasionRadius = settings?.evasionRadius ?? 180;
|
||||
const evasionStrength = settings?.evasionStrength ?? 0.6; // High evasion
|
||||
const glowSizeMultiplier = settings?.glowSize ?? 4;
|
||||
const flickerSpeed = settings?.flickerSpeed ?? 0.05;
|
||||
|
||||
const getSeed = (str: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
const seed = useMemo(() => getSeed(username), [username]);
|
||||
|
||||
const brightColors = useMemo(() => {
|
||||
const colors =
|
||||
palette.length >= 2
|
||||
? palette
|
||||
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const getBrightness = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
||||
return (r * 299 + g * 587 + b * 114) / 1000;
|
||||
};
|
||||
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
||||
return sorted.slice(0, 3);
|
||||
}, [palette, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
|
||||
interface Firefly {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
color: string;
|
||||
phase: number;
|
||||
noiseOffset: number;
|
||||
isEvading: boolean;
|
||||
}
|
||||
|
||||
let swarm: Firefly[] = [];
|
||||
|
||||
const pseudoRandom = (offset: number) => {
|
||||
const x = Math.sin(seed + offset) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
const seed = useMemo(() => getSeed(username), [username]);
|
||||
const initSwarm = (width: number, height: number) => {
|
||||
const newSwarm = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
newSwarm.push({
|
||||
x: pseudoRandom(i) * width,
|
||||
y: pseudoRandom(i + 500) * height,
|
||||
vx: (pseudoRandom(i + 1000) - 0.5) * baseSpeed,
|
||||
vy: (pseudoRandom(i + 1500) - 0.5) * baseSpeed,
|
||||
size: 1 + pseudoRandom(i + 2000) * 2,
|
||||
color: brightColors[i % brightColors.length],
|
||||
phase: pseudoRandom(i + 2500) * Math.PI * 2,
|
||||
noiseOffset: pseudoRandom(i + 3000) * 100,
|
||||
isEvading: false,
|
||||
});
|
||||
}
|
||||
return newSwarm;
|
||||
};
|
||||
|
||||
const brightColors = useMemo(() => {
|
||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const getBrightness = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
||||
return (r * 299 + g * 587 + b * 114) / 1000;
|
||||
};
|
||||
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
||||
return sorted.slice(0, 3);
|
||||
}, [palette, theme]);
|
||||
const resize = () => {
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
swarm = initSwarm(rect.width, rect.height);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const resizeObserver = new ResizeObserver(() => resize());
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
resize();
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) return;
|
||||
const mouse = { x: -1000, y: -1000 };
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
|
||||
let animationFrameId: number;
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mouseleave', () => {
|
||||
mouse.x = -1000;
|
||||
mouse.y = -1000;
|
||||
});
|
||||
}
|
||||
|
||||
interface Firefly {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
color: string;
|
||||
phase: number;
|
||||
noiseOffset: number;
|
||||
isEvading: boolean;
|
||||
let time = 0;
|
||||
const draw = () => {
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
if (!width || !height) {
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Vibrant theme-aware night sky background
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const bgGrad = ctx.createRadialGradient(
|
||||
width / 2,
|
||||
height / 2,
|
||||
0,
|
||||
width / 2,
|
||||
height / 2,
|
||||
width,
|
||||
);
|
||||
if (isLight) {
|
||||
// For light themes, we still need a "dusk/night" feel for fireflies to make sense.
|
||||
// We use a deeper, more saturated version of the theme's colors.
|
||||
bgGrad.addColorStop(0, alpha(brightColors[2] || '#1a1a2e', 0.85));
|
||||
bgGrad.addColorStop(1, alpha(brightColors[2] || '#16213e', 1.0));
|
||||
} else {
|
||||
bgGrad.addColorStop(0, '#141a2a'); // Noticeable blue-grey tint
|
||||
bgGrad.addColorStop(1, '#05080f');
|
||||
}
|
||||
ctx.fillStyle = bgGrad;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
time += flickerSpeed;
|
||||
|
||||
swarm.forEach((f, i) => {
|
||||
// 1. Autonomous Chaotic Movement (Perlin + Jitter)
|
||||
const driftX = Math.sin(time * 0.5 + f.noiseOffset) * 0.05;
|
||||
const driftY = Math.cos(time * 0.4 + f.noiseOffset) * 0.05;
|
||||
|
||||
// High-frequency jitter for insect feel
|
||||
const jitterX = (pseudoRandom(i + time * 10) - 0.5) * 0.2;
|
||||
const jitterY = (pseudoRandom(i + 500 + time * 10) - 0.5) * 0.2;
|
||||
|
||||
f.vx += driftX + jitterX;
|
||||
f.vy += driftY + jitterY;
|
||||
|
||||
// 2. Mouse Evasion (Fast Dash)
|
||||
const dx = mouse.x - f.x;
|
||||
const dy = mouse.y - f.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
f.isEvading = dist < evasionRadius;
|
||||
if (f.isEvading) {
|
||||
const force = (evasionRadius - dist) / evasionRadius;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
// Sharp acceleration away
|
||||
f.vx -= Math.cos(angle) * force * evasionStrength * 1.5;
|
||||
f.vy -= Math.sin(angle) * force * evasionStrength * 1.5;
|
||||
}
|
||||
|
||||
let swarm: Firefly[] = [];
|
||||
|
||||
const pseudoRandom = (offset: number) => {
|
||||
const x = Math.sin(seed + offset) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
// 3. Dynamic Speed Limits
|
||||
const currentSpeed = Math.sqrt(f.vx * f.vx + f.vy * f.vy);
|
||||
const maxSpeed = f.isEvading ? baseSpeed * 10 : baseSpeed * 1.5;
|
||||
|
||||
const initSwarm = (width: number, height: number) => {
|
||||
const newSwarm = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
newSwarm.push({
|
||||
x: pseudoRandom(i) * width,
|
||||
y: pseudoRandom(i + 500) * height,
|
||||
vx: (pseudoRandom(i + 1000) - 0.5) * baseSpeed,
|
||||
vy: (pseudoRandom(i + 1500) - 0.5) * baseSpeed,
|
||||
size: 1 + pseudoRandom(i + 2000) * 2,
|
||||
color: brightColors[i % brightColors.length],
|
||||
phase: pseudoRandom(i + 2500) * Math.PI * 2,
|
||||
noiseOffset: pseudoRandom(i + 3000) * 100,
|
||||
isEvading: false
|
||||
});
|
||||
}
|
||||
return newSwarm;
|
||||
};
|
||||
|
||||
const resize = () => {
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
swarm = initSwarm(rect.width, rect.height);
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => resize());
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
resize();
|
||||
|
||||
let mouse = { x: -1000, y: -1000 };
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; });
|
||||
if (currentSpeed > maxSpeed) {
|
||||
f.vx = (f.vx / currentSpeed) * maxSpeed;
|
||||
f.vy = (f.vy / currentSpeed) * maxSpeed;
|
||||
}
|
||||
|
||||
let time = 0;
|
||||
const draw = () => {
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
if (!width || !height) {
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
// 4. Smooth Friction
|
||||
f.vx *= 0.97;
|
||||
f.vy *= 0.97;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Vibrant theme-aware night sky background
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
|
||||
if (isLight) {
|
||||
// For light themes, we still need a "dusk/night" feel for fireflies to make sense.
|
||||
// We use a deeper, more saturated version of the theme's colors.
|
||||
bgGrad.addColorStop(0, alpha(brightColors[2] || '#1a1a2e', 0.85));
|
||||
bgGrad.addColorStop(1, alpha(brightColors[2] || '#16213e', 1.0));
|
||||
} else {
|
||||
bgGrad.addColorStop(0, '#141a2a'); // Noticeable blue-grey tint
|
||||
bgGrad.addColorStop(1, '#05080f');
|
||||
}
|
||||
ctx.fillStyle = bgGrad;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
f.x += f.vx;
|
||||
f.y += f.vy;
|
||||
|
||||
time += flickerSpeed;
|
||||
// Screen Wrap
|
||||
const margin = 20;
|
||||
if (f.x < -margin) f.x = width + margin;
|
||||
if (f.x > width + margin) f.x = -margin;
|
||||
if (f.y < -margin) f.y = height + margin;
|
||||
if (f.y > height + margin) f.y = -margin;
|
||||
|
||||
swarm.forEach((f, i) => {
|
||||
// 1. Autonomous Chaotic Movement (Perlin + Jitter)
|
||||
const driftX = Math.sin(time * 0.5 + f.noiseOffset) * 0.05;
|
||||
const driftY = Math.cos(time * 0.4 + f.noiseOffset) * 0.05;
|
||||
|
||||
// High-frequency jitter for insect feel
|
||||
const jitterX = (pseudoRandom(i + time * 10) - 0.5) * 0.2;
|
||||
const jitterY = (pseudoRandom(i + 500 + time * 10) - 0.5) * 0.2;
|
||||
// Flicker
|
||||
const flicker = 0.4 + Math.sin(time + f.phase) * 0.6;
|
||||
const opacity = flicker > 0 ? flicker : 0;
|
||||
|
||||
f.vx += driftX + jitterX;
|
||||
f.vy += driftY + jitterY;
|
||||
// Render
|
||||
const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.size * glowSizeMultiplier);
|
||||
grad.addColorStop(0, alpha(f.color, opacity));
|
||||
grad.addColorStop(0.4, alpha(f.color, opacity * 0.3));
|
||||
grad.addColorStop(1, alpha(f.color, 0));
|
||||
|
||||
// 2. Mouse Evasion (Fast Dash)
|
||||
const dx = mouse.x - f.x;
|
||||
const dy = mouse.y - f.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
f.isEvading = dist < evasionRadius;
|
||||
if (f.isEvading) {
|
||||
const force = (evasionRadius - dist) / evasionRadius;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
// Sharp acceleration away
|
||||
f.vx -= Math.cos(angle) * force * evasionStrength * 1.5;
|
||||
f.vy -= Math.sin(angle) * force * evasionStrength * 1.5;
|
||||
}
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(f.x, f.y, f.size * glowSizeMultiplier, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 3. Dynamic Speed Limits
|
||||
const currentSpeed = Math.sqrt(f.vx * f.vx + f.vy * f.vy);
|
||||
const maxSpeed = f.isEvading ? baseSpeed * 10 : baseSpeed * 1.5;
|
||||
|
||||
if (currentSpeed > maxSpeed) {
|
||||
f.vx = (f.vx / currentSpeed) * maxSpeed;
|
||||
f.vy = (f.vy / currentSpeed) * maxSpeed;
|
||||
}
|
||||
ctx.fillStyle = alpha('#fff', opacity * 0.8);
|
||||
ctx.beginPath();
|
||||
ctx.arc(f.x, f.y, f.size * 0.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
// 4. Smooth Friction
|
||||
f.vx *= 0.97;
|
||||
f.vy *= 0.97;
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
f.x += f.vx;
|
||||
f.y += f.vy;
|
||||
draw();
|
||||
|
||||
// Screen Wrap
|
||||
const margin = 20;
|
||||
if (f.x < -margin) f.x = width + margin;
|
||||
if (f.x > width + margin) f.x = -margin;
|
||||
if (f.y < -margin) f.y = height + margin;
|
||||
if (f.y > height + margin) f.y = -margin;
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMouseMove);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [
|
||||
seed,
|
||||
brightColors,
|
||||
count,
|
||||
baseSpeed,
|
||||
evasionRadius,
|
||||
evasionStrength,
|
||||
glowSizeMultiplier,
|
||||
flickerSpeed,
|
||||
theme,
|
||||
]);
|
||||
|
||||
// Flicker
|
||||
const flicker = 0.4 + Math.sin(time + f.phase) * 0.6;
|
||||
const opacity = flicker > 0 ? flicker : 0;
|
||||
|
||||
// Render
|
||||
const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.size * glowSizeMultiplier);
|
||||
grad.addColorStop(0, alpha(f.color, opacity));
|
||||
grad.addColorStop(0.4, alpha(f.color, opacity * 0.3));
|
||||
grad.addColorStop(1, alpha(f.color, 0));
|
||||
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(f.x, f.y, f.size * glowSizeMultiplier, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = alpha('#fff', opacity * 0.8);
|
||||
ctx.beginPath();
|
||||
ctx.arc(f.x, f.y, f.size * 0.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMouseMove);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [seed, brightColors, count, baseSpeed, evasionRadius, evasionStrength, glowSizeMultiplier, flickerSpeed, theme]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default FirefliesBanner;
|
||||
@@ -8,25 +8,62 @@ const Footer: React.FC = () => {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<Box component="footer" sx={{ bgcolor: 'background.paper', pt: 6, pb: 4, mt: 'auto', borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
pt: 6,
|
||||
pb: 4,
|
||||
mt: 'auto',
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, justifyContent: 'space-between', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
|
||||
© {year} Holy Flavors Archive
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||
<MuiLink component={Link} to="/about" color="inherit" sx={{ fontSize: '0.8rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
|
||||
Impressum
|
||||
</MuiLink>
|
||||
<MuiLink component={Link} to="/privacy" color="inherit" sx={{ fontSize: '0.8rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
|
||||
Privacy Policy
|
||||
</MuiLink>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
|
||||
© {year} Holy Flavors Archive
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||
<MuiLink
|
||||
component={Link}
|
||||
to="/about"
|
||||
color="inherit"
|
||||
sx={{
|
||||
fontSize: '0.8rem',
|
||||
textDecoration: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
Impressum
|
||||
</MuiLink>
|
||||
<MuiLink
|
||||
component={Link}
|
||||
to="/privacy"
|
||||
color="inherit"
|
||||
sx={{
|
||||
fontSize: '0.8rem',
|
||||
textDecoration: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
Privacy Policy
|
||||
</MuiLink>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider sx={{ my: 2, opacity: 0.5 }} />
|
||||
<Typography variant="caption" color="text.secondary" align="center" display="block">
|
||||
{t('nav.about')} • Fan Project
|
||||
{t('nav.about')} • Fan Project
|
||||
</Typography>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
@@ -2,277 +2,315 @@ import React, { useRef, useEffect } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
|
||||
interface GenerativeBannerProps {
|
||||
username: string;
|
||||
palette: string[];
|
||||
ratingsCount: number;
|
||||
followersCount: number;
|
||||
settings?: {
|
||||
nodeCountBase?: number;
|
||||
connectionDist?: number;
|
||||
speed?: number;
|
||||
opacity?: number;
|
||||
};
|
||||
username: string;
|
||||
palette: string[];
|
||||
ratingsCount: number;
|
||||
followersCount: number;
|
||||
settings?: {
|
||||
nodeCountBase?: number;
|
||||
connectionDist?: number;
|
||||
speed?: number;
|
||||
opacity?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
const nodeCountBase = settings?.nodeCountBase ?? 60;
|
||||
const connectionDist = settings?.connectionDist ?? 140;
|
||||
const speed = settings?.speed ?? 0.005;
|
||||
const globalOpacity = settings?.opacity ?? 0.35;
|
||||
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({
|
||||
username,
|
||||
palette,
|
||||
ratingsCount,
|
||||
followersCount,
|
||||
settings,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
const getSeed = (str: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return hash;
|
||||
const nodeCountBase = settings?.nodeCountBase ?? 60;
|
||||
const connectionDist = settings?.connectionDist ?? 140;
|
||||
const speed = settings?.speed ?? 0.005;
|
||||
const globalOpacity = settings?.opacity ?? 0.35;
|
||||
|
||||
const getSeed = (str: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
const seed = getSeed(username);
|
||||
|
||||
// Configuration - High density for "complex web" feel
|
||||
const nodeCount = Math.min(nodeCountBase + Math.floor(ratingsCount * 1.5), 120);
|
||||
const colors =
|
||||
palette.length >= 2
|
||||
? palette
|
||||
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
|
||||
interface Node {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
baseX: number;
|
||||
baseY: number;
|
||||
color: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
let nodes: Node[] = [];
|
||||
|
||||
const pseudoRandom = (offset: number) => {
|
||||
const x = Math.sin(seed + offset) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const initNodes = (width: number, height: number) => {
|
||||
const newNodes = [];
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const bx = pseudoRandom(i) * width;
|
||||
const by = pseudoRandom(i + 100) * height;
|
||||
newNodes.push({
|
||||
x: bx,
|
||||
y: by,
|
||||
baseX: bx,
|
||||
baseY: by,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
color: colors[i % colors.length],
|
||||
size: 1 + pseudoRandom(i + 200) * 3,
|
||||
});
|
||||
}
|
||||
return newNodes;
|
||||
};
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) return;
|
||||
const resize = () => {
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
nodes = initNodes(rect.width, rect.height);
|
||||
}
|
||||
};
|
||||
|
||||
let animationFrameId: number;
|
||||
const seed = getSeed(username);
|
||||
|
||||
// Configuration - High density for "complex web" feel
|
||||
const nodeCount = Math.min(nodeCountBase + Math.floor(ratingsCount * 1.5), 120);
|
||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const resizeObserver = new ResizeObserver(() => resize());
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
resize();
|
||||
|
||||
interface Node {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
baseX: number;
|
||||
baseY: number;
|
||||
color: string;
|
||||
size: number;
|
||||
const mouse = { x: -1000, y: -1000, down: false };
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (e.touches.length > 0) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.touches[0].clientX - rect.left;
|
||||
mouse.y = e.touches[0].clientY - rect.top;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = () => {
|
||||
mouse.down = true;
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
mouse.down = false;
|
||||
};
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
parent.addEventListener('mousedown', handleMouseDown);
|
||||
parent.addEventListener('mouseup', handleMouseUp);
|
||||
parent.addEventListener('touchstart', (e) => {
|
||||
mouse.down = true;
|
||||
handleTouchMove(e);
|
||||
});
|
||||
parent.addEventListener('touchend', handleMouseUp);
|
||||
parent.addEventListener('mouseleave', () => {
|
||||
mouse.x = -1000;
|
||||
mouse.y = -1000;
|
||||
mouse.down = false;
|
||||
});
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
|
||||
if (!width || !height) {
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Physics Update
|
||||
nodes.forEach((node) => {
|
||||
// Return force (Elasticity) - slightly slower return
|
||||
const dxBase = node.baseX - node.x;
|
||||
const dyBase = node.baseY - node.y;
|
||||
node.vx += dxBase * speed;
|
||||
node.vy += dyBase * speed;
|
||||
|
||||
// Mouse interaction
|
||||
const dxMouse = mouse.x - node.x;
|
||||
const dyMouse = mouse.y - node.y;
|
||||
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
|
||||
|
||||
if (mouse.down) {
|
||||
// Grab and drag effect - less strong
|
||||
const dragDist = 300;
|
||||
if (distMouse < dragDist) {
|
||||
const power = (dragDist - distMouse) / dragDist;
|
||||
node.vx += dxMouse * power * 0.08;
|
||||
node.vy += dyMouse * power * 0.08;
|
||||
}
|
||||
} else {
|
||||
// Hover disturbance - less strong pushing effect
|
||||
const hoverDist = 150;
|
||||
if (distMouse < hoverDist) {
|
||||
const power = (hoverDist - distMouse) / hoverDist;
|
||||
node.vx -= dxMouse * power * 0.02;
|
||||
node.vy -= dyMouse * power * 0.02;
|
||||
}
|
||||
}
|
||||
|
||||
let nodes: Node[] = [];
|
||||
|
||||
const pseudoRandom = (offset: number) => {
|
||||
const x = Math.sin(seed + offset) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
// Damping (Friction) - stronger damping for less "fast" feel
|
||||
node.vx *= 0.88;
|
||||
node.vy *= 0.88;
|
||||
|
||||
const initNodes = (width: number, height: number) => {
|
||||
const newNodes = [];
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const bx = pseudoRandom(i) * width;
|
||||
const by = pseudoRandom(i + 100) * height;
|
||||
newNodes.push({
|
||||
x: bx, y: by,
|
||||
baseX: bx, baseY: by,
|
||||
vx: 0, vy: 0,
|
||||
color: colors[i % colors.length],
|
||||
size: 1 + pseudoRandom(i + 200) * 3
|
||||
});
|
||||
// Update Position
|
||||
node.x += node.vx;
|
||||
node.y += node.vy;
|
||||
});
|
||||
|
||||
// Draw Mesh (Connections)
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
let currentConnections = 0;
|
||||
// We only connect a few to keep it a "web" and not a solid block
|
||||
for (let j = i + 1; j < nodes.length && currentConnections < 6; j++) {
|
||||
const dx = nodes[i].x - nodes[j].x;
|
||||
const dy = nodes[i].y - nodes[j].y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < connectionDist) {
|
||||
currentConnections++;
|
||||
const opacity = (1 - dist / connectionDist) * globalOpacity;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(nodes[i].x, nodes[i].y);
|
||||
ctx.lineTo(nodes[j].x, nodes[j].y);
|
||||
|
||||
// Dynamic gradient for the line
|
||||
const lineGrad = ctx.createLinearGradient(
|
||||
nodes[i].x,
|
||||
nodes[i].y,
|
||||
nodes[j].x,
|
||||
nodes[j].y,
|
||||
);
|
||||
lineGrad.addColorStop(0, alpha(nodes[i].color, opacity));
|
||||
lineGrad.addColorStop(1, alpha(nodes[j].color, opacity));
|
||||
|
||||
ctx.strokeStyle = lineGrad;
|
||||
ctx.stroke();
|
||||
|
||||
// Fill some triangles for complexity
|
||||
if (dist < connectionDist * 0.6 && pseudoRandom(i + j) > 0.92) {
|
||||
for (let k = j + 1; k < nodes.length; k++) {
|
||||
const d3x = nodes[i].x - nodes[k].x;
|
||||
const d3y = nodes[i].y - nodes[k].y;
|
||||
if (Math.sqrt(d3x * d3x + d3y * d3y) < connectionDist * 0.6) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(nodes[i].x, nodes[i].y);
|
||||
ctx.lineTo(nodes[j].x, nodes[j].y);
|
||||
ctx.lineTo(nodes[k].x, nodes[k].y);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = alpha(nodes[i].color, 0.04);
|
||||
ctx.fill();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return newNodes;
|
||||
};
|
||||
|
||||
const resize = () => {
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
nodes = initNodes(rect.width, rect.height);
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => resize());
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
resize();
|
||||
|
||||
let mouse = { x: -1000, y: -1000, down: false };
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (e.touches.length > 0) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.touches[0].clientX - rect.left;
|
||||
mouse.y = e.touches[0].clientY - rect.top;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = () => { mouse.down = true; };
|
||||
const handleMouseUp = () => { mouse.down = false; };
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
parent.addEventListener('mousedown', handleMouseDown);
|
||||
parent.addEventListener('mouseup', handleMouseUp);
|
||||
parent.addEventListener('touchstart', (e) => { mouse.down = true; handleTouchMove(e); });
|
||||
parent.addEventListener('touchend', handleMouseUp);
|
||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
|
||||
if (!width || !height) {
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
// Draw Nodes
|
||||
nodes.forEach((node) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, node.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = node.color;
|
||||
ctx.fill();
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Physics Update
|
||||
nodes.forEach((node) => {
|
||||
// Return force (Elasticity) - slightly slower return
|
||||
const dxBase = node.baseX - node.x;
|
||||
const dyBase = node.baseY - node.y;
|
||||
node.vx += dxBase * speed;
|
||||
node.vy += dyBase * speed;
|
||||
// Core glow
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, node.size * 2.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = alpha(node.color, 0.15);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
// Mouse interaction
|
||||
const dxMouse = mouse.x - node.x;
|
||||
const dyMouse = mouse.y - node.y;
|
||||
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
|
||||
|
||||
if (mouse.down) {
|
||||
// Grab and drag effect - less strong
|
||||
const dragDist = 300;
|
||||
if (distMouse < dragDist) {
|
||||
const power = (dragDist - distMouse) / dragDist;
|
||||
node.vx += dxMouse * power * 0.08;
|
||||
node.vy += dyMouse * power * 0.08;
|
||||
}
|
||||
} else {
|
||||
// Hover disturbance - less strong pushing effect
|
||||
const hoverDist = 150;
|
||||
if (distMouse < hoverDist) {
|
||||
const power = (hoverDist - distMouse) / hoverDist;
|
||||
node.vx -= dxMouse * power * 0.02;
|
||||
node.vy -= dyMouse * power * 0.02;
|
||||
}
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
// Damping (Friction) - stronger damping for less "fast" feel
|
||||
node.vx *= 0.88;
|
||||
node.vy *= 0.88;
|
||||
draw();
|
||||
|
||||
// Update Position
|
||||
node.x += node.vx;
|
||||
node.y += node.vy;
|
||||
});
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMouseMove);
|
||||
parent.removeEventListener('touchmove', handleTouchMove);
|
||||
parent.removeEventListener('mousedown', handleMouseDown);
|
||||
parent.removeEventListener('mouseup', handleMouseUp);
|
||||
parent.removeEventListener('touchstart', handleMouseDown);
|
||||
parent.removeEventListener('touchend', handleMouseUp);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [
|
||||
username,
|
||||
palette,
|
||||
ratingsCount,
|
||||
followersCount,
|
||||
theme,
|
||||
nodeCountBase,
|
||||
connectionDist,
|
||||
speed,
|
||||
globalOpacity,
|
||||
]);
|
||||
|
||||
// Draw Mesh (Connections)
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
let currentConnections = 0;
|
||||
// We only connect a few to keep it a "web" and not a solid block
|
||||
for (let j = i + 1; j < nodes.length && currentConnections < 6; j++) {
|
||||
const dx = nodes[i].x - nodes[j].x;
|
||||
const dy = nodes[i].y - nodes[j].y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < connectionDist) {
|
||||
currentConnections++;
|
||||
const opacity = (1 - dist / connectionDist) * globalOpacity;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(nodes[i].x, nodes[i].y);
|
||||
ctx.lineTo(nodes[j].x, nodes[j].y);
|
||||
|
||||
// Dynamic gradient for the line
|
||||
const lineGrad = ctx.createLinearGradient(nodes[i].x, nodes[i].y, nodes[j].x, nodes[j].y);
|
||||
lineGrad.addColorStop(0, alpha(nodes[i].color, opacity));
|
||||
lineGrad.addColorStop(1, alpha(nodes[j].color, opacity));
|
||||
|
||||
ctx.strokeStyle = lineGrad;
|
||||
ctx.stroke();
|
||||
|
||||
// Fill some triangles for complexity
|
||||
if (dist < connectionDist * 0.6 && pseudoRandom(i + j) > 0.92) {
|
||||
for (let k = j + 1; k < nodes.length; k++) {
|
||||
const d3x = nodes[i].x - nodes[k].x;
|
||||
const d3y = nodes[i].y - nodes[k].y;
|
||||
if (Math.sqrt(d3x*d3x + d3y*d3y) < connectionDist * 0.6) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(nodes[i].x, nodes[i].y);
|
||||
ctx.lineTo(nodes[j].x, nodes[j].y);
|
||||
ctx.lineTo(nodes[k].x, nodes[k].y);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = alpha(nodes[i].color, 0.04);
|
||||
ctx.fill();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Nodes
|
||||
nodes.forEach(node => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, node.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = node.color;
|
||||
ctx.fill();
|
||||
|
||||
// Core glow
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, node.size * 2.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = alpha(node.color, 0.15);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMouseMove);
|
||||
parent.removeEventListener('touchmove', handleTouchMove);
|
||||
parent.removeEventListener('mousedown', handleMouseDown);
|
||||
parent.removeEventListener('mouseup', handleMouseUp);
|
||||
parent.removeEventListener('touchstart', handleMouseDown);
|
||||
parent.removeEventListener('touchend', handleMouseUp);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [username, palette, ratingsCount, followersCount, theme, nodeCountBase, connectionDist, speed, globalOpacity]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
mixBlendMode: theme.palette.mode === 'light' ? 'multiply' : 'screen'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
mixBlendMode: theme.palette.mode === 'light' ? 'multiply' : 'screen',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerativeBanner;
|
||||
@@ -2,222 +2,244 @@ import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
|
||||
interface GenerativeFlowProps {
|
||||
username: string;
|
||||
palette: string[];
|
||||
ratingsCount: number;
|
||||
followersCount: number;
|
||||
settings?: {
|
||||
blobCount?: number;
|
||||
flowSpeed?: number;
|
||||
vibrancy?: number;
|
||||
blur?: number;
|
||||
distortion?: number;
|
||||
};
|
||||
username: string;
|
||||
palette: string[];
|
||||
ratingsCount: number;
|
||||
followersCount: number;
|
||||
settings?: {
|
||||
blobCount?: number;
|
||||
flowSpeed?: number;
|
||||
vibrancy?: number;
|
||||
blur?: number;
|
||||
distortion?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette, settings }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
const blobCount = settings?.blobCount ?? 8;
|
||||
const flowSpeed = settings?.flowSpeed ?? 0.008;
|
||||
const vibrancy = settings?.vibrancy ?? 0.8;
|
||||
const blurAmount = settings?.blur ?? 60;
|
||||
const distortionFactor = settings?.distortion ?? 1.2;
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
const getSeed = (str: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return hash;
|
||||
const blobCount = settings?.blobCount ?? 8;
|
||||
const flowSpeed = settings?.flowSpeed ?? 0.008;
|
||||
const vibrancy = settings?.vibrancy ?? 0.8;
|
||||
const blurAmount = settings?.blur ?? 60;
|
||||
const distortionFactor = settings?.distortion ?? 1.2;
|
||||
|
||||
const getSeed = (str: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
const seed = useMemo(() => getSeed(username), [username]);
|
||||
|
||||
const brightColors = useMemo(() => {
|
||||
const colors =
|
||||
palette.length >= 2
|
||||
? palette
|
||||
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const getBrightness = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
||||
return (r * 299 + g * 587 + b * 114) / 1000;
|
||||
};
|
||||
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
||||
return sorted.slice(0, 4);
|
||||
}, [palette, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
let time = 0;
|
||||
|
||||
interface Blob {
|
||||
x: number;
|
||||
y: number;
|
||||
ox: number;
|
||||
oy: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
color: string;
|
||||
phase: number;
|
||||
speedFactor: number;
|
||||
}
|
||||
|
||||
let blobs: Blob[] = [];
|
||||
|
||||
const pseudoRandom = (offset: number) => {
|
||||
const x = Math.sin(seed + offset) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
const seed = useMemo(() => getSeed(username), [username]);
|
||||
const initBlobs = (width: number, height: number) => {
|
||||
const newBlobs = [];
|
||||
for (let i = 0; i < blobCount; i++) {
|
||||
const rx = pseudoRandom(i) * width;
|
||||
const ry = pseudoRandom(i + 500) * height;
|
||||
newBlobs.push({
|
||||
x: rx,
|
||||
y: ry,
|
||||
ox: rx,
|
||||
oy: ry,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
size: width / 4 + pseudoRandom(i + 1000) * (width / 3),
|
||||
color: brightColors[i % brightColors.length],
|
||||
phase: pseudoRandom(i + 1500) * Math.PI * 2,
|
||||
speedFactor: 0.5 + pseudoRandom(i + 2000),
|
||||
});
|
||||
}
|
||||
return newBlobs;
|
||||
};
|
||||
|
||||
const brightColors = useMemo(() => {
|
||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const getBrightness = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
||||
return (r * 299 + g * 587 + b * 114) / 1000;
|
||||
};
|
||||
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
||||
return sorted.slice(0, 4);
|
||||
}, [palette, theme]);
|
||||
const resize = () => {
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
blobs = initBlobs(rect.width, rect.height);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const resizeObserver = new ResizeObserver(() => resize());
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
resize();
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) return;
|
||||
const mouse = { x: -1000, y: -1000 };
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
|
||||
let animationFrameId: number;
|
||||
let time = 0;
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mouseleave', () => {
|
||||
mouse.x = -1000;
|
||||
mouse.y = -1000;
|
||||
});
|
||||
}
|
||||
|
||||
interface Blob {
|
||||
x: number;
|
||||
y: number;
|
||||
ox: number;
|
||||
oy: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
color: string;
|
||||
phase: number;
|
||||
speedFactor: number;
|
||||
}
|
||||
const draw = () => {
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
if (!width || !height) {
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
|
||||
let blobs: Blob[] = [];
|
||||
|
||||
const pseudoRandom = (offset: number) => {
|
||||
const x = Math.sin(seed + offset) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const initBlobs = (width: number, height: number) => {
|
||||
const newBlobs = [];
|
||||
for (let i = 0; i < blobCount; i++) {
|
||||
const rx = pseudoRandom(i) * width;
|
||||
const ry = pseudoRandom(i + 500) * height;
|
||||
newBlobs.push({
|
||||
x: rx, y: ry,
|
||||
ox: rx, oy: ry,
|
||||
vx: 0, vy: 0,
|
||||
size: (width / 4) + pseudoRandom(i + 1000) * (width / 3),
|
||||
color: brightColors[i % brightColors.length],
|
||||
phase: pseudoRandom(i + 1500) * Math.PI * 2,
|
||||
speedFactor: 0.5 + pseudoRandom(i + 2000)
|
||||
});
|
||||
}
|
||||
return newBlobs;
|
||||
};
|
||||
// Background Base
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const bgGrad = ctx.createRadialGradient(
|
||||
width / 2,
|
||||
height / 2,
|
||||
0,
|
||||
width / 2,
|
||||
height / 2,
|
||||
width,
|
||||
);
|
||||
if (isLight) {
|
||||
// For light themes, use a deeper, more saturated version of the theme color
|
||||
bgGrad.addColorStop(
|
||||
0,
|
||||
alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.6),
|
||||
);
|
||||
bgGrad.addColorStop(
|
||||
1,
|
||||
alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.9),
|
||||
);
|
||||
} else {
|
||||
bgGrad.addColorStop(0, '#0a0a1a');
|
||||
bgGrad.addColorStop(1, '#020205');
|
||||
}
|
||||
ctx.fillStyle = bgGrad;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
const resize = () => {
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
blobs = initBlobs(rect.width, rect.height);
|
||||
}
|
||||
};
|
||||
time += flowSpeed;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => resize());
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
resize();
|
||||
// Apply filter for "Liquid" look
|
||||
ctx.filter = `blur(${blurAmount}px)`;
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
|
||||
let mouse = { x: -1000, y: -1000 };
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
blobs.forEach((b) => {
|
||||
// Harmonic autonomous drift
|
||||
const driftX = Math.sin(time * b.speedFactor + b.phase) * (width / 5);
|
||||
const driftY = Math.cos(time * 0.8 * b.speedFactor + b.phase) * (height / 5);
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; });
|
||||
}
|
||||
// Mouse distortion
|
||||
const dxMouse = mouse.x - b.ox;
|
||||
const dyMouse = mouse.y - b.oy;
|
||||
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
|
||||
const mouseForce = Math.max(0, (500 - distMouse) / 500);
|
||||
|
||||
const draw = () => {
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
if (!width || !height) {
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
const mX = dxMouse * mouseForce * distortionFactor;
|
||||
const mY = dyMouse * mouseForce * distortionFactor;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Background Base
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
|
||||
if (isLight) {
|
||||
// For light themes, use a deeper, more saturated version of the theme color
|
||||
bgGrad.addColorStop(0, alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.6));
|
||||
bgGrad.addColorStop(1, alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.9));
|
||||
} else {
|
||||
bgGrad.addColorStop(0, '#0a0a1a');
|
||||
bgGrad.addColorStop(1, '#020205');
|
||||
}
|
||||
ctx.fillStyle = bgGrad;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
b.x = b.ox + driftX + mX;
|
||||
b.y = b.oy + driftY + mY;
|
||||
|
||||
time += flowSpeed;
|
||||
// Pulsing size
|
||||
const pulse = 1 + Math.sin(time + b.phase) * 0.1;
|
||||
const currentSize = b.size * pulse;
|
||||
|
||||
// Apply filter for "Liquid" look
|
||||
ctx.filter = `blur(${blurAmount}px)`;
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
const grad = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, currentSize);
|
||||
grad.addColorStop(0, alpha(b.color, vibrancy));
|
||||
grad.addColorStop(1, alpha(b.color, 0));
|
||||
|
||||
blobs.forEach((b) => {
|
||||
// Harmonic autonomous drift
|
||||
const driftX = Math.sin(time * b.speedFactor + b.phase) * (width / 5);
|
||||
const driftY = Math.cos(time * 0.8 * b.speedFactor + b.phase) * (height / 5);
|
||||
|
||||
// Mouse distortion
|
||||
const dxMouse = mouse.x - b.ox;
|
||||
const dyMouse = mouse.y - b.oy;
|
||||
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
|
||||
const mouseForce = Math.max(0, (500 - distMouse) / 500);
|
||||
|
||||
const mX = dxMouse * mouseForce * distortionFactor;
|
||||
const mY = dyMouse * mouseForce * distortionFactor;
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(b.x, b.y, currentSize, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
b.x = b.ox + driftX + mX;
|
||||
b.y = b.oy + driftY + mY;
|
||||
ctx.filter = 'none';
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
|
||||
// Pulsing size
|
||||
const pulse = 1 + Math.sin(time + b.phase) * 0.1;
|
||||
const currentSize = b.size * pulse;
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
const grad = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, currentSize);
|
||||
grad.addColorStop(0, alpha(b.color, vibrancy));
|
||||
grad.addColorStop(1, alpha(b.color, 0));
|
||||
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(b.x, b.y, currentSize, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
draw();
|
||||
|
||||
ctx.filter = 'none';
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMouseMove);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme]);
|
||||
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMouseMove);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerativeFlowBanner;
|
||||
@@ -2,268 +2,297 @@ import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
|
||||
interface HextechBannerProps {
|
||||
username: string;
|
||||
palette: string[];
|
||||
ratingsCount: number;
|
||||
followersCount: number;
|
||||
settings?: {
|
||||
gridSize?: number;
|
||||
pulseSpeed?: number;
|
||||
glowIntensity?: number;
|
||||
streamCount?: number;
|
||||
distortion?: number;
|
||||
};
|
||||
username: string;
|
||||
palette: string[];
|
||||
ratingsCount: number;
|
||||
followersCount: number;
|
||||
settings?: {
|
||||
gridSize?: number;
|
||||
pulseSpeed?: number;
|
||||
glowIntensity?: number;
|
||||
streamCount?: number;
|
||||
distortion?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settings }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
const gridSize = settings?.gridSize ?? 40;
|
||||
const pulseSpeed = settings?.pulseSpeed ?? 0.02;
|
||||
const glowIntensity = settings?.glowIntensity ?? 0.8;
|
||||
const streamCount = settings?.streamCount ?? 12;
|
||||
const distortionFactor = settings?.distortion ?? 0.5;
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
const getSeed = (str: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return hash;
|
||||
const gridSize = settings?.gridSize ?? 40;
|
||||
const pulseSpeed = settings?.pulseSpeed ?? 0.02;
|
||||
const glowIntensity = settings?.glowIntensity ?? 0.8;
|
||||
const streamCount = settings?.streamCount ?? 12;
|
||||
const distortionFactor = settings?.distortion ?? 0.5;
|
||||
|
||||
const getSeed = (str: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
const seed = useMemo(() => getSeed(username), [username]);
|
||||
|
||||
const brightColors = useMemo(() => {
|
||||
const colors =
|
||||
palette.length >= 2
|
||||
? palette
|
||||
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const getBrightness = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
||||
return (r * 299 + g * 587 + b * 114) / 1000;
|
||||
};
|
||||
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
||||
return sorted.slice(0, 3);
|
||||
}, [palette, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
let time = 0;
|
||||
|
||||
const pseudoRandom = (offset: number) => {
|
||||
const x = Math.sin(seed + offset) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
const seed = useMemo(() => getSeed(username), [username]);
|
||||
// Mana Stream Logic
|
||||
interface Stream {
|
||||
segments: { x: number; y: number }[];
|
||||
color: string;
|
||||
life: number;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
const brightColors = useMemo(() => {
|
||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const getBrightness = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
||||
return (r * 299 + g * 587 + b * 114) / 1000;
|
||||
};
|
||||
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
||||
return sorted.slice(0, 3);
|
||||
}, [palette, theme]);
|
||||
let streams: Stream[] = [];
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const createStream = (width: number, height: number) => {
|
||||
const color = brightColors[Math.floor(pseudoRandom(time) * brightColors.length)];
|
||||
const x = pseudoRandom(time + 1) * width;
|
||||
const y = pseudoRandom(time + 2) * height;
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) return;
|
||||
return {
|
||||
segments: [{ x, y }],
|
||||
color,
|
||||
life: 1.0,
|
||||
speed: 2 + pseudoRandom(time + 3) * 4,
|
||||
};
|
||||
};
|
||||
|
||||
let animationFrameId: number;
|
||||
let time = 0;
|
||||
const drawHexagon = (x: number, y: number, r: number, tilt: number) => {
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (Math.PI / 3) * i + tilt;
|
||||
const px = x + r * Math.cos(angle);
|
||||
const py = y + r * Math.sin(angle);
|
||||
if (i === 0) ctx.moveTo(px, py);
|
||||
else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
};
|
||||
|
||||
const pseudoRandom = (offset: number) => {
|
||||
const x = Math.sin(seed + offset) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
const resize = () => {
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
}
|
||||
};
|
||||
|
||||
// Mana Stream Logic
|
||||
interface Stream {
|
||||
segments: { x: number, y: number }[];
|
||||
color: string;
|
||||
life: number;
|
||||
speed: number;
|
||||
const resizeObserver = new ResizeObserver(() => resize());
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
resize();
|
||||
|
||||
const mouse = { x: -1000, y: -1000, down: false };
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
const handleMouseDown = () => {
|
||||
mouse.down = true;
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
mouse.down = false;
|
||||
};
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mousedown', handleMouseDown);
|
||||
parent.addEventListener('mouseup', handleMouseUp);
|
||||
parent.addEventListener('mouseleave', () => {
|
||||
mouse.x = -1000;
|
||||
mouse.y = -1000;
|
||||
mouse.down = false;
|
||||
});
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
if (!width || !height) {
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Background
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const bgGrad = ctx.createRadialGradient(
|
||||
width / 2,
|
||||
height / 2,
|
||||
0,
|
||||
width / 2,
|
||||
height / 2,
|
||||
width,
|
||||
);
|
||||
if (isLight) {
|
||||
// For light themes, use a deeper, more saturated version of the theme color
|
||||
bgGrad.addColorStop(0, alpha(brightColors[2] || brightColors[0], 0.65));
|
||||
bgGrad.addColorStop(1, alpha(brightColors[2] || brightColors[0], 0.95));
|
||||
} else {
|
||||
bgGrad.addColorStop(0, '#0a051a'); // Arcane Purple tint
|
||||
bgGrad.addColorStop(1, '#020105');
|
||||
}
|
||||
ctx.fillStyle = bgGrad;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
time += pulseSpeed;
|
||||
|
||||
// 1. Draw Hex Grid
|
||||
ctx.lineWidth = 1;
|
||||
const h = gridSize * Math.sqrt(3);
|
||||
const w = gridSize * 2;
|
||||
|
||||
for (let y = -gridSize; y < height + gridSize; y += h / 2) {
|
||||
for (let x = -gridSize; x < width + gridSize; x += w * 0.75) {
|
||||
const xOffset = Math.floor(y / (h / 2)) % 2 === 0 ? 0 : w * 0.375;
|
||||
const finalX = x + xOffset;
|
||||
const finalY = y;
|
||||
|
||||
// Apply Trippiness (Distortion)
|
||||
const dist = Math.sqrt(Math.pow(finalX - mouse.x, 2) + Math.pow(finalY - mouse.y, 2));
|
||||
const mouseImpact = Math.max(0, (200 - dist) / 200) * (mouse.down ? 2 : 1);
|
||||
|
||||
const dx =
|
||||
Math.sin(time + finalX * 0.01) * gridSize * distortionFactor * (1 + mouseImpact);
|
||||
const dy =
|
||||
Math.cos(time + finalY * 0.01) * gridSize * distortionFactor * (1 + mouseImpact);
|
||||
|
||||
const drawX = finalX + dx;
|
||||
const drawY = finalY + dy;
|
||||
|
||||
// Pulse glow
|
||||
const pulseVal = 0.1 + Math.sin(time * 2 + (finalX + finalY) * 0.005) * 0.1;
|
||||
const opacity = (0.1 + pulseVal + mouseImpact * 0.3) * glowIntensity;
|
||||
|
||||
ctx.strokeStyle = alpha(brightColors[0], opacity);
|
||||
drawHexagon(drawX, drawY, gridSize * 0.9, time * 0.1);
|
||||
ctx.stroke();
|
||||
|
||||
// Occasional Runes
|
||||
if (pseudoRandom(Math.floor(finalX) + Math.floor(finalY)) > 0.92) {
|
||||
ctx.fillStyle = alpha(brightColors[1], opacity * 0.5);
|
||||
ctx.font = `${gridSize * 0.5}px serif`;
|
||||
ctx.fillText('⚡', drawX - 5, drawY + 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let streams: Stream[] = [];
|
||||
// 2. Process Mana Streams
|
||||
if (streams.length < streamCount && Math.random() > 0.9) {
|
||||
streams.push(createStream(width, height));
|
||||
}
|
||||
|
||||
const createStream = (width: number, height: number) => {
|
||||
const color = brightColors[Math.floor(pseudoRandom(time) * brightColors.length)];
|
||||
let x = pseudoRandom(time + 1) * width;
|
||||
let y = pseudoRandom(time + 2) * height;
|
||||
|
||||
return {
|
||||
segments: [{ x, y }],
|
||||
color,
|
||||
life: 1.0,
|
||||
speed: 2 + pseudoRandom(time + 3) * 4
|
||||
};
|
||||
};
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
streams.forEach((s, idx) => {
|
||||
s.life -= 0.005;
|
||||
const last = s.segments[s.segments.length - 1];
|
||||
|
||||
const drawHexagon = (x: number, y: number, r: number, tilt: number) => {
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (Math.PI / 3) * i + tilt;
|
||||
const px = x + r * Math.cos(angle);
|
||||
const py = y + r * Math.sin(angle);
|
||||
if (i === 0) ctx.moveTo(px, py);
|
||||
else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
};
|
||||
const ang = pseudoRandom(time + idx) * Math.PI * 2;
|
||||
const nextX = last.x + Math.cos(ang) * s.speed;
|
||||
const nextY = last.y + Math.sin(ang) * s.speed;
|
||||
|
||||
const resize = () => {
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
}
|
||||
};
|
||||
s.segments.push({ x: nextX, y: nextY });
|
||||
if (s.segments.length > 20) s.segments.shift();
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => resize());
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
resize();
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = alpha(s.color, s.life * glowIntensity);
|
||||
ctx.lineWidth = 2;
|
||||
s.segments.forEach((seg, i) => {
|
||||
if (i === 0) ctx.moveTo(seg.x, seg.y);
|
||||
else ctx.lineTo(seg.x, seg.y);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
let mouse = { x: -1000, y: -1000, down: false };
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
const handleMouseDown = () => { mouse.down = true; };
|
||||
const handleMouseUp = () => { mouse.down = false; };
|
||||
const head = s.segments[s.segments.length - 1];
|
||||
const g = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 15);
|
||||
g.addColorStop(0, alpha(s.color, s.life));
|
||||
g.addColorStop(1, alpha(s.color, 0));
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.arc(head.x, head.y, 15, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mousedown', handleMouseDown);
|
||||
parent.addEventListener('mouseup', handleMouseUp);
|
||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
|
||||
}
|
||||
streams = streams.filter((s) => s.life > 0);
|
||||
|
||||
const draw = () => {
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
if (!width || !height) {
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Background
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
|
||||
if (isLight) {
|
||||
// For light themes, use a deeper, more saturated version of the theme color
|
||||
bgGrad.addColorStop(0, alpha(brightColors[2] || brightColors[0], 0.65));
|
||||
bgGrad.addColorStop(1, alpha(brightColors[2] || brightColors[0], 0.95));
|
||||
} else {
|
||||
bgGrad.addColorStop(0, '#0a051a'); // Arcane Purple tint
|
||||
bgGrad.addColorStop(1, '#020105');
|
||||
}
|
||||
ctx.fillStyle = bgGrad;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
draw();
|
||||
|
||||
time += pulseSpeed;
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMouseMove);
|
||||
parent.removeEventListener('mousedown', handleMouseDown);
|
||||
parent.removeEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [
|
||||
seed,
|
||||
brightColors,
|
||||
gridSize,
|
||||
pulseSpeed,
|
||||
glowIntensity,
|
||||
streamCount,
|
||||
distortionFactor,
|
||||
theme,
|
||||
]);
|
||||
|
||||
// 1. Draw Hex Grid
|
||||
ctx.lineWidth = 1;
|
||||
const h = gridSize * Math.sqrt(3);
|
||||
const w = gridSize * 2;
|
||||
|
||||
for (let y = -gridSize; y < height + gridSize; y += h/2) {
|
||||
for (let x = -gridSize; x < width + gridSize; x += w * 0.75) {
|
||||
const xOffset = (Math.floor(y / (h/2)) % 2 === 0) ? 0 : w * 0.375;
|
||||
const finalX = x + xOffset;
|
||||
const finalY = y;
|
||||
|
||||
// Apply Trippiness (Distortion)
|
||||
const dist = Math.sqrt(Math.pow(finalX - mouse.x, 2) + Math.pow(finalY - mouse.y, 2));
|
||||
const mouseImpact = Math.max(0, (200 - dist) / 200) * (mouse.down ? 2 : 1);
|
||||
|
||||
const dx = Math.sin(time + (finalX * 0.01)) * gridSize * distortionFactor * (1 + mouseImpact);
|
||||
const dy = Math.cos(time + (finalY * 0.01)) * gridSize * distortionFactor * (1 + mouseImpact);
|
||||
|
||||
const drawX = finalX + dx;
|
||||
const drawY = finalY + dy;
|
||||
|
||||
// Pulse glow
|
||||
const pulseVal = 0.1 + Math.sin(time * 2 + (finalX + finalY) * 0.005) * 0.1;
|
||||
const opacity = (0.1 + pulseVal + mouseImpact * 0.3) * glowIntensity;
|
||||
|
||||
ctx.strokeStyle = alpha(brightColors[0], opacity);
|
||||
drawHexagon(drawX, drawY, gridSize * 0.9, time * 0.1);
|
||||
ctx.stroke();
|
||||
|
||||
// Occasional Runes
|
||||
if (pseudoRandom(Math.floor(finalX) + Math.floor(finalY)) > 0.92) {
|
||||
ctx.fillStyle = alpha(brightColors[1], opacity * 0.5);
|
||||
ctx.font = `${gridSize * 0.5}px serif`;
|
||||
ctx.fillText("⚡", drawX - 5, drawY + 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Process Mana Streams
|
||||
if (streams.length < streamCount && Math.random() > 0.9) {
|
||||
streams.push(createStream(width, height));
|
||||
}
|
||||
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
streams.forEach((s, idx) => {
|
||||
s.life -= 0.005;
|
||||
const last = s.segments[s.segments.length - 1];
|
||||
|
||||
const ang = pseudoRandom(time + idx) * Math.PI * 2;
|
||||
const nextX = last.x + Math.cos(ang) * s.speed;
|
||||
const nextY = last.y + Math.sin(ang) * s.speed;
|
||||
|
||||
s.segments.push({ x: nextX, y: nextY });
|
||||
if (s.segments.length > 20) s.segments.shift();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = alpha(s.color, s.life * glowIntensity);
|
||||
ctx.lineWidth = 2;
|
||||
s.segments.forEach((seg, i) => {
|
||||
if (i === 0) ctx.moveTo(seg.x, seg.y);
|
||||
else ctx.lineTo(seg.x, seg.y);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
const head = s.segments[s.segments.length - 1];
|
||||
const g = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 15);
|
||||
g.addColorStop(0, alpha(s.color, s.life));
|
||||
g.addColorStop(1, alpha(s.color, 0));
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.arc(head.x, head.y, 15, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
|
||||
streams = streams.filter(s => s.life > 0);
|
||||
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMouseMove);
|
||||
parent.removeEventListener('mousedown', handleMouseDown);
|
||||
parent.removeEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [seed, brightColors, gridSize, pulseSpeed, glowIntensity, streamCount, distortionFactor, theme]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HextechBanner;
|
||||
@@ -2,257 +2,287 @@ import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
|
||||
interface HextechCorruptionBannerProps {
|
||||
username: string;
|
||||
palette: string[];
|
||||
ratingsCount: number;
|
||||
followersCount: number;
|
||||
settings?: {
|
||||
cellCount?: number;
|
||||
shimmerSpeed?: number;
|
||||
glowIntensity?: number;
|
||||
instability?: number;
|
||||
shellDarkness?: number;
|
||||
};
|
||||
username: string;
|
||||
palette: string[];
|
||||
ratingsCount: number;
|
||||
followersCount: number;
|
||||
settings?: {
|
||||
cellCount?: number;
|
||||
shimmerSpeed?: number;
|
||||
glowIntensity?: number;
|
||||
instability?: number;
|
||||
shellDarkness?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ username, palette, settings }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
const cellCount = settings?.cellCount ?? 40;
|
||||
const shimmerSpeed = settings?.shimmerSpeed ?? 0.015;
|
||||
const glowIntensity = settings?.glowIntensity ?? 0.9;
|
||||
const instability = settings?.instability ?? 0.6;
|
||||
const shellDarkness = settings?.shellDarkness ?? 0.85;
|
||||
const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({
|
||||
username,
|
||||
palette,
|
||||
settings,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
const getSeed = (str: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return hash;
|
||||
const cellCount = settings?.cellCount ?? 40;
|
||||
const shimmerSpeed = settings?.shimmerSpeed ?? 0.015;
|
||||
const glowIntensity = settings?.glowIntensity ?? 0.9;
|
||||
const instability = settings?.instability ?? 0.6;
|
||||
const shellDarkness = settings?.shellDarkness ?? 0.85;
|
||||
|
||||
const getSeed = (str: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
const seed = useMemo(() => getSeed(username), [username]);
|
||||
|
||||
const brightColors = useMemo(() => {
|
||||
const colors =
|
||||
palette.length >= 2
|
||||
? palette
|
||||
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const getBrightness = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
||||
return (r * 299 + g * 587 + b * 114) / 1000;
|
||||
};
|
||||
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
||||
return sorted.slice(0, 3);
|
||||
}, [palette, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
let time = 0;
|
||||
|
||||
interface ControlPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
ox: number;
|
||||
oy: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
phase: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
let points: ControlPoint[] = [];
|
||||
|
||||
const pseudoRandom = (offset: number) => {
|
||||
const x = Math.sin(seed + offset) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
const seed = useMemo(() => getSeed(username), [username]);
|
||||
const initPoints = (width: number, height: number) => {
|
||||
const newPoints = [];
|
||||
for (let i = 0; i < cellCount; i++) {
|
||||
const rx = pseudoRandom(i) * width;
|
||||
const ry = pseudoRandom(i + 500) * height;
|
||||
newPoints.push({
|
||||
x: rx,
|
||||
y: ry,
|
||||
ox: rx,
|
||||
oy: ry,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
phase: pseudoRandom(i + 1000) * Math.PI * 2,
|
||||
color: brightColors[i % brightColors.length],
|
||||
});
|
||||
}
|
||||
return newPoints;
|
||||
};
|
||||
|
||||
const brightColors = useMemo(() => {
|
||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
const getBrightness = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
||||
return (r * 299 + g * 587 + b * 114) / 1000;
|
||||
};
|
||||
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
||||
return sorted.slice(0, 3);
|
||||
}, [palette, theme]);
|
||||
const resize = () => {
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
points = initPoints(rect.width, rect.height);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const resizeObserver = new ResizeObserver(() => resize());
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
resize();
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) return;
|
||||
const mouse = { x: -1000, y: -1000, down: false };
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
const handleMouseDown = () => {
|
||||
mouse.down = true;
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
mouse.down = false;
|
||||
};
|
||||
|
||||
let animationFrameId: number;
|
||||
let time = 0;
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mousedown', handleMouseDown);
|
||||
parent.addEventListener('mouseup', handleMouseUp);
|
||||
parent.addEventListener('mouseleave', () => {
|
||||
mouse.x = -1000;
|
||||
mouse.y = -1000;
|
||||
mouse.down = false;
|
||||
});
|
||||
}
|
||||
|
||||
interface ControlPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
ox: number;
|
||||
oy: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
phase: number;
|
||||
color: string;
|
||||
}
|
||||
const draw = () => {
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
if (!width || !height) {
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
|
||||
let points: ControlPoint[] = [];
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
time += shimmerSpeed;
|
||||
|
||||
const pseudoRandom = (offset: number) => {
|
||||
const x = Math.sin(seed + offset) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
// 1. Draw Under-Glow (Inner Cores)
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
points.forEach((p) => {
|
||||
// Harmonic motion + mouse interaction
|
||||
const dxMouse = mouse.x - p.x;
|
||||
const dyMouse = mouse.y - p.y;
|
||||
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
|
||||
const mouseForce = Math.max(0, (300 - distMouse) / 300);
|
||||
|
||||
const initPoints = (width: number, height: number) => {
|
||||
const newPoints = [];
|
||||
for (let i = 0; i < cellCount; i++) {
|
||||
const rx = pseudoRandom(i) * width;
|
||||
const ry = pseudoRandom(i + 500) * height;
|
||||
newPoints.push({
|
||||
x: rx, y: ry,
|
||||
ox: rx, oy: ry,
|
||||
vx: 0, vy: 0,
|
||||
phase: pseudoRandom(i + 1000) * Math.PI * 2,
|
||||
color: brightColors[i % brightColors.length]
|
||||
});
|
||||
}
|
||||
return newPoints;
|
||||
};
|
||||
const driftX = Math.sin(time + p.phase) * 20 * instability;
|
||||
const driftY = Math.cos(time * 0.8 + p.phase) * 20 * instability;
|
||||
|
||||
const resize = () => {
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
points = initPoints(rect.width, rect.height);
|
||||
}
|
||||
};
|
||||
p.x = p.ox + driftX + (mouse.down ? -dxMouse * mouseForce * 0.5 : 0);
|
||||
p.y = p.oy + driftY + (mouse.down ? -dyMouse * mouseForce * 0.5 : 0);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => resize());
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
resize();
|
||||
const pulse = 0.7 + Math.sin(time * 3 + p.phase) * 0.3;
|
||||
const glowSize = 60 + mouseForce * 40;
|
||||
|
||||
let mouse = { x: -1000, y: -1000, down: false };
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
const handleMouseDown = () => { mouse.down = true; };
|
||||
const handleMouseUp = () => { mouse.down = false; };
|
||||
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize);
|
||||
grad.addColorStop(0, alpha(p.color, 0.8 * glowIntensity * pulse));
|
||||
grad.addColorStop(0.5, alpha(p.color, 0.3 * glowIntensity));
|
||||
grad.addColorStop(1, alpha(p.color, 0));
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mousedown', handleMouseDown);
|
||||
parent.addEventListener('mouseup', handleMouseUp);
|
||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
|
||||
}
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, glowSize, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
const draw = () => {
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
if (!width || !height) {
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
// 2. Draw Dark "Void" Shell (Voronoi-based cutout)
|
||||
// We use a temporary canvas to draw the shell then mask it, or just use a heavy overlay
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
time += shimmerSpeed;
|
||||
// Create the "holes" effect by using a pattern of circles that are actually transparent
|
||||
// Actually, to get the specific look from the image, we can use a simpler approach:
|
||||
// Layer a dark color over everything, but with 'destination-out' circles at the point locations.
|
||||
|
||||
// 1. Draw Under-Glow (Inner Cores)
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
points.forEach((p) => {
|
||||
// Harmonic motion + mouse interaction
|
||||
const dxMouse = mouse.x - p.x;
|
||||
const dyMouse = mouse.y - p.y;
|
||||
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
|
||||
const mouseForce = Math.max(0, (300 - distMouse) / 300);
|
||||
|
||||
const driftX = Math.sin(time + p.phase) * 20 * instability;
|
||||
const driftY = Math.cos(time * 0.8 + p.phase) * 20 * instability;
|
||||
|
||||
p.x = p.ox + driftX + (mouse.down ? -dxMouse * mouseForce * 0.5 : 0);
|
||||
p.y = p.oy + driftY + (mouse.down ? -dyMouse * mouseForce * 0.5 : 0);
|
||||
const offCanvas = document.createElement('canvas');
|
||||
offCanvas.width = canvas.width;
|
||||
offCanvas.height = canvas.height;
|
||||
const offCtx = offCanvas.getContext('2d');
|
||||
if (offCtx) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
offCtx.scale(dpr, dpr);
|
||||
|
||||
const pulse = 0.7 + Math.sin(time * 3 + p.phase) * 0.3;
|
||||
const glowSize = 60 + (mouseForce * 40);
|
||||
|
||||
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize);
|
||||
grad.addColorStop(0, alpha(p.color, 0.8 * glowIntensity * pulse));
|
||||
grad.addColorStop(0.5, alpha(p.color, 0.3 * glowIntensity));
|
||||
grad.addColorStop(1, alpha(p.color, 0));
|
||||
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, glowSize, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
// Draw full dark shell
|
||||
const shellColor =
|
||||
theme.palette.mode === 'light'
|
||||
? alpha('#1a0a2a', shellDarkness)
|
||||
: alpha('#05020a', shellDarkness);
|
||||
offCtx.fillStyle = shellColor;
|
||||
offCtx.fillRect(0, 0, width, height);
|
||||
|
||||
// 2. Draw Dark "Void" Shell (Voronoi-based cutout)
|
||||
// We use a temporary canvas to draw the shell then mask it, or just use a heavy overlay
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
|
||||
// Create the "holes" effect by using a pattern of circles that are actually transparent
|
||||
// Actually, to get the specific look from the image, we can use a simpler approach:
|
||||
// Layer a dark color over everything, but with 'destination-out' circles at the point locations.
|
||||
|
||||
const offCanvas = document.createElement('canvas');
|
||||
offCanvas.width = canvas.width;
|
||||
offCanvas.height = canvas.height;
|
||||
const offCtx = offCanvas.getContext('2d');
|
||||
if (offCtx) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
offCtx.scale(dpr, dpr);
|
||||
|
||||
// Draw full dark shell
|
||||
const shellColor = theme.palette.mode === 'light' ? alpha('#1a0a2a', shellDarkness) : alpha('#05020a', shellDarkness);
|
||||
offCtx.fillStyle = shellColor;
|
||||
offCtx.fillRect(0, 0, width, height);
|
||||
|
||||
// Cut holes
|
||||
offCtx.globalCompositeOperation = 'destination-out';
|
||||
points.forEach(p => {
|
||||
const holeSize = 15 + Math.sin(time + p.phase) * 5 + (instability * 10);
|
||||
|
||||
// Main hole
|
||||
offCtx.beginPath();
|
||||
offCtx.arc(p.x, p.y, holeSize, 0, Math.PI * 2);
|
||||
offCtx.fill();
|
||||
|
||||
// Add secondary smaller holes for that "punctured" look
|
||||
for(let j=0; j<3; j++) {
|
||||
const angle = p.phase + (j * Math.PI * 0.6);
|
||||
const hx = p.x + Math.cos(angle) * (holeSize * 1.5);
|
||||
const hy = p.y + Math.sin(angle) * (holeSize * 1.5);
|
||||
offCtx.beginPath();
|
||||
offCtx.arc(hx, hy, holeSize * 0.4, 0, Math.PI * 2);
|
||||
offCtx.fill();
|
||||
}
|
||||
});
|
||||
|
||||
ctx.drawImage(offCanvas, 0, 0, width, height);
|
||||
}
|
||||
// Cut holes
|
||||
offCtx.globalCompositeOperation = 'destination-out';
|
||||
points.forEach((p) => {
|
||||
const holeSize = 15 + Math.sin(time + p.phase) * 5 + instability * 10;
|
||||
|
||||
// 3. Add Iridescent Edge Highlights
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
ctx.lineWidth = 2;
|
||||
points.forEach(p => {
|
||||
const pulse = 0.5 + Math.sin(time * 2 + p.phase) * 0.5;
|
||||
ctx.strokeStyle = alpha(p.color, pulse * 0.4);
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, 20 + pulse * 5, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
});
|
||||
// Main hole
|
||||
offCtx.beginPath();
|
||||
offCtx.arc(p.x, p.y, holeSize, 0, Math.PI * 2);
|
||||
offCtx.fill();
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
// Add secondary smaller holes for that "punctured" look
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const angle = p.phase + j * Math.PI * 0.6;
|
||||
const hx = p.x + Math.cos(angle) * (holeSize * 1.5);
|
||||
const hy = p.y + Math.sin(angle) * (holeSize * 1.5);
|
||||
offCtx.beginPath();
|
||||
offCtx.arc(hx, hy, holeSize * 0.4, 0, Math.PI * 2);
|
||||
offCtx.fill();
|
||||
}
|
||||
});
|
||||
|
||||
draw();
|
||||
ctx.drawImage(offCanvas, 0, 0, width, height);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMouseMove);
|
||||
parent.removeEventListener('mousedown', handleMouseDown);
|
||||
parent.removeEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [seed, brightColors, cellCount, shimmerSpeed, glowIntensity, instability, shellDarkness, theme]);
|
||||
// 3. Add Iridescent Edge Highlights
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
ctx.lineWidth = 2;
|
||||
points.forEach((p) => {
|
||||
const pulse = 0.5 + Math.sin(time * 2 + p.phase) * 0.5;
|
||||
ctx.strokeStyle = alpha(p.color, pulse * 0.4);
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, 20 + pulse * 5, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMouseMove);
|
||||
parent.removeEventListener('mousedown', handleMouseDown);
|
||||
parent.removeEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [
|
||||
seed,
|
||||
brightColors,
|
||||
cellCount,
|
||||
shimmerSpeed,
|
||||
glowIntensity,
|
||||
instability,
|
||||
shellDarkness,
|
||||
theme,
|
||||
]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HextechCorruptionBanner;
|
||||
@@ -1,199 +1,208 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
TextField,
|
||||
Popper,
|
||||
Paper,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Avatar,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
Box,
|
||||
alpha
|
||||
import {
|
||||
TextField,
|
||||
Popper,
|
||||
Paper,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Avatar,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
Box,
|
||||
alpha,
|
||||
} from '@mui/material';
|
||||
import api from '../api';
|
||||
import api from '../lib/api';
|
||||
|
||||
interface UserSuggestion {
|
||||
id: number;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
id: number;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
interface MentionTextFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
fullWidth?: boolean;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
size?: 'small' | 'medium';
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
fullWidth?: boolean;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
size?: 'small' | 'medium';
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
fullWidth = true,
|
||||
multiline = false,
|
||||
rows,
|
||||
size = 'small',
|
||||
onKeyDown
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
fullWidth = true,
|
||||
multiline = false,
|
||||
rows,
|
||||
size = 'small',
|
||||
onKeyDown,
|
||||
}) => {
|
||||
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
// Fetch users for suggestions (we could cache this or search on demand)
|
||||
const [allUsers, setAllUsers] = useState<UserSuggestion[]>([]);
|
||||
// Fetch users for suggestions (we could cache this or search on demand)
|
||||
const [allUsers, setAllUsers] = useState<UserSuggestion[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await api.get('flavors/search/');
|
||||
const users = (res.data || [])
|
||||
.filter((item: any) => item.type === 'user')
|
||||
.map((item: any) => ({
|
||||
id: item.id,
|
||||
username: item.name,
|
||||
avatar: item.image_url
|
||||
}));
|
||||
setAllUsers(users);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch users for mentions');
|
||||
}
|
||||
};
|
||||
fetchUsers();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await api.get('flavors/search/');
|
||||
const users = (res.data || [])
|
||||
.filter((item: any) => item.type === 'user')
|
||||
.map((item: any) => ({
|
||||
id: item.id,
|
||||
username: item.name,
|
||||
avatar: item.image_url,
|
||||
}));
|
||||
setAllUsers(users);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch users for mentions');
|
||||
}
|
||||
};
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
onChange(newValue);
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
onChange(newValue);
|
||||
|
||||
const selectionStart = e.target.selectionStart || 0;
|
||||
const textBeforeCursor = newValue.substring(0, selectionStart);
|
||||
const lastAtPos = textBeforeCursor.lastIndexOf('@');
|
||||
const selectionStart = e.target.selectionStart || 0;
|
||||
const textBeforeCursor = newValue.substring(0, selectionStart);
|
||||
const lastAtPos = textBeforeCursor.lastIndexOf('@');
|
||||
|
||||
if (lastAtPos !== -1) {
|
||||
const query = textBeforeCursor.substring(lastAtPos + 1);
|
||||
// Ensure there's no space or newline between @ and the cursor
|
||||
if (!query.includes(' ') && !query.includes('\n')) {
|
||||
const filtered = allUsers.filter(u =>
|
||||
u.username.toLowerCase().startsWith(query.toLowerCase())
|
||||
);
|
||||
setSuggestions(filtered);
|
||||
setShowSuggestions(filtered.length > 0);
|
||||
setSelectedIndex(0);
|
||||
setAnchorEl(inputRef.current);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (lastAtPos !== -1) {
|
||||
const query = textBeforeCursor.substring(lastAtPos + 1);
|
||||
// Ensure there's no space or newline between @ and the cursor
|
||||
if (!query.includes(' ') && !query.includes('\n')) {
|
||||
const filtered = allUsers.filter((u) =>
|
||||
u.username.toLowerCase().startsWith(query.toLowerCase()),
|
||||
);
|
||||
setSuggestions(filtered);
|
||||
setShowSuggestions(filtered.length > 0);
|
||||
setSelectedIndex(0);
|
||||
setAnchorEl(inputRef.current);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setShowSuggestions(false);
|
||||
};
|
||||
|
||||
const handleSelectSuggestion = (user: UserSuggestion) => {
|
||||
const selectionStart = inputRef.current?.selectionStart || 0;
|
||||
const textBeforeCursor = value.substring(0, selectionStart);
|
||||
const textAfterCursor = value.substring(selectionStart);
|
||||
const lastAtPos = textBeforeCursor.lastIndexOf('@');
|
||||
|
||||
const newValue = value.substring(0, lastAtPos) + '@' + user.username + ' ' + textAfterCursor;
|
||||
|
||||
onChange(newValue);
|
||||
setShowSuggestions(false);
|
||||
|
||||
// Return focus to input and move cursor after the mention
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
const newPos = lastAtPos + user.username.length + 2; // +1 for @, +1 for space
|
||||
inputRef.current.setSelectionRange(newPos, newPos);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (showSuggestions) {
|
||||
if (e.key === 'Tab' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSelectSuggestion(suggestions[selectedIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % suggestions.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setShowSuggestions(false);
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (onKeyDown) onKeyDown(e);
|
||||
};
|
||||
|
||||
const handleSelectSuggestion = (user: UserSuggestion) => {
|
||||
const selectionStart = inputRef.current?.selectionStart || 0;
|
||||
const textBeforeCursor = value.substring(0, selectionStart);
|
||||
const textAfterCursor = value.substring(selectionStart);
|
||||
const lastAtPos = textBeforeCursor.lastIndexOf('@');
|
||||
|
||||
const newValue =
|
||||
value.substring(0, lastAtPos) +
|
||||
'@' + user.username + ' ' +
|
||||
textAfterCursor;
|
||||
|
||||
onChange(newValue);
|
||||
setShowSuggestions(false);
|
||||
|
||||
// Return focus to input and move cursor after the mention
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
const newPos = lastAtPos + user.username.length + 2; // +1 for @, +1 for space
|
||||
inputRef.current.setSelectionRange(newPos, newPos);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (showSuggestions) {
|
||||
if (e.key === 'Tab' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSelectSuggestion(suggestions[selectedIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % suggestions.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setShowSuggestions(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (onKeyDown) onKeyDown(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', width: fullWidth ? '100%' : 'auto' }}>
|
||||
<TextField
|
||||
inputRef={inputRef}
|
||||
fullWidth={fullWidth}
|
||||
multiline={multiline}
|
||||
rows={rows}
|
||||
size={size}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Popper
|
||||
open={showSuggestions}
|
||||
anchorEl={anchorEl}
|
||||
placement="bottom-start"
|
||||
style={{ zIndex: 2000, width: 250 }}
|
||||
>
|
||||
<Paper elevation={8} sx={{ mt: 1, borderRadius: 2, overflow: 'hidden', border: '1px solid', borderColor: 'divider' }}>
|
||||
<MenuList dense sx={{ py: 0 }}>
|
||||
{suggestions.map((user, index) => (
|
||||
<MenuItem
|
||||
key={user.id}
|
||||
selected={index === selectedIndex}
|
||||
onClick={() => handleSelectSuggestion(user)}
|
||||
sx={{
|
||||
py: 1,
|
||||
'&.Mui-selected': {
|
||||
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.15),
|
||||
'&:hover': { bgcolor: (theme) => alpha(theme.palette.primary.main, 0.25) }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemAvatar sx={{ minWidth: 40 }}>
|
||||
<Avatar src={user.avatar || undefined} sx={{ width: 28, height: 28, fontSize: '0.8rem' }}>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={user.username}
|
||||
primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold' }}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</Paper>
|
||||
</Popper>
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box sx={{ position: 'relative', width: fullWidth ? '100%' : 'auto' }}>
|
||||
<TextField
|
||||
inputRef={inputRef}
|
||||
fullWidth={fullWidth}
|
||||
multiline={multiline}
|
||||
rows={rows}
|
||||
size={size}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Popper
|
||||
open={showSuggestions}
|
||||
anchorEl={anchorEl}
|
||||
placement="bottom-start"
|
||||
style={{ zIndex: 2000, width: 250 }}
|
||||
>
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{
|
||||
mt: 1,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<MenuList dense sx={{ py: 0 }}>
|
||||
{suggestions.map((user, index) => (
|
||||
<MenuItem
|
||||
key={user.id}
|
||||
selected={index === selectedIndex}
|
||||
onClick={() => handleSelectSuggestion(user)}
|
||||
sx={{
|
||||
py: 1,
|
||||
'&.Mui-selected': {
|
||||
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.15),
|
||||
'&:hover': { bgcolor: (theme) => alpha(theme.palette.primary.main, 0.25) },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemAvatar sx={{ minWidth: 40 }}>
|
||||
<Avatar
|
||||
src={user.avatar || undefined}
|
||||
sx={{ width: 28, height: 28, fontSize: '0.8rem' }}
|
||||
>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={user.username}
|
||||
primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold' }}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</Paper>
|
||||
</Popper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MentionTextField;
|
||||
@@ -2,293 +2,334 @@ import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
|
||||
interface NebulaBannerProps {
|
||||
username: string;
|
||||
palette: string[];
|
||||
ratingsCount: number;
|
||||
followersCount: number;
|
||||
settings?: {
|
||||
particleCount?: number;
|
||||
speed?: number;
|
||||
opacity?: number;
|
||||
smearRadius?: number;
|
||||
turbulence?: number;
|
||||
returnSpeed?: number;
|
||||
starDensity?: number;
|
||||
};
|
||||
username: string;
|
||||
palette: string[];
|
||||
ratingsCount: number;
|
||||
followersCount: number;
|
||||
settings?: {
|
||||
particleCount?: number;
|
||||
speed?: number;
|
||||
opacity?: number;
|
||||
smearRadius?: number;
|
||||
turbulence?: number;
|
||||
returnSpeed?: number;
|
||||
starDensity?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
const particleCount = settings?.particleCount ?? 400;
|
||||
const speed = settings?.speed ?? 0.0015;
|
||||
const globalOpacity = settings?.opacity ?? 0.6;
|
||||
const smearRadius = settings?.smearRadius ?? 120;
|
||||
const turbulenceStrength = settings?.turbulence ?? 0.4;
|
||||
const returnSpeed = settings?.returnSpeed ?? 0.004; // Much lower for smoke-like feel
|
||||
const starDensity = settings?.starDensity ?? 150;
|
||||
const NebulaBanner: React.FC<NebulaBannerProps> = ({
|
||||
username,
|
||||
palette,
|
||||
ratingsCount,
|
||||
followersCount,
|
||||
settings,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
const getSeed = (str: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return hash;
|
||||
const particleCount = settings?.particleCount ?? 400;
|
||||
const speed = settings?.speed ?? 0.0015;
|
||||
const globalOpacity = settings?.opacity ?? 0.6;
|
||||
const smearRadius = settings?.smearRadius ?? 120;
|
||||
const turbulenceStrength = settings?.turbulence ?? 0.4;
|
||||
const returnSpeed = settings?.returnSpeed ?? 0.004; // Much lower for smoke-like feel
|
||||
const starDensity = settings?.starDensity ?? 150;
|
||||
|
||||
const getSeed = (str: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
const seed = useMemo(() => getSeed(username), [username]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
const colors =
|
||||
palette.length >= 2
|
||||
? palette
|
||||
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
baseX: number;
|
||||
baseY: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
color: string;
|
||||
alpha: number;
|
||||
targetAlpha: number;
|
||||
phase: number;
|
||||
}
|
||||
|
||||
interface Star {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
let particles: Particle[] = [];
|
||||
let stars: Star[] = [];
|
||||
|
||||
const pseudoRandom = (offset: number) => {
|
||||
const x = Math.sin(seed + offset) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
const seed = useMemo(() => getSeed(username), [username]);
|
||||
const initScene = (width: number, height: number) => {
|
||||
// Init Stars
|
||||
const newStars = [];
|
||||
for (let i = 0; i < starDensity; i++) {
|
||||
newStars.push({
|
||||
x: pseudoRandom(i + 5000) * width,
|
||||
y: pseudoRandom(i + 6000) * height,
|
||||
size: pseudoRandom(i + 7000) * 1.5,
|
||||
opacity: 0.1 + pseudoRandom(i + 8000) * 0.5,
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
// Init Gas Clusters
|
||||
const newParticles = [];
|
||||
const clusterCount = colors.length * 3; // More clusters
|
||||
const groupCenters = Array.from({ length: clusterCount }).map((_, i) => ({
|
||||
x: (pseudoRandom(i * 15) * 0.8 + 0.1) * width,
|
||||
y: (pseudoRandom(i * 25 + 7) * 0.8 + 0.1) * height,
|
||||
}));
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) return;
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const clusterIdx = i % clusterCount;
|
||||
const colorIdx = clusterIdx % colors.length;
|
||||
const center = groupCenters[clusterIdx];
|
||||
const angle = pseudoRandom(i + 100) * Math.PI * 2;
|
||||
const dist = pseudoRandom(i + 200) * (width * 0.12);
|
||||
const bx = center.x + Math.cos(angle) * dist;
|
||||
const by = center.y + Math.sin(angle) * dist;
|
||||
|
||||
let animationFrameId: number;
|
||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||
newParticles.push({
|
||||
x: bx,
|
||||
y: by,
|
||||
baseX: bx,
|
||||
baseY: by,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
size: 40 + pseudoRandom(i + 300) * 80, // Larger, softer blobs
|
||||
color: colors[colorIdx],
|
||||
alpha: 0,
|
||||
targetAlpha: 0.05 + pseudoRandom(i + 400) * 0.15,
|
||||
phase: pseudoRandom(i + 500) * Math.PI * 2,
|
||||
});
|
||||
}
|
||||
return { stars: newStars, particles: newParticles };
|
||||
};
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
baseX: number;
|
||||
baseY: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
color: string;
|
||||
alpha: number;
|
||||
targetAlpha: number;
|
||||
phase: number;
|
||||
const resize = () => {
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
const scene = initScene(rect.width, rect.height);
|
||||
stars = scene.stars;
|
||||
particles = scene.particles;
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => resize());
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
resize();
|
||||
|
||||
const mouse = { x: -1000, y: -1000, px: -1000, py: -1000, down: false };
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.px = mouse.x;
|
||||
mouse.py = mouse.y;
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
const handleMouseDown = () => {
|
||||
mouse.down = true;
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
mouse.down = false;
|
||||
};
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mousedown', handleMouseDown);
|
||||
parent.addEventListener('mouseup', handleMouseUp);
|
||||
parent.addEventListener('mouseleave', () => {
|
||||
mouse.x = -1000;
|
||||
mouse.y = -1000;
|
||||
mouse.down = false;
|
||||
});
|
||||
}
|
||||
|
||||
let time = 0;
|
||||
const draw = () => {
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
if (!width || !height) {
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// 1. Dynamic Celestial Background
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const skyGrad = ctx.createRadialGradient(
|
||||
width / 2,
|
||||
height / 2,
|
||||
0,
|
||||
width / 2,
|
||||
height / 2,
|
||||
width,
|
||||
);
|
||||
|
||||
if (isLight) {
|
||||
// Vibrant Deep Primary for light mode - but dark enough for glowing gas to pop
|
||||
skyGrad.addColorStop(0, alpha(colors[colors.length - 1], 0.7));
|
||||
skyGrad.addColorStop(0.5, alpha(colors[colors.length - 1], 0.9));
|
||||
skyGrad.addColorStop(1, alpha(colors[colors.length - 1], 1.0));
|
||||
} else {
|
||||
// True Deep Space with visible tints for dark mode
|
||||
skyGrad.addColorStop(0, '#1a1a3a'); // More blue/purple tint
|
||||
skyGrad.addColorStop(0.5, '#0a0a1a');
|
||||
skyGrad.addColorStop(1, '#050508');
|
||||
}
|
||||
|
||||
ctx.fillStyle = skyGrad;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// 2. Draw Distant Stars
|
||||
ctx.fillStyle = '#fff';
|
||||
stars.forEach((s) => {
|
||||
ctx.globalAlpha = s.opacity * (0.7 + Math.sin(time * 2 + s.x) * 0.3);
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
ctx.globalAlpha = 1.0;
|
||||
|
||||
time += speed;
|
||||
|
||||
// 3. Draw Gas Clouds
|
||||
particles.forEach((p) => {
|
||||
// Restoration Force (Airy return)
|
||||
const dxBase = p.baseX - p.x;
|
||||
const dyBase = p.baseY - p.y;
|
||||
p.vx += dxBase * returnSpeed;
|
||||
p.vy += dyBase * returnSpeed;
|
||||
|
||||
// Mouse Interaction (Airy Smear)
|
||||
const dx = mouse.x - p.x;
|
||||
const dy = mouse.y - p.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < smearRadius) {
|
||||
const force = (smearRadius - dist) / smearRadius;
|
||||
if (mouse.down) {
|
||||
// High turbulence on click
|
||||
const angle = Math.atan2(dy, dx);
|
||||
p.vx += Math.cos(angle + Math.PI / 2) * turbulenceStrength * 2;
|
||||
p.vy += Math.sin(angle + Math.PI / 2) * turbulenceStrength * 2;
|
||||
} else {
|
||||
// Smoke-like smear
|
||||
const mdx = mouse.x - mouse.px;
|
||||
const mdy = mouse.y - mouse.py;
|
||||
p.vx += mdx * force * 0.15;
|
||||
p.vy += mdy * force * 0.15;
|
||||
p.vx -= (dx / dist) * force * 0.8;
|
||||
p.vy -= (dy / dist) * force * 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
interface Star {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
opacity: number;
|
||||
}
|
||||
// Smooth drift
|
||||
p.vx += Math.sin(time + p.phase) * 0.03;
|
||||
p.vy += Math.cos(time * 0.8 + p.phase) * 0.03;
|
||||
|
||||
let particles: Particle[] = [];
|
||||
let stars: Star[] = [];
|
||||
|
||||
const pseudoRandom = (offset: number) => {
|
||||
const x = Math.sin(seed + offset) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
// High friction for gas feel
|
||||
p.vx *= 0.95;
|
||||
p.vy *= 0.95;
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
|
||||
const initScene = (width: number, height: number) => {
|
||||
// Init Stars
|
||||
const newStars = [];
|
||||
for (let i = 0; i < starDensity; i++) {
|
||||
newStars.push({
|
||||
x: pseudoRandom(i + 5000) * width,
|
||||
y: pseudoRandom(i + 6000) * height,
|
||||
size: pseudoRandom(i + 7000) * 1.5,
|
||||
opacity: 0.1 + pseudoRandom(i + 8000) * 0.5
|
||||
});
|
||||
}
|
||||
if (p.alpha < p.targetAlpha) p.alpha += 0.001;
|
||||
|
||||
// Init Gas Clusters
|
||||
const newParticles = [];
|
||||
const clusterCount = colors.length * 3; // More clusters
|
||||
const groupCenters = Array.from({ length: clusterCount }).map((_, i) => ({
|
||||
x: (pseudoRandom(i * 15) * 0.8 + 0.1) * width,
|
||||
y: (pseudoRandom(i * 25 + 7) * 0.8 + 0.1) * height
|
||||
}));
|
||||
// Render as very soft additive blob
|
||||
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
|
||||
const colorAlpha = p.alpha * globalOpacity;
|
||||
grad.addColorStop(0, alpha(p.color, colorAlpha));
|
||||
grad.addColorStop(0.6, alpha(p.color, colorAlpha * 0.2));
|
||||
grad.addColorStop(1, alpha(p.color, 0));
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const clusterIdx = i % clusterCount;
|
||||
const colorIdx = clusterIdx % colors.length;
|
||||
const center = groupCenters[clusterIdx];
|
||||
const angle = pseudoRandom(i + 100) * Math.PI * 2;
|
||||
const dist = pseudoRandom(i + 200) * (width * 0.12);
|
||||
const bx = center.x + Math.cos(angle) * dist;
|
||||
const by = center.y + Math.sin(angle) * dist;
|
||||
ctx.fillStyle = grad;
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
newParticles.push({
|
||||
x: bx, y: by,
|
||||
baseX: bx, baseY: by,
|
||||
vx: 0, vy: 0,
|
||||
size: 40 + pseudoRandom(i + 300) * 80, // Larger, softer blobs
|
||||
color: colors[colorIdx],
|
||||
alpha: 0,
|
||||
targetAlpha: 0.05 + pseudoRandom(i + 400) * 0.15,
|
||||
phase: pseudoRandom(i + 500) * Math.PI * 2
|
||||
});
|
||||
}
|
||||
return { stars: newStars, particles: newParticles };
|
||||
};
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
const resize = () => {
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
const scene = initScene(rect.width, rect.height);
|
||||
stars = scene.stars;
|
||||
particles = scene.particles;
|
||||
}
|
||||
};
|
||||
draw();
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => resize());
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
resize();
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMouseMove);
|
||||
parent.removeEventListener('mousedown', handleMouseDown);
|
||||
parent.removeEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [
|
||||
seed,
|
||||
palette,
|
||||
ratingsCount,
|
||||
followersCount,
|
||||
theme,
|
||||
particleCount,
|
||||
speed,
|
||||
globalOpacity,
|
||||
smearRadius,
|
||||
turbulenceStrength,
|
||||
returnSpeed,
|
||||
starDensity,
|
||||
]);
|
||||
|
||||
let mouse = { x: -1000, y: -1000, px: -1000, py: -1000, down: false };
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.px = mouse.x; mouse.py = mouse.y;
|
||||
mouse.x = e.clientX - rect.left;
|
||||
mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
const handleMouseDown = () => { mouse.down = true; };
|
||||
const handleMouseUp = () => { mouse.down = false; };
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
parent.addEventListener('mousemove', handleMouseMove);
|
||||
parent.addEventListener('mousedown', handleMouseDown);
|
||||
parent.addEventListener('mouseup', handleMouseUp);
|
||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
|
||||
}
|
||||
|
||||
let time = 0;
|
||||
const draw = () => {
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
if (!width || !height) {
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// 1. Dynamic Celestial Background
|
||||
const isLight = theme.palette.mode === 'light';
|
||||
const skyGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
|
||||
|
||||
if (isLight) {
|
||||
// Vibrant Deep Primary for light mode - but dark enough for glowing gas to pop
|
||||
skyGrad.addColorStop(0, alpha(colors[colors.length - 1], 0.7));
|
||||
skyGrad.addColorStop(0.5, alpha(colors[colors.length - 1], 0.9));
|
||||
skyGrad.addColorStop(1, alpha(colors[colors.length - 1], 1.0));
|
||||
} else {
|
||||
// True Deep Space with visible tints for dark mode
|
||||
skyGrad.addColorStop(0, '#1a1a3a'); // More blue/purple tint
|
||||
skyGrad.addColorStop(0.5, '#0a0a1a');
|
||||
skyGrad.addColorStop(1, '#050508');
|
||||
}
|
||||
|
||||
ctx.fillStyle = skyGrad;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// 2. Draw Distant Stars
|
||||
ctx.fillStyle = '#fff';
|
||||
stars.forEach(s => {
|
||||
ctx.globalAlpha = s.opacity * (0.7 + Math.sin(time * 2 + s.x) * 0.3);
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
ctx.globalAlpha = 1.0;
|
||||
|
||||
time += speed;
|
||||
|
||||
// 3. Draw Gas Clouds
|
||||
particles.forEach((p) => {
|
||||
// Restoration Force (Airy return)
|
||||
const dxBase = p.baseX - p.x;
|
||||
const dyBase = p.baseY - p.y;
|
||||
p.vx += dxBase * returnSpeed;
|
||||
p.vy += dyBase * returnSpeed;
|
||||
|
||||
// Mouse Interaction (Airy Smear)
|
||||
const dx = mouse.x - p.x;
|
||||
const dy = mouse.y - p.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < smearRadius) {
|
||||
const force = (smearRadius - dist) / smearRadius;
|
||||
if (mouse.down) {
|
||||
// High turbulence on click
|
||||
const angle = Math.atan2(dy, dx);
|
||||
p.vx += Math.cos(angle + Math.PI/2) * turbulenceStrength * 2;
|
||||
p.vy += Math.sin(angle + Math.PI/2) * turbulenceStrength * 2;
|
||||
} else {
|
||||
// Smoke-like smear
|
||||
const mdx = mouse.x - mouse.px;
|
||||
const mdy = mouse.y - mouse.py;
|
||||
p.vx += mdx * force * 0.15;
|
||||
p.vy += mdy * force * 0.15;
|
||||
p.vx -= (dx / dist) * force * 0.8;
|
||||
p.vy -= (dy / dist) * force * 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth drift
|
||||
p.vx += Math.sin(time + p.phase) * 0.03;
|
||||
p.vy += Math.cos(time * 0.8 + p.phase) * 0.03;
|
||||
|
||||
// High friction for gas feel
|
||||
p.vx *= 0.95;
|
||||
p.vy *= 0.95;
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
|
||||
if (p.alpha < p.targetAlpha) p.alpha += 0.001;
|
||||
|
||||
// Render as very soft additive blob
|
||||
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
|
||||
const colorAlpha = p.alpha * globalOpacity;
|
||||
grad.addColorStop(0, alpha(p.color, colorAlpha));
|
||||
grad.addColorStop(0.6, alpha(p.color, colorAlpha * 0.2));
|
||||
grad.addColorStop(1, alpha(p.color, 0));
|
||||
|
||||
ctx.fillStyle = grad;
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (parent) {
|
||||
parent.removeEventListener('mousemove', handleMouseMove);
|
||||
parent.removeEventListener('mousedown', handleMouseDown);
|
||||
parent.removeEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [seed, palette, ratingsCount, followersCount, theme, particleCount, speed, globalOpacity, smearRadius, turbulenceStrength, returnSpeed, starDensity]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NebulaBanner;
|
||||
@@ -2,54 +2,57 @@ import React from 'react';
|
||||
import { Box, Typography, useTheme } from '@mui/material';
|
||||
|
||||
interface RatingBadgeProps {
|
||||
score: number;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
sx?: any;
|
||||
score: number;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
sx?: any;
|
||||
}
|
||||
|
||||
const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx }) => {
|
||||
const theme = useTheme();
|
||||
const isLarge = size === 'large';
|
||||
const isSmall = size === 'small';
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 0.5,
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
px: isLarge ? 2 : (isSmall ? 1 : 1.5),
|
||||
py: isLarge ? 0.5 : (isSmall ? 0.2 : 0.4),
|
||||
borderRadius: 2,
|
||||
minWidth: 'fit-content',
|
||||
height: 'fit-content',
|
||||
boxShadow: theme.palette.mode === 'dark'
|
||||
? '0 4px 12px rgba(0,0,0,0.4)'
|
||||
: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
...sx
|
||||
}}>
|
||||
<Typography
|
||||
variant={isLarge ? "h5" : (isSmall ? "caption" : "subtitle1")}
|
||||
sx={{ fontWeight: '900', lineHeight: 1, display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
{typeof score === 'number' ? (isLarge ? score.toFixed(1) : score.toFixed(0)) : score}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: isLarge ? '0.75rem' : (isSmall ? '0.55rem' : '0.65rem'),
|
||||
fontWeight: '900',
|
||||
opacity: 0.9,
|
||||
lineHeight: 1,
|
||||
mt: 0.2
|
||||
}}
|
||||
>
|
||||
/ 10
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
const theme = useTheme();
|
||||
const isLarge = size === 'large';
|
||||
const isSmall = size === 'small';
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 0.5,
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
px: isLarge ? 2 : isSmall ? 1 : 1.5,
|
||||
py: isLarge ? 0.5 : isSmall ? 0.2 : 0.4,
|
||||
borderRadius: 2,
|
||||
minWidth: 'fit-content',
|
||||
height: 'fit-content',
|
||||
boxShadow:
|
||||
theme.palette.mode === 'dark'
|
||||
? '0 4px 12px rgba(0,0,0,0.4)'
|
||||
: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant={isLarge ? 'h5' : isSmall ? 'caption' : 'subtitle1'}
|
||||
sx={{ fontWeight: '900', lineHeight: 1, display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
{typeof score === 'number' ? (isLarge ? score.toFixed(1) : score.toFixed(0)) : score}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: isLarge ? '0.75rem' : isSmall ? '0.55rem' : '0.65rem',
|
||||
fontWeight: '900',
|
||||
opacity: 0.9,
|
||||
lineHeight: 1,
|
||||
mt: 0.2,
|
||||
}}
|
||||
>
|
||||
/ 10
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RatingBadge;
|
||||
@@ -3,41 +3,41 @@ import { Link } from 'react-router-dom';
|
||||
import { useTheme, alpha } from '@mui/material';
|
||||
|
||||
interface RichTextProps {
|
||||
text: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const RichText: React.FC<RichTextProps> = ({ text }) => {
|
||||
const theme = useTheme();
|
||||
const theme = useTheme();
|
||||
|
||||
if (!text) return null;
|
||||
if (!text) return null;
|
||||
|
||||
const parts = text.split(/(@\w+)/g);
|
||||
return (
|
||||
<span>
|
||||
{parts.map((part, i) => {
|
||||
if (part.startsWith('@')) {
|
||||
const username = part.substring(1);
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
to={`/profile/${username}`}
|
||||
style={{
|
||||
color: theme.palette.primary.main,
|
||||
textDecoration: 'none',
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||
padding: '0 4px',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
const parts = text.split(/(@\w+)/g);
|
||||
return (
|
||||
<span>
|
||||
{parts.map((part, i) => {
|
||||
if (part.startsWith('@')) {
|
||||
const username = part.substring(1);
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
to={`/profile/${username}`}
|
||||
style={{
|
||||
color: theme.palette.primary.main,
|
||||
textDecoration: 'none',
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||
padding: '0 4px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default RichText;
|
||||
@@ -4,53 +4,60 @@ import HistoryIcon from '@mui/icons-material/History';
|
||||
import BlockIcon from '@mui/icons-material/Block';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
isLegacy?: boolean;
|
||||
isAvailable?: boolean;
|
||||
size?: 'small' | 'medium';
|
||||
sx?: any;
|
||||
isLegacy?: boolean;
|
||||
isAvailable?: boolean;
|
||||
size?: 'small' | 'medium';
|
||||
sx?: any;
|
||||
}
|
||||
|
||||
const StatusBadge: React.FC<StatusBadgeProps> = ({ isLegacy, isAvailable, size = 'medium', sx }) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = size === 'small';
|
||||
const StatusBadge: React.FC<StatusBadgeProps> = ({
|
||||
isLegacy,
|
||||
isAvailable,
|
||||
size = 'medium',
|
||||
sx,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = size === 'small';
|
||||
|
||||
if (isAvailable && !isLegacy) return null;
|
||||
if (isAvailable && !isLegacy) return null;
|
||||
|
||||
const label = isLegacy ? 'Limited' : 'Out of Stock';
|
||||
const color = isLegacy ? theme.palette.warning.main : theme.palette.error.main;
|
||||
const Icon = isLegacy ? HistoryIcon : BlockIcon;
|
||||
const label = isLegacy ? 'Limited' : 'Out of Stock';
|
||||
const color = isLegacy ? theme.palette.warning.main : theme.palette.error.main;
|
||||
const Icon = isLegacy ? HistoryIcon : BlockIcon;
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
bgcolor: alpha(color, 0.9),
|
||||
color: '#fff',
|
||||
px: isSmall ? 0.8 : 1.2,
|
||||
py: isSmall ? 0.2 : 0.4,
|
||||
borderRadius: 1.5,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: '1px solid',
|
||||
borderColor: alpha('#fff', 0.2),
|
||||
...sx
|
||||
}}>
|
||||
<Icon sx={{ fontSize: isSmall ? '0.7rem' : '0.9rem' }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: '900',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fontSize: isSmall ? '0.55rem' : '0.65rem',
|
||||
lineHeight: 1
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
bgcolor: alpha(color, 0.9),
|
||||
color: '#fff',
|
||||
px: isSmall ? 0.8 : 1.2,
|
||||
py: isSmall ? 0.2 : 0.4,
|
||||
borderRadius: 1.5,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: '1px solid',
|
||||
borderColor: alpha('#fff', 0.2),
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Icon sx={{ fontSize: isSmall ? '0.7rem' : '0.9rem' }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: '900',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fontSize: isSmall ? '0.55rem' : '0.65rem',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusBadge;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createContext, useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import api, { clearTokens } from '../lib/api';
|
||||
|
||||
export interface AuthUser {
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
unread_notifications_count: number;
|
||||
is_superuser: boolean;
|
||||
theme?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface FollowingEntry {
|
||||
id: number;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
export interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
setUser: (user: AuthUser | null) => void;
|
||||
following: FollowingEntry[];
|
||||
loadingUser: boolean;
|
||||
refetchUser: () => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { i18n } = useTranslation();
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [following, setFollowing] = useState<FollowingEntry[]>([]);
|
||||
const [loadingUser, setLoadingUser] = useState(true);
|
||||
|
||||
const refetchUser = useCallback(async () => {
|
||||
const token = localStorage.getItem('access');
|
||||
if (!token) {
|
||||
setUser(null);
|
||||
setLoadingUser(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.get('users/me/');
|
||||
if (res.data?.username) {
|
||||
setUser(res.data);
|
||||
if (res.data.theme) localStorage.setItem('theme', res.data.theme);
|
||||
if (res.data.language) i18n.changeLanguage(res.data.language);
|
||||
try {
|
||||
const followRes = await api.get('users/following_list/');
|
||||
const data = Array.isArray(followRes.data)
|
||||
? followRes.data
|
||||
: followRes.data.results || [];
|
||||
setFollowing(data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoadingUser(false);
|
||||
}
|
||||
}, [i18n]);
|
||||
|
||||
useEffect(() => {
|
||||
refetchUser();
|
||||
}, [refetchUser]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
setFollowing([]);
|
||||
i18n.changeLanguage(navigator.language.split('-')[0] || 'en');
|
||||
window.location.href = '/';
|
||||
}, [i18n]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, setUser, following, loadingUser, refetchUser, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createContext, useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import api from '../lib/api';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
export interface Notification {
|
||||
id: number;
|
||||
actor_username: string;
|
||||
actor_avatar: string | null;
|
||||
notification_type:
|
||||
| 'reply'
|
||||
| 'mention'
|
||||
| 'follow'
|
||||
| 'ticket_new'
|
||||
| 'ticket_reply'
|
||||
| 'profile_comment';
|
||||
rating: number | null;
|
||||
reply: number | null;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
flavor_name: string | null;
|
||||
flavor_id: number | null;
|
||||
profile_owner_username: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationContextValue {
|
||||
notifications: Notification[];
|
||||
fetchNotifications: () => Promise<void>;
|
||||
markAllRead: () => Promise<void>;
|
||||
markRead: (id: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export const NotificationContext = createContext<NotificationContextValue | null>(null);
|
||||
|
||||
export const NotificationProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { user, setUser, refetchUser } = useAuth();
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get('notifications/');
|
||||
const data = Array.isArray(res.data) ? res.data : res.data.results || [];
|
||||
setNotifications(data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchNotifications();
|
||||
}
|
||||
}, [user, fetchNotifications]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (localStorage.getItem('access')) {
|
||||
fetchNotifications();
|
||||
refetchUser();
|
||||
}
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchNotifications, refetchUser]);
|
||||
|
||||
const markAllRead = useCallback(async () => {
|
||||
try {
|
||||
await api.post('notifications/mark_all_read/');
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })));
|
||||
if (user) setUser({ ...user, unread_notifications_count: 0 });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [user, setUser]);
|
||||
|
||||
const markRead = useCallback(
|
||||
async (id: number) => {
|
||||
try {
|
||||
await api.post(`notifications/${id}/mark_read/`);
|
||||
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)));
|
||||
if (user) {
|
||||
setUser({
|
||||
...user,
|
||||
unread_notifications_count: Math.max(0, user.unread_notifications_count - 1),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[user, setUser],
|
||||
);
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider
|
||||
value={{ notifications, fetchNotifications, markAllRead, markRead }}
|
||||
>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createContext, useCallback, useMemo, useState, type ReactNode } from 'react';
|
||||
import { ThemeProvider as MuiThemeProvider, CssBaseline } from '@mui/material';
|
||||
import { getTheme, type CatppuccinTheme } from '../theme';
|
||||
import api from '../lib/api';
|
||||
|
||||
export interface ThemeContextValue {
|
||||
themeName: CatppuccinTheme;
|
||||
setThemeName: (name: CatppuccinTheme) => void;
|
||||
handleThemeChange: (name: CatppuccinTheme, isLoggedIn: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [themeName, setThemeName] = useState<CatppuccinTheme>(
|
||||
(localStorage.getItem('theme') as CatppuccinTheme) || 'holy_light',
|
||||
);
|
||||
|
||||
const theme = useMemo(() => getTheme(themeName), [themeName]);
|
||||
|
||||
const handleThemeChange = useCallback(async (name: CatppuccinTheme, isLoggedIn: boolean) => {
|
||||
setThemeName(name);
|
||||
localStorage.setItem('theme', name);
|
||||
document.body.style.backgroundColor = getTheme(name).palette.background.default;
|
||||
document.body.style.color = getTheme(name).palette.text.primary;
|
||||
if (isLoggedIn) {
|
||||
try {
|
||||
await api.patch('users/update_preferences/', { theme: name });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ themeName, setThemeName, handleThemeChange }}>
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</MuiThemeProvider>
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useContext } from 'react';
|
||||
import { AuthContext } from '../contexts/AuthContext';
|
||||
|
||||
export const useAuth = () => {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import api from '../lib/api';
|
||||
|
||||
export interface SearchResult {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'flavor' | 'category' | 'user';
|
||||
subtitle: string;
|
||||
image_url: string | null;
|
||||
slug: string | null;
|
||||
}
|
||||
|
||||
export const useGlobalSearch = () => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [options, setOptions] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setOptions([]);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get(`flavors/search/?q=${encodeURIComponent(query)}`);
|
||||
const results = Array.isArray(res.data) ? res.data : res.data.results || [];
|
||||
setOptions(results);
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const q = params.get('q');
|
||||
if (q) setQuery(q);
|
||||
}, [location.search]);
|
||||
|
||||
return { query, setQuery, options, loading };
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useContext } from 'react';
|
||||
import { NotificationContext } from '../contexts/NotificationContext';
|
||||
|
||||
export const useNotifications = () => {
|
||||
const ctx = useContext(NotificationContext);
|
||||
if (!ctx) throw new Error('useNotifications must be used within NotificationProvider');
|
||||
return ctx;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useContext } from 'react';
|
||||
import { ThemeContext } from '../contexts/ThemeContext';
|
||||
|
||||
export const useTheme = () => {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||
return ctx;
|
||||
};
|
||||
+505
-456
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { clearTokens, getAccessToken, setTokens } from './api';
|
||||
|
||||
describe('token helpers', () => {
|
||||
beforeEach(() => {
|
||||
clearTokens();
|
||||
});
|
||||
|
||||
it('returns null when no access token is stored', () => {
|
||||
expect(getAccessToken()).toBeNull();
|
||||
});
|
||||
|
||||
it('stores and retrieves the access token', () => {
|
||||
setTokens('access-123', 'refresh-abc');
|
||||
expect(getAccessToken()).toBe('access-123');
|
||||
expect(localStorage.getItem('refresh')).toBe('refresh-abc');
|
||||
});
|
||||
|
||||
it('updates access without clobbering refresh when refresh is omitted', () => {
|
||||
setTokens('first-access', 'first-refresh');
|
||||
setTokens('second-access');
|
||||
expect(getAccessToken()).toBe('second-access');
|
||||
expect(localStorage.getItem('refresh')).toBe('first-refresh');
|
||||
});
|
||||
|
||||
it('clearTokens removes both access and refresh', () => {
|
||||
setTokens('a', 'b');
|
||||
clearTokens();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(localStorage.getItem('refresh')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
|
||||
const ACCESS_KEY = 'access';
|
||||
const REFRESH_KEY = 'refresh';
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return localStorage.getItem(ACCESS_KEY);
|
||||
}
|
||||
|
||||
export function setTokens(access: string, refresh?: string): void {
|
||||
localStorage.setItem(ACCESS_KEY, access);
|
||||
if (refresh) {
|
||||
localStorage.setItem(REFRESH_KEY, refresh);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
}
|
||||
|
||||
/** One-time migration: remove the legacy DRF TokenAuth key from localStorage. */
|
||||
export function clearLegacyToken(): void {
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/',
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const access = getAccessToken();
|
||||
if (access) {
|
||||
config.headers.Authorization = `Bearer ${access}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
type RetriableConfig = AxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
async function refreshAccessToken(): Promise<string | null> {
|
||||
const refresh = localStorage.getItem(REFRESH_KEY);
|
||||
if (!refresh) return null;
|
||||
|
||||
try {
|
||||
const res = await axios.post(`${api.defaults.baseURL}auth/token/refresh/`, { refresh });
|
||||
const newAccess = res.data.access as string;
|
||||
const newRefresh = res.data.refresh as string | undefined;
|
||||
setTokens(newAccess, newRefresh);
|
||||
return newAccess;
|
||||
} catch {
|
||||
clearTokens();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const original = error.config as RetriableConfig | undefined;
|
||||
if (!original || error.response?.status !== 401 || original._retry) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const url = original.url ?? '';
|
||||
if (url.includes('auth/token/')) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
original._retry = true;
|
||||
refreshPromise = refreshPromise ?? refreshAccessToken();
|
||||
const newAccess = await refreshPromise;
|
||||
refreshPromise = null;
|
||||
|
||||
if (!newAccess) {
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
original.headers = {
|
||||
...(original.headers ?? {}),
|
||||
Authorization: `Bearer ${newAccess}`,
|
||||
};
|
||||
return api(original);
|
||||
},
|
||||
);
|
||||
|
||||
export default api;
|
||||
+13
-7
@@ -1,13 +1,19 @@
|
||||
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';
|
||||
import { AppProviders } from './app/providers';
|
||||
import { clearLegacyToken } from './lib/api';
|
||||
|
||||
clearLegacyToken();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<Router>
|
||||
<App />
|
||||
<AppProviders>
|
||||
<App />
|
||||
</AppProviders>
|
||||
</Router>
|
||||
</StrictMode>,
|
||||
)
|
||||
);
|
||||
@@ -12,10 +12,10 @@ const About: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component={Link}
|
||||
to="/"
|
||||
<Button
|
||||
variant="outlined"
|
||||
component={Link}
|
||||
to="/"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ mb: 4, textTransform: 'none', borderRadius: 2 }}
|
||||
>
|
||||
@@ -23,67 +23,103 @@ const About: React.FC = () => {
|
||||
</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')}
|
||||
<Typography variant={isMobile ? 'h4' : 'h3'} gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('about.title')}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
paragraph
|
||||
sx={{ fontSize: { xs: '1rem', md: '1.1rem' }, lineHeight: 1.8 }}
|
||||
>
|
||||
{t('about.mission')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ my: 4 }}>
|
||||
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.canDoTitle')}</Typography>
|
||||
<Typography component="ul" sx={{ lineHeight: 2, pl: 2 }}>
|
||||
<li>{t('about.featureExplore')}</li>
|
||||
<li>{t('about.featureRate')}</li>
|
||||
<li>{t('about.featureCommunity')}</li>
|
||||
<li>{t('about.featureProfile')}</li>
|
||||
<li>{t('about.featurePersonalize')}</li>
|
||||
<li>{t('about.featureEngage')}</li>
|
||||
<li>{t('about.featureSupport')}</li>
|
||||
</Typography>
|
||||
<Typography variant={isMobile ? 'h6' : 'h5'} gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('about.canDoTitle')}
|
||||
</Typography>
|
||||
<Typography component="ul" sx={{ lineHeight: 2, pl: 2 }}>
|
||||
<li>{t('about.featureExplore')}</li>
|
||||
<li>{t('about.featureRate')}</li>
|
||||
<li>{t('about.featureCommunity')}</li>
|
||||
<li>{t('about.featureProfile')}</li>
|
||||
<li>{t('about.featurePersonalize')}</li>
|
||||
<li>{t('about.featureEngage')}</li>
|
||||
<li>{t('about.featureSupport')}</li>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
{t('about.disclaimer')}
|
||||
{t('about.disclaimer')}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: { xs: 4, md: 6 } }} />
|
||||
|
||||
<Typography variant={isMobile ? "h5" : "h4"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.impressumTitle')}</Typography>
|
||||
<Typography variant={isMobile ? 'h5' : 'h4'} gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('about.impressumTitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 2 }}>
|
||||
<strong>{t('about.legalInfo')}</strong><br />
|
||||
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name or Project Name]'}<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}<br />
|
||||
{t('about.region')}
|
||||
<strong>{t('about.legalInfo')}</strong>
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name or Project Name]'}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}
|
||||
<br />
|
||||
{t('about.region')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('about.contact')}:</Typography>
|
||||
<Typography variant="body2">
|
||||
E-Mail: {import.meta.env.VITE_IMPRESSUM_EMAIL || '[Your Support Email Address]'}<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_PHONE && (
|
||||
<>Telefon: {import.meta.env.VITE_IMPRESSUM_PHONE}<br /></>
|
||||
)}
|
||||
Internet: holy.narl.io
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
{t('about.contact')}:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
E-Mail: {import.meta.env.VITE_IMPRESSUM_EMAIL || '[Your Support Email Address]'}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_PHONE && (
|
||||
<>
|
||||
Telefon: {import.meta.env.VITE_IMPRESSUM_PHONE}
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
Internet: holy.narl.io
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('about.responsible')}</Typography>
|
||||
<Typography variant="body2">
|
||||
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
{t('about.responsible')}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.disclaimerTitle')}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" component="p" sx={{ mb: 2, lineHeight: 1.6 }}>
|
||||
<strong>{t('about.liabilityContentTitle')}</strong> {t('about.liabilityContentDesc')}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" component="p" sx={{ lineHeight: 1.6 }}>
|
||||
<strong>{t('about.copyrightTitle')}</strong> {t('about.copyrightDesc')}
|
||||
</Typography>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('about.disclaimerTitle')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
component="p"
|
||||
sx={{ mb: 2, lineHeight: 1.6 }}
|
||||
>
|
||||
<strong>{t('about.liabilityContentTitle')}</strong> {t('about.liabilityContentDesc')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
component="p"
|
||||
sx={{ lineHeight: 1.6 }}
|
||||
>
|
||||
<strong>{t('about.copyrightTitle')}</strong> {t('about.copyrightDesc')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,98 +1,124 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container, Typography, Box, Button,
|
||||
Stack, CircularProgress, TextField, Paper
|
||||
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 api from '../lib/api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import MentionTextField from '../components/MentionTextField';
|
||||
|
||||
const AdminRatingDetail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [score, setScore] = useState(0);
|
||||
const [comment, setComment] = useState('');
|
||||
const [rating, setRating] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [score, setScore] = useState(0);
|
||||
const [comment, setComment] = useState('');
|
||||
const [rating, setRating] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchRating = async () => {
|
||||
try {
|
||||
const res = await api.get(`ratings/${id}/`);
|
||||
setRating(res.data);
|
||||
setScore(res.data.score);
|
||||
setComment(res.data.comment || '');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
navigate('/admin-panel');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const fetchRating = async () => {
|
||||
try {
|
||||
const res = await api.get(`ratings/${id}/`);
|
||||
setRating(res.data);
|
||||
setScore(res.data.score);
|
||||
setComment(res.data.comment || '');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
navigate('/admin-panel');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRating();
|
||||
}, [id]);
|
||||
useEffect(() => {
|
||||
fetchRating();
|
||||
}, [id]);
|
||||
|
||||
useTitle(rating ? `${t('admin.manageRating')}: ${rating.flavor_name}` : 'Rating Management');
|
||||
useTitle(rating ? `${t('admin.manageRating')}: ${rating.flavor_name}` : 'Rating Management');
|
||||
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
await api.patch(`ratings/${id}/`, { score, comment });
|
||||
fetchRating();
|
||||
alert('Rating updated!');
|
||||
} catch (err) { alert('Update failed'); }
|
||||
};
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
await api.patch(`ratings/${id}/`, { score, comment });
|
||||
fetchRating();
|
||||
alert('Rating updated!');
|
||||
} catch (err) {
|
||||
alert('Update failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Delete this rating and all replies?')) return;
|
||||
try {
|
||||
await api.delete(`ratings/${id}/`);
|
||||
navigate('/admin-panel');
|
||||
} catch (err) { alert('Delete failed'); }
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Delete this rating and all replies?')) return;
|
||||
try {
|
||||
await api.delete(`ratings/${id}/`);
|
||||
navigate('/admin-panel');
|
||||
} catch (err) {
|
||||
alert('Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ py: 4 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 4 }}>{t('admin.manageRating')}</Typography>
|
||||
|
||||
<Paper sx={{ p: 3, borderRadius: 4 }}>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{rating.flavor_name} (User: {rating.user})
|
||||
</Typography>
|
||||
<Stack spacing={3} sx={{ mt: 3 }}>
|
||||
<TextField
|
||||
label="Score" type="number"
|
||||
slotProps={{ input: { inputProps: { min: 1, max: 10 } } }}
|
||||
value={score} onChange={(e) => setScore(Number(e.target.value))}
|
||||
/>
|
||||
<MentionTextField
|
||||
multiline rows={4}
|
||||
value={comment} onChange={setComment}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button variant="contained" fullWidth onClick={handleUpdate}>{t('common.save')}</Button>
|
||||
<Button variant="outlined" color="error" fullWidth startIcon={<DeleteIcon />} onClick={handleDelete}>{t('common.delete')}</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ py: 4 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 4 }}>
|
||||
{t('admin.manageRating')}
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ p: 3, borderRadius: 4 }}>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{rating.flavor_name} (User: {rating.user})
|
||||
</Typography>
|
||||
<Stack spacing={3} sx={{ mt: 3 }}>
|
||||
<TextField
|
||||
label="Score"
|
||||
type="number"
|
||||
slotProps={{ input: { inputProps: { min: 1, max: 10 } } }}
|
||||
value={score}
|
||||
onChange={(e) => setScore(Number(e.target.value))}
|
||||
/>
|
||||
<MentionTextField multiline rows={4} value={comment} onChange={setComment} />
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button variant="contained" fullWidth onClick={handleUpdate}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
fullWidth
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminRatingDetail;
|
||||
@@ -1,92 +1,107 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container, Typography, Box, Button,
|
||||
Stack, CircularProgress, Paper
|
||||
} from '@mui/material';
|
||||
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 api from '../lib/api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import MentionTextField from '../components/MentionTextField';
|
||||
|
||||
const AdminReplyDetail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [text, setText] = useState('');
|
||||
const [reply, setReply] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [text, setText] = useState('');
|
||||
const [reply, setReply] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchReply = async () => {
|
||||
try {
|
||||
// We use the replies list/detail endpoint
|
||||
const res = await api.get(`replies/${id}/`);
|
||||
setReply(res.data);
|
||||
setText(res.data.text);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
navigate('/admin-panel');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const fetchReply = async () => {
|
||||
try {
|
||||
// We use the replies list/detail endpoint
|
||||
const res = await api.get(`replies/${id}/`);
|
||||
setReply(res.data);
|
||||
setText(res.data.text);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
navigate('/admin-panel');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchReply();
|
||||
}, [id]);
|
||||
useEffect(() => {
|
||||
fetchReply();
|
||||
}, [id]);
|
||||
|
||||
useTitle(reply ? `${t('admin.manageReply')}` : 'Reply Management');
|
||||
useTitle(reply ? `${t('admin.manageReply')}` : 'Reply Management');
|
||||
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
await api.patch(`replies/${id}/`, { text });
|
||||
fetchReply();
|
||||
alert('Reply updated!');
|
||||
} catch (err) { alert('Update failed'); }
|
||||
};
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
await api.patch(`replies/${id}/`, { text });
|
||||
fetchReply();
|
||||
alert('Reply updated!');
|
||||
} catch (err) {
|
||||
alert('Update failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Delete this reply?')) return;
|
||||
try {
|
||||
await api.delete(`replies/${id}/`);
|
||||
navigate('/admin-panel');
|
||||
} catch (err) { alert('Delete failed'); }
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Delete this reply?')) return;
|
||||
try {
|
||||
await api.delete(`replies/${id}/`);
|
||||
navigate('/admin-panel');
|
||||
} catch (err) {
|
||||
alert('Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ py: 4 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 4 }}>{t('admin.manageReply')}</Typography>
|
||||
|
||||
<Paper sx={{ p: 3, borderRadius: 4 }}>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
Reply by {reply.user}
|
||||
</Typography>
|
||||
<Stack spacing={3} sx={{ mt: 3 }}>
|
||||
<MentionTextField
|
||||
multiline rows={4}
|
||||
value={text} onChange={setText}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button variant="contained" fullWidth onClick={handleUpdate}>{t('common.save')}</Button>
|
||||
<Button variant="outlined" color="error" fullWidth startIcon={<DeleteIcon />} onClick={handleDelete}>{t('common.delete')}</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ py: 4 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 4 }}>
|
||||
{t('admin.manageReply')}
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ p: 3, borderRadius: 4 }}>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
Reply by {reply.user}
|
||||
</Typography>
|
||||
<Stack spacing={3} sx={{ mt: 3 }}>
|
||||
<MentionTextField multiline rows={4} value={text} onChange={setText} />
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button variant="contained" fullWidth onClick={handleUpdate}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
fullWidth
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminReplyDetail;
|
||||
@@ -1,8 +1,26 @@
|
||||
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
|
||||
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';
|
||||
@@ -11,304 +29,484 @@ 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 api from '../lib/api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { formatDate } from '../utils/date';
|
||||
import MentionTextField from '../components/MentionTextField';
|
||||
import RichText from '../components/RichText';
|
||||
|
||||
interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
date_joined: string;
|
||||
last_login: string;
|
||||
ips: string[];
|
||||
ratings: any[];
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
date_joined: string;
|
||||
last_login: string;
|
||||
ips: string[];
|
||||
ratings: any[];
|
||||
}
|
||||
|
||||
const AdminUserDetail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [user, setUser] = useState<AdminUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [user, setUser] = useState<AdminUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
// Edit state for Ratings
|
||||
const [editingRatingId, setEditingRatingId] = useState<number | null>(null);
|
||||
const [editScore, setEditScore] = useState(0);
|
||||
const [editComment, setEditComment] = useState('');
|
||||
// Edit state for Ratings
|
||||
const [editingRatingId, setEditingRatingId] = useState<number | null>(null);
|
||||
const [editScore, setEditScore] = useState(0);
|
||||
const [editComment, setEditComment] = useState('');
|
||||
|
||||
// Edit state for Replies
|
||||
const [editingReplyId, setEditingReplyId] = useState<number | null>(null);
|
||||
const [editReplyText, setEditReplyText] = useState('');
|
||||
// Edit state for Replies
|
||||
const [editingReplyId, setEditingReplyId] = useState<number | null>(null);
|
||||
const [editReplyText, setEditReplyText] = useState('');
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const res = await api.get(`admin-custom/${id}/user_detail/`);
|
||||
setUser(res.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
navigate('/admin-panel');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const res = await api.get(`admin-custom/${id}/user_detail/`);
|
||||
setUser(res.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
navigate('/admin-panel');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, [id]);
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, [id]);
|
||||
|
||||
useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : 'User Management');
|
||||
useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : 'User Management');
|
||||
|
||||
const handleToggleActive = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
await api.patch(`admin-custom/${id}/user_detail/`, { is_active: !user.is_active });
|
||||
fetchUser();
|
||||
} catch (err) { alert('Action failed'); }
|
||||
};
|
||||
const handleToggleActive = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
await api.patch(`admin-custom/${id}/user_detail/`, { is_active: !user.is_active });
|
||||
fetchUser();
|
||||
} catch (err) {
|
||||
alert('Action failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRating = async (ratingId: number) => {
|
||||
try {
|
||||
await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment });
|
||||
setEditingRatingId(null);
|
||||
fetchUser();
|
||||
} catch (err) { alert('Update failed'); }
|
||||
};
|
||||
const handleUpdateRating = async (ratingId: number) => {
|
||||
try {
|
||||
await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment });
|
||||
setEditingRatingId(null);
|
||||
fetchUser();
|
||||
} catch (err) {
|
||||
alert('Update failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRating = async (ratingId: number) => {
|
||||
if (!confirm('Delete this rating?')) return;
|
||||
try {
|
||||
await api.delete(`ratings/${ratingId}/`);
|
||||
fetchUser();
|
||||
} catch (err) { alert('Delete failed'); }
|
||||
};
|
||||
const handleDeleteRating = async (ratingId: number) => {
|
||||
if (!confirm('Delete this rating?')) return;
|
||||
try {
|
||||
await api.delete(`ratings/${ratingId}/`);
|
||||
fetchUser();
|
||||
} catch (err) {
|
||||
alert('Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateReply = async (replyId: number) => {
|
||||
try {
|
||||
await api.patch(`replies/${replyId}/`, { text: editReplyText });
|
||||
setEditingReplyId(null);
|
||||
fetchUser();
|
||||
} catch (err) { alert('Update failed'); }
|
||||
};
|
||||
const handleUpdateReply = async (replyId: number) => {
|
||||
try {
|
||||
await api.patch(`replies/${replyId}/`, { text: editReplyText });
|
||||
setEditingReplyId(null);
|
||||
fetchUser();
|
||||
} catch (err) {
|
||||
alert('Update failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteReply = async (replyId: number) => {
|
||||
if (!confirm('Delete this reply?')) return;
|
||||
try {
|
||||
await api.delete(`replies/${replyId}/`);
|
||||
fetchUser();
|
||||
} catch (err) { alert('Delete failed'); }
|
||||
};
|
||||
const handleDeleteReply = async (replyId: number) => {
|
||||
if (!confirm('Delete this reply?')) return;
|
||||
try {
|
||||
await api.delete(`replies/${replyId}/`);
|
||||
fetchUser();
|
||||
} catch (err) {
|
||||
alert('Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.')) return;
|
||||
try {
|
||||
await api.delete(`admin-custom/${id}/user_detail/`);
|
||||
navigate('/admin-panel');
|
||||
} catch (err) { alert('Delete failed'); }
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||
if (!user) return <Typography>User not found</Typography>;
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.'))
|
||||
return;
|
||||
try {
|
||||
await api.delete(`admin-custom/${id}/user_detail/`);
|
||||
navigate('/admin-panel');
|
||||
} catch (err) {
|
||||
alert('Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
|
||||
{/* Profile Header */}
|
||||
<Card elevation={0} sx={{ borderRadius: 4, border: '1px solid', borderColor: 'divider', mb: 4 }}>
|
||||
<CardContent sx={{ p: { xs: 2, md: 4 } }}>
|
||||
<Grid container spacing={3} alignItems="center">
|
||||
<Grid size={{ xs: 12, sm: 'auto' }}>
|
||||
<Avatar sx={{ width: 80, height: 80, fontSize: '2rem', bgcolor: 'primary.main', mx: 'auto' }}>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 8, md: 9 }}>
|
||||
<Box sx={{ textAlign: { xs: 'center', sm: 'left' } }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>{user.username}</Typography>
|
||||
<Typography variant="body1" color="text.secondary">{user.email}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.5 }}>
|
||||
Joined: {formatDate(user.date_joined)} • Last Login: {formatDate(user.last_login)}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1.5, justifyContent: { xs: 'center', sm: 'flex-start' } }}>
|
||||
<Chip label={user.is_active ? 'Active' : 'Inactive'} color={user.is_active ? 'success' : 'default'} size="small" />
|
||||
{user.is_superuser && <Chip label="Superuser" color="secondary" size="small" />}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 'auto' }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color={user.is_active ? "warning" : "success"}
|
||||
onClick={handleToggleActive}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
|
||||
>
|
||||
{user.is_active ? t('admin.deactivate') : t('admin.activate')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Content Tabs */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} variant="fullWidth">
|
||||
<Tab icon={<RateReviewIcon />} label={`Content (${user.ratings.length})`} sx={{ fontWeight: 'bold' }} />
|
||||
<Tab icon={<HistoryIcon />} label="Security & IP" sx={{ fontWeight: 'bold' }} />
|
||||
<Tab icon={<SecurityIcon />} label="Danger Zone" sx={{ fontWeight: 'bold', color: 'error.main' }} />
|
||||
</Tabs>
|
||||
<Divider />
|
||||
</Box>
|
||||
|
||||
{/* TAB 0: USER CONTENT (Ratings & Replies) */}
|
||||
{activeTab === 0 && (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold' }}>User Ratings & Discussion</Typography>
|
||||
<Stack spacing={2}>
|
||||
{user.ratings.map(rating => (
|
||||
<Card key={rating.id} variant="outlined" sx={{ borderRadius: 3, bgcolor: alpha(theme.palette.background.paper, 0.4) }}>
|
||||
<CardContent sx={{ p: { xs: 2, md: 3 } }}>
|
||||
{editingRatingId === rating.id ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<Typography variant="body2">Score:</Typography>
|
||||
<TextField
|
||||
type="number" size="small"
|
||||
slotProps={{ input: { inputProps: { min: 1, max: 10 } } }}
|
||||
value={editScore} onChange={(e) => setEditScore(Number(e.target.value))}
|
||||
sx={{ width: 80 }}
|
||||
/>
|
||||
</Stack>
|
||||
<MentionTextField
|
||||
multiline rows={3}
|
||||
value={editComment}
|
||||
onChange={setEditComment}
|
||||
/>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button variant="contained" size="small" onClick={() => handleUpdateRating(rating.id)}>{t('common.save')}</Button>
|
||||
<Button variant="outlined" size="small" onClick={() => setEditingRatingId(null)}>{t('common.cancel')}</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{rating.flavor_name}</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Chip label={`${rating.score}/10`} size="small" color="primary" sx={{ fontWeight: '900' }} />
|
||||
<Button size="small" onClick={() => { setEditingRatingId(rating.id); setEditScore(rating.score); setEditComment(rating.comment || ''); }}>Edit</Button>
|
||||
<Button size="small" color="error" onClick={() => handleDeleteRating(rating.id)}>Delete</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box sx={{ bgcolor: 'action.hover', p: 1.5, borderRadius: 2, mb: 2 }}>
|
||||
{rating.comment ? (
|
||||
<Typography variant="body2" color="text.primary">
|
||||
<RichText text={rating.comment} />
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>No comment</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{rating.replies.length > 0 && (
|
||||
<Box sx={{ pl: 2, borderLeft: '2px solid', borderColor: 'divider' }}>
|
||||
{rating.replies.map((reply: any) => (
|
||||
<Box key={reply.id} sx={{ mb: 1.5 }}>
|
||||
{editingReplyId === reply.id ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<MentionTextField value={editReplyText} onChange={setEditReplyText} />
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button variant="contained" size="small" onClick={() => handleUpdateReply(reply.id)}>{t('common.save')}</Button>
|
||||
<Button variant="outlined" size="small" onClick={() => setEditingReplyId(null)}>{t('common.cancel')}</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', color: reply.user === user.username ? 'primary.main' : 'text.secondary' }}>
|
||||
{reply.user}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Button size="small" sx={{ minWidth: 0, p: 0, fontSize: '0.7rem' }} onClick={() => { setEditingReplyId(reply.id); setEditReplyText(reply.text); }}>Edit</Button>
|
||||
<Button size="small" color="error" sx={{ minWidth: 0, p: 0, fontSize: '0.7rem' }} onClick={() => handleDeleteReply(reply.id)}>Delete</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Typography variant="caption" display="block" sx={{ mt: 0.2 }}><RichText text={reply.text} /></Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{user.ratings.length === 0 && (
|
||||
<Box sx={{ py: 6, textAlign: 'center', bgcolor: 'action.hover', borderRadius: 3, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography color="text.secondary">No ratings yet.</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* TAB 1: SECURITY & IP */}
|
||||
{activeTab === 1 && (
|
||||
<Paper sx={{ p: 3, borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SecurityIcon /> {t('admin.ipHistory')}
|
||||
</Typography>
|
||||
<List dense>
|
||||
{user.ips.map((ip, idx) => (
|
||||
<ListItem key={idx} divider={idx < user.ips.length - 1}>
|
||||
<ListItemText
|
||||
primary={ip}
|
||||
primaryTypographyProps={{ sx: { fontWeight: 'bold', fontFamily: 'monospace' } }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
{user.ips.length === 0 && <Typography variant="caption">No IP history available.</Typography>}
|
||||
</List>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* TAB 2: DANGER ZONE */}
|
||||
{activeTab === 2 && (
|
||||
<Paper sx={{ p: 4, borderRadius: 3, border: '1px solid', borderColor: 'error.main', bgcolor: (theme) => alpha(theme.palette.error.main, 0.05) }}>
|
||||
<Typography variant="h5" color="error" gutterBottom sx={{ fontWeight: 'bold' }}>{t('admin.dangerZone')}</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 4, maxWidth: 600 }}>
|
||||
Deleting this user will permanently remove their profile, all their ratings, replies, and IP history.
|
||||
<strong> This action is irreversible.</strong>
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
size="large"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
sx={{ borderRadius: 2, fontWeight: 'bold', px: 4 }}
|
||||
>
|
||||
{t('admin.deleteUser')}
|
||||
</Button>
|
||||
</Paper>
|
||||
)}
|
||||
</Container>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
if (!user) return <Typography>User not found</Typography>;
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
|
||||
{/* Profile Header */}
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{ borderRadius: 4, border: '1px solid', borderColor: 'divider', mb: 4 }}
|
||||
>
|
||||
<CardContent sx={{ p: { xs: 2, md: 4 } }}>
|
||||
<Grid container spacing={3} alignItems="center">
|
||||
<Grid size={{ xs: 12, sm: 'auto' }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
fontSize: '2rem',
|
||||
bgcolor: 'primary.main',
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 8, md: 9 }}>
|
||||
<Box sx={{ textAlign: { xs: 'center', sm: 'left' } }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
|
||||
{user.username}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{user.email}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
display="block"
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Joined: {formatDate(user.date_joined)} • Last Login: {formatDate(user.last_login)}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ mt: 1.5, justifyContent: { xs: 'center', sm: 'flex-start' } }}
|
||||
>
|
||||
<Chip
|
||||
label={user.is_active ? 'Active' : 'Inactive'}
|
||||
color={user.is_active ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
{user.is_superuser && <Chip label="Superuser" color="secondary" size="small" />}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 'auto' }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color={user.is_active ? 'warning' : 'success'}
|
||||
onClick={handleToggleActive}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
|
||||
>
|
||||
{user.is_active ? t('admin.deactivate') : t('admin.activate')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Content Tabs */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} variant="fullWidth">
|
||||
<Tab
|
||||
icon={<RateReviewIcon />}
|
||||
label={`Content (${user.ratings.length})`}
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
/>
|
||||
<Tab icon={<HistoryIcon />} label="Security & IP" sx={{ fontWeight: 'bold' }} />
|
||||
<Tab
|
||||
icon={<SecurityIcon />}
|
||||
label="Danger Zone"
|
||||
sx={{ fontWeight: 'bold', color: 'error.main' }}
|
||||
/>
|
||||
</Tabs>
|
||||
<Divider />
|
||||
</Box>
|
||||
|
||||
{/* TAB 0: USER CONTENT (Ratings & Replies) */}
|
||||
{activeTab === 0 && (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold' }}>
|
||||
User Ratings & Discussion
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
{user.ratings.map((rating) => (
|
||||
<Card
|
||||
key={rating.id}
|
||||
variant="outlined"
|
||||
sx={{ borderRadius: 3, bgcolor: alpha(theme.palette.background.paper, 0.4) }}
|
||||
>
|
||||
<CardContent sx={{ p: { xs: 2, md: 3 } }}>
|
||||
{editingRatingId === rating.id ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<Typography variant="body2">Score:</Typography>
|
||||
<TextField
|
||||
type="number"
|
||||
size="small"
|
||||
slotProps={{ input: { inputProps: { min: 1, max: 10 } } }}
|
||||
value={editScore}
|
||||
onChange={(e) => setEditScore(Number(e.target.value))}
|
||||
sx={{ width: 80 }}
|
||||
/>
|
||||
</Stack>
|
||||
<MentionTextField
|
||||
multiline
|
||||
rows={3}
|
||||
value={editComment}
|
||||
onChange={setEditComment}
|
||||
/>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => handleUpdateRating(rating.id)}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => setEditingRatingId(null)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-start"
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
{rating.flavor_name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Chip
|
||||
label={`${rating.score}/10`}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ fontWeight: '900' }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditingRatingId(rating.id);
|
||||
setEditScore(rating.score);
|
||||
setEditComment(rating.comment || '');
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteRating(rating.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box sx={{ bgcolor: 'action.hover', p: 1.5, borderRadius: 2, mb: 2 }}>
|
||||
{rating.comment ? (
|
||||
<Typography variant="body2" color="text.primary">
|
||||
<RichText text={rating.comment} />
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ fontStyle: 'italic' }}
|
||||
>
|
||||
No comment
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{rating.replies.length > 0 && (
|
||||
<Box sx={{ pl: 2, borderLeft: '2px solid', borderColor: 'divider' }}>
|
||||
{rating.replies.map((reply: any) => (
|
||||
<Box key={reply.id} sx={{ mb: 1.5 }}>
|
||||
{editingReplyId === reply.id ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<MentionTextField
|
||||
value={editReplyText}
|
||||
onChange={setEditReplyText}
|
||||
/>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => handleUpdateReply(reply.id)}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => setEditingReplyId(null)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color:
|
||||
reply.user === user.username
|
||||
? 'primary.main'
|
||||
: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{reply.user}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ minWidth: 0, p: 0, fontSize: '0.7rem' }}
|
||||
onClick={() => {
|
||||
setEditingReplyId(reply.id);
|
||||
setEditReplyText(reply.text);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
sx={{ minWidth: 0, p: 0, fontSize: '0.7rem' }}
|
||||
onClick={() => handleDeleteReply(reply.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Typography variant="caption" display="block" sx={{ mt: 0.2 }}>
|
||||
<RichText text={reply.text} />
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{user.ratings.length === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
py: 6,
|
||||
textAlign: 'center',
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 3,
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary">No ratings yet.</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* TAB 1: SECURITY & IP */}
|
||||
{activeTab === 1 && (
|
||||
<Paper sx={{ p: 3, borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
<SecurityIcon /> {t('admin.ipHistory')}
|
||||
</Typography>
|
||||
<List dense>
|
||||
{user.ips.map((ip, idx) => (
|
||||
<ListItem key={idx} divider={idx < user.ips.length - 1}>
|
||||
<ListItemText
|
||||
primary={ip}
|
||||
primaryTypographyProps={{ sx: { fontWeight: 'bold', fontFamily: 'monospace' } }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
{user.ips.length === 0 && (
|
||||
<Typography variant="caption">No IP history available.</Typography>
|
||||
)}
|
||||
</List>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* TAB 2: DANGER ZONE */}
|
||||
{activeTab === 2 && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: 'error.main',
|
||||
bgcolor: (theme) => alpha(theme.palette.error.main, 0.05),
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" color="error" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('admin.dangerZone')}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 4, maxWidth: 600 }}>
|
||||
Deleting this user will permanently remove their profile, all their ratings, replies,
|
||||
and IP history.
|
||||
<strong> This action is irreversible.</strong>
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
size="large"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
sx={{ borderRadius: 2, fontWeight: 'bold', px: 4 }}
|
||||
>
|
||||
{t('admin.deleteUser')}
|
||||
</Button>
|
||||
</Paper>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUserDetail;
|
||||
File diff suppressed because it is too large.
Load diff
+878
-501
File diff suppressed because it is too large.
Load diff
+172
-118
@@ -1,26 +1,26 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Rating as MuiRating
|
||||
Rating as MuiRating,
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import api from '../api';
|
||||
import api from '../lib/api';
|
||||
import RatingBadge from '../components/RatingBadge';
|
||||
|
||||
interface Rating {
|
||||
id: number;
|
||||
user: string;
|
||||
score: number;
|
||||
comment: string;
|
||||
created_at: string;
|
||||
id: number;
|
||||
user: string;
|
||||
score: number;
|
||||
comment: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Flavor {
|
||||
@@ -46,15 +46,14 @@ 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/')
|
||||
]);
|
||||
const [catRes, flavRes] = await Promise.all([api.get('categories/'), api.get('flavors/')]);
|
||||
setCategories(catRes.data);
|
||||
setFlavors(flavRes.data);
|
||||
} catch (err) {
|
||||
@@ -74,7 +73,7 @@ const FlavorList: React.FC = () => {
|
||||
await api.post('ratings/', {
|
||||
flavor: flavorId,
|
||||
score: input.score,
|
||||
comment: input.comment
|
||||
comment: input.comment,
|
||||
});
|
||||
// Refresh flavors
|
||||
const res = await api.get('flavors/');
|
||||
@@ -89,111 +88,166 @@ 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>
|
||||
<Typography variant="h5" color="primary" gutterBottom>
|
||||
{category.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
{flavors.filter(f => f.category_name === category.name).map(flavor => (
|
||||
<Box sx={{ flex: '1 1 30%', minWidth: 280 }} key={flavor.id}>
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', position: 'relative' }}>
|
||||
{!flavor.is_available && (
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
bgcolor: 'error.main',
|
||||
color: 'white',
|
||||
px: 1,
|
||||
borderRadius: 1,
|
||||
fontSize: '0.75rem',
|
||||
zIndex: 1
|
||||
}}>
|
||||
{flavors
|
||||
.filter((f) => f.category_name === category.name)
|
||||
.map((flavor) => (
|
||||
<Box sx={{ flex: '1 1 30%', minWidth: 280 }} key={flavor.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{!flavor.is_available && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
bgcolor: 'error.main',
|
||||
color: 'white',
|
||||
px: 1,
|
||||
borderRadius: 1,
|
||||
fontSize: '0.75rem',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
Out of Stock
|
||||
</Box>
|
||||
)}
|
||||
{flavor.image_url && (
|
||||
<Box
|
||||
component="img"
|
||||
src={flavor.image_url}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 200,
|
||||
objectFit: 'contain',
|
||||
p: 2,
|
||||
bgcolor: 'rgba(255,255,255,0.05)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6">{flavor.name}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{flavor.description}
|
||||
</Typography>
|
||||
{flavor.shop_url && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
component="a"
|
||||
href={flavor.shop_url}
|
||||
target="_blank"
|
||||
sx={{ mb: 2, fontSize: '0.7rem' }}
|
||||
</Box>
|
||||
)}
|
||||
{flavor.image_url && (
|
||||
<Box
|
||||
component="img"
|
||||
src={flavor.image_url}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 200,
|
||||
objectFit: 'contain',
|
||||
p: 2,
|
||||
bgcolor: 'rgba(255,255,255,0.05)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6">{flavor.name}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{flavor.description}
|
||||
</Typography>
|
||||
{flavor.shop_url && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
component="a"
|
||||
href={flavor.shop_url}
|
||||
target="_blank"
|
||||
sx={{ mb: 2, fontSize: '0.7rem' }}
|
||||
>
|
||||
View in Shop
|
||||
View in Shop
|
||||
</Button>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ mr: 1 }}>Avg:</Typography>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="small" />
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ mr: 1 }}>
|
||||
Avg:
|
||||
</Typography>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="small" />
|
||||
</Box>
|
||||
|
||||
{flavor.ratings && flavor.ratings.length > 0 && (
|
||||
{flavor.ratings && flavor.ratings.length > 0 && (
|
||||
<Accordion variant="outlined" sx={{ mt: 1, mb: 1 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="caption">Comments ({flavor.ratings.filter(r => r.comment).length})</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ maxHeight: 200, overflow: 'auto', p: 1 }}>
|
||||
{flavor.ratings.filter(r => r.comment).map(r => (
|
||||
<Box key={r.id} sx={{ mb: 1, borderBottom: '1px solid rgba(0,0,0,0.1)', pb: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block' }}>{r.user} ({r.score}/10):</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>{r.comment}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</AccordionDetails>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="caption">
|
||||
Comments ({flavor.ratings.filter((r) => r.comment).length})
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ maxHeight: 200, overflow: 'auto', p: 1 }}>
|
||||
{flavor.ratings
|
||||
.filter((r) => r.comment)
|
||||
.map((r) => (
|
||||
<Box
|
||||
key={r.id}
|
||||
sx={{ mb: 1, borderBottom: '1px solid rgba(0,0,0,0.1)', pb: 0.5 }}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 'bold', display: 'block' }}
|
||||
>
|
||||
{r.user} ({r.score}/10):
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
|
||||
{r.comment}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
)}
|
||||
|
||||
{flavor.user_rating ? (
|
||||
<Typography variant="body2" color="secondary">Your Rating: {flavor.user_rating}/10</Typography>
|
||||
) : (
|
||||
<Accordion variant="outlined" sx={{ mt: 2 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="body2">Rate this flavor</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<MuiRating
|
||||
max={10}
|
||||
value={ratingInput[flavor.id]?.score || 0}
|
||||
onChange={(_: any, val: number | null) => setRatingInput({...ratingInput, [flavor.id]: {...(ratingInput[flavor.id] || {comment: ''}), score: val || 0}})}
|
||||
/>
|
||||
<TextField
|
||||
label="Comment (Optional)"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
multiline
|
||||
value={ratingInput[flavor.id]?.comment || ''}
|
||||
onChange={(e) => setRatingInput({...ratingInput, [flavor.id]: {...(ratingInput[flavor.id] || {score: 0}), comment: e.target.value}})}
|
||||
/>
|
||||
<Button variant="contained" size="small" onClick={() => handleRate(flavor.id)}>Submit</Button>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
))}
|
||||
{flavor.user_rating ? (
|
||||
<Typography variant="body2" color="secondary">
|
||||
Your Rating: {flavor.user_rating}/10
|
||||
</Typography>
|
||||
) : (
|
||||
<Accordion variant="outlined" sx={{ mt: 2 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="body2">Rate this flavor</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<MuiRating
|
||||
max={10}
|
||||
value={ratingInput[flavor.id]?.score || 0}
|
||||
onChange={(_: any, val: number | null) =>
|
||||
setRatingInput({
|
||||
...ratingInput,
|
||||
[flavor.id]: {
|
||||
...(ratingInput[flavor.id] || { comment: '' }),
|
||||
score: val || 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="Comment (Optional)"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
multiline
|
||||
value={ratingInput[flavor.id]?.comment || ''}
|
||||
onChange={(e) =>
|
||||
setRatingInput({
|
||||
...ratingInput,
|
||||
[flavor.id]: {
|
||||
...(ratingInput[flavor.id] || { score: 0 }),
|
||||
comment: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => handleRate(flavor.id)}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
+252
-205
@@ -2,12 +2,11 @@ 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 api, { setTokens } from '../lib/api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LoginProps {
|
||||
}
|
||||
interface LoginProps {}
|
||||
|
||||
const Login: React.FC<LoginProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -21,253 +20,301 @@ const Login: React.FC<LoginProps> = () => {
|
||||
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('auth/token/', {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
localStorage.setItem('token', res.data.token);
|
||||
setTokens(res.data.access, res.data.refresh);
|
||||
window.location.href = '/';
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.non_field_errors?.[0] || t('auth.loginFailed');
|
||||
if (errorMsg.toLowerCase().includes('inactive') || errorMsg.toLowerCase().includes('verify') || errorMsg.includes('disabled')) {
|
||||
setMessage({ type: 'error', text: t('auth.unverifiedError') });
|
||||
setIsVerifying(true);
|
||||
if (
|
||||
errorMsg.toLowerCase().includes('inactive') ||
|
||||
errorMsg.toLowerCase().includes('verify') ||
|
||||
errorMsg.includes('disabled')
|
||||
) {
|
||||
setMessage({ type: 'error', text: t('auth.unverifiedError') });
|
||||
setIsVerifying(true);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: errorMsg });
|
||||
setMessage({ type: 'error', text: errorMsg });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/signup/', { username, email, password });
|
||||
setIsVerifying(true);
|
||||
setMessage({ type: 'success', text: t('auth.signupSuccess') });
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Signup failed' });
|
||||
}
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/signup/', { username, email, password });
|
||||
setIsVerifying(true);
|
||||
setMessage({ type: 'success', text: t('auth.signupSuccess') });
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Signup failed' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/verify_signup/', { username, code: verificationCode });
|
||||
setMessage({ type: 'success', text: t('auth.verifySuccess') });
|
||||
setIsVerifying(false);
|
||||
setTab(0);
|
||||
setPassword('');
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Verification failed' });
|
||||
setShowResend(true);
|
||||
}
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/verify_signup/', { username, code: verificationCode });
|
||||
setMessage({ type: 'success', text: t('auth.verifySuccess') });
|
||||
setIsVerifying(false);
|
||||
setTab(0);
|
||||
setPassword('');
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Verification failed' });
|
||||
setShowResend(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendCode = async () => {
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/resend_verification/', { username });
|
||||
setMessage({ type: 'success', text: t('auth.resendSuccess') });
|
||||
setShowResend(false);
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to resend code' });
|
||||
}
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/resend_verification/', { username });
|
||||
setMessage({ type: 'success', text: t('auth.resendSuccess') });
|
||||
setShowResend(false);
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to resend code' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/request_password_reset/', { email });
|
||||
setResetStep(2);
|
||||
setMessage({ type: 'success', text: 'If an account exists, a reset code has been sent.' });
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: 'An error occurred. Please try again later.' });
|
||||
}
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/request_password_reset/', { email });
|
||||
setResetStep(2);
|
||||
setMessage({ type: 'success', text: 'If an account exists, a reset code has been sent.' });
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: 'An error occurred. Please try again later.' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/complete_password_reset/', { email, code: verificationCode, password });
|
||||
setMessage({ type: 'success', text: 'Password reset successful! You can now login.' });
|
||||
setIsResetting(false);
|
||||
setResetStep(1);
|
||||
setTab(0);
|
||||
setPassword('');
|
||||
setVerificationCode('');
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Reset failed' });
|
||||
}
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/complete_password_reset/', { email, code: verificationCode, password });
|
||||
setMessage({ type: 'success', text: 'Password reset successful! You can now login.' });
|
||||
setIsResetting(false);
|
||||
setResetStep(1);
|
||||
setTab(0);
|
||||
setPassword('');
|
||||
setVerificationCode('');
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Reset failed' });
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (isVerifying) {
|
||||
return (
|
||||
<form onSubmit={handleVerify}>
|
||||
<Typography variant="h6" gutterBottom>{t('auth.verifyTitle')}</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }} dangerouslySetInnerHTML={{ __html: t('auth.verifyDesc', { username }) }} />
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Verification Code"
|
||||
margin="normal"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
Verify
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant={showResend ? "contained" : "outlined"}
|
||||
color={showResend ? "secondary" : "primary"}
|
||||
onClick={handleResendCode}
|
||||
sx={{ mt: 2, textTransform: 'none' }}
|
||||
>
|
||||
{t('auth.resendButton')}
|
||||
</Button>
|
||||
|
||||
<Button fullWidth onClick={() => { setIsVerifying(false); setMessage(null); }} sx={{ mt: 1 }}>
|
||||
{t('auth.backToAuth')}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
if (isResetting) {
|
||||
return (
|
||||
<form onSubmit={resetStep === 1 ? handleRequestReset : handleCompleteReset}>
|
||||
<Typography variant="h6" gutterBottom>Reset Password</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
margin="normal"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={resetStep === 2}
|
||||
/>
|
||||
{resetStep === 2 && (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Reset Code"
|
||||
margin="normal"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="New Password"
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
{resetStep === 1 ? 'Send Reset Code' : 'Reset Password'}
|
||||
</Button>
|
||||
<Button fullWidth onClick={() => { setIsResetting(false); setResetStep(1); setMessage(null); }} sx={{ mt: 1 }}>
|
||||
Back to Login
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
if (isVerifying) {
|
||||
return (
|
||||
<>
|
||||
<Tabs value={tab} onChange={(_, v) => { setTab(v); setMessage(null); }} sx={{ mb: 2 }}>
|
||||
<Tab label={t('auth.loginTitle')} />
|
||||
<Tab label={t('auth.signupTitle')} />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 ? (
|
||||
<form onSubmit={handleLogin}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
{t('auth.loginTitle')}
|
||||
</Button>
|
||||
<Button fullWidth onClick={() => { setIsResetting(true); setMessage(null); }} sx={{ mt: 1, textTransform: 'none' }} size="small">
|
||||
{t('auth.forgotPassword')}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleSignup}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
type="email"
|
||||
margin="normal"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
{t('auth.signupTitle')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
<form onSubmit={handleVerify}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('auth.verifyTitle')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ mb: 2 }}
|
||||
dangerouslySetInnerHTML={{ __html: t('auth.verifyDesc', { username }) }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Verification Code"
|
||||
margin="normal"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
Verify
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant={showResend ? 'contained' : 'outlined'}
|
||||
color={showResend ? 'secondary' : 'primary'}
|
||||
onClick={handleResendCode}
|
||||
sx={{ mt: 2, textTransform: 'none' }}
|
||||
>
|
||||
{t('auth.resendButton')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
setIsVerifying(false);
|
||||
setMessage(null);
|
||||
}}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
{t('auth.backToAuth')}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
if (isResetting) {
|
||||
return (
|
||||
<form onSubmit={resetStep === 1 ? handleRequestReset : handleCompleteReset}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Reset Password
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
margin="normal"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={resetStep === 2}
|
||||
/>
|
||||
{resetStep === 2 && (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Reset Code"
|
||||
margin="normal"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="New Password"
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
{resetStep === 1 ? 'Send Reset Code' : 'Reset Password'}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
setIsResetting(false);
|
||||
setResetStep(1);
|
||||
setMessage(null);
|
||||
}}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Back to Login
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_, v) => {
|
||||
setTab(v);
|
||||
setMessage(null);
|
||||
}}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
<Tab label={t('auth.loginTitle')} />
|
||||
<Tab label={t('auth.signupTitle')} />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 ? (
|
||||
<form onSubmit={handleLogin}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
{t('auth.loginTitle')}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
setIsResetting(true);
|
||||
setMessage(null);
|
||||
}}
|
||||
sx={{ mt: 1, textTransform: 'none' }}
|
||||
size="small"
|
||||
>
|
||||
{t('auth.forgotPassword')}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleSignup}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
type="email"
|
||||
margin="normal"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
{t('auth.signupTitle')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xs" sx={{ py: 8 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component={Link}
|
||||
to="/"
|
||||
<Button
|
||||
variant="outlined"
|
||||
component={Link}
|
||||
to="/"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ mb: 4, textTransform: 'none', borderRadius: 2 }}
|
||||
>
|
||||
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>
|
||||
|
||||
+918
-518
File diff suppressed because it is too large.
Load diff
+142
-72
@@ -1,5 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Typography, Box, Container, Paper, Button, ToggleButton, ToggleButtonGroup, useMediaQuery } from '@mui/material';
|
||||
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';
|
||||
@@ -12,94 +21,155 @@ const Privacy: React.FC = () => {
|
||||
const isMobile = useMediaQuery('(max-width:600px)');
|
||||
|
||||
const handleLangChange = (_: any, newLang: string | null) => {
|
||||
if (newLang) {
|
||||
i18n.changeLanguage(newLang);
|
||||
}
|
||||
if (newLang) {
|
||||
i18n.changeLanguage(newLang);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4, flexWrap: 'wrap', gap: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component={Link}
|
||||
to="/"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ textTransform: 'none', borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 4,
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component={Link}
|
||||
to="/"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ textTransform: 'none', borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
|
||||
<ToggleButtonGroup
|
||||
value={lang}
|
||||
exclusive
|
||||
onChange={handleLangChange}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="de">Deutsch</ToggleButton>
|
||||
<ToggleButton value="en">English</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<ToggleButtonGroup value={lang} exclusive onChange={handleLangChange} size="small">
|
||||
<ToggleButton value="de">Deutsch</ToggleButton>
|
||||
<ToggleButton value="en">English</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: { xs: 2.5, sm: 4, md: 6 }, borderRadius: 4, overflow: 'hidden' }}>
|
||||
<Typography variant={isMobile ? "h4" : "h3"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('privacy.title')}</Typography>
|
||||
<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
+364
-279
@@ -1,10 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
Paper,
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
Paper,
|
||||
Alert,
|
||||
Container,
|
||||
Avatar,
|
||||
@@ -14,18 +14,18 @@ import {
|
||||
MenuItem,
|
||||
ListSubheader,
|
||||
Divider,
|
||||
Grid
|
||||
Grid,
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import api from '../lib/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import type { CatppuccinTheme } from '../theme';
|
||||
|
||||
interface SettingsProps {
|
||||
themeName: CatppuccinTheme;
|
||||
onThemeChange: (newTheme: CatppuccinTheme) => void;
|
||||
themeName: CatppuccinTheme;
|
||||
onThemeChange: (newTheme: CatppuccinTheme) => void;
|
||||
}
|
||||
|
||||
const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
@@ -43,38 +43,40 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [deletionCode, setDeletionCode] = useState('');
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [banners, setBanners] = useState<any[]>([]);
|
||||
const [selectedBannerId, setSelectedBannerId] = useState<number | string>("");
|
||||
const [selectedBannerId, setSelectedBannerId] = useState<number | string>('');
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const [userRes, bannersRes] = await Promise.all([
|
||||
api.get('users/me/'),
|
||||
api.get('banners/')
|
||||
api.get('users/me/'),
|
||||
api.get('banners/'),
|
||||
]);
|
||||
|
||||
|
||||
setUsername(userRes.data.username);
|
||||
setEmail(userRes.data.email);
|
||||
setCurrentEmail(userRes.data.email);
|
||||
setAvatar(userRes.data.avatar);
|
||||
setSelectedBannerId(userRes.data.selected_banner || "");
|
||||
|
||||
setSelectedBannerId(userRes.data.selected_banner || '');
|
||||
|
||||
if (userRes.data.language) {
|
||||
setLanguage(userRes.data.language);
|
||||
setLanguage(userRes.data.language);
|
||||
}
|
||||
|
||||
// Handle both paginated and non-paginated
|
||||
const bannersData = Array.isArray(bannersRes.data) ? bannersRes.data : (bannersRes.data.results || []);
|
||||
const bannersData = Array.isArray(bannersRes.data)
|
||||
? bannersRes.data
|
||||
: bannersRes.data.results || [];
|
||||
setBanners(bannersData);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -84,36 +86,36 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
}, []);
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
setMessage({ type: 'error', text: 'File size exceeds 2MB limit' });
|
||||
return;
|
||||
}
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
setMessage({ type: 'error', text: 'File size exceeds 2MB limit' });
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
|
||||
try {
|
||||
const res = await api.post('users/update_avatar/', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
setAvatar(res.data.avatar);
|
||||
setMessage({ type: 'success', text: t('settings.avatarSuccess') });
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to upload avatar' });
|
||||
}
|
||||
try {
|
||||
const res = await api.post('users/update_avatar/', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
setAvatar(res.data.avatar);
|
||||
setMessage({ type: 'success', text: t('settings.avatarSuccess') });
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to upload avatar' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageChange = async (newLang: string) => {
|
||||
setLanguage(newLang);
|
||||
i18n.changeLanguage(newLang);
|
||||
try {
|
||||
await api.patch('users/update_preferences/', { language: newLang });
|
||||
} catch (err) {
|
||||
console.error('Failed to update language on server');
|
||||
}
|
||||
setLanguage(newLang);
|
||||
i18n.changeLanguage(newLang);
|
||||
try {
|
||||
await api.patch('users/update_preferences/', { language: newLang });
|
||||
} catch (err) {
|
||||
console.error('Failed to update language on server');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateProfile = async (e: React.FormEvent) => {
|
||||
@@ -148,7 +150,10 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.post('users/change_password/', { old_password: oldPassword, new_password: newPassword });
|
||||
await api.post('users/change_password/', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
setMessage({ type: 'success', text: t('settings.passwordSuccess') });
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
@@ -158,60 +163,68 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
};
|
||||
|
||||
const handleRequestDeletion = async () => {
|
||||
try {
|
||||
await api.post('users/request_account_deletion/');
|
||||
setIsDeleting(true);
|
||||
setMessage({ type: 'success', text: t('settings.codeSent') });
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: 'Failed to request deletion.' });
|
||||
}
|
||||
try {
|
||||
await api.post('users/request_account_deletion/');
|
||||
setIsDeleting(true);
|
||||
setMessage({ type: 'success', text: t('settings.codeSent') });
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: 'Failed to request deletion.' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDeletion = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!window.confirm('Are you absolutely sure? This cannot be undone.')) return;
|
||||
try {
|
||||
await api.post('users/confirm_account_deletion/', { code: deletionCode });
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/';
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: 'Invalid code. Deletion failed.' });
|
||||
}
|
||||
e.preventDefault();
|
||||
if (!window.confirm('Are you absolutely sure? This cannot be undone.')) return;
|
||||
try {
|
||||
await api.post('users/confirm_account_deletion/', { code: deletionCode });
|
||||
localStorage.removeItem('access');
|
||||
localStorage.removeItem('refresh');
|
||||
window.location.href = '/';
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: 'Invalid code. Deletion failed.' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{
|
||||
mb: 4,
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
color: 'primary.main',
|
||||
bgcolor: 'transparent'
|
||||
}
|
||||
sx={{
|
||||
mb: 4,
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
color: 'primary.main',
|
||||
bgcolor: 'transparent',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
</Button>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 6 }}>
|
||||
<Avatar src={avatar || undefined} sx={{ width: 80, height: 80, border: '3px solid', borderColor: 'primary.main' }}>
|
||||
{!avatar && username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>{username}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t('settings.subtitle')}</Typography>
|
||||
</Box>
|
||||
<Avatar
|
||||
src={avatar || undefined}
|
||||
sx={{ width: 80, height: 80, border: '3px solid', borderColor: 'primary.main' }}
|
||||
>
|
||||
{!avatar && username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
|
||||
{username}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('settings.subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
{message && (
|
||||
<Alert severity={message.type} sx={{ mb: 4 }} onClose={() => setMessage(null)}>
|
||||
{message.text}
|
||||
@@ -219,205 +232,277 @@ 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>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('settings.themeLabel')}</InputLabel>
|
||||
<Select
|
||||
value={themeName}
|
||||
label={t('settings.themeLabel')}
|
||||
onChange={(e) => onThemeChange(e.target.value as CatppuccinTheme)}
|
||||
>
|
||||
<ListSubheader>Holy Archive</ListSubheader>
|
||||
<MenuItem value="holy_light">Holy Light</MenuItem>
|
||||
<MenuItem value="holy_dark">Holy Dark</MenuItem>
|
||||
{/* Left Column: Visuals & Language */}
|
||||
<Grid size={{ xs: 12, lg: 5 }}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
borderRadius: 3,
|
||||
'& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 },
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('settings.appearanceTitle')}
|
||||
</Typography>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('settings.themeLabel')}</InputLabel>
|
||||
<Select
|
||||
value={themeName}
|
||||
label={t('settings.themeLabel')}
|
||||
onChange={(e) => onThemeChange(e.target.value as CatppuccinTheme)}
|
||||
>
|
||||
<ListSubheader>Holy Archive</ListSubheader>
|
||||
<MenuItem value="holy_light">Holy Light</MenuItem>
|
||||
<MenuItem value="holy_dark">Holy Dark</MenuItem>
|
||||
|
||||
<ListSubheader>Light Themes</ListSubheader>
|
||||
<MenuItem value="latte">Catppuccin Latte</MenuItem>
|
||||
<MenuItem value="pink_pastel">Pink Pastel</MenuItem>
|
||||
<MenuItem value="mint_pastel">Mint Pastel</MenuItem>
|
||||
<MenuItem value="lavender_pastel">Lavender Pastel</MenuItem>
|
||||
<MenuItem value="t0p_sai">Scaled and Icy (T0P)</MenuItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ListSubheader>Dark Themes</ListSubheader>
|
||||
<MenuItem value="mocha">Catppuccin Mocha</MenuItem>
|
||||
<MenuItem value="frappe">Catppuccin Frappé</MenuItem>
|
||||
<MenuItem value="macchiato">Catppuccin Macchiato</MenuItem>
|
||||
<MenuItem value="dracula">Dracula</MenuItem>
|
||||
<MenuItem value="nord">Nordic Frost</MenuItem>
|
||||
<MenuItem value="gruvbox">Gruvbox Retro</MenuItem>
|
||||
<MenuItem value="oceanic">Oceanic Deep</MenuItem>
|
||||
<MenuItem value="t0p_trench">Trench (T0P)</MenuItem>
|
||||
<MenuItem value="t0p_blurryface">Blurryface (T0P)</MenuItem>
|
||||
<MenuItem value="t0p_clancy">Clancy (T0P)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<ListSubheader>Light Themes</ListSubheader>
|
||||
<MenuItem value="latte">Catppuccin Latte</MenuItem>
|
||||
<MenuItem value="pink_pastel">Pink Pastel</MenuItem>
|
||||
<MenuItem value="mint_pastel">Mint Pastel</MenuItem>
|
||||
<MenuItem value="lavender_pastel">Lavender Pastel</MenuItem>
|
||||
<MenuItem value="t0p_sai">Scaled and Icy (T0P)</MenuItem>
|
||||
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('settings.bannerLabel')}</InputLabel>
|
||||
<Select
|
||||
value={selectedBannerId}
|
||||
label={t('settings.bannerLabel')}
|
||||
onChange={async (e) => {
|
||||
const newId = e.target.value;
|
||||
setSelectedBannerId(newId);
|
||||
try {
|
||||
await api.patch('users/update_preferences/', { selected_banner: newId });
|
||||
} catch (err) {
|
||||
console.error('Failed to update banner preference');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">{t('settings.bannerDefault')}</MenuItem>
|
||||
{banners.map(b => (
|
||||
<MenuItem key={b.id} value={b.id}>{b.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Divider />
|
||||
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('settings.langLabel')}</InputLabel>
|
||||
<Select
|
||||
value={language}
|
||||
label={t('settings.langLabel')}
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="de">Deutsch</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Paper>
|
||||
<ListSubheader>Dark Themes</ListSubheader>
|
||||
<MenuItem value="mocha">Catppuccin Mocha</MenuItem>
|
||||
<MenuItem value="frappe">Catppuccin Frappé</MenuItem>
|
||||
<MenuItem value="macchiato">Catppuccin Macchiato</MenuItem>
|
||||
<MenuItem value="dracula">Dracula</MenuItem>
|
||||
<MenuItem value="nord">Nordic Frost</MenuItem>
|
||||
<MenuItem value="gruvbox">Gruvbox Retro</MenuItem>
|
||||
<MenuItem value="oceanic">Oceanic Deep</MenuItem>
|
||||
<MenuItem value="t0p_trench">Trench (T0P)</MenuItem>
|
||||
<MenuItem value="t0p_blurryface">Blurryface (T0P)</MenuItem>
|
||||
<MenuItem value="t0p_clancy">Clancy (T0P)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Paper sx={{ p: 3, borderRadius: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('settings.avatarTitle')}</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, my: 2 }}>
|
||||
<Avatar src={avatar || undefined} sx={{ width: 100, height: 100 }}>
|
||||
{!avatar && username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Button variant="outlined" component="label" size="small">
|
||||
{t('settings.avatarButton')}
|
||||
<input type="file" hidden accept="image/*" onChange={handleAvatarUpload} />
|
||||
</Button>
|
||||
<Typography variant="caption" display="block" sx={{ mt: 1 }} color="text.secondary">
|
||||
{t('settings.avatarHint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('settings.bannerLabel')}</InputLabel>
|
||||
<Select
|
||||
value={selectedBannerId}
|
||||
label={t('settings.bannerLabel')}
|
||||
onChange={async (e) => {
|
||||
const newId = e.target.value;
|
||||
setSelectedBannerId(newId);
|
||||
try {
|
||||
await api.patch('users/update_preferences/', { selected_banner: newId });
|
||||
} catch (err) {
|
||||
console.error('Failed to update banner preference');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">{t('settings.bannerDefault')}</MenuItem>
|
||||
{banners.map((b) => (
|
||||
<MenuItem key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Right Column: Account Info & Security */}
|
||||
<Grid size={{ xs: 12, lg: 7 }}>
|
||||
<Paper sx={{ p: 3, mb: 4, borderRadius: 3, '& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 } }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('settings.infoTitle')}</Typography>
|
||||
<form onSubmit={handleUpdateProfile}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.usernameLabel')}
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.emailLabel')}
|
||||
margin="normal"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
helperText={email !== currentEmail ? t('settings.emailHint') : ""}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Button variant="contained" type="submit" sx={{ mt: 2, borderRadius: 2 }}>
|
||||
{t('settings.updateButton')}
|
||||
</Button>
|
||||
</form>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('settings.langLabel')}</InputLabel>
|
||||
<Select
|
||||
value={language}
|
||||
label={t('settings.langLabel')}
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="de">Deutsch</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Paper>
|
||||
|
||||
{pendingConfirmation && (
|
||||
<Box sx={{ mt: 4, pt: 3, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="h6" color="primary" gutterBottom>{t('settings.confirmEmailTitle')}</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>{t('settings.confirmEmailHint', { email })}</Typography>
|
||||
<form onSubmit={handleConfirmEmail}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.deletionCodeLabel')}
|
||||
margin="normal"
|
||||
value={confirmationCode}
|
||||
onChange={(e) => setConfirmationCode(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Button variant="contained" type="submit" sx={{ mt: 1, borderRadius: 2 }} color="secondary">
|
||||
{t('settings.confirmButton')}
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 4, borderRadius: 3, '& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 } }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('settings.passwordTitle')}</Typography>
|
||||
<form onSubmit={handleChangePassword}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.oldPasswordLabel')}
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.newPasswordLabel')}
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Button variant="contained" type="submit" sx={{ mt: 2, borderRadius: 2 }} color="warning">
|
||||
{t('settings.passwordButton')}
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, borderRadius: 3, border: '1px solid', borderColor: 'error.main', bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255,0,0,0.05)' : 'rgba(255,0,0,0.02)', '& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 } }}>
|
||||
<Typography variant="h6" gutterBottom color="error" sx={{ fontWeight: 'bold' }}>{t('settings.dangerTitle')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{t('settings.dangerDesc')}
|
||||
<Paper sx={{ p: 3, borderRadius: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('settings.avatarTitle')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, my: 2 }}>
|
||||
<Avatar src={avatar || undefined} sx={{ width: 100, height: 100 }}>
|
||||
{!avatar && username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Button variant="outlined" component="label" size="small">
|
||||
{t('settings.avatarButton')}
|
||||
<input type="file" hidden accept="image/*" onChange={handleAvatarUpload} />
|
||||
</Button>
|
||||
<Typography variant="caption" display="block" sx={{ mt: 1 }} color="text.secondary">
|
||||
{t('settings.avatarHint')}
|
||||
</Typography>
|
||||
|
||||
{!isDeleting ? (
|
||||
<Button variant="outlined" color="error" onClick={handleRequestDeletion} sx={{ borderRadius: 2 }}>
|
||||
{t('settings.deleteButton')}
|
||||
</Button>
|
||||
) : (
|
||||
<form onSubmit={handleConfirmDeletion}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>{t('settings.deletionCodeHint')}</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('settings.deletionCodeLabel')}
|
||||
value={deletionCode}
|
||||
onChange={(e) => setDeletionCode(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button variant="contained" color="error" type="submit" disabled={!deletionCode} sx={{ borderRadius: 2 }}>
|
||||
{t('settings.confirmDeleteButton')}
|
||||
</Button>
|
||||
<Button onClick={() => setIsDeleting(false)} sx={{ borderRadius: 2 }}>{t('settings.cancelButton')}</Button>
|
||||
</Box>
|
||||
</form>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Right Column: Account Info & Security */}
|
||||
<Grid size={{ xs: 12, lg: 7 }}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
borderRadius: 3,
|
||||
'& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 },
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('settings.infoTitle')}
|
||||
</Typography>
|
||||
<form onSubmit={handleUpdateProfile}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.usernameLabel')}
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.emailLabel')}
|
||||
margin="normal"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
helperText={email !== currentEmail ? t('settings.emailHint') : ''}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Button variant="contained" type="submit" sx={{ mt: 2, borderRadius: 2 }}>
|
||||
{t('settings.updateButton')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{pendingConfirmation && (
|
||||
<Box sx={{ mt: 4, pt: 3, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="h6" color="primary" gutterBottom>
|
||||
{t('settings.confirmEmailTitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
{t('settings.confirmEmailHint', { email })}
|
||||
</Typography>
|
||||
<form onSubmit={handleConfirmEmail}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.deletionCodeLabel')}
|
||||
margin="normal"
|
||||
value={confirmationCode}
|
||||
onChange={(e) => setConfirmationCode(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
sx={{ mt: 1, borderRadius: 2 }}
|
||||
color="secondary"
|
||||
>
|
||||
{t('settings.confirmButton')}
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
borderRadius: 3,
|
||||
'& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 },
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('settings.passwordTitle')}
|
||||
</Typography>
|
||||
<form onSubmit={handleChangePassword}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.oldPasswordLabel')}
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.newPasswordLabel')}
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
sx={{ mt: 2, borderRadius: 2 }}
|
||||
color="warning"
|
||||
>
|
||||
{t('settings.passwordButton')}
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: 'error.main',
|
||||
bgcolor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'rgba(255,0,0,0.05)' : 'rgba(255,0,0,0.02)',
|
||||
'& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 },
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom color="error" sx={{ fontWeight: 'bold' }}>
|
||||
{t('settings.dangerTitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{t('settings.dangerDesc')}
|
||||
</Typography>
|
||||
|
||||
{!isDeleting ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={handleRequestDeletion}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{t('settings.deleteButton')}
|
||||
</Button>
|
||||
) : (
|
||||
<form onSubmit={handleConfirmDeletion}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
|
||||
{t('settings.deletionCodeHint')}
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('settings.deletionCodeLabel')}
|
||||
value={deletionCode}
|
||||
onChange={(e) => setDeletionCode(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
type="submit"
|
||||
disabled={!deletionCode}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{t('settings.confirmDeleteButton')}
|
||||
</Button>
|
||||
<Button onClick={() => setIsDeleting(false)} sx={{ borderRadius: 2 }}>
|
||||
{t('settings.cancelButton')}
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
|
||||
+464
-313
@@ -1,8 +1,22 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container, Typography, Box, Card, CardContent, Button,
|
||||
TextField, Stack, Chip, Divider, Collapse, Paper, alpha, CircularProgress,
|
||||
IconButton, Avatar, useTheme
|
||||
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';
|
||||
@@ -10,337 +24,474 @@ 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 api from '../lib/api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { formatDate } from '../utils/date';
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
username: string;
|
||||
text: string;
|
||||
created_at: string;
|
||||
is_admin: boolean;
|
||||
id: number;
|
||||
username: string;
|
||||
text: string;
|
||||
created_at: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
interface Ticket {
|
||||
id: number;
|
||||
user: number;
|
||||
username: string;
|
||||
user_email: string;
|
||||
user_avatar: string | null;
|
||||
subject: string;
|
||||
description: string;
|
||||
status: 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||
created_at: string;
|
||||
messages: Message[];
|
||||
id: number;
|
||||
user: number;
|
||||
username: string;
|
||||
user_email: string;
|
||||
user_avatar: string | null;
|
||||
subject: string;
|
||||
description: string;
|
||||
status: 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||
created_at: string;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
const Support: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
useTitle(t('support.title'));
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
// Create form
|
||||
const [subject, setSubject] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
// Reply state
|
||||
const [replyText, setReplyText] = useState<{[key: number]: string}>({});
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [currentUser, setCurrentUser] = useState<any>(null);
|
||||
const [unreadTicketIds, setUnreadTicketIds] = useState<Set<number>>(new Set());
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
useTitle(t('support.title'));
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const fetchTickets = async () => {
|
||||
try {
|
||||
const [ticketRes, userRes, notifRes] = await Promise.all([
|
||||
api.get('tickets/'),
|
||||
api.get('users/me/'),
|
||||
api.get('notifications/')
|
||||
]);
|
||||
setTickets(ticketRes.data.results || ticketRes.data);
|
||||
setCurrentUser(userRes.data);
|
||||
|
||||
const notifs = notifRes.data.results || notifRes.data;
|
||||
const unreadIds = new Set<number>(
|
||||
notifs
|
||||
.filter((n: any) => !n.is_read && n.notification_type.startsWith('ticket') && n.ticket)
|
||||
.map((n: any) => n.ticket)
|
||||
);
|
||||
setUnreadTicketIds(unreadIds);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
// Create form
|
||||
const [subject, setSubject] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, []);
|
||||
// Reply state
|
||||
const [replyText, setReplyText] = useState<{ [key: number]: string }>({});
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [currentUser, setCurrentUser] = useState<any>(null);
|
||||
const [unreadTicketIds, setUnreadTicketIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const handleExpand = async (ticketId: number) => {
|
||||
if (expandedId === ticketId) {
|
||||
setExpandedId(null);
|
||||
return;
|
||||
}
|
||||
setExpandedId(ticketId);
|
||||
|
||||
// Mark notifications for this ticket as read
|
||||
if (unreadTicketIds.has(ticketId)) {
|
||||
try {
|
||||
// We'd need an endpoint to mark all for a specific ticket,
|
||||
// or just rely on the user clicking the notification.
|
||||
// For now, let's just clear it from the local state.
|
||||
const newUnread = new Set(unreadTicketIds);
|
||||
newUnread.delete(ticketId);
|
||||
setUnreadTicketIds(newUnread);
|
||||
} catch (e) {}
|
||||
}
|
||||
};
|
||||
const fetchTickets = async () => {
|
||||
try {
|
||||
const [ticketRes, userRes, notifRes] = await Promise.all([
|
||||
api.get('tickets/'),
|
||||
api.get('users/me/'),
|
||||
api.get('notifications/'),
|
||||
]);
|
||||
setTickets(ticketRes.data.results || ticketRes.data);
|
||||
setCurrentUser(userRes.data);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.post('tickets/', { subject, description });
|
||||
setSubject('');
|
||||
setDescription('');
|
||||
setShowCreate(false);
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
alert('Failed to create ticket');
|
||||
}
|
||||
};
|
||||
const notifs = notifRes.data.results || notifRes.data;
|
||||
const unreadIds = new Set<number>(
|
||||
notifs
|
||||
.filter((n: any) => !n.is_read && n.notification_type.startsWith('ticket') && n.ticket)
|
||||
.map((n: any) => n.ticket),
|
||||
);
|
||||
setUnreadTicketIds(unreadIds);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = async (ticketId: number) => {
|
||||
const text = replyText[ticketId];
|
||||
if (!text) return;
|
||||
try {
|
||||
await api.post(`tickets/${ticketId}/add_message/`, { text });
|
||||
setReplyText({ ...replyText, [ticketId]: '' });
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
alert('Failed to send message');
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, []);
|
||||
|
||||
const handleUpdateStatus = async (ticketId: number, status: string) => {
|
||||
try {
|
||||
await api.post(`tickets/${ticketId}/update_status/`, { status });
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
alert('Failed to update status');
|
||||
}
|
||||
};
|
||||
const handleExpand = async (ticketId: number) => {
|
||||
if (expandedId === ticketId) {
|
||||
setExpandedId(null);
|
||||
return;
|
||||
}
|
||||
setExpandedId(ticketId);
|
||||
|
||||
const handleDeleteTicket = async (ticketId: number) => {
|
||||
if (!confirm('Delete this ticket?')) return;
|
||||
try {
|
||||
await api.delete(`tickets/${ticketId}/`);
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
alert('Failed to delete ticket');
|
||||
}
|
||||
};
|
||||
// Mark notifications for this ticket as read
|
||||
if (unreadTicketIds.has(ticketId)) {
|
||||
try {
|
||||
// We'd need an endpoint to mark all for a specific ticket,
|
||||
// or just rely on the user clicking the notification.
|
||||
// For now, let's just clear it from the local state.
|
||||
const newUnread = new Set(unreadTicketIds);
|
||||
newUnread.delete(ticketId);
|
||||
setUnreadTicketIds(newUnread);
|
||||
} catch (e) {}
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'open': return 'error';
|
||||
case 'in_progress': return 'warning';
|
||||
case 'resolved': return 'success';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.post('tickets/', { subject, description });
|
||||
setSubject('');
|
||||
setDescription('');
|
||||
setShowCreate(false);
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
alert('Failed to create ticket');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||
const handleReply = async (ticketId: number) => {
|
||||
const text = replyText[ticketId];
|
||||
if (!text) return;
|
||||
try {
|
||||
await api.post(`tickets/${ticketId}/add_message/`, { text });
|
||||
setReplyText({ ...replyText, [ticketId]: '' });
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
alert('Failed to send message');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStatus = async (ticketId: number, status: string) => {
|
||||
try {
|
||||
await api.post(`tickets/${ticketId}/update_status/`, { status });
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
alert('Failed to update status');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTicket = async (ticketId: number) => {
|
||||
if (!confirm('Delete this ticket?')) return;
|
||||
try {
|
||||
await api.delete(`tickets/${ticketId}/`);
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
alert('Failed to delete ticket');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return 'error';
|
||||
case 'in_progress':
|
||||
return 'warning';
|
||||
case 'resolved':
|
||||
return 'success';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 4 }}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 'bold' }}>{t('support.title')}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{t('support.createTicket')}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Collapse in={showCreate}>
|
||||
<Paper sx={{ p: 3, mb: 4, borderRadius: 3, border: '1px solid', borderColor: 'primary.main' }}>
|
||||
<form onSubmit={handleCreate}>
|
||||
<TextField
|
||||
fullWidth label={t('support.subject')} margin="normal"
|
||||
value={subject} onChange={(e) => setSubject(e.target.value)} required
|
||||
/>
|
||||
<TextField
|
||||
fullWidth multiline rows={4} label={t('support.description')} margin="normal"
|
||||
value={description} onChange={(e) => setDescription(e.target.value)} required
|
||||
/>
|
||||
<Button type="submit" variant="contained" sx={{ mt: 2, borderRadius: 2 }}>
|
||||
{t('support.createTicket')}
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
|
||||
<Typography variant="h5" sx={{ mb: 2, fontWeight: 'bold' }}>
|
||||
{currentUser?.is_superuser ? t('admin.users') + ' Tickets' : t('support.myTickets')}
|
||||
</Typography>
|
||||
|
||||
{tickets.length === 0 ? (
|
||||
<Box sx={{ py: 8, textAlign: 'center', bgcolor: 'action.hover', borderRadius: 4 }}>
|
||||
<Typography color="text.secondary">{t('support.noTickets')}</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{tickets.map((ticket: Ticket) => (
|
||||
<Card key={ticket.id} sx={{
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: unreadTicketIds.has(ticket.id) ? 'primary.main' : 'divider',
|
||||
bgcolor: unreadTicketIds.has(ticket.id) ? alpha(theme.palette.primary.main, 0.02) : 'inherit'
|
||||
}}>
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
<Box
|
||||
onClick={() => handleExpand(ticket.id)}
|
||||
sx={{ p: 2, cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
{unreadTicketIds.has(ticket.id) && (
|
||||
<Box sx={{ width: 10, height: 10, bgcolor: 'primary.main', borderRadius: '50%' }} />
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
{ticket.subject}
|
||||
{currentUser?.is_superuser && (
|
||||
<Typography component="span" variant="caption" sx={{ ml: 1, color: 'primary.main' }}>
|
||||
({ticket.username})
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{formatDate(ticket.created_at)}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
{unreadTicketIds.has(ticket.id) && (
|
||||
<Chip label={t('support.unread')} color="primary" size="small" sx={{ fontWeight: 'bold', height: 20, fontSize: '0.6rem' }} />
|
||||
)}
|
||||
<Chip label={t(`support.${ticket.status}`)} color={getStatusColor(ticket.status) as any} size="small" sx={{ fontWeight: 'bold' }} />
|
||||
{currentUser?.is_superuser && (
|
||||
<IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); handleDeleteTicket(ticket.id); }}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{currentUser?.is_superuser && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 1, p: 1, borderRadius: 2, bgcolor: alpha('#000', 0.03) }}>
|
||||
<Avatar src={ticket.user_avatar || undefined} sx={{ width: 24, height: 24, fontSize: '0.7rem' }}>
|
||||
{ticket.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{ticket.username} • <span style={{ opacity: 0.7 }}>{ticket.user_email}</span>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/profile/${ticket.username}`); }}
|
||||
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
|
||||
>
|
||||
View Profile
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/admin-panel/user/${ticket.user}`); }}
|
||||
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
|
||||
>
|
||||
{t('admin.manageUser')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Collapse in={expandedId === ticket.id}>
|
||||
<Divider />
|
||||
<Box sx={{ p: 2, bgcolor: (theme) => alpha(theme.palette.background.default, 0.5) }}>
|
||||
{currentUser?.is_superuser && (
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2, overflowX: 'auto', pb: 1 }}>
|
||||
{['open', 'in_progress', 'resolved', 'closed'].map((s) => (
|
||||
<Button
|
||||
key={s} size="small" variant={ticket.status === s ? "contained" : "outlined"}
|
||||
onClick={() => handleUpdateStatus(ticket.id, s)}
|
||||
color={getStatusColor(s) as any}
|
||||
sx={{ textTransform: 'none', borderRadius: 4, fontSize: '0.7rem', flexShrink: 0 }}
|
||||
>
|
||||
{t(`support.${s}`)}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" paragraph sx={{ whiteSpace: 'pre-wrap', mb: 3, bgcolor: 'background.paper', p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
{ticket.description}
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||
{ticket.messages.map((msg: Message) => (
|
||||
<Box key={msg.id} sx={{
|
||||
alignSelf: msg.is_admin ? 'flex-start' : 'flex-end',
|
||||
maxWidth: '85%',
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: msg.is_admin ? 'primary.main' : 'action.selected',
|
||||
color: msg.is_admin ? 'white' : 'inherit',
|
||||
boxShadow: msg.is_admin ? 2 : 0
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block' }}>
|
||||
{msg.username} {msg.is_admin && '(Support)'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ my: 0.5 }}>{msg.text}</Typography>
|
||||
<Typography variant="caption" sx={{ opacity: 0.7, display: 'block', textAlign: 'right' }}>{formatDate(msg.created_at)}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{ticket.status !== 'closed' && (
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextField
|
||||
fullWidth size="small" placeholder={t('community.writeReply')}
|
||||
value={replyText[ticket.id] || ''}
|
||||
onChange={(e) => setReplyText({ ...replyText, [ticket.id]: e.target.value })}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleReply(ticket.id)}
|
||||
/>
|
||||
<IconButton color="primary" onClick={() => handleReply(ticket.id)} disabled={!replyText[ticket.id]}>
|
||||
<SendIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Container>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 4 }}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 'bold' }}>
|
||||
{t('support.title')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{t('support.createTicket')}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Collapse in={showCreate}>
|
||||
<Paper
|
||||
sx={{ p: 3, mb: 4, borderRadius: 3, border: '1px solid', borderColor: 'primary.main' }}
|
||||
>
|
||||
<form onSubmit={handleCreate}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('support.subject')}
|
||||
margin="normal"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
label={t('support.description')}
|
||||
margin="normal"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="contained" sx={{ mt: 2, borderRadius: 2 }}>
|
||||
{t('support.createTicket')}
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
|
||||
<Typography variant="h5" sx={{ mb: 2, fontWeight: 'bold' }}>
|
||||
{currentUser?.is_superuser ? t('admin.users') + ' Tickets' : t('support.myTickets')}
|
||||
</Typography>
|
||||
|
||||
{tickets.length === 0 ? (
|
||||
<Box sx={{ py: 8, textAlign: 'center', bgcolor: 'action.hover', borderRadius: 4 }}>
|
||||
<Typography color="text.secondary">{t('support.noTickets')}</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{tickets.map((ticket: Ticket) => (
|
||||
<Card
|
||||
key={ticket.id}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: unreadTicketIds.has(ticket.id) ? 'primary.main' : 'divider',
|
||||
bgcolor: unreadTicketIds.has(ticket.id)
|
||||
? alpha(theme.palette.primary.main, 0.02)
|
||||
: 'inherit',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
<Box
|
||||
onClick={() => handleExpand(ticket.id)}
|
||||
sx={{ p: 2, cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
{unreadTicketIds.has(ticket.id) && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
bgcolor: 'primary.main',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
{ticket.subject}
|
||||
{currentUser?.is_superuser && (
|
||||
<Typography
|
||||
component="span"
|
||||
variant="caption"
|
||||
sx={{ ml: 1, color: 'primary.main' }}
|
||||
>
|
||||
({ticket.username})
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(ticket.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
{unreadTicketIds.has(ticket.id) && (
|
||||
<Chip
|
||||
label={t('support.unread')}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{ fontWeight: 'bold', height: 20, fontSize: '0.6rem' }}
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
label={t(`support.${ticket.status}`)}
|
||||
color={getStatusColor(ticket.status) as any}
|
||||
size="small"
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
/>
|
||||
{currentUser?.is_superuser && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteTicket(ticket.id);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{currentUser?.is_superuser && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
mt: 1,
|
||||
p: 1,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha('#000', 0.03),
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
src={ticket.user_avatar || undefined}
|
||||
sx={{ width: 24, height: 24, fontSize: '0.7rem' }}
|
||||
>
|
||||
{ticket.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{ticket.username} •{' '}
|
||||
<span style={{ opacity: 0.7 }}>{ticket.user_email}</span>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/profile/${ticket.username}`);
|
||||
}}
|
||||
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
|
||||
>
|
||||
View Profile
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/admin-panel/user/${ticket.user}`);
|
||||
}}
|
||||
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
|
||||
>
|
||||
{t('admin.manageUser')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Collapse in={expandedId === ticket.id}>
|
||||
<Divider />
|
||||
<Box
|
||||
sx={{ p: 2, bgcolor: (theme) => alpha(theme.palette.background.default, 0.5) }}
|
||||
>
|
||||
{currentUser?.is_superuser && (
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2, overflowX: 'auto', pb: 1 }}>
|
||||
{['open', 'in_progress', 'resolved', 'closed'].map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
size="small"
|
||||
variant={ticket.status === s ? 'contained' : 'outlined'}
|
||||
onClick={() => handleUpdateStatus(ticket.id, s)}
|
||||
color={getStatusColor(s) as any}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
borderRadius: 4,
|
||||
fontSize: '0.7rem',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{t(`support.${s}`)}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
paragraph
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
mb: 3,
|
||||
bgcolor: 'background.paper',
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{ticket.description}
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||
{ticket.messages.map((msg: Message) => (
|
||||
<Box
|
||||
key={msg.id}
|
||||
sx={{
|
||||
alignSelf: msg.is_admin ? 'flex-start' : 'flex-end',
|
||||
maxWidth: '85%',
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: msg.is_admin ? 'primary.main' : 'action.selected',
|
||||
color: msg.is_admin ? 'white' : 'inherit',
|
||||
boxShadow: msg.is_admin ? 2 : 0,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 'bold', display: 'block' }}
|
||||
>
|
||||
{msg.username} {msg.is_admin && '(Support)'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ my: 0.5 }}>
|
||||
{msg.text}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ opacity: 0.7, display: 'block', textAlign: 'right' }}
|
||||
>
|
||||
{formatDate(msg.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{ticket.status !== 'closed' && (
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder={t('community.writeReply')}
|
||||
value={replyText[ticket.id] || ''}
|
||||
onChange={(e) =>
|
||||
setReplyText({ ...replyText, [ticket.id]: e.target.value })
|
||||
}
|
||||
onKeyDown={(e) =>
|
||||
e.key === 'Enter' && !e.shiftKey && handleReply(ticket.id)
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => handleReply(ticket.id)}
|
||||
disabled={!replyText[ticket.id]}
|
||||
>
|
||||
<SendIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Support;
|
||||
@@ -4,96 +4,108 @@ import { Container, Paper, Typography, Box, CircularProgress, Button, Alert } fr
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import api from '../api';
|
||||
import api from '../lib/api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
|
||||
const VerifyEmail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
useTitle(t('auth.verifyTitle'));
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const query = new URLSearchParams(location.search);
|
||||
const username = query.get('username');
|
||||
const code = query.get('code');
|
||||
useTitle(t('auth.verifyTitle'));
|
||||
|
||||
if (!username || !code) {
|
||||
setStatus('error');
|
||||
setErrorMsg('Invalid verification link.');
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
const query = new URLSearchParams(location.search);
|
||||
const username = query.get('username');
|
||||
const code = query.get('code');
|
||||
|
||||
const verify = async () => {
|
||||
try {
|
||||
await api.post('users/verify_signup/', { username, code });
|
||||
setStatus('success');
|
||||
// Optional: Auto redirect after 3 seconds
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 3000);
|
||||
} catch (err: any) {
|
||||
setStatus('error');
|
||||
setErrorMsg(err.response?.data?.error || 'Verification failed.');
|
||||
}
|
||||
};
|
||||
if (!username || !code) {
|
||||
setStatus('error');
|
||||
setErrorMsg('Invalid verification link.');
|
||||
return;
|
||||
}
|
||||
|
||||
verify();
|
||||
}, [location, navigate]);
|
||||
const verify = async () => {
|
||||
try {
|
||||
await api.post('users/verify_signup/', { username, code });
|
||||
setStatus('success');
|
||||
// Optional: Auto redirect after 3 seconds
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 3000);
|
||||
} catch (err: any) {
|
||||
setStatus('error');
|
||||
setErrorMsg(err.response?.data?.error || 'Verification failed.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ py: 12 }}>
|
||||
<Paper sx={{ p: 6, textAlign: 'center', borderRadius: 4 }}>
|
||||
{status === 'loading' && (
|
||||
<Box>
|
||||
<CircularProgress size={60} sx={{ mb: 4 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
Verifying your account...
|
||||
</Typography>
|
||||
<Typography color="text.secondary" sx={{ mt: 1 }}>
|
||||
Please wait while we confirm your email.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
verify();
|
||||
}, [location, navigate]);
|
||||
|
||||
{status === 'success' && (
|
||||
<Box>
|
||||
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 80, mb: 2 }} />
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 2 }}>
|
||||
{t('auth.verifySuccess')}
|
||||
</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 4 }}>
|
||||
You will be redirected to the login page shortly.
|
||||
</Typography>
|
||||
<Button variant="contained" component={Link} to="/login" size="large" sx={{ borderRadius: 2 }}>
|
||||
Go to Login Now
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ py: 12 }}>
|
||||
<Paper sx={{ p: 6, textAlign: 'center', borderRadius: 4 }}>
|
||||
{status === 'loading' && (
|
||||
<Box>
|
||||
<CircularProgress size={60} sx={{ mb: 4 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
Verifying your account...
|
||||
</Typography>
|
||||
<Typography color="text.secondary" sx={{ mt: 1 }}>
|
||||
Please wait while we confirm your email.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<Box>
|
||||
<ErrorOutlineIcon color="error" sx={{ fontSize: 80, mb: 2 }} />
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 2 }}>
|
||||
Verification Failed
|
||||
</Typography>
|
||||
<Alert severity="error" sx={{ mb: 4, textAlign: 'left' }}>
|
||||
{errorMsg}
|
||||
</Alert>
|
||||
<Typography color="text.secondary" sx={{ mb: 4 }}>
|
||||
The link might be expired or already used.
|
||||
</Typography>
|
||||
<Button variant="outlined" component={Link} to="/login" size="large" sx={{ borderRadius: 2 }}>
|
||||
Back to Login / Signup
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
{status === 'success' && (
|
||||
<Box>
|
||||
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 80, mb: 2 }} />
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 2 }}>
|
||||
{t('auth.verifySuccess')}
|
||||
</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 4 }}>
|
||||
You will be redirected to the login page shortly.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
component={Link}
|
||||
to="/login"
|
||||
size="large"
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
Go to Login Now
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<Box>
|
||||
<ErrorOutlineIcon color="error" sx={{ fontSize: 80, mb: 2 }} />
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 2 }}>
|
||||
Verification Failed
|
||||
</Typography>
|
||||
<Alert severity="error" sx={{ mb: 4, textAlign: 'left' }}>
|
||||
{errorMsg}
|
||||
</Alert>
|
||||
<Typography color="text.secondary" sx={{ mb: 4 }}>
|
||||
The link might be expired or already used.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component={Link}
|
||||
to="/login"
|
||||
size="large"
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
Back to Login / Signup
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyEmail;
|
||||
@@ -1,27 +1,27 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Button
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import api from '../../api';
|
||||
import api from '../../lib/api';
|
||||
import { useTitle } from '../../hooks/useTitle';
|
||||
import RatingBadge from '../../components/RatingBadge';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Rating {
|
||||
id: number;
|
||||
user: string;
|
||||
score: number;
|
||||
comment: string;
|
||||
created_at: string;
|
||||
id: number;
|
||||
user: string;
|
||||
score: number;
|
||||
comment: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Flavor {
|
||||
@@ -47,11 +47,11 @@ const CategoryFlavors: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,7 +59,7 @@ 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);
|
||||
@@ -75,83 +75,119 @@ const CategoryFlavors: React.FC = () => {
|
||||
|
||||
useTitle(categoryName || 'Flavors');
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
|
||||
if (loading)
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth={false} sx={{ px: { xs: 2, sm: 4, md: 6 }, py: 4 }}>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>{categoryName || 'Flavors'}</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
color: 'primary.main',
|
||||
bgcolor: 'transparent'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
</Button>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{categoryName || 'Flavors'}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
color: 'primary.main',
|
||||
bgcolor: 'transparent',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
lg: 'repeat(auto-fill, minmax(240px, 1fr))'
|
||||
xs: '1fr',
|
||||
sm: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
lg: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
},
|
||||
gap: 3
|
||||
}}>
|
||||
{flavors.map(flavor => (
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
{flavors.map((flavor) => (
|
||||
<Box key={flavor.id}>
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', position: 'relative', transition: 'transform 0.2s', '&:hover': { transform: 'scale(1.02)' } }}>
|
||||
<Box component={Link} to={`/flavor/${flavor.id}`} sx={{ textDecoration: 'none', color: 'inherit', flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<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" />
|
||||
<StatusBadge
|
||||
isLegacy={flavor.is_legacy}
|
||||
isAvailable={flavor.is_available}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
{flavor.image_url && (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '1/1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'background.default',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={flavor.image_url}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
transition: 'transform 0.5s ease',
|
||||
'&:hover': { transform: 'scale(1.1)' }
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '1/1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'background.default',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={flavor.image_url}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
transition: 'transform 0.5s ease',
|
||||
'&:hover': { transform: 'scale(1.1)' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontSize: '1.1rem', mb: 1 }}>{flavor.name}</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="small" />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">{flavor.ratings.length} reviews</Typography>
|
||||
<Typography variant="h6" sx={{ fontSize: '1.1rem', mb: 1 }}>
|
||||
{flavor.name}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="small" />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{flavor.ratings.length} reviews
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,31 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { afterEach, beforeEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
// Node 25 ships an incomplete experimental localStorage that collides with
|
||||
// jsdom's. Replace it with a clean in-memory Storage shim before every test.
|
||||
function createStorageShim(): Storage {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear: () => store.clear(),
|
||||
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
|
||||
setItem: (key: string, value: string) => void store.set(key, String(value)),
|
||||
removeItem: (key: string) => void store.delete(key),
|
||||
key: (index: number) => Array.from(store.keys())[index] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: createStorageShim(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
+253
-233
@@ -1,14 +1,34 @@
|
||||
import { createTheme, type ThemeOptions, alpha } from '@mui/material/styles';
|
||||
import { flavors } from '@catppuccin/palette';
|
||||
|
||||
export type CatppuccinTheme =
|
||||
| 'holy_light' | 'holy_dark'
|
||||
| 'latte' | 'frappe' | 'macchiato' | 'mocha'
|
||||
| 'pink_pastel' | 'mint_pastel' | 'lavender_pastel' | 'dracula' | 'nord' | 'gruvbox' | 'oceanic'
|
||||
| 't0p_sai' | 't0p_trench' | 't0p_blurryface' | 't0p_clancy';
|
||||
export type CatppuccinTheme =
|
||||
| 'holy_light'
|
||||
| 'holy_dark'
|
||||
| 'latte'
|
||||
| 'frappe'
|
||||
| 'macchiato'
|
||||
| 'mocha'
|
||||
| 'pink_pastel'
|
||||
| 'mint_pastel'
|
||||
| 'lavender_pastel'
|
||||
| 'dracula'
|
||||
| 'nord'
|
||||
| 'gruvbox'
|
||||
| 'oceanic'
|
||||
| 't0p_sai'
|
||||
| 't0p_trench'
|
||||
| 't0p_blurryface'
|
||||
| 't0p_clancy';
|
||||
|
||||
export const isLightTheme = (mode: CatppuccinTheme): boolean => {
|
||||
return ['holy_light', 'latte', 'pink_pastel', 'mint_pastel', 'lavender_pastel', 't0p_sai'].includes(mode);
|
||||
return [
|
||||
'holy_light',
|
||||
'latte',
|
||||
'pink_pastel',
|
||||
'mint_pastel',
|
||||
'lavender_pastel',
|
||||
't0p_sai',
|
||||
].includes(mode);
|
||||
};
|
||||
|
||||
export const getTheme = (mode: CatppuccinTheme) => {
|
||||
@@ -19,164 +39,164 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
const holyPink = { light: '#db2777', dark: '#f472b6' };
|
||||
|
||||
if (mode === 'holy_light') {
|
||||
palette = {
|
||||
base: { hex: '#f8fafc' },
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#f1f5f9' },
|
||||
text: { hex: '#0f172a' },
|
||||
subtext0: { hex: '#64748b' },
|
||||
primary: { hex: holyPurple.light },
|
||||
secondary: { hex: holyPink.light },
|
||||
};
|
||||
palette = {
|
||||
base: { hex: '#f8fafc' },
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#f1f5f9' },
|
||||
text: { hex: '#0f172a' },
|
||||
subtext0: { hex: '#64748b' },
|
||||
primary: { hex: holyPurple.light },
|
||||
secondary: { hex: holyPink.light },
|
||||
};
|
||||
} else if (mode === 'holy_dark') {
|
||||
palette = {
|
||||
base: { hex: '#020617' },
|
||||
surface0: { hex: '#0f172a' },
|
||||
crust: { hex: '#000000' },
|
||||
text: { hex: '#f8fafc' },
|
||||
subtext0: { hex: '#94a3b8' },
|
||||
primary: { hex: holyPurple.dark },
|
||||
secondary: { hex: holyPink.dark },
|
||||
};
|
||||
palette = {
|
||||
base: { hex: '#020617' },
|
||||
surface0: { hex: '#0f172a' },
|
||||
crust: { hex: '#000000' },
|
||||
text: { hex: '#f8fafc' },
|
||||
subtext0: { hex: '#94a3b8' },
|
||||
primary: { hex: holyPurple.dark },
|
||||
secondary: { hex: holyPink.dark },
|
||||
};
|
||||
} else if (mode === 'pink_pastel') {
|
||||
palette = {
|
||||
base: { hex: '#fff1f2' },
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#ffe4e6' },
|
||||
text: { hex: '#881337' },
|
||||
subtext0: { hex: '#be123c' },
|
||||
primary: { hex: '#fb7185' },
|
||||
secondary: { hex: '#2dd4bf' },
|
||||
};
|
||||
palette = {
|
||||
base: { hex: '#fff1f2' },
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#ffe4e6' },
|
||||
text: { hex: '#881337' },
|
||||
subtext0: { hex: '#be123c' },
|
||||
primary: { hex: '#fb7185' },
|
||||
secondary: { hex: '#2dd4bf' },
|
||||
};
|
||||
} else if (mode === 'mint_pastel') {
|
||||
palette = {
|
||||
base: { hex: '#f0fdf4' },
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#dcfce7' },
|
||||
text: { hex: '#14532d' },
|
||||
subtext0: { hex: '#16a34a' },
|
||||
primary: { hex: '#4ade80' },
|
||||
secondary: { hex: '#fb7185' },
|
||||
};
|
||||
palette = {
|
||||
base: { hex: '#f0fdf4' },
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#dcfce7' },
|
||||
text: { hex: '#14532d' },
|
||||
subtext0: { hex: '#16a34a' },
|
||||
primary: { hex: '#4ade80' },
|
||||
secondary: { hex: '#fb7185' },
|
||||
};
|
||||
} else if (mode === 'lavender_pastel') {
|
||||
palette = {
|
||||
base: { hex: '#f5f3ff' },
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#ede9fe' },
|
||||
text: { hex: '#4c1d95' },
|
||||
subtext0: { hex: '#7c3aed' },
|
||||
primary: { hex: '#8b5cf6' },
|
||||
secondary: { hex: '#facc15' },
|
||||
};
|
||||
palette = {
|
||||
base: { hex: '#f5f3ff' },
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#ede9fe' },
|
||||
text: { hex: '#4c1d95' },
|
||||
subtext0: { hex: '#7c3aed' },
|
||||
primary: { hex: '#8b5cf6' },
|
||||
secondary: { hex: '#facc15' },
|
||||
};
|
||||
} else if (mode === 't0p_sai') {
|
||||
palette = {
|
||||
base: { hex: '#fdf2f8' }, // Very light SAI Pink
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#fce7f3' }, // SAI Pink accent
|
||||
text: { hex: '#1e1b4b' },
|
||||
subtext0: { hex: '#4b5563' },
|
||||
primary: { hex: '#00d7ff' }, // SAI Cyan
|
||||
secondary: { hex: '#ff8ad8' }, // SAI Pink
|
||||
};
|
||||
palette = {
|
||||
base: { hex: '#fdf2f8' }, // Very light SAI Pink
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#fce7f3' }, // SAI Pink accent
|
||||
text: { hex: '#1e1b4b' },
|
||||
subtext0: { hex: '#4b5563' },
|
||||
primary: { hex: '#00d7ff' }, // SAI Cyan
|
||||
secondary: { hex: '#ff8ad8' }, // SAI Pink
|
||||
};
|
||||
} else if (mode === 't0p_trench') {
|
||||
palette = {
|
||||
base: { hex: '#1a1a1a' },
|
||||
surface0: { hex: '#242424' },
|
||||
crust: { hex: '#000000' },
|
||||
text: { hex: '#fce300' }, // Trench Yellow
|
||||
subtext0: { hex: '#a0a0a0' },
|
||||
primary: { hex: '#fce300' },
|
||||
secondary: { hex: '#4b5320' }, // Olive
|
||||
};
|
||||
palette = {
|
||||
base: { hex: '#1a1a1a' },
|
||||
surface0: { hex: '#242424' },
|
||||
crust: { hex: '#000000' },
|
||||
text: { hex: '#fce300' }, // Trench Yellow
|
||||
subtext0: { hex: '#a0a0a0' },
|
||||
primary: { hex: '#fce300' },
|
||||
secondary: { hex: '#4b5320' }, // Olive
|
||||
};
|
||||
} else if (mode === 't0p_blurryface') {
|
||||
palette = {
|
||||
base: { hex: '#000000' },
|
||||
surface0: { hex: '#1a1a1a' },
|
||||
crust: { hex: '#000000' },
|
||||
text: { hex: '#ffffff' },
|
||||
subtext0: { hex: '#e31e24' },
|
||||
primary: { hex: '#e31e24' }, // BF Red
|
||||
secondary: { hex: '#ffffff' },
|
||||
};
|
||||
palette = {
|
||||
base: { hex: '#000000' },
|
||||
surface0: { hex: '#1a1a1a' },
|
||||
crust: { hex: '#000000' },
|
||||
text: { hex: '#ffffff' },
|
||||
subtext0: { hex: '#e31e24' },
|
||||
primary: { hex: '#e31e24' }, // BF Red
|
||||
secondary: { hex: '#ffffff' },
|
||||
};
|
||||
} else if (mode === 't0p_clancy') {
|
||||
palette = {
|
||||
base: { hex: '#000000' },
|
||||
surface0: { hex: '#121212' },
|
||||
crust: { hex: '#000000' },
|
||||
text: { hex: '#ffffff' },
|
||||
subtext0: { hex: '#fce300' },
|
||||
primary: { hex: '#e31e24' }, // Clancy Red
|
||||
secondary: { hex: '#fce300' }, // Clancy Yellow
|
||||
};
|
||||
palette = {
|
||||
base: { hex: '#000000' },
|
||||
surface0: { hex: '#121212' },
|
||||
crust: { hex: '#000000' },
|
||||
text: { hex: '#ffffff' },
|
||||
subtext0: { hex: '#fce300' },
|
||||
primary: { hex: '#e31e24' }, // Clancy Red
|
||||
secondary: { hex: '#fce300' }, // Clancy Yellow
|
||||
};
|
||||
} else if (mode === 'dracula') {
|
||||
palette = {
|
||||
base: { hex: '#282a36' },
|
||||
surface0: { hex: '#44475a' },
|
||||
crust: { hex: '#191a21' },
|
||||
text: { hex: '#f8f8f2' },
|
||||
subtext0: { hex: '#6272a4' },
|
||||
primary: { hex: '#bd93f9' },
|
||||
secondary: { hex: '#ff79c6' },
|
||||
};
|
||||
palette = {
|
||||
base: { hex: '#282a36' },
|
||||
surface0: { hex: '#44475a' },
|
||||
crust: { hex: '#191a21' },
|
||||
text: { hex: '#f8f8f2' },
|
||||
subtext0: { hex: '#6272a4' },
|
||||
primary: { hex: '#bd93f9' },
|
||||
secondary: { hex: '#ff79c6' },
|
||||
};
|
||||
} else if (mode === 'nord') {
|
||||
palette = {
|
||||
base: { hex: '#2e3440' },
|
||||
surface0: { hex: '#3b4252' },
|
||||
crust: { hex: '#242933' },
|
||||
text: { hex: '#eceff4' },
|
||||
subtext0: { hex: '#d8dee9' },
|
||||
primary: { hex: '#88c0d0' },
|
||||
secondary: { hex: '#b48ead' },
|
||||
};
|
||||
palette = {
|
||||
base: { hex: '#2e3440' },
|
||||
surface0: { hex: '#3b4252' },
|
||||
crust: { hex: '#242933' },
|
||||
text: { hex: '#eceff4' },
|
||||
subtext0: { hex: '#d8dee9' },
|
||||
primary: { hex: '#88c0d0' },
|
||||
secondary: { hex: '#b48ead' },
|
||||
};
|
||||
} else if (mode === 'gruvbox') {
|
||||
palette = {
|
||||
base: { hex: '#282828' },
|
||||
surface0: { hex: '#3c3836' },
|
||||
crust: { hex: '#1d2021' },
|
||||
text: { hex: '#ebdbb2' },
|
||||
subtext0: { hex: '#928374' },
|
||||
primary: { hex: '#fabd2f' },
|
||||
secondary: { hex: '#fb4934' },
|
||||
};
|
||||
palette = {
|
||||
base: { hex: '#282828' },
|
||||
surface0: { hex: '#3c3836' },
|
||||
crust: { hex: '#1d2021' },
|
||||
text: { hex: '#ebdbb2' },
|
||||
subtext0: { hex: '#928374' },
|
||||
primary: { hex: '#fabd2f' },
|
||||
secondary: { hex: '#fb4934' },
|
||||
};
|
||||
} else if (mode === 'oceanic') {
|
||||
palette = {
|
||||
base: { hex: '#1b262c' },
|
||||
surface0: { hex: '#0f4c75' },
|
||||
crust: { hex: '#0f161c' },
|
||||
text: { hex: '#bbe1fa' },
|
||||
subtext0: { hex: '#3282b8' },
|
||||
primary: { hex: '#3282b8' },
|
||||
secondary: { hex: '#bbe1fa' },
|
||||
};
|
||||
palette = {
|
||||
base: { hex: '#1b262c' },
|
||||
surface0: { hex: '#0f4c75' },
|
||||
crust: { hex: '#0f161c' },
|
||||
text: { hex: '#bbe1fa' },
|
||||
subtext0: { hex: '#3282b8' },
|
||||
primary: { hex: '#3282b8' },
|
||||
secondary: { hex: '#bbe1fa' },
|
||||
};
|
||||
} else {
|
||||
// Handle Catppuccin flavors
|
||||
const flavor = (flavors as any)[mode];
|
||||
if (flavor) {
|
||||
const colors = flavor.colors;
|
||||
palette = {
|
||||
base: colors.base,
|
||||
surface0: colors.surface0,
|
||||
crust: colors.crust,
|
||||
text: colors.text,
|
||||
subtext0: colors.subtext0,
|
||||
primary: colors.mauve,
|
||||
secondary: colors.pink,
|
||||
};
|
||||
} else {
|
||||
// Fallback to holy_light
|
||||
palette = {
|
||||
base: { hex: '#f8fafc' },
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#f1f5f9' },
|
||||
text: { hex: '#0f172a' },
|
||||
subtext0: { hex: '#64748b' },
|
||||
primary: { hex: holyPurple.light },
|
||||
secondary: { hex: holyPink.light },
|
||||
};
|
||||
mode = 'holy_light';
|
||||
}
|
||||
// Handle Catppuccin flavors
|
||||
const flavor = (flavors as any)[mode];
|
||||
if (flavor) {
|
||||
const colors = flavor.colors;
|
||||
palette = {
|
||||
base: colors.base,
|
||||
surface0: colors.surface0,
|
||||
crust: colors.crust,
|
||||
text: colors.text,
|
||||
subtext0: colors.subtext0,
|
||||
primary: colors.mauve,
|
||||
secondary: colors.pink,
|
||||
};
|
||||
} else {
|
||||
// Fallback to holy_light
|
||||
palette = {
|
||||
base: { hex: '#f8fafc' },
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#f1f5f9' },
|
||||
text: { hex: '#0f172a' },
|
||||
subtext0: { hex: '#64748b' },
|
||||
primary: { hex: holyPurple.light },
|
||||
secondary: { hex: holyPink.light },
|
||||
};
|
||||
mode = 'holy_light';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const isLight = isLightTheme(mode);
|
||||
|
||||
const themeOptions: ThemeOptions = {
|
||||
@@ -199,12 +219,12 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
},
|
||||
divider: alpha(palette.text.hex, 0.1),
|
||||
action: {
|
||||
hover: alpha(palette.primary.hex, 0.08),
|
||||
selected: alpha(palette.primary.hex, 0.12),
|
||||
}
|
||||
hover: alpha(palette.primary.hex, 0.08),
|
||||
selected: alpha(palette.primary.hex, 0.12),
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 8,
|
||||
borderRadius: 8,
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Plus Jakarta Sans", "Inter", "Roboto", sans-serif',
|
||||
@@ -217,90 +237,90 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
button: { textTransform: 'none', fontWeight: 600 },
|
||||
},
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
body: {
|
||||
scrollbarColor: `${alpha(palette.text.hex, 0.2)} transparent`,
|
||||
'&::-webkit-scrollbar': { width: '8px', height: '8px' },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: alpha(palette.text.hex, 0.2),
|
||||
borderRadius: '10px',
|
||||
'&:hover': { backgroundColor: alpha(palette.text.hex, 0.3) }
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: isLight ? palette.surface0.hex : alpha(palette.surface0.hex, 0.8),
|
||||
backgroundImage: 'none',
|
||||
backdropFilter: isLight ? 'none' : 'blur(16px)',
|
||||
border: '1px solid',
|
||||
borderColor: alpha(palette.text.hex, 0.08),
|
||||
boxShadow: isLight
|
||||
? '0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.1)'
|
||||
: '0 4px 20px rgba(0,0,0,0.2)',
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiCard: {
|
||||
defaultProps: {
|
||||
elevation: 0,
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
body: {
|
||||
scrollbarColor: `${alpha(palette.text.hex, 0.2)} transparent`,
|
||||
'&::-webkit-scrollbar': { width: '8px', height: '8px' },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: alpha(palette.text.hex, 0.2),
|
||||
borderRadius: '10px',
|
||||
'&:hover': { backgroundColor: alpha(palette.text.hex, 0.3) },
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: isLight ? palette.surface0.hex : alpha(palette.surface0.hex, 0.6),
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '1px solid',
|
||||
borderColor: alpha(palette.text.hex, 0.1),
|
||||
borderRadius: '12px',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: alpha(palette.base.hex, 0.8),
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: alpha(palette.text.hex, 0.08),
|
||||
color: palette.text.hex,
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: isLight ? palette.surface0.hex : alpha(palette.surface0.hex, 0.8),
|
||||
backgroundImage: 'none',
|
||||
backdropFilter: isLight ? 'none' : 'blur(16px)',
|
||||
border: '1px solid',
|
||||
borderColor: alpha(palette.text.hex, 0.08),
|
||||
boxShadow: isLight
|
||||
? '0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.1)'
|
||||
: '0 4px 20px rgba(0,0,0,0.2)',
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '8px',
|
||||
padding: '8px 20px',
|
||||
},
|
||||
containedPrimary: {
|
||||
boxShadow: `0 4px 14px 0 ${alpha(palette.primary.hex, 0.39)}`,
|
||||
'&:hover': {
|
||||
boxShadow: `0 6px 20px rgba(0,0,0,0.23)`,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiCard: {
|
||||
defaultProps: {
|
||||
elevation: 0,
|
||||
},
|
||||
MuiTab: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
minHeight: '48px',
|
||||
fontWeight: 700,
|
||||
borderRadius: '8px',
|
||||
}
|
||||
}
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: isLight ? palette.surface0.hex : alpha(palette.surface0.hex, 0.6),
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '1px solid',
|
||||
borderColor: alpha(palette.text.hex, 0.1),
|
||||
borderRadius: '12px',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
},
|
||||
},
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '8px',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: alpha(palette.base.hex, 0.8),
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: alpha(palette.text.hex, 0.08),
|
||||
color: palette.text.hex,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '8px',
|
||||
padding: '8px 20px',
|
||||
},
|
||||
containedPrimary: {
|
||||
boxShadow: `0 4px 14px 0 ${alpha(palette.primary.hex, 0.39)}`,
|
||||
'&:hover': {
|
||||
boxShadow: `0 6px 20px rgba(0,0,0,0.23)`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTab: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
minHeight: '48px',
|
||||
fontWeight: 700,
|
||||
borderRadius: '8px',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '8px',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return createTheme(themeOptions);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const year = d.getFullYear();
|
||||
const hours = String(d.getHours()).padStart(2, '0');
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const year = d.getFullYear();
|
||||
const hours = String(d.getHours()).padStart(2, '0');
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
css: false,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user