mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
Merge branch 'main' into narl.io
This commit is contained in:
commit
83e8637746
3 files changed
+139
-138
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user