fixed mobile view and dashboard

This commit is contained in:
nvrl committed 2026-04-26 18:10:54 +02:00
1 parent b521c82042
commit caf3830d72
10 files changed
+530 -324

No files matched your search

+256 -100
View File
@@ -1,5 +1,5 @@
import React, { Suspense, lazy, useEffect, useState } from 'react';
import { Routes, Route, Link, useNavigate } from 'react-router-dom';
import { Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom';
import {
Typography,
Button,
@@ -8,16 +8,16 @@ import {
Avatar,
Menu,
MenuItem,
Drawer,
SwipeableDrawer,
Divider,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Collapse,
CircularProgress,
ListItemAvatar,
ListSubheader,
Badge,
alpha,
useMediaQuery,
@@ -29,6 +29,18 @@ import NotificationsIcon from '@mui/icons-material/Notifications';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore';
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined';
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined';
import PersonOutlineOutlinedIcon from '@mui/icons-material/PersonOutlineOutlined';
import CollectionsBookmarkOutlinedIcon from '@mui/icons-material/CollectionsBookmarkOutlined';
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
import HelpOutlineOutlinedIcon from '@mui/icons-material/HelpOutlineOutlined';
import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined';
import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined';
import LogoutOutlinedIcon from '@mui/icons-material/LogoutOutlined';
import LoginOutlinedIcon from '@mui/icons-material/LoginOutlined';
import PeopleOutlineOutlinedIcon from '@mui/icons-material/PeopleOutlineOutlined';
import { useTranslation } from 'react-i18next';
import api from './lib/api';
import Footer from './components/Footer';
@@ -61,6 +73,7 @@ const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail'));
const App: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const muiTheme = useMuiTheme();
const { user, following, loadingUser, logout } = useAuth();
const { themeName, handleThemeChange } = useTheme();
@@ -72,8 +85,9 @@ const App: React.FC = () => {
const [categories, setCategories] = useState<{ name: string; slug: string }[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [catOpen, setCatOpen] = useState(false);
const [followingOpen, setFollowingOpen] = useState(false);
const [adminMode, setAdminMode] = useState(localStorage.getItem('admin-mode') === 'true');
const isMobile = useMediaQuery('(max-width:1150px)');
const isMobile = useMediaQuery(muiTheme.breakpoints.down('md'));
useEffect(() => {
api
@@ -115,36 +129,104 @@ const App: React.FC = () => {
}
};
const closeDrawer = () => setDrawerOpen(false);
const isActive = (path: string) =>
path === '/' ? location.pathname === '/' : location.pathname.startsWith(path);
const drawer = (
<Box sx={{ width: 280 }} role="presentation">
<Box sx={{ p: 2, display: 'flex', alignItems: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
<Link
to="/"
onClick={() => setDrawerOpen(false)}
style={{ color: 'inherit', textDecoration: 'none' }}
<Box
sx={{ width: 300, height: '100%', display: 'flex', flexDirection: 'column' }}
role="presentation"
>
{user ? (
<Box
component={Link}
to={`/profile/${user.username}`}
onClick={closeDrawer}
sx={{
p: 2,
display: 'flex',
alignItems: 'center',
gap: 1.5,
color: 'inherit',
textDecoration: 'none',
borderBottom: '1px solid',
borderColor: 'divider',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Avatar
src={user.avatar || undefined}
sx={{
width: 44,
height: 44,
border: '2px solid',
borderColor: 'primary.main',
}}
>
Holy Flavors Archive
</Link>
</Typography>
</Box>
<Divider />
<List>
{user.username.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="subtitle1"
sx={{
fontWeight: 700,
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{user.username}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('nav.profile')}
</Typography>
</Box>
</Box>
) : (
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
<Link to="/" onClick={closeDrawer} style={{ color: 'inherit', textDecoration: 'none' }}>
Holy Flavors Archive
</Link>
</Typography>
</Box>
)}
<List sx={{ flex: 1, overflowY: 'auto', py: 1 }}>
<ListItem disablePadding>
<ListItemButton component={Link} to="/" onClick={() => setDrawerOpen(false)}>
<ListItemButton component={Link} to="/" onClick={closeDrawer} selected={isActive('/')}>
<ListItemIcon sx={{ minWidth: 40 }}>
<HomeOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.home')} />
</ListItemButton>
</ListItem>
{user && (
<ListItem disablePadding>
<ListItemButton component={Link} to="/community" onClick={() => setDrawerOpen(false)}>
<ListItemButton
component={Link}
to="/community"
onClick={closeDrawer}
selected={isActive('/community')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<GroupsOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.community')} />
</ListItemButton>
</ListItem>
)}
<ListItemButton onClick={() => setCatOpen(!catOpen)}>
<ListItemButton
onClick={() => setCatOpen(!catOpen)}
selected={location.pathname.startsWith('/category/')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<CategoryOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.categories')} />
{catOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
@@ -155,8 +237,9 @@ const App: React.FC = () => {
key={cat.slug}
component={Link}
to={`/category/${cat.slug}`}
onClick={() => setDrawerOpen(false)}
sx={{ pl: 4 }}
onClick={closeDrawer}
selected={location.pathname === `/category/${cat.slug}`}
sx={{ pl: 7 }}
>
<ListItemText primary={t(`categories.${cat.slug}`, { defaultValue: cat.name })} />
</ListItemButton>
@@ -164,118 +247,184 @@ const App: React.FC = () => {
</List>
</Collapse>
<Divider sx={{ my: 1 }} />
{user ? (
{user && (
<>
<Divider sx={{ my: 1 }} />
<ListItem disablePadding>
<ListItemButton
component={Link}
to={`/profile/${user.username}`}
onClick={() => setDrawerOpen(false)}
onClick={closeDrawer}
selected={location.pathname === `/profile/${user.username}`}
>
<ListItemText
primary={t('nav.profile')}
secondary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
<Avatar src={user.avatar || undefined} sx={{ width: 20, height: 20 }}>
{user.username.charAt(0).toUpperCase()}
</Avatar>
<Typography variant="caption" color="text.secondary">
{user.username}
</Typography>
</Box>
}
/>
<ListItemIcon sx={{ minWidth: 40 }}>
<PersonOutlineOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.profile')} />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton component={Link} to="/dashboard" onClick={() => setDrawerOpen(false)}>
<ListItemButton
component={Link}
to="/dashboard"
onClick={closeDrawer}
selected={isActive('/dashboard')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<CollectionsBookmarkOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.dashboard')} />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton component={Link} to="/settings" onClick={() => setDrawerOpen(false)}>
<ListItemButton
component={Link}
to="/settings"
onClick={closeDrawer}
selected={isActive('/settings')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<SettingsOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.settings')} />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton component={Link} to="/support" onClick={() => setDrawerOpen(false)}>
<ListItemButton
component={Link}
to="/support"
onClick={closeDrawer}
selected={isActive('/support')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<HelpOutlineOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('support.title')} />
</ListItemButton>
</ListItem>
{user.is_superuser && (
<ListItem disablePadding>
<ListItemButton onClick={toggleAdminMode}>
<ListItemText
primary={t('admin.adminMode')}
secondary={adminMode ? 'ON' : 'OFF'}
sx={{ color: adminMode ? 'primary.main' : 'inherit' }}
/>
</ListItemButton>
</ListItem>
)}
{user.is_superuser && (
<ListItem disablePadding>
<ListItemButton
component={Link}
to="/admin-panel"
onClick={() => setDrawerOpen(false)}
>
<ListItemText
primary="Admin Panel"
sx={{ color: 'primary.main', fontWeight: 'bold' }}
/>
</ListItemButton>
</ListItem>
<>
<Divider sx={{ my: 1 }} />
<ListItem disablePadding>
<ListItemButton onClick={toggleAdminMode}>
<ListItemIcon sx={{ minWidth: 40 }}>
<ShieldOutlinedIcon color={adminMode ? 'primary' : 'inherit'} />
</ListItemIcon>
<ListItemText
primary={t('admin.adminMode')}
secondary={adminMode ? 'ON' : 'OFF'}
sx={{ color: adminMode ? 'primary.main' : 'inherit' }}
/>
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton
component={Link}
to="/admin-panel"
onClick={closeDrawer}
selected={isActive('/admin-panel')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<AdminPanelSettingsOutlinedIcon color="primary" />
</ListItemIcon>
<ListItemText
primary="Admin Panel"
primaryTypographyProps={{ sx: { color: 'primary.main', fontWeight: 700 } }}
/>
</ListItemButton>
</ListItem>
</>
)}
{following.length > 0 && (
<>
<Divider sx={{ my: 1 }} />
<ListSubheader
sx={{ bgcolor: 'transparent', fontWeight: 'bold', lineHeight: '32px' }}
>
{t('nav.following')}
</ListSubheader>
{following.map((f) => (
<ListItem key={f.id} disablePadding>
<ListItemButton
component={Link}
to={`/profile/${f.username}`}
onClick={() => setDrawerOpen(false)}
>
<ListItemAvatar>
<Avatar src={f.avatar || undefined} sx={{ width: 32, height: 32 }}>
{!f.avatar && f.username.charAt(0).toUpperCase()}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={f.username}
primaryTypographyProps={{ variant: 'body2' }}
/>
</ListItemButton>
</ListItem>
))}
<ListItemButton onClick={() => setFollowingOpen(!followingOpen)}>
<ListItemIcon sx={{ minWidth: 40 }}>
<PeopleOutlineOutlinedIcon />
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<span>{t('nav.following')}</span>
<Typography variant="caption" color="text.secondary">
{following.length}
</Typography>
</Box>
}
/>
{followingOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
<Collapse in={followingOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{following.map((f) => (
<ListItem key={f.id} disablePadding>
<ListItemButton
component={Link}
to={`/profile/${f.username}`}
onClick={closeDrawer}
selected={location.pathname === `/profile/${f.username}`}
sx={{ pl: 5 }}
>
<ListItemAvatar sx={{ minWidth: 40 }}>
<Avatar src={f.avatar || undefined} sx={{ width: 28, height: 28 }}>
{!f.avatar && f.username.charAt(0).toUpperCase()}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={f.username}
primaryTypographyProps={{ variant: 'body2' }}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Collapse>
</>
)}
</>
)}
{!user && (
<>
<Divider sx={{ my: 1 }} />
<ListItem disablePadding>
<ListItemButton onClick={logout}>
<ListItemText primary={t('nav.logout')} sx={{ color: 'error.main' }} />
<ListItemButton
component={Link}
to="/login"
onClick={closeDrawer}
selected={isActive('/login')}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<LoginOutlinedIcon />
</ListItemIcon>
<ListItemText primary={t('nav.login')} />
</ListItemButton>
</ListItem>
</>
) : (
<ListItem disablePadding>
<ListItemButton component={Link} to="/login" onClick={() => setDrawerOpen(false)}>
<ListItemText primary={t('nav.login')} />
</ListItemButton>
</ListItem>
)}
</List>
{user && (
<Box sx={{ borderTop: '1px solid', borderColor: 'divider' }}>
<ListItemButton
onClick={() => {
closeDrawer();
logout();
}}
>
<ListItemIcon sx={{ minWidth: 40, color: 'error.main' }}>
<LogoutOutlinedIcon />
</ListItemIcon>
<ListItemText
primary={t('nav.logout')}
primaryTypographyProps={{ sx: { color: 'error.main', fontWeight: 600 } }}
/>
</ListItemButton>
</Box>
)}
</Box>
);
@@ -528,9 +677,16 @@ const App: React.FC = () => {
</Box>
</GlassAppBar>
<Drawer anchor="right" open={drawerOpen} onClose={() => setDrawerOpen(false)}>
<SwipeableDrawer
anchor="right"
open={drawerOpen}
onOpen={() => setDrawerOpen(true)}
onClose={() => setDrawerOpen(false)}
disableBackdropTransition
disableDiscovery
>
{drawer}
</Drawer>
</SwipeableDrawer>
<Box sx={{ flexGrow: 1, width: '100%' }}>
<Suspense
@@ -20,6 +20,7 @@ export interface MainPageFlavor {
id: number;
name: string;
category_name: string;
category_slug: string;
description: string;
average_rating: number;
image_url: string | null;
+3 -1
View File
@@ -7,6 +7,7 @@ import {
TextField,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import { useNavigate } from 'react-router-dom';
@@ -16,7 +17,8 @@ import { useGlobalSearch } from '../hooks/useGlobalSearch';
export const GlobalSearch = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useMediaQuery('(max-width:1150px)');
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const { query, setQuery, options, loading } = useGlobalSearch();
const handleSearch = (e: React.FormEvent) => {
+10 -6
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { Box, Typography, alpha, useTheme } from '@mui/material';
import { Box, alpha, useTheme } from '@mui/material';
interface RatingBadgeProps {
score: number;
@@ -42,7 +42,7 @@ const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx })
<Box
sx={{
display: 'inline-flex',
alignItems: 'baseline',
alignItems: 'center',
justifyContent: 'center',
gap: dims.gap,
px: dims.px,
@@ -52,10 +52,11 @@ const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx })
color: theme.palette.primary.contrastText,
boxShadow: `0 6px 16px ${alpha(theme.palette.primary.main, 0.28)}, inset 0 1px 0 ${alpha('#ffffff', 0.2)}`,
minWidth: 'fit-content',
lineHeight: 1,
...sx,
}}
>
<Typography
<Box
component="span"
sx={{
fontWeight: 800,
@@ -63,11 +64,12 @@ const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx })
fontSize: dims.score,
fontVariantNumeric: 'tabular-nums',
letterSpacing: '-0.02em',
display: 'inline-block',
}}
>
{display}
</Typography>
<Typography
</Box>
<Box
component="span"
sx={{
fontWeight: 700,
@@ -75,10 +77,12 @@ const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx })
fontSize: dims.suffix,
opacity: 0.75,
letterSpacing: '0.02em',
display: 'inline-block',
mt: '1px',
}}
>
/ 10
</Typography>
</Box>
</Box>
);
};
+4 -19
View File
@@ -46,8 +46,7 @@ export const FlavorCard = ({
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
transition:
'border-color 200ms ease, box-shadow 200ms ease, background-color 200ms ease',
transition: 'border-color 200ms ease, box-shadow 200ms ease, background-color 200ms ease',
'&:hover': {
borderColor: alpha(theme.palette.primary.main, 0.5),
boxShadow: theme.tokens.elevation.md,
@@ -185,30 +184,16 @@ export const FlavorCard = ({
justifyContent: 'space-between',
gap: 1,
mt: 'auto',
minHeight: 24,
}}
>
{hasRating ? (
<RatingBadge score={flavor.average_rating || 0} size="small" />
) : (
<Typography
variant="caption"
color="text.secondary"
sx={{
fontWeight: 600,
fontSize: '0.7rem',
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}
>
—
</Typography>
)}
{hasRating && <RatingBadge score={flavor.average_rating || 0} size="small" />}
{caption && (
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{ fontWeight: 600, fontSize: '0.7rem' }}
sx={{ fontWeight: 600, fontSize: '0.7rem', ml: 'auto' }}
>
{caption}
</Typography>
+3 -46
View File
@@ -1,11 +1,11 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { Box, ButtonBase, IconButton, Modal, Typography, alpha, useTheme } from '@mui/material';
import { useTranslation } from 'react-i18next';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import CloseIcon from '@mui/icons-material/Close';
import useEmblaCarousel from 'embla-carousel-react';
import { GlassCard, GlassSurface } from './Glass';
import { GlassCard } from './Glass';
export interface HeroGalleryProps {
images: string[];
@@ -28,8 +28,6 @@ const Carousel: React.FC<CarouselProps> = ({ images, alt, onMainClick, variant }
const [selectedIndex, setSelectedIndex] = useState(0);
const [canPrev, setCanPrev] = useState(false);
const [canNext, setCanNext] = useState(false);
const [counterVisible, setCounterVisible] = useState(true);
const counterTimeoutRef = useRef<number | null>(null);
const updateState = useCallback(() => {
if (!emblaApi) return;
@@ -38,23 +36,12 @@ const Carousel: React.FC<CarouselProps> = ({ images, alt, onMainClick, variant }
setCanNext(emblaApi.canScrollNext());
}, [emblaApi]);
const flashCounter = useCallback(() => {
setCounterVisible(true);
if (counterTimeoutRef.current) window.clearTimeout(counterTimeoutRef.current);
counterTimeoutRef.current = window.setTimeout(() => setCounterVisible(false), 1500);
}, []);
useEffect(() => {
if (!emblaApi) return;
updateState();
emblaApi.on('select', updateState);
emblaApi.on('reInit', updateState);
emblaApi.on('select', flashCounter);
flashCounter();
return () => {
if (counterTimeoutRef.current) window.clearTimeout(counterTimeoutRef.current);
};
}, [emblaApi, updateState, flashCounter]);
}, [emblaApi, updateState]);
const scrollPrev = () => emblaApi?.scrollPrev();
const scrollNext = () => emblaApi?.scrollNext();
@@ -118,36 +105,6 @@ const Carousel: React.FC<CarouselProps> = ({ images, alt, onMainClick, variant }
</Box>
</Box>
{/* Counter pill */}
{images.length > 1 && (
<GlassSurface
intensity="strong"
sx={{
position: 'absolute',
bottom: 12,
left: '50%',
transform: 'translateX(-50%)',
px: 1.25,
py: 0.4,
borderRadius: theme.tokens.radius.pill + 'px',
opacity: counterVisible ? 0.95 : 0,
transition: 'opacity 300ms ease',
pointerEvents: 'none',
}}
>
<Typography
variant="caption"
sx={{
fontWeight: 700,
fontVariantNumeric: 'tabular-nums',
color: 'text.primary',
}}
>
{selectedIndex + 1} / {images.length}
</Typography>
</GlassSurface>
)}
{/* Chevrons (desktop only) */}
{images.length > 1 && (
<Box
@@ -14,7 +14,7 @@ const SCORES = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const;
const RatingDistribution: React.FC<RatingDistributionProps> = ({
distribution,
total,
height = 56,
height = 48,
showLegend = true,
}) => {
const theme = useTheme();
@@ -29,24 +29,25 @@ const RatingDistribution: React.FC<RatingDistributionProps> = ({
const interpolate = (n: number) => {
const ratio = (n - 1) / 9;
return alpha(highColor, 0.25 + ratio * 0.7);
return alpha(highColor, 0.3 + ratio * 0.65);
};
if (sum === 0) return null;
return (
<Box sx={{ width: '100%', mb: 1 }}>
<Box sx={{ width: '100%' }}>
{showLegend && (
<Typography
variant="caption"
sx={{
display: 'block',
mb: 0.5,
mb: 0.75,
color: 'text.secondary',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.06em',
fontSize: '0.65rem',
letterSpacing: '0.08em',
fontSize: '0.6rem',
lineHeight: 1,
}}
>
{t('flavorDetail.ratingDistribution')}
@@ -58,30 +59,26 @@ const RatingDistribution: React.FC<RatingDistributionProps> = ({
sx={{
display: 'flex',
alignItems: 'flex-end',
gap: 0.5,
gap: 0.4,
height,
px: 0.5,
py: 0.5,
borderRadius: theme.tokens.radius.sm + 'px',
bgcolor: alpha(theme.palette.text.primary, 0.04),
border: '1px solid',
borderColor: 'divider',
width: '100%',
}}
>
{counts.map((c, i) => {
const score = SCORES[i];
const ratio = c / max;
const minH = c > 0 ? 4 : 2;
return (
<Tooltip
key={score}
title={`${score} / 10 — ${c} ${c === 1 ? 'rating' : 'ratings'}`}
title={`${score} / 10 — ${c}`}
placement="top"
arrow
disableInteractive
>
<Box
sx={{
flex: 1,
minWidth: 0,
display: 'flex',
alignItems: 'flex-end',
height: '100%',
@@ -91,10 +88,9 @@ const RatingDistribution: React.FC<RatingDistributionProps> = ({
<Box
sx={{
width: '100%',
height: c > 0 ? `calc(${ratio * 100}% + ${minH}px)` : `${minH}px`,
minHeight: minH,
height: c > 0 ? `max(${ratio * 100}%, 6px)` : '3px',
bgcolor: c > 0 ? interpolate(score) : lowColor,
borderRadius: '3px',
borderRadius: '2px',
transition: 'height 240ms ease, background-color 200ms ease',
}}
/>
@@ -103,21 +99,6 @@ const RatingDistribution: React.FC<RatingDistributionProps> = ({
);
})}
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
mt: 0.25,
px: 0.5,
}}
>
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.65rem' }}>
1
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.65rem' }}>
10
</Typography>
</Box>
</Box>
);
};
+4 -2
View File
@@ -36,7 +36,7 @@ const resources = {
exploreFlavors: 'Explore New Flavors',
allRated: "Amazing! You've rated everything! 🏆",
myReviews: 'My Rated Flavors',
exploreNew: 'Explore New',
exploreNew: 'To Try',
editingReview: 'Editing review for {{flavor}}',
newScore: 'New Score:',
searchMissing: "Search flavors you haven't rated...",
@@ -134,6 +134,7 @@ const resources = {
delete: 'Delete',
save: 'Save',
cancel: 'Cancel',
all: 'All',
},
status: {
limited: 'Limited',
@@ -338,7 +339,7 @@ const resources = {
exploreFlavors: 'Neue Sorten entdecken',
allRated: 'Wahnsinn! Du hast alles bewertet! 🏆',
myReviews: 'Meine Sammlungen',
exploreNew: 'Neu entdecken',
exploreNew: 'Zum Probieren',
editingReview: 'Rezension bearbeiten für {{flavor}}',
newScore: 'Neue Bewertung:',
searchMissing: 'Suche nach Sorten, die du noch nicht bewertet hast...',
@@ -436,6 +437,7 @@ const resources = {
delete: 'Löschen',
save: 'Speichern',
cancel: 'Abbrechen',
all: 'Alle',
},
status: {
limited: 'Limitiert',
+232 -116
View File
@@ -4,7 +4,8 @@ import {
CardContent,
Box,
Button,
CircularProgress,
IconButton,
Skeleton,
Avatar,
Tabs,
Tab,
@@ -30,7 +31,7 @@ import CommentIcon from '@mui/icons-material/Comment';
import VerifiedIcon from '@mui/icons-material/Verified';
import SearchIcon from '@mui/icons-material/Search';
import ColorThief from 'colorthief';
import { useNavigate, Link } from 'react-router-dom';
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useDashboard } from '../api/queries/useDashboard';
import {
@@ -54,13 +55,26 @@ const Dashboard: React.FC = () => {
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('lg'));
const isXs = useMediaQuery(theme.breakpoints.down('sm'));
useTitle(t('dashboard.title'));
const { data, isLoading: loading } = useDashboard();
const updateRating = useUpdateRating();
const deleteRating = useDeleteRating();
const createReply = useCreateReply();
const deleteReply = useDeleteReply();
const [activeTab, setActiveTab] = useState(0);
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get('tab') === 'explore' ? 1 : 0;
const setActiveTab = (v: number) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (v === 1) next.set('tab', 'explore');
else next.delete('tab');
return next;
},
{ replace: true },
);
};
const [palette, setPalette] = useState<string[]>([]);
const [exploreCategory, setExploreCategory] = useState<string>('All');
@@ -93,6 +107,10 @@ const Dashboard: React.FC = () => {
};
useEffect(() => {
if (isXs) {
setPalette([]);
return;
}
if (data?.user?.avatar) {
const img = new Image();
img.crossOrigin = 'Anonymous';
@@ -125,7 +143,7 @@ const Dashboard: React.FC = () => {
} else {
setPalette([]);
}
}, [data?.user?.avatar]);
}, [data?.user?.avatar, isXs]);
const handleUpdateRating = async (ratingId: number) => {
try {
@@ -221,9 +239,14 @@ const Dashboard: React.FC = () => {
if (loading)
return (
<PageShell>
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
<CircularProgress />
</Box>
<Stack spacing={3}>
<Skeleton variant="rounded" height={isXs ? 140 : 220} sx={{ borderRadius: 2 }} />
<Skeleton variant="rounded" height={48} />
<Skeleton variant="rounded" height={56} />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} variant="rounded" height={160} sx={{ borderRadius: 2 }} />
))}
</Stack>
</PageShell>
);
if (!data)
@@ -238,51 +261,83 @@ const Dashboard: React.FC = () => {
{/* Action Bar */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant="outlined"
onClick={handleGoBack}
startIcon={<ArrowBackIcon />}
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
>
{t('common.back')}
</Button>
{isXs ? (
<IconButton
onClick={handleGoBack}
aria-label={t('common.back')}
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<ArrowBackIcon />
</IconButton>
) : (
<Button
variant="outlined"
onClick={handleGoBack}
startIcon={<ArrowBackIcon />}
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
>
{t('common.back')}
</Button>
)}
<Typography variant="h5" sx={{ fontWeight: '900', display: { xs: 'none', md: 'block' } }}>
{t('dashboard.title')}
</Typography>
</Box>
<Button
variant="contained"
onClick={handleCopyLink}
startIcon={<ShareIcon />}
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
>
{t('dashboard.shareProfile')}
</Button>
{isXs ? (
<Tooltip title={t('dashboard.shareProfile')}>
<IconButton
color="primary"
onClick={handleCopyLink}
aria-label={t('dashboard.shareProfile')}
sx={{ bgcolor: 'primary.main', color: 'primary.contrastText', borderRadius: 2 }}
>
<ShareIcon />
</IconButton>
</Tooltip>
) : (
<Button
variant="contained"
onClick={handleCopyLink}
startIcon={<ShareIcon />}
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
>
{t('dashboard.shareProfile')}
</Button>
)}
</Box>
{/* Profile Header — keeps inline ColorThief gradient per Phase-2 risk fallback */}
<GlassCard intensity="strong" sx={{ overflow: 'hidden' }}>
<Box sx={{ height: { xs: 80, sm: 100 }, position: 'relative', overflow: 'hidden' }}>
<Box
sx={{
position: 'absolute',
inset: 0,
background: `
radial-gradient(at 0% 0%, ${alpha(palette[0] || theme.palette.primary.main, 0.6)} 0px, transparent 55%),
radial-gradient(at 100% 0%, ${alpha(palette[1] || theme.palette.secondary.main, 0.5)} 0px, transparent 55%),
radial-gradient(at 50% 100%, ${alpha(theme.palette.primary.main, 0.3)} 0px, transparent 55%),
linear-gradient(135deg, ${alpha(palette[0] || theme.palette.primary.main, 0.1)} 0%, ${alpha(palette[1] || theme.palette.secondary.main, 0.1)} 100%)
`,
}}
/>
</Box>
{!isXs && (
<Box sx={{ height: 100, position: 'relative', overflow: 'hidden' }}>
<Box
sx={{
position: 'absolute',
inset: 0,
background: `
radial-gradient(at 0% 0%, ${alpha(palette[0] || theme.palette.primary.main, 0.6)} 0px, transparent 55%),
radial-gradient(at 100% 0%, ${alpha(palette[1] || theme.palette.secondary.main, 0.5)} 0px, transparent 55%),
radial-gradient(at 50% 100%, ${alpha(theme.palette.primary.main, 0.3)} 0px, transparent 55%),
linear-gradient(135deg, ${alpha(palette[0] || theme.palette.primary.main, 0.1)} 0%, ${alpha(palette[1] || theme.palette.secondary.main, 0.1)} 100%)
`,
}}
/>
</Box>
)}
<CardContent sx={{ pt: 0, px: { xs: 2, sm: 4 }, pb: 4, position: 'relative' }}>
<CardContent
sx={{
pt: { xs: 2, sm: 0 },
px: { xs: 2, sm: 4 },
pb: { xs: 2, sm: 4 },
position: 'relative',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
flexDirection: { xs: 'row', sm: 'row' },
alignItems: { xs: 'center', sm: 'flex-end' },
gap: { xs: 2, sm: 4 },
}}
@@ -290,22 +345,23 @@ const Dashboard: React.FC = () => {
<Box
sx={{
position: 'relative',
mt: { xs: -5, sm: -6 },
mt: { xs: 0, sm: -6 },
p: 0.5,
borderRadius: '50%',
background: `linear-gradient(135deg, ${palette[0] || theme.palette.primary.main}, ${theme.palette.secondary.main})`,
boxShadow: '0 12px 48px rgba(0,0,0,0.25)',
boxShadow: { xs: 'none', sm: '0 12px 48px rgba(0,0,0,0.25)' },
display: 'flex',
flexShrink: 0,
}}
>
<Avatar
src={data.user.avatar || undefined}
sx={{
width: { xs: 90, sm: 110 },
height: { xs: 90, sm: 110 },
border: '4px solid',
width: { xs: 56, sm: 110 },
height: { xs: 56, sm: 110 },
border: { xs: '2px solid', sm: '4px solid' },
borderColor: theme.palette.background.paper,
fontSize: '3rem',
fontSize: { xs: '1.5rem', sm: '3rem' },
bgcolor: theme.palette.background.paper,
color: palette[0] || 'primary.main',
}}
@@ -314,15 +370,34 @@ const Dashboard: React.FC = () => {
</Avatar>
</Box>
<Box sx={{ flex: 1, textAlign: { xs: 'center', sm: 'left' }, mt: { xs: 1, sm: 4 } }}>
<Typography variant="h3" sx={{ fontWeight: '900', mb: 0.5, letterSpacing: -1 }}>
<Box
sx={{
flex: 1,
minWidth: 0,
textAlign: { xs: 'left', sm: 'left' },
mt: { xs: 0, sm: 4 },
}}
>
<Typography
variant={isXs ? 'h5' : 'h3'}
sx={{
fontWeight: '900',
mb: 0.25,
letterSpacing: -1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{data.user.username}
</Typography>
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
{t('dashboard.welcome', { username: data.user.username })}
</Typography>
{!isXs && (
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
{t('dashboard.welcome', { username: data.user.username })}
</Typography>
)}
<Box sx={{ mt: 2 }}>
<Box sx={{ mt: { xs: 1, sm: 2 } }}>
<GlassSurface
intensity="subtle"
sx={{ display: 'inline-flex', borderRadius: 1, overflow: 'hidden' }}
@@ -332,7 +407,13 @@ const Dashboard: React.FC = () => {
{ label: t('dashboard.missing'), val: data.missing_count },
].map((stat, i) => (
<React.Fragment key={stat.label}>
<Box sx={{ py: 1, width: { xs: 100, sm: 140 }, textAlign: 'center' }}>
<Box
sx={{
py: { xs: 0.5, sm: 1 },
width: { xs: 80, sm: 140 },
textAlign: 'center',
}}
>
<Typography
variant="h6"
sx={{ fontWeight: '900', lineHeight: 1, color: 'text.primary' }}
@@ -391,9 +472,9 @@ const Dashboard: React.FC = () => {
{/* TAB 0: MY REVIEWS */}
{activeTab === 0 && (
<Box>
<GlassSurface intensity="subtle" sx={{ p: 2, mb: 3 }}>
<Grid container spacing={{ xs: 2, lg: 1.5 }} alignItems="center">
<Grid size={{ xs: 12, lg: 4, xl: 5 }}>
<GlassSurface intensity="subtle" sx={{ p: { xs: 1.5, sm: 2 }, mb: 3 }}>
<Grid container spacing={{ xs: 1.5, lg: 1.5 }} alignItems="center">
<Grid size={{ xs: 8, sm: 12, lg: 4, xl: 5 }}>
<TextField
fullWidth
size="small"
@@ -412,7 +493,29 @@ const Dashboard: React.FC = () => {
/>
</Grid>
<Grid size={{ xs: 12, lg: 'auto' }} sx={{ flexGrow: 1, overflow: 'hidden' }}>
<Grid size={{ xs: 4, sm: 12, lg: 'auto' }} order={{ xs: 2, sm: 3, lg: 3 }}>
<Select
size="small"
fullWidth
value={ratedSort}
onChange={(e) => setRatedSort(e.target.value as 'date' | 'rating')}
startAdornment={
<InputAdornment position="start">
<SortIcon fontSize="small" />
</InputAdornment>
}
sx={{ minWidth: { lg: 140 }, fontWeight: 'bold' }}
>
<MenuItem value="date">{t('dashboard.date')}</MenuItem>
<MenuItem value="rating">{t('dashboard.rating')}</MenuItem>
</Select>
</Grid>
<Grid
size={{ xs: 12, lg: 'auto' }}
order={{ xs: 3, sm: 2, lg: 2 }}
sx={{ flexGrow: 1, overflow: 'hidden' }}
>
<Box
sx={{
display: 'flex',
@@ -427,7 +530,7 @@ const Dashboard: React.FC = () => {
key={cat}
label={
cat === 'All'
? t('nav.home').replace('Home', 'All')
? t('common.all')
: t(
`categories.${data.my_ratings.find((r) => r.category_name === cat)?.category_slug}`,
{ defaultValue: cat },
@@ -441,23 +544,6 @@ const Dashboard: React.FC = () => {
))}
</Box>
</Grid>
<Grid size={{ xs: 12, lg: 'auto' }}>
<Select
size="small"
value={ratedSort}
onChange={(e) => setRatedSort(e.target.value as 'date' | 'rating')}
startAdornment={
<InputAdornment position="start">
<SortIcon fontSize="small" />
</InputAdornment>
}
sx={{ minWidth: { xs: '100%', lg: 140 }, fontWeight: 'bold' }}
>
<MenuItem value="date">{t('dashboard.date')}</MenuItem>
<MenuItem value="rating">{t('dashboard.rating')}</MenuItem>
</Select>
</Grid>
</Grid>
</GlassSurface>
@@ -520,16 +606,18 @@ const Dashboard: React.FC = () => {
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
justifyContent: 'space-between',
alignItems: 'flex-start',
alignItems: { xs: 'stretch', sm: 'flex-start' },
gap: { xs: 1.5, sm: 2 },
mb: 2,
}}
>
<Box sx={{ display: 'flex', gap: 2 }}>
<Box sx={{ display: 'flex', gap: 2, minWidth: 0, flex: 1 }}>
<Box
sx={{
width: 64,
height: 64,
width: { xs: 56, sm: 64 },
height: { xs: 56, sm: 64 },
bgcolor: 'background.default',
border: '1px solid',
borderColor: 'divider',
@@ -545,8 +633,15 @@ const Dashboard: React.FC = () => {
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
<Box>
<Typography variant="h6" sx={{ fontWeight: 'bold', lineHeight: 1.2 }}>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant={isXs ? 'subtitle1' : 'h6'}
sx={{
fontWeight: 'bold',
lineHeight: 1.2,
wordBreak: 'break-word',
}}
>
<Link
to={`/flavor/${rating.flavor}`}
style={{ color: 'inherit', textDecoration: 'none' }}
@@ -558,7 +653,8 @@ const Dashboard: React.FC = () => {
direction="row"
spacing={1}
alignItems="center"
sx={{ mt: 0.5 }}
flexWrap="wrap"
sx={{ mt: 0.5, rowGap: 0.5 }}
>
<Typography variant="caption" color="text.secondary">
{t(`categories.${rating.category_slug}`, {
@@ -574,7 +670,12 @@ const Dashboard: React.FC = () => {
</Stack>
</Box>
</Box>
<RatingBadge score={rating.score} size={isMobile ? 'medium' : 'large'} />
<Box sx={{ alignSelf: { xs: 'flex-start', sm: 'auto' }, flexShrink: 0 }}>
<RatingBadge
score={rating.score}
size={isXs ? 'medium' : isMobile ? 'medium' : 'large'}
/>
</Box>
</Box>
<Box
@@ -692,23 +793,33 @@ const Dashboard: React.FC = () => {
</Typography>
</Box>
))}
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
<MentionTextField
placeholder={t('community.writeReply')}
value={replyInputs[rating.id] || ''}
onChange={(val) =>
setReplyInputs({ ...replyInputs, [rating.id]: val })
}
onKeyDown={(e) =>
e.key === 'Enter' && !e.shiftKey && handleReplySubmit(rating.id)
}
/>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1}
sx={{ mt: 2 }}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<MentionTextField
placeholder={t('community.writeReply')}
value={replyInputs[rating.id] || ''}
onChange={(val) =>
setReplyInputs({ ...replyInputs, [rating.id]: val })
}
onKeyDown={(e) =>
e.key === 'Enter' && !e.shiftKey && handleReplySubmit(rating.id)
}
/>
</Box>
<Button
variant="contained"
size="small"
onClick={() => handleReplySubmit(rating.id)}
disabled={!replyInputs[rating.id]}
sx={{ height: 40, px: 3 }}
sx={{
height: 40,
px: 3,
alignSelf: { xs: 'flex-end', sm: 'auto' },
}}
>
{t('common.reply')}
</Button>
@@ -728,9 +839,9 @@ const Dashboard: React.FC = () => {
{/* TAB 1: EXPLORE NEW */}
{activeTab === 1 && (
<Box>
<GlassSurface intensity="subtle" sx={{ p: 2, mb: 3 }}>
<Grid container spacing={{ xs: 2, lg: 1.5 }} alignItems="center">
<Grid size={{ xs: 12, lg: 4, xl: 5 }}>
<GlassSurface intensity="subtle" sx={{ p: { xs: 1.5, sm: 2 }, mb: 3 }}>
<Grid container spacing={{ xs: 1.5, lg: 1.5 }} alignItems="center">
<Grid size={{ xs: 7, sm: 12, lg: 4, xl: 5 }}>
<TextField
fullWidth
size="small"
@@ -749,7 +860,29 @@ const Dashboard: React.FC = () => {
/>
</Grid>
<Grid size={{ xs: 12, lg: 'auto' }} sx={{ flexGrow: 1, overflow: 'hidden' }}>
<Grid size={{ xs: 5, sm: 12, lg: 'auto' }} order={{ xs: 2, sm: 3, lg: 3 }}>
<Select
size="small"
fullWidth
value={exploreSort}
onChange={(e) => setExploreSort(e.target.value as 'community' | 'circle')}
startAdornment={
<InputAdornment position="start">
<SortIcon fontSize="small" />
</InputAdornment>
}
sx={{ minWidth: { lg: 180 }, fontWeight: 'bold' }}
>
<MenuItem value="community">{t('dashboard.communityRating')}</MenuItem>
<MenuItem value="circle">{t('dashboard.circleRating')}</MenuItem>
</Select>
</Grid>
<Grid
size={{ xs: 12, lg: 'auto' }}
order={{ xs: 3, sm: 2, lg: 2 }}
sx={{ flexGrow: 1, overflow: 'hidden' }}
>
<Box
sx={{
display: 'flex',
@@ -764,7 +897,7 @@ const Dashboard: React.FC = () => {
key={cat}
label={
cat === 'All'
? t('nav.home').replace('Home', 'All')
? t('common.all')
: t(
`categories.${data.missing_flavors.find((f) => f.category_name === cat)?.category_slug}`,
{ defaultValue: cat },
@@ -778,23 +911,6 @@ const Dashboard: React.FC = () => {
))}
</Box>
</Grid>
<Grid size={{ xs: 12, lg: 'auto' }}>
<Select
size="small"
value={exploreSort}
onChange={(e) => setExploreSort(e.target.value as 'community' | 'circle')}
startAdornment={
<InputAdornment position="start">
<SortIcon fontSize="small" />
</InputAdornment>
}
sx={{ minWidth: { xs: '100%', lg: 180 }, fontWeight: 'bold' }}
>
<MenuItem value="community">{t('dashboard.communityRating')}</MenuItem>
<MenuItem value="circle">{t('dashboard.circleRating')}</MenuItem>
</Select>
</Grid>
</Grid>
</GlassSurface>
+3 -1
View File
@@ -92,7 +92,9 @@ const MainPage: React.FC<MainPageProps> = (_props) => {
const { data: topFlavorsAll = [], isLoading: topLoading } = useTopFlavors(null);
const { data: newestFlavors = [], isLoading: newestLoading } = useNewestFlavors();
const topFlavors = topFlavorsAll.slice(0, TOP_LIMIT);
const topFlavors = topFlavorsAll
.filter((f) => f.category_slug !== 'packs-and-other')
.slice(0, TOP_LIMIT);
const safeIndex = topFlavors.length > 0 ? activeIndex % topFlavors.length : 0;
const currentTop = topFlavors[safeIndex];
const featuredReview = currentTop?.ratings?.find((r) => r.comment) || null;