Files
holy-flavor-rater/frontend/src/pages/PublicProfile.tsx
T
2026-04-24 12:20:54 +02:00

851 lines
30 KiB
TypeScript

import React, { useCallback, useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
Typography,
Box,
CardContent,
Avatar,
CircularProgress,
Tabs,
Tab,
Divider,
Button,
Stack,
alpha,
useTheme,
Grid,
TextField,
IconButton,
Chip,
Tooltip,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SendIcon from '@mui/icons-material/Send';
import DeleteIcon from '@mui/icons-material/Delete';
import VerifiedIcon from '@mui/icons-material/Verified';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import PersonRemoveIcon from '@mui/icons-material/PersonRemove';
import ColorThief from 'colorthief';
import api from '../lib/api';
import { useTitle } from '../hooks/useTitle';
import { useTranslation } from 'react-i18next';
import RatingBadge from '../components/RatingBadge';
import RichText from '../components/RichText';
import DynamicBanner from '../components/DynamicBanner';
import { formatDate } from '../utils/date';
import { PageShell, GlassCard, GlassSurface, FormCard, EmptyState } from '../components/ui';
import { useToast } from '../hooks/useToast';
import { useConfirm } from '../hooks/useConfirm';
interface Rating {
id: number;
flavor: number;
flavor_name: string;
flavor_image: string | null;
category_name: string;
score: number;
comment: string;
created_at: string;
}
interface ProfileComment {
id: number;
author_username: string;
author_avatar: string | null;
text: string;
created_at: string;
}
interface MiniUser {
id: number;
username: string;
avatar: string | null;
is_following?: boolean;
}
interface ProfileData {
id: number;
username: string;
theme: string;
avatar: string | null;
following_count: number;
followers_count: number;
is_following: boolean;
ratings: Rating[];
comments: ProfileComment[];
followers: MiniUser[];
following: MiniUser[];
}
interface PublicProfileProps {
adminMode?: boolean;
}
const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
const { t } = useTranslation();
const theme = useTheme();
const { username } = useParams<{ username: string }>();
const navigate = useNavigate();
const [data, setData] = useState<ProfileData | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState(0);
const [categoryTab, setCategoryTab] = useState(0);
const [currentUser, setCurrentUser] = useState<{
username: string;
is_superuser: boolean;
} | null>(null);
const [palette, setPalette] = useState<string[]>([]);
const [newComment, setNewComment] = useState('');
const { notify } = useToast();
const { confirm } = useConfirm();
const fetchProfile = useCallback(async () => {
try {
const [profileRes, meRes] = await Promise.all([
api.get(`users/profile/${username}/`),
localStorage.getItem('access') ? api.get('users/me/') : Promise.resolve({ data: null }),
]);
setData(profileRes.data);
if (meRes.data) setCurrentUser(meRes.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [username]);
useEffect(() => {
fetchProfile();
}, [fetchProfile]);
useEffect(() => {
if (data?.avatar) {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.src = data.avatar;
img.onload = () => {
const colorThief = new ColorThief();
try {
const rawPalette = colorThief.getPalette(img, 10);
const processed = rawPalette.map((c) => {
const r = c[0] / 255,
g = c[1] / 255,
b = c[2] / 255;
const max = Math.max(r, g, b),
min = Math.min(r, g, b);
const l = (max + min) / 2;
const s =
max === min ? 0 : l > 0.5 ? (max - min) / (2 - max - min) : (max - min) / (max + min);
return { rgb: `rgb(${c[0]}, ${c[1]}, ${c[2]})`, l, s };
});
const vibrant = processed.filter((c) => c.l > 0.2 && c.l < 0.85).sort((a, b) => b.s - a.s);
if (vibrant.length >= 2) setPalette([vibrant[0].rgb, vibrant[1].rgb]);
else if (vibrant.length === 1) setPalette([vibrant[0].rgb, vibrant[0].rgb]);
else setPalette([]);
} catch {
setPalette([]);
}
};
} else {
setPalette([]);
}
}, [data?.avatar]);
const handleFollowToggle = async () => {
if (!data) return;
try {
if (data.is_following) {
await api.post(`users/${data.id}/unfollow/`);
setData({ ...data, is_following: false, followers_count: data.followers_count - 1 });
} else {
await api.post(`users/${data.id}/follow/`);
setData({ ...data, is_following: true, followers_count: data.followers_count + 1 });
}
} catch {
notify({ message: 'Failed to update follow status. Please login.', severity: 'error' });
}
};
const handleMiniFollowToggle = async (user: MiniUser) => {
try {
if (user.is_following) await api.post(`users/${user.id}/unfollow/`);
else await api.post(`users/${user.id}/follow/`);
fetchProfile();
} catch {
notify({ message: 'Failed', severity: 'error' });
}
};
const handleAddComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!newComment.trim() || !data) return;
try {
await api.post(`users/${data.id}/add_comment/`, { text: newComment });
setNewComment('');
fetchProfile();
} catch {
notify({ message: 'Failed to add comment', severity: 'error' });
}
};
const handleDeleteComment = async (commentId: number) => {
if (!data) return;
if (!(await confirm({ message: 'Delete this comment?', danger: true }))) return;
try {
await api.delete(`users/${data.id}/delete_comment/${commentId}/`);
fetchProfile();
} catch {
notify({ message: 'Failed to delete comment', severity: 'error' });
}
};
useTitle(data ? `${data.username}'s Ratings` : 'Profile');
if (loading)
return (
<PageShell>
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
<CircularProgress />
</Box>
</PageShell>
);
if (!data)
return (
<PageShell>
<Typography>Profile not found.</Typography>
</PageShell>
);
const categoryGroups = data.ratings.reduce<Record<string, Rating[]>>((acc, rating) => {
const cat = rating.category_name || 'Other';
if (!acc[cat]) acc[cat] = [];
acc[cat].push(rating);
return acc;
}, {});
const ratingCategories = ['All', ...Object.keys(categoryGroups)];
const currentRatings: Rating[] =
categoryTab === 0 ? data.ratings : categoryGroups[ratingCategories[categoryTab]];
const tiers = [
{ key: 'S', title: 'S-Tier', color: '#ff7f7f' },
{ key: 'A', title: 'A-Tier', color: '#ffbf7f' },
{ key: 'B', title: 'B-Tier', color: '#ffff7f' },
{ key: 'C', title: 'C-Tier', color: '#bfff7f' },
{ key: 'D', title: 'D-Tier', color: '#7fff7f' },
];
const bannerColors = [
palette[0] || theme.palette.primary.main,
palette[1] || theme.palette.secondary.main,
theme.palette.secondary.main,
theme.palette.primary.main,
];
const abstractBanner = `
radial-gradient(at 0% 0%, ${alpha(bannerColors[0], 0.6)} 0px, transparent 55%),
radial-gradient(at 100% 0%, ${alpha(bannerColors[1], 0.5)} 0px, transparent 55%),
radial-gradient(at 100% 100%, ${alpha(bannerColors[2], 0.4)} 0px, transparent 55%),
radial-gradient(at 0% 100%, ${alpha(bannerColors[3], 0.3)} 0px, transparent 55%),
radial-gradient(at 50% 50%, ${alpha(bannerColors[0], 0.3)} 0px, transparent 60%),
linear-gradient(135deg, ${alpha(bannerColors[0], 0.1)} 0%, ${alpha(bannerColors[1], 0.1)} 100%)
`;
const handleGoBack = () => {
if (window.history.length > 1) navigate(-1);
else navigate('/');
};
const renderUserCard = (user: MiniUser) => (
<Grid key={user.id} size={{ xs: 12, sm: 6, lg: 4 }}>
<GlassCard
intensity="subtle"
sx={{
overflow: 'hidden',
transition: 'all 0.3s ease',
'&:hover': { borderColor: 'primary.main', transform: 'translateY(-4px)' },
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', p: 2, gap: 2 }}>
<Avatar
component={Link}
to={`/profile/${user.username}`}
src={user.avatar || undefined}
sx={{
width: 56,
height: 56,
cursor: 'pointer',
border: '2px solid',
borderColor: 'divider',
}}
>
{user.username.charAt(0)}
</Avatar>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
component={Link}
to={`/profile/${user.username}`}
variant="subtitle1"
sx={{
fontWeight: '900',
color: 'text.primary',
textDecoration: 'none',
'&:hover': { color: 'primary.main' },
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{user.username}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold' }}>
Community Member
</Typography>
</Box>
{currentUser && currentUser.username !== user.username && (
<IconButton
color={user.is_following ? 'secondary' : 'primary'}
onClick={() => handleMiniFollowToggle(user)}
sx={{
bgcolor: alpha(
user.is_following ? theme.palette.secondary.main : theme.palette.primary.main,
0.1,
),
'&:hover': {
bgcolor: alpha(
user.is_following ? theme.palette.secondary.main : theme.palette.primary.main,
0.2,
),
},
}}
>
{user.is_following ? <PersonRemoveIcon /> : <PersonAddIcon />}
</IconButton>
)}
</Box>
</GlassCard>
</Grid>
);
return (
<PageShell>
<Button
variant="outlined"
onClick={handleGoBack}
startIcon={<ArrowBackIcon />}
sx={{
alignSelf: 'flex-start',
borderRadius: 2,
textTransform: 'none',
fontWeight: 'bold',
color: 'text.secondary',
}}
>
{t('common.back')}
</Button>
{/* Profile Header */}
<GlassCard intensity="strong" sx={{ overflow: 'hidden' }}>
<Box sx={{ height: { xs: 120, sm: 180 }, position: 'relative', overflow: 'hidden' }}>
<Box sx={{ position: 'absolute', inset: 0, background: abstractBanner }} />
<DynamicBanner
username={data.username}
palette={palette}
ratingsCount={data.ratings.length}
followersCount={data.followers_count}
/>
</Box>
<CardContent sx={{ pt: 0, px: { xs: 2, md: 5 }, pb: { xs: 2, md: 4 }, position: 'relative' }}>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
alignItems: { xs: 'center', sm: 'flex-end' },
gap: { xs: 2, sm: 4 },
}}
>
<Box
sx={{
position: 'relative',
mt: { xs: -7, sm: -10 },
p: 0.75,
borderRadius: '50%',
background: `linear-gradient(135deg, ${bannerColors[0]}, ${bannerColors[2]})`,
boxShadow: '0 12px 48px rgba(0,0,0,0.25)',
display: 'flex',
}}
>
<Avatar
src={data.avatar || undefined}
sx={{
width: { xs: 140, sm: 180 },
height: { xs: 140, sm: 180 },
border: '6px solid',
borderColor: theme.palette.background.paper,
fontSize: '4.5rem',
bgcolor: theme.palette.background.paper,
color: bannerColors[0],
}}
>
{!data.avatar && data.username.charAt(0).toUpperCase()}
</Avatar>
{data.ratings.length > 50 && (
<Tooltip title="Master Collector">
<VerifiedIcon
color="primary"
sx={{
position: 'absolute',
bottom: 12,
right: 12,
bgcolor: 'background.paper',
borderRadius: '50%',
fontSize: '2.2rem',
p: 0.2,
border: '3px solid',
borderColor: bannerColors[0],
}}
/>
</Tooltip>
)}
</Box>
<Box sx={{ flex: 1, textAlign: { xs: 'center', sm: 'left' }, mt: { xs: 2, sm: 6 } }}>
<Typography
variant="h2"
sx={{
fontWeight: '900',
letterSpacing: -2,
fontSize: { xs: '2.8rem', sm: '4.2rem' },
lineHeight: 0.9,
color: 'text.primary',
display: 'inline-block',
mb: 2,
}}
>
{data.username}
</Typography>
<Box>
<GlassSurface
intensity="subtle"
sx={{ display: 'inline-flex', borderRadius: 1, overflow: 'hidden' }}
>
{[
{ label: 'Rated', val: data.ratings.length },
{ label: 'Followers', val: data.followers_count },
{ label: 'Following', val: data.following_count },
].map((stat, i) => (
<React.Fragment key={stat.label}>
<Box sx={{ py: 1.2, width: { xs: 90, sm: 120 }, textAlign: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: '900', lineHeight: 1 }}>
{stat.val}
</Typography>
<Typography
variant="caption"
sx={{
fontWeight: '900',
textTransform: 'uppercase',
opacity: 0.5,
fontSize: '0.6rem',
letterSpacing: 1,
}}
>
{stat.label}
</Typography>
</Box>
{i < 2 && (
<Divider
orientation="vertical"
flexItem
sx={{ borderStyle: 'solid', opacity: 0.1 }}
/>
)}
</React.Fragment>
))}
</GlassSurface>
</Box>
</Box>
<Stack
direction="row"
spacing={1.5}
sx={{ width: { xs: '100%', sm: 'auto' }, justifyContent: 'center', mb: 1 }}
>
{currentUser?.username !== data.username && (
<Button
variant={data.is_following ? 'outlined' : 'contained'}
onClick={handleFollowToggle}
startIcon={data.is_following ? <PersonRemoveIcon /> : <PersonAddIcon />}
sx={{
borderRadius: 1,
px: 4,
fontWeight: '900',
py: 1.2,
textTransform: 'none',
fontSize: '1rem',
}}
>
{data.is_following ? 'Unfollow' : 'Follow'}
</Button>
)}
{currentUser?.is_superuser && (
<Button
variant="outlined"
color="secondary"
onClick={() => navigate(`/admin-panel/user/${data.id}`)}
sx={{ borderRadius: 1, px: 2.5, fontWeight: 'bold', textTransform: 'none' }}
>
{t('admin.manageUser')}
</Button>
)}
</Stack>
</Box>
</CardContent>
</GlassCard>
{/* Tabs */}
<GlassSurface
intensity="subtle"
sx={{
position: 'sticky',
top: 72,
zIndex: 10,
py: 1,
borderRadius: 1,
}}
>
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
variant="fullWidth"
sx={{
'& .MuiTab-root': {
fontWeight: '900',
textTransform: 'none',
fontSize: '1rem',
borderRadius: 1,
},
'& .Mui-selected': { color: 'primary.main' },
}}
>
<Tab label="Ratings" />
<Tab label="Guestbook" />
<Tab label="Followers" />
<Tab label="Following" />
</Tabs>
</GlassSurface>
<Box sx={{ minHeight: 400 }}>
{activeTab === 0 && (
<Box>
<Box
sx={{
mb: 5,
display: 'flex',
overflowX: 'auto',
pb: 1,
gap: 1.5,
'&::-webkit-scrollbar': { display: 'none' },
}}
>
{ratingCategories.map((cat, idx) => (
<Chip
key={cat}
label={cat}
onClick={() => setCategoryTab(idx)}
color={categoryTab === idx ? 'primary' : 'default'}
variant={categoryTab === idx ? 'filled' : 'outlined'}
sx={{ fontWeight: '900', px: 1.5, borderRadius: 1 }}
/>
))}
</Box>
{currentRatings.length === 0 ? (
<EmptyState title="No ratings in this category" />
) : (
<Stack spacing={10}>
{tiers.map((tier) => {
const items = currentRatings.filter((r) => {
const s = r.score;
if (tier.key === 'S') return s >= 9;
if (tier.key === 'A') return s >= 7 && s < 9;
if (tier.key === 'B') return s >= 5 && s < 7;
if (tier.key === 'C') return s >= 3 && s < 5;
return s < 3;
});
if (items.length === 0) return null;
return (
<Box key={tier.key}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 4, gap: 3 }}>
<Box
sx={{
width: 64,
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: tier.color,
color: 'black',
fontWeight: '900',
fontSize: '2rem',
borderRadius: 0.5,
boxShadow: `0 0 20px ${alpha(tier.color, 0.6)}`,
}}
>
{tier.key}
</Box>
<Box>
<Typography
variant="h4"
sx={{ fontWeight: '900', lineHeight: 1.1, letterSpacing: -0.5 }}
>
{tier.title}
</Typography>
<Typography
variant="subtitle2"
color="text.secondary"
sx={{ fontWeight: 'bold', opacity: 0.7 }}
>
{items.length} items
</Typography>
</Box>
<Divider sx={{ flexGrow: 1, borderStyle: 'dashed', opacity: 0.3 }} />
</Box>
<Grid container spacing={3}>
{items.map((rating) => (
<Grid key={rating.id} size={{ xs: 6, sm: 4, lg: 2.4 }}>
<GlassCard
intensity="subtle"
sx={{
height: '100%',
transition: 'all 0.3s ease',
overflow: 'hidden',
'&:hover': {
transform: 'translateY(-8px)',
borderColor: 'primary.main',
boxShadow: (th) => th.tokens.elevation.md,
},
}}
>
<Link
to={`/flavor/${rating.flavor}`}
style={{ textDecoration: 'none', color: 'inherit' }}
>
<Box
sx={{
position: 'relative',
aspectRatio: '1/1',
bgcolor: 'background.default',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid',
borderColor: 'divider',
overflow: 'hidden',
}}
>
<Box
component="img"
src={rating.flavor_image || undefined}
alt={rating.flavor_name}
loading="lazy"
sx={{
height: '100%',
width: '100%',
objectFit: 'cover',
transition: 'transform 0.5s ease',
'&:hover': { transform: 'scale(1.1)' },
}}
/>
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 2 }}>
<RatingBadge score={rating.score} size="small" />
</Box>
</Box>
<CardContent sx={{ p: 1.5, textAlign: 'center' }}>
<Typography
variant="subtitle2"
sx={{
fontWeight: '900',
fontSize: '0.85rem',
lineHeight: 1.2,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{rating.flavor_name}
</Typography>
</CardContent>
</Link>
{adminMode && (
<Box sx={{ px: 1, pb: 1 }}>
<Button
size="small"
fullWidth
variant="outlined"
color="secondary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
navigate(`/admin-panel/rating/${rating.id}`);
}}
sx={{
borderRadius: 1,
textTransform: 'none',
fontSize: '0.6rem',
py: 0,
fontWeight: 'bold',
}}
>
Manage
</Button>
</Box>
)}
</GlassCard>
</Grid>
))}
</Grid>
</Box>
);
})}
</Stack>
)}
</Box>
)}
{activeTab === 1 && (
<Box sx={{ maxWidth: 900, mx: 'auto', width: '100%' }}>
{currentUser && (
<Box sx={{ mb: 5 }}>
<FormCard
title="Leave a message"
onSubmit={handleAddComment}
actions={
<Button
variant="contained"
type="submit"
endIcon={<SendIcon />}
sx={{ borderRadius: 2, px: 5, fontWeight: '900', textTransform: 'none' }}
>
Send Message
</Button>
}
>
<TextField
fullWidth
multiline
rows={3}
placeholder="Say something about their taste..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
/>
</FormCard>
</Box>
)}
<Stack spacing={2.5}>
{data.comments.length === 0 ? (
<EmptyState
title="No entries in the guestbook yet."
subtitle="Be the first to leave a message!"
/>
) : (
data.comments.map((comment) => (
<GlassCard key={comment.id} intensity="subtle">
<CardContent sx={{ p: 3 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<Stack direction="row" spacing={2.5} alignItems="center">
<Link to={`/profile/${comment.author_username}`} style={{ textDecoration: 'none' }}>
<Avatar
src={comment.author_avatar || undefined}
sx={{
width: 48,
height: 48,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
border: '2px solid white',
}}
>
{comment.author_username.charAt(0).toUpperCase()}
</Avatar>
</Link>
<Box>
<Link
to={`/profile/${comment.author_username}`}
style={{ textDecoration: 'none', color: 'inherit' }}
>
<Typography variant="subtitle1" sx={{ fontWeight: '900', lineHeight: 1.2 }}>
{comment.author_username}
</Typography>
</Link>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontWeight: 'bold' }}
>
{formatDate(comment.created_at)}
</Typography>
</Box>
</Stack>
{(currentUser?.username === comment.author_username ||
currentUser?.username === data.username ||
currentUser?.is_superuser) && (
<IconButton size="small" color="error" onClick={() => handleDeleteComment(comment.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</Box>
<Typography
variant="body1"
sx={{ mt: 3, pl: { xs: 0, sm: 9.5 }, lineHeight: 1.7, fontSize: '1rem' }}
>
<RichText text={comment.text} />
</Typography>
</CardContent>
</GlassCard>
))
)}
</Stack>
</Box>
)}
{activeTab === 2 && (
<Grid container spacing={3}>
{data.followers.length === 0 ? (
<Grid size={12}>
<EmptyState title="No followers yet." />
</Grid>
) : (
data.followers.map(renderUserCard)
)}
</Grid>
)}
{activeTab === 3 && (
<Grid container spacing={3}>
{data.following.length === 0 ? (
<Grid size={12}>
<EmptyState title="Not following anyone yet." />
</Grid>
) : (
data.following.map(renderUserCard)
)}
</Grid>
)}
</Box>
</PageShell>
);
};
export default PublicProfile;