diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 05762a2..4c33a78 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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 = (
-
-
-
- setDrawerOpen(false)}
- style={{ color: 'inherit', textDecoration: 'none' }}
+
+ {user ? (
+
+
- Holy Flavors Archive
-
-
-
-
-
+ {user.username.charAt(0).toUpperCase()}
+
+
+
+ {user.username}
+
+
+ {t('nav.profile')}
+
+
+
+ ) : (
+
+
+
+ Holy Flavors Archive
+
+
+
+ )}
+
+
- setDrawerOpen(false)}>
+
+
+
+
{user && (
- setDrawerOpen(false)}>
+
+
+
+
)}
- setCatOpen(!catOpen)}>
+ setCatOpen(!catOpen)}
+ selected={location.pathname.startsWith('/category/')}
+ >
+
+
+
{catOpen ? : }
@@ -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 }}
>
@@ -164,118 +247,184 @@ const App: React.FC = () => {
-
-
- {user ? (
+ {user && (
<>
+
+
setDrawerOpen(false)}
+ onClick={closeDrawer}
+ selected={location.pathname === `/profile/${user.username}`}
>
-
-
- {user.username.charAt(0).toUpperCase()}
-
-
- {user.username}
-
-
- }
- />
+
+
+
+
- setDrawerOpen(false)}>
+
+
+
+
- setDrawerOpen(false)}>
+
+
+
+
- setDrawerOpen(false)}>
+
+
+
+
{user.is_superuser && (
-
-
-
-
-
- )}
-
- {user.is_superuser && (
-
- setDrawerOpen(false)}
- >
-
-
-
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
)}
{following.length > 0 && (
<>
-
- {t('nav.following')}
-
- {following.map((f) => (
-
- setDrawerOpen(false)}
- >
-
-
- {!f.avatar && f.username.charAt(0).toUpperCase()}
-
-
-
-
-
- ))}
+ setFollowingOpen(!followingOpen)}>
+
+
+
+
+ {t('nav.following')}
+
+ {following.length}
+
+
+ }
+ />
+ {followingOpen ? : }
+
+
+
+ {following.map((f) => (
+
+
+
+
+ {!f.avatar && f.username.charAt(0).toUpperCase()}
+
+
+
+
+
+ ))}
+
+
>
)}
+ >
+ )}
+ {!user && (
+ <>
+
-
-
+
+
+
+
+
>
- ) : (
-
- setDrawerOpen(false)}>
-
-
-
)}
+
+ {user && (
+
+ {
+ closeDrawer();
+ logout();
+ }}
+ >
+
+
+
+
+
+
+ )}
);
@@ -528,9 +677,16 @@ const App: React.FC = () => {
- setDrawerOpen(false)}>
+ setDrawerOpen(true)}
+ onClose={() => setDrawerOpen(false)}
+ disableBackdropTransition
+ disableDiscovery
+ >
{drawer}
-
+
{
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) => {
diff --git a/frontend/src/components/RatingBadge.tsx b/frontend/src/components/RatingBadge.tsx
index 54c4bd0..094b9cf 100644
--- a/frontend/src/components/RatingBadge.tsx
+++ b/frontend/src/components/RatingBadge.tsx
@@ -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 = ({ score, size = 'medium', sx })
= ({ 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,
}}
>
- = ({ score, size = 'medium', sx })
fontSize: dims.score,
fontVariantNumeric: 'tabular-nums',
letterSpacing: '-0.02em',
+ display: 'inline-block',
}}
>
{display}
-
-
+ = ({ score, size = 'medium', sx })
fontSize: dims.suffix,
opacity: 0.75,
letterSpacing: '0.02em',
+ display: 'inline-block',
+ mt: '1px',
}}
>
/ 10
-
+
);
};
diff --git a/frontend/src/components/ui/FlavorCard.tsx b/frontend/src/components/ui/FlavorCard.tsx
index 9d037f4..92af02d 100644
--- a/frontend/src/components/ui/FlavorCard.tsx
+++ b/frontend/src/components/ui/FlavorCard.tsx
@@ -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 ? (
-
- ) : (
-
- —
-
- )}
+ {hasRating && }
{caption && (
{caption}
diff --git a/frontend/src/components/ui/HeroGallery.tsx b/frontend/src/components/ui/HeroGallery.tsx
index 08af4a0..4e2e6f2 100644
--- a/frontend/src/components/ui/HeroGallery.tsx
+++ b/frontend/src/components/ui/HeroGallery.tsx
@@ -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 = ({ 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(null);
const updateState = useCallback(() => {
if (!emblaApi) return;
@@ -38,23 +36,12 @@ const Carousel: React.FC = ({ 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 = ({ images, alt, onMainClick, variant }
- {/* Counter pill */}
- {images.length > 1 && (
-
-
- {selectedIndex + 1} / {images.length}
-
-
- )}
-
{/* Chevrons (desktop only) */}
{images.length > 1 && (
= ({
distribution,
total,
- height = 56,
+ height = 48,
showLegend = true,
}) => {
const theme = useTheme();
@@ -29,24 +29,25 @@ const RatingDistribution: React.FC = ({
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 (
-
+
{showLegend && (
{t('flavorDetail.ratingDistribution')}
@@ -58,30 +59,26 @@ const RatingDistribution: React.FC = ({
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 (
= ({
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 = ({
);
})}
-
-
- 1
-
-
- 10
-
-
);
};
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 4597e25..6572773 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -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',
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index 9fbe1ad..1ad2d19 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -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([]);
const [exploreCategory, setExploreCategory] = useState('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 (
-
-
-
+
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
);
if (!data)
@@ -238,51 +261,83 @@ const Dashboard: React.FC = () => {
{/* Action Bar */}
- }
- sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
- >
- {t('common.back')}
-
+ {isXs ? (
+
+
+
+ ) : (
+ }
+ sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
+ >
+ {t('common.back')}
+
+ )}
{t('dashboard.title')}
- }
- sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
- >
- {t('dashboard.shareProfile')}
-
+ {isXs ? (
+
+
+
+
+
+ ) : (
+ }
+ sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
+ >
+ {t('dashboard.shareProfile')}
+
+ )}
{/* Profile Header — keeps inline ColorThief gradient per Phase-2 risk fallback */}
-
-
-
+ {!isXs && (
+
+
+
+ )}
-
+
{
{
-
-
+
+
{data.user.username}
-
- {t('dashboard.welcome', { username: data.user.username })}
-
+ {!isXs && (
+
+ {t('dashboard.welcome', { username: data.user.username })}
+
+ )}
-
+
{
{ label: t('dashboard.missing'), val: data.missing_count },
].map((stat, i) => (
-
+
{
{/* TAB 0: MY REVIEWS */}
{activeTab === 0 && (
-
-
-
+
+
+
{
/>
-
+
+
+
+
+
{
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 = () => {
))}
-
-
-
-
@@ -520,16 +606,18 @@ const Dashboard: React.FC = () => {
-
+
{
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
-
-
+
+
{
direction="row"
spacing={1}
alignItems="center"
- sx={{ mt: 0.5 }}
+ flexWrap="wrap"
+ sx={{ mt: 0.5, rowGap: 0.5 }}
>
{t(`categories.${rating.category_slug}`, {
@@ -574,7 +670,12 @@ const Dashboard: React.FC = () => {
-
+
+
+
{
))}
-
-
- setReplyInputs({ ...replyInputs, [rating.id]: val })
- }
- onKeyDown={(e) =>
- e.key === 'Enter' && !e.shiftKey && handleReplySubmit(rating.id)
- }
- />
+
+
+
+ setReplyInputs({ ...replyInputs, [rating.id]: val })
+ }
+ onKeyDown={(e) =>
+ e.key === 'Enter' && !e.shiftKey && handleReplySubmit(rating.id)
+ }
+ />
+
@@ -728,9 +839,9 @@ const Dashboard: React.FC = () => {
{/* TAB 1: EXPLORE NEW */}
{activeTab === 1 && (
-
-
-
+
+
+
{
/>
-
+
+
+
+
+
{
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 = () => {
))}
-
-
-
-
diff --git a/frontend/src/pages/MainPage.tsx b/frontend/src/pages/MainPage.tsx
index dd9460d..269344e 100644
--- a/frontend/src/pages/MainPage.tsx
+++ b/frontend/src/pages/MainPage.tsx
@@ -92,7 +92,9 @@ const MainPage: React.FC = (_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;