Merge branch 'main' into narl.io

This commit is contained in:
nvrl committed 2026-02-16 15:36:45 +01:00
commit 83e8637746
3 files changed
+139 -138

No files matched your search

+3 -1
View File
@@ -34,11 +34,13 @@ class RatingSerializer(serializers.ModelSerializer):
user_avatar = serializers.SerializerMethodField()
flavor_name = serializers.CharField(source='flavor.name', read_only=True)
flavor_image = serializers.SerializerMethodField()
category_name = serializers.CharField(source='flavor.category.name', read_only=True)
category_slug = serializers.CharField(source='flavor.category.slug', read_only=True)
replies = ReplySerializer(many=True, read_only=True)
class Meta:
model = Rating
fields = ['id', 'user', 'user_avatar', 'flavor', 'flavor_name', 'flavor_image', 'score', 'comment', 'created_at', 'replies']
fields = ['id', 'user', 'user_avatar', 'flavor', 'flavor_name', 'flavor_image', 'category_name', 'category_slug', 'score', 'comment', 'created_at', 'replies']
def get_user_avatar(self, obj):
if obj.user.avatar:
+71 -102
View File
@@ -18,7 +18,6 @@ import {
ListItem,
ListItemButton,
ListItemText,
useMediaQuery,
TextField,
InputAdornment,
Autocomplete
@@ -75,7 +74,7 @@ const GlobalSearch = () => {
};
return (
<Box sx={{ flexGrow: 1, maxWidth: { xs: 'none', sm: 400 }, mx: { xs: 1, sm: 2 } }}>
<Box sx={{ flexGrow: 1, maxWidth: { xs: 'none', sm: 400 }, mx: { xs: 1, sm: 4 } }}>
<Autocomplete
freeSolo
size="small"
@@ -96,10 +95,10 @@ const GlobalSearch = () => {
renderInput={(params) => (
<TextField
{...params}
placeholder="Search..."
placeholder="Search flavors..."
sx={{
bgcolor: 'action.hover',
borderRadius: 1,
borderRadius: 2,
'& .MuiOutlinedInput-root': {
color: 'inherit',
'& fieldset': { border: 'none' },
@@ -140,7 +139,6 @@ const App: React.FC = () => {
const [user, setUser] = useState<{username: string, avatar: string | null} | null>(null);
const [loadingUser, setLoadingUser] = useState(true);
const [drawerOpen, setDrawerOpen] = useState(false);
const isMobile = useMediaQuery('(max-width:900px)');
useEffect(() => {
const fetchUser = async () => {
@@ -184,33 +182,42 @@ const App: React.FC = () => {
window.location.href = '/';
};
const navItems = user ? [
{ label: 'Catalog', path: '/' },
{ label: 'Dashboard', path: '/dashboard' },
{ label: 'Settings', path: '/settings' },
] : [
{ label: 'Catalog', path: '/' },
{ label: 'Login', path: '/login' },
];
const drawer = (
<Box onClick={() => setDrawerOpen(false)} sx={{ textAlign: 'center' }}>
<Typography variant="h6" sx={{ my: 2 }}>Holy Flavors</Typography>
<Divider />
<List>
{navItems.map((item) => (
<ListItem key={item.label} disablePadding>
<ListItemButton component={Link} to={item.path} sx={{ textAlign: 'center' }}>
<ListItemText primary={item.label} />
<ListItem disablePadding>
<ListItemButton component={Link} to="/" sx={{ textAlign: 'center' }}>
<ListItemText primary="Catalog" />
</ListItemButton>
</ListItem>
))}
</ListItem>
{user && (
<ListItem disablePadding>
<ListItemButton onClick={handleLogout} sx={{ textAlign: 'center' }}>
<ListItemText primary="Logout" />
</ListItemButton>
</ListItem>
<>
<ListItem disablePadding>
<ListItemButton component={Link} to="/dashboard" sx={{ textAlign: 'center' }}>
<ListItemText primary="Dashboard" />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton component={Link} to="/settings" sx={{ textAlign: 'center' }}>
<ListItemText primary="Settings" />
</ListItemButton>
</ListItem>
<Divider />
<ListItem disablePadding>
<ListItemButton onClick={handleLogout} sx={{ textAlign: 'center' }}>
<ListItemText primary="Logout" />
</ListItemButton>
</ListItem>
</>
)}
{!user && !loadingUser && (
<ListItem disablePadding>
<ListItemButton component={Link} to="/login" sx={{ textAlign: 'center' }}>
<ListItemText primary="Login" />
</ListItemButton>
</ListItem>
)}
</List>
</Box>
@@ -220,19 +227,16 @@ const App: React.FC = () => {
<ThemeProvider theme={theme}>
<CssBaseline />
<Router>
<Box sx={{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
width: '100vw',
maxWidth: '100%',
overflowX: 'hidden'
}}>
<AppBar position="fixed" elevation={1}>
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', width: '100%', overflowX: 'hidden' }}>
<AppBar position="fixed" elevation={0} sx={{
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
color: 'text.primary'
}}>
<Toolbar sx={{ minHeight: { xs: 56, sm: 64 }, px: { xs: 1, sm: 4, md: 6 } }}>
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={() => setDrawerOpen(true)}
sx={{ mr: { xs: 0, sm: 2 }, display: { md: 'none' } }}
@@ -240,75 +244,41 @@ const App: React.FC = () => {
<MenuIcon />
</IconButton>
<Typography variant="h6" component="div" sx={{ fontWeight: 'bold', display: { xs: 'none', md: 'block' } }}>
<Typography variant="h6" component="div" sx={{ fontWeight: 'bold', display: { xs: 'none', sm: 'block' } }}>
<Link to="/" style={{ color: 'inherit', textDecoration: 'none' }}>Holy Flavors</Link>
</Typography>
<GlobalSearch />
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box sx={{ flexGrow: 1 }} />
{!isMobile && !loadingUser && (
<Box sx={{ display: 'flex', gap: 1 }}>
<Button color="inherit" component={Link} to="/">Catalog</Button>
</Box>
)}
{!loadingUser && (
user ? (
<>
<IconButton color="inherit" onClick={(e) => setAnchorEl(e.currentTarget)} sx={{ ml: 1 }}>
<Avatar src={user.avatar || undefined} sx={{ width: 32, height: 32 }}>
{!user.avatar && user.username.charAt(0).toUpperCase()}
</Avatar>
</IconButton>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
>
<MenuItem component={Link} to="/dashboard" onClick={() => setAnchorEl(null)}>Dashboard</MenuItem>
<MenuItem component={Link} to="/settings" onClick={() => setAnchorEl(null)}>Settings</MenuItem>
<Divider />
<MenuItem onClick={handleLogout}>Logout</MenuItem>
</Menu>
</>
) : (
<Button color="inherit" component={Link} to="/login">Login</Button>
)
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{!loadingUser && (
user ? (
<>
<IconButton color="inherit" onClick={(e) => setAnchorEl(e.currentTarget)}>
<Avatar src={user.avatar || undefined} sx={{ width: 36, height: 36, border: '2px solid', borderColor: 'primary.main' }}>
{!user.avatar && user.username.charAt(0).toUpperCase()}
</Avatar>
</IconButton>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
elevation={3}
sx={{ mt: 1 }}
>
<MenuItem component={Link} to="/dashboard" onClick={() => setAnchorEl(null)}>Dashboard</MenuItem>
<MenuItem component={Link} to="/settings" onClick={() => setAnchorEl(null)}>Settings</MenuItem>
<Divider />
<MenuItem onClick={handleLogout} sx={{ color: 'error.main' }}>Logout</MenuItem>
</Menu>
</>
) : (
<Button variant="contained" component={Link} to="/login" sx={{ borderRadius: 2 }}>Login</Button>
)
)}
</Box>
</Toolbar>
</AppBar>
<Toolbar sx={{ minHeight: { xs: 56, sm: 64 } }} />
@@ -319,10 +289,9 @@ const App: React.FC = () => {
<Route path="/category/:slug" element={<CategoryFlavors />} />
<Route path="/flavor/:id" element={<FlavorDetail />} />
<Route path="/profile/:username" element={<PublicProfile />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings themeName={themeName} onThemeChange={handleThemeChange} />} />
<Route path="/login" element={<Login />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings themeName={themeName} onThemeChange={handleThemeChange} />} />
<Route path="/login" element={<Login />} />
</Routes>
</Box>
+65 -35
View File
@@ -11,7 +11,8 @@ import {
CircularProgress,
Avatar,
Tabs,
Tab
Tab,
IconButton
} from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { Link } from 'react-router-dom';
@@ -56,10 +57,11 @@ const Dashboard: React.FC = () => {
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
if (!data) return <Typography>Please login to view dashboard.</Typography>;
// Helper to group by category
// Robust grouping helper
const groupBy = (array: any[]) => {
return array.reduce((result, currentValue) => {
const groupKey = currentValue.flavor?.category_name || currentValue.category_name;
// Use category_name from either the rating or the flavor object
const groupKey = currentValue.category_name || currentValue.flavor?.category_name || 'Other';
(result[groupKey] = result[groupKey] || []).push(currentValue);
return result;
}, {});
@@ -70,47 +72,58 @@ const Dashboard: React.FC = () => {
return (
<Container maxWidth={false} sx={{ px: { xs: 2, sm: 4, md: 6 }, py: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4, flexWrap: 'wrap', gap: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 6, flexWrap: 'wrap', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Avatar src={data.user.avatar || undefined} sx={{ width: 48, height: 48 }}>
<Avatar src={data.user.avatar || undefined} sx={{ width: 64, height: 64, border: '3px solid', borderColor: 'primary.main' }}>
{!data.user.avatar && data.user.username.charAt(0).toUpperCase()}
</Avatar>
<Typography variant="h4">Dashboard</Typography>
<Box>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>Dashboard</Typography>
<Typography variant="body2" color="text.secondary">Welcome back, {data.user.username}!</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.paper', p: 1, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" sx={{ ml: 1, mr: 2, display: { xs: 'none', md: 'block' } }}>Share Profile:</Typography>
<TextField
size="small"
variant="standard"
value={shareUrl}
slotProps={{ input: { readOnly: true } }}
sx={{ width: { xs: '100%', sm: 300 } }}
slotProps={{ input: { readOnly: true, disableUnderline: true } }}
sx={{ width: { xs: 150, sm: 250 }, px: 1 }}
/>
<Button variant="contained" startIcon={<ContentCopyIcon />} onClick={copyToClipboard}>
Share
</Button>
<IconButton size="small" onClick={copyToClipboard} color="primary">
<ContentCopyIcon fontSize="small" />
</IconButton>
</Box>
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)}>
<Tab label={`My Ratings (${data.rated_count})`} />
<Tab label={`Missing (${data.missing_count})`} />
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 4 }}>
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} textColor="primary" indicatorColor="primary">
<Tab label={`My Ratings (${data.rated_count})`} sx={{ fontWeight: 'bold' }} />
<Tab label={`Missing (${data.missing_count})`} sx={{ fontWeight: 'bold' }} />
</Tabs>
</Box>
{activeTab === 0 ? (
<Box>
{Object.keys(ratedGrouped).length === 0 ? (
<Typography color="text.secondary">You haven't rated any flavors yet.</Typography>
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography color="text.secondary">You haven't rated any flavors yet.</Typography>
<Button component={Link} to="/" sx={{ mt: 2 }}>Go explore flavors</Button>
</Box>
) : (
Object.entries(ratedGrouped).map(([category, ratings]: [string, any]) => (
<Box key={category} sx={{ mb: 4 }}>
<Typography variant="h6" color="secondary" gutterBottom sx={{ fontWeight: 'bold' }}>{category}</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
{ratings.map((rating: any) => (
<Card key={rating.id} sx={{ flex: { xs: '1 1 100%', sm: '1 1 45%', md: '1 1 30%', lg: '1 1 23%' }, minWidth: 280 }}>
Object.entries(ratedGrouped).map(([category, items]: [string, any]) => (
<Box key={category} sx={{ mb: 6 }}>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold', display: 'flex', alignItems: 'center' }}>
{category}
<Box sx={{ ml: 2, flexGrow: 1, height: '1px', bgcolor: 'divider' }} />
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
{items.map((rating: any) => (
<Card key={rating.id} sx={{ flex: { xs: '1 1 100%', sm: '1 1 45%', md: '1 1 30%', lg: '1 1 23%' }, minWidth: 280, transition: 'transform 0.2s', '&:hover': { transform: 'scale(1.02)' } }}>
<CardContent sx={{ display: 'flex', gap: 2 }}>
{rating.flavor_image && (
<Box component="img" src={rating.flavor_image} sx={{ width: 50, height: 50, objectFit: 'contain' }} />
<Box component="img" src={rating.flavor_image} sx={{ width: 60, height: 60, objectFit: 'contain' }} />
)}
<Box sx={{ flex: 1 }}>
<Link to={`/flavor/${rating.flavor}`} style={{ textDecoration: 'none', color: 'inherit' }}>
@@ -120,10 +133,16 @@ const Dashboard: React.FC = () => {
</Link>
<Box sx={{ display: 'flex', alignItems: 'center', my: 0.5 }}>
<MuiRating value={rating.score} readOnly max={10} size="small" />
<Typography variant="caption" sx={{ ml: 1 }}>{rating.score}/10</Typography>
<Typography variant="caption" sx={{ ml: 1, fontWeight: 'bold' }}>{rating.score}/10</Typography>
</Box>
{rating.comment && (
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block', maxWidth: 200 }}>
<Typography variant="caption" color="text.secondary" sx={{
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
fontStyle: 'italic'
}}>
"{rating.comment}"
</Typography>
)}
@@ -139,17 +158,22 @@ const Dashboard: React.FC = () => {
) : (
<Box>
{Object.keys(missingGrouped).length === 0 ? (
<Typography color="text.secondary">Amazing! You've rated everything!</Typography>
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h5" color="primary">Amazing! You've rated everything! 🏆</Typography>
</Box>
) : (
Object.entries(missingGrouped).map(([category, flavors]: [string, any]) => (
<Box key={category} sx={{ mb: 4 }}>
<Typography variant="h6" color="primary" gutterBottom sx={{ fontWeight: 'bold' }}>{category}</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
<Box key={category} sx={{ mb: 6 }}>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold', display: 'flex', alignItems: 'center' }}>
{category}
<Box sx={{ ml: 2, flexGrow: 1, height: '1px', bgcolor: 'divider' }} />
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
{flavors.map((flavor: any) => (
<Card key={flavor.id} sx={{ flex: { xs: '1 1 100%', sm: '1 1 45%', md: '1 1 30%', lg: '1 1 23%' }, minWidth: 280 }}>
<Card key={flavor.id} sx={{ flex: { xs: '1 1 100%', sm: '1 1 45%', md: '1 1 30%', lg: '1 1 23%' }, minWidth: 280, transition: 'transform 0.2s', '&:hover': { transform: 'scale(1.02)' } }}>
<CardContent sx={{ display: 'flex', gap: 2 }}>
{flavor.image_url && (
<Box component="img" src={flavor.image_url} sx={{ width: 50, height: 50, objectFit: 'contain' }} />
<Box component="img" src={flavor.image_url} sx={{ width: 60, height: 60, objectFit: 'contain' }} />
)}
<Box sx={{ flex: 1 }}>
<Link to={`/flavor/${flavor.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
@@ -157,11 +181,17 @@ const Dashboard: React.FC = () => {
{flavor.name}
</Typography>
</Link>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
{flavor.description.substring(0, 60)}...
<Typography variant="caption" color="text.secondary" sx={{
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
mb: 1
}}>
{flavor.description}
</Typography>
{flavor.shop_url && (
<Button size="small" variant="text" component="a" href={flavor.shop_url} target="_blank" sx={{ fontSize: '0.7rem', p: 0 }}>
<Button size="small" variant="outlined" component="a" href={flavor.shop_url} target="_blank" sx={{ fontSize: '0.65rem', py: 0, borderRadius: 1.5 }}>
Buy Now
</Button>
)}