mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
fixed mobile view and dashboard
This commit is contained in:
1 parent
b521c82042
commit
caf3830d72
10 files changed
+530
-324
No files matched your search
+256
-100
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user