admin edit button + hall of fame

This commit is contained in:
nvrl committed 2026-06-16 14:08:24 +02:00
1 parent fccac10e64
commit 34fdb3e2ce
5 files changed
+299 -205

No files matched your search

@@ -51,7 +51,9 @@ export const NavSidebar = ({
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const { user, following, logout } = useAuth(); const { user, following, logout } = useAuth();
const [catOpen, setCatOpen] = useState(false); // Categories are the primary browse axis — open by default so they're
// discoverable without an extra click (UX audit #4).
const [catOpen, setCatOpen] = useState(true);
const [followingOpen, setFollowingOpen] = useState(false); const [followingOpen, setFollowingOpen] = useState(false);
const [notifAnchorEl, setNotifAnchorEl] = useState<null | HTMLElement>(null); const [notifAnchorEl, setNotifAnchorEl] = useState<null | HTMLElement>(null);
+90 -4
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { import {
Typography, Typography,
Box, Box,
@@ -8,6 +8,7 @@ import {
Chip, Chip,
IconButton, IconButton,
Skeleton, Skeleton,
useMediaQuery,
} from '@mui/material'; } from '@mui/material';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -34,6 +35,8 @@ import {
import MainPageRecommendations from './MainPageRecommendations'; import MainPageRecommendations from './MainPageRecommendations';
const TOP_LIMIT = 5; const TOP_LIMIT = 5;
const AUTO_ADVANCE_MS = 6000;
const SWIPE_THRESHOLD = 50;
const FlavorCardSkeleton: React.FC<{ compact?: boolean }> = ({ compact }) => ( const FlavorCardSkeleton: React.FC<{ compact?: boolean }> = ({ compact }) => (
<Box sx={{ height: '100%' }}> <Box sx={{ height: '100%' }}>
@@ -82,6 +85,12 @@ const MainPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
// Auto-advance stops for good once the user takes manual control; hover only
// pauses temporarily. Reduced-motion users never get auto-advance.
const [autoPlay, setAutoPlay] = useState(true);
const [hovered, setHovered] = useState(false);
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
const touchStartX = useRef<number | null>(null);
const query = new URLSearchParams(location.search).get('q') || ''; const query = new URLSearchParams(location.search).get('q') || '';
const { user } = useAuth(); const { user } = useAuth();
@@ -96,18 +105,50 @@ const MainPage: React.FC = () => {
.slice(0, TOP_LIMIT); .slice(0, TOP_LIMIT);
const safeIndex = topFlavors.length > 0 ? activeIndex % topFlavors.length : 0; const safeIndex = topFlavors.length > 0 ? activeIndex % topFlavors.length : 0;
const currentTop = topFlavors[safeIndex]; const currentTop = topFlavors[safeIndex];
const featuredReview = currentTop?.ratings?.find((r) => r.comment) || null; // Feature the highest-scored review with a comment, not just the first one —
// a Hall-of-Fame quote should be the most glowing, not whatever loaded first.
const featuredReview = useMemo(() => {
const withComment = (currentTop?.ratings ?? []).filter((r) => r.comment);
if (withComment.length === 0) return null;
return withComment.reduce((best, r) => (r.score > best.score ? r : best));
}, [currentTop]);
useTitle(query ? `Search: ${query}` : 'Holy Flavors Archive'); useTitle(query ? `Search: ${query}` : 'Holy Flavors Archive');
const handlePrev = () => { const handlePrev = () => {
if (topFlavors.length === 0) return; if (topFlavors.length === 0) return;
setAutoPlay(false);
setActiveIndex((prev) => (prev - 1 + topFlavors.length) % topFlavors.length); setActiveIndex((prev) => (prev - 1 + topFlavors.length) % topFlavors.length);
}; };
const handleNext = () => { const handleNext = () => {
if (topFlavors.length === 0) return; if (topFlavors.length === 0) return;
setAutoPlay(false);
setActiveIndex((prev) => (prev + 1) % topFlavors.length); setActiveIndex((prev) => (prev + 1) % topFlavors.length);
}; };
const goTo = (i: number) => {
setAutoPlay(false);
setActiveIndex(i);
};
const handleTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
};
const handleTouchEnd = (e: React.TouchEvent) => {
if (touchStartX.current === null) return;
const delta = e.changedTouches[0].clientX - touchStartX.current;
touchStartX.current = null;
if (Math.abs(delta) < SWIPE_THRESHOLD) return;
if (delta < 0) handleNext();
else handlePrev();
};
useEffect(() => {
if (topFlavors.length <= 1 || !autoPlay || hovered || prefersReducedMotion) return;
const timer = setInterval(() => {
setActiveIndex((prev) => (prev + 1) % topFlavors.length);
}, AUTO_ADVANCE_MS);
return () => clearInterval(timer);
}, [topFlavors.length, autoPlay, hovered, prefersReducedMotion]);
// --- SEARCH VIEW --- // --- SEARCH VIEW ---
if (query) { if (query) {
@@ -258,7 +299,7 @@ const MainPage: React.FC = () => {
subtitle={t('home.hallOfFameSubtitle')} subtitle={t('home.hallOfFameSubtitle')}
action={ action={
topFlavors.length > 1 ? ( topFlavors.length > 1 ? (
<Box sx={{ display: 'flex', gap: 1 }}> <Box sx={{ display: { xs: 'none', md: 'flex' }, gap: 1 }}>
<IconButton <IconButton
onClick={handlePrev} onClick={handlePrev}
aria-label={t('home.previousFlavor')} aria-label={t('home.previousFlavor')}
@@ -284,6 +325,13 @@ const MainPage: React.FC = () => {
<> <>
<GlassCard <GlassCard
intensity="strong" intensity="strong"
role="region"
aria-roledescription="carousel"
aria-label={t('home.hallOfFame')}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: { xs: 'column', md: 'row' }, flexDirection: { xs: 'column', md: 'row' },
@@ -292,6 +340,44 @@ const MainPage: React.FC = () => {
position: 'relative', position: 'relative',
}} }}
> >
{topFlavors.length > 1 && (
<>
<IconButton
onClick={handlePrev}
aria-label={t('home.previousFlavor')}
sx={{
display: { xs: 'flex', md: 'none' },
position: 'absolute',
top: '50%',
left: 8,
transform: 'translateY(-50%)',
zIndex: 2,
bgcolor: 'background.paper',
boxShadow: 2,
'&:hover': { bgcolor: 'background.paper' },
}}
>
<ChevronLeftIcon />
</IconButton>
<IconButton
onClick={handleNext}
aria-label={t('home.nextFlavor')}
sx={{
display: { xs: 'flex', md: 'none' },
position: 'absolute',
top: '50%',
right: 8,
transform: 'translateY(-50%)',
zIndex: 2,
bgcolor: 'background.paper',
boxShadow: 2,
'&:hover': { bgcolor: 'background.paper' },
}}
>
<ChevronRightIcon />
</IconButton>
</>
)}
<Box <Box
component={Link} component={Link}
to={`/flavor/${currentTop.id}`} to={`/flavor/${currentTop.id}`}
@@ -432,7 +518,7 @@ const MainPage: React.FC = () => {
<Box <Box
key={i} key={i}
component="button" component="button"
onClick={() => setActiveIndex(i)} onClick={() => goTo(i)}
aria-label={t('home.goToFlavor', { n: i + 1 })} aria-label={t('home.goToFlavor', { n: i + 1 })}
aria-current={safeIndex === i ? 'true' : undefined} aria-current={safeIndex === i ? 'true' : undefined}
sx={{ sx={{
+50 -198
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom'; import { useParams, Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -14,14 +14,9 @@ import {
alpha, alpha,
useTheme, useTheme,
Collapse, Collapse,
TextField,
FormControlLabel,
Switch,
Divider,
} from '@mui/material'; } from '@mui/material';
import { useFlavorDetail, type FlavorDetailRating } from '../../api/queries/useFlavorDetail'; import { useFlavorDetail, type FlavorDetailRating } from '../../api/queries/useFlavorDetail';
import { useCurrentUserLite } from '../../api/queries/useCurrentUserLite'; import { useCurrentUserLite } from '../../api/queries/useCurrentUserLite';
import { useUpdateFlavor } from '../../api/mutations/useFlavorMutations';
import { import {
useDeleteRating, useDeleteRating,
useCreateReply, useCreateReply,
@@ -34,8 +29,6 @@ import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import VerifiedIcon from '@mui/icons-material/Verified'; import VerifiedIcon from '@mui/icons-material/Verified';
import CommentIcon from '@mui/icons-material/Comment'; import CommentIcon from '@mui/icons-material/Comment';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import SaveIcon from '@mui/icons-material/Save';
import CancelIcon from '@mui/icons-material/Cancel';
import { formatDate } from '../../utils/date'; import { formatDate } from '../../utils/date';
import MentionTextField from '../../components/MentionTextField'; import MentionTextField from '../../components/MentionTextField';
import RichText from '../../components/RichText'; import RichText from '../../components/RichText';
@@ -54,6 +47,7 @@ import {
} from '../../components/ui'; } from '../../components/ui';
import RatingForm from './components/RatingForm'; import RatingForm from './components/RatingForm';
import SimilarFlavorsSection from './components/SimilarFlavorsSection'; import SimilarFlavorsSection from './components/SimilarFlavorsSection';
import AdminFlavorEditDialog from './components/AdminFlavorEditDialog';
import { useToast } from '../../hooks/useToast'; import { useToast } from '../../hooks/useToast';
import { useConfirm } from '../../hooks/useConfirm'; import { useConfirm } from '../../hooks/useConfirm';
@@ -68,7 +62,6 @@ const FlavorDetail: React.FC = () => {
const { data: meData } = useCurrentUserLite(); const { data: meData } = useCurrentUserLite();
const currentUser = meData?.username ?? null; const currentUser = meData?.username ?? null;
const isSuperuser = !!meData?.is_superuser; const isSuperuser = !!meData?.is_superuser;
const updateFlavor = useUpdateFlavor();
const deleteRatingMutation = useDeleteRating(); const deleteRatingMutation = useDeleteRating();
const updateRating = useUpdateRating(); const updateRating = useUpdateRating();
const createReply = useCreateReply(); const createReply = useCreateReply();
@@ -77,16 +70,8 @@ const FlavorDetail: React.FC = () => {
const [replyInputs, setReplyInputs] = useState<{ [key: number]: string }>({}); const [replyInputs, setReplyInputs] = useState<{ [key: number]: string }>({});
const [expandedReplies, setExpandedReplies] = useState<{ [key: number]: boolean }>({}); const [expandedReplies, setExpandedReplies] = useState<{ [key: number]: boolean }>({});
// Product Edit state (Admin) // Product Edit dialog (Admin) — form moved out of the card (UX audit #6).
const [isAdminEditing, setIsAdminEditing] = useState(false); const [adminDialogOpen, setAdminDialogOpen] = useState(false);
const [uploadingImage, setUploadingImage] = useState(false);
const [editFlavorData, setEditFlavorData] = useState({
name: '',
description: '',
shop_url: '',
is_available: true,
is_legacy: false,
});
// Edit state // Edit state
const [editMode, setEditMode] = useState<number | null>(null); const [editMode, setEditMode] = useState<number | null>(null);
@@ -97,49 +82,10 @@ const FlavorDetail: React.FC = () => {
const [editingReplyId, setEditingReplyId] = useState<number | null>(null); const [editingReplyId, setEditingReplyId] = useState<number | null>(null);
const [editReplyText, setEditReplyText] = useState(''); const [editReplyText, setEditReplyText] = useState('');
useEffect(() => {
if (flavor) {
setEditFlavorData({
name: flavor.name,
description: flavor.description,
shop_url: flavor.shop_url || '',
is_available: flavor.is_available,
is_legacy: flavor.is_legacy,
});
}
}, [flavor]);
useTitle(flavor?.name || t('common.loading')); useTitle(flavor?.name || t('common.loading'));
const galleryImages = useMemo(() => flavor?.image_urls ?? [], [flavor?.image_urls]); const galleryImages = useMemo(() => flavor?.image_urls ?? [], [flavor?.image_urls]);
const handleAdminUpdate = async () => {
if (!id) return;
try {
await updateFlavor.mutateAsync({ id, data: editFlavorData });
setIsAdminEditing(false);
} catch {
notify({ message: t('flavorDetail.adminUpdateFailed'), severity: 'error' });
}
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !id) return;
const formData = new FormData();
formData.append('image', file);
try {
setUploadingImage(true);
await updateFlavor.mutateAsync({ id, data: formData, isFormData: true });
} catch {
notify({ message: t('flavorDetail.imageUploadFailed'), severity: 'error' });
} finally {
setUploadingImage(false);
}
};
const handleReplySubmit = async (ratingId: number) => { const handleReplySubmit = async (ratingId: number) => {
const text = replyInputs[ratingId]; const text = replyInputs[ratingId];
if (!text) return; if (!text) return;
@@ -241,154 +187,51 @@ const FlavorDetail: React.FC = () => {
<CardContent sx={{ p: 3 }}> <CardContent sx={{ p: 3 }}>
{isSuperuser && ( {isSuperuser && (
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}> <Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
{!isAdminEditing ? ( <Button
<Button startIcon={<EditIcon />}
startIcon={<EditIcon />} size="small"
size="small" onClick={() => setAdminDialogOpen(true)}
onClick={() => setIsAdminEditing(true)} >
> {t('flavorDetail.editProductAdmin')}
{t('flavorDetail.editProductAdmin')} </Button>
</Button>
) : (
<Stack direction="row" spacing={1}>
<Button
startIcon={<SaveIcon />}
size="small"
color="success"
variant="contained"
onClick={handleAdminUpdate}
>
{t('common.save')}
</Button>
<Button
startIcon={<CancelIcon />}
size="small"
onClick={() => setIsAdminEditing(false)}
>
{t('common.cancel')}
</Button>
</Stack>
)}
</Box> </Box>
)} )}
{isAdminEditing ? ( <Chip
<Stack spacing={2} sx={{ mb: 2 }}> label={flavor.category_name}
<TextField size="small"
label={t('flavorDetail.nameLabel')} sx={{
fullWidth mb: 2,
size="small" fontWeight: 'bold',
value={editFlavorData.name} bgcolor: alpha(theme.palette.primary.main, 0.1),
onChange={(e) => color: 'primary.main',
setEditFlavorData({ ...editFlavorData, name: e.target.value }) }}
} />
/> <Typography variant="h4" sx={{ fontWeight: '800', mb: 2, lineHeight: 1.2 }}>
<TextField {flavor.name}
label={t('flavorDetail.descriptionLabel')} </Typography>
fullWidth
size="small"
multiline
rows={4}
value={editFlavorData.description}
onChange={(e) =>
setEditFlavorData({ ...editFlavorData, description: e.target.value })
}
/>
<TextField
label={t('flavorDetail.shopUrlLabel')}
fullWidth
size="small"
value={editFlavorData.shop_url}
onChange={(e) =>
setEditFlavorData({ ...editFlavorData, shop_url: e.target.value })
}
/>
<Box> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Button <RatingBadge score={flavor.average_rating || 0} size="large" />
variant="outlined" <Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
component="label" {flavor.ratings.length} {t('common.reviews')}
size="small" </Typography>
fullWidth </Box>
disabled={uploadingImage}
>
{uploadingImage
? t('flavorDetail.uploading')
: t('flavorDetail.uploadImage')}
<input type="file" hidden accept="image/*" onChange={handleImageUpload} />
</Button>
</Box>
<FormControlLabel {flavor.ratings.length > 0 && (
control={ <Box sx={{ mb: 3 }}>
<Switch <RatingDistribution
checked={editFlavorData.is_available} distribution={flavor.rating_distribution}
onChange={(e) => total={flavor.ratings.length}
setEditFlavorData({ ...editFlavorData, is_available: e.target.checked })
}
/>
}
label={t('flavorDetail.inStock')}
/> />
<FormControlLabel </Box>
control={
<Switch
checked={editFlavorData.is_legacy}
onChange={(e) =>
setEditFlavorData({ ...editFlavorData, is_legacy: e.target.checked })
}
/>
}
label={t('flavorDetail.legacyLimited')}
/>
<Divider sx={{ my: 1 }} />
</Stack>
) : (
<>
<Chip
label={flavor.category_name}
size="small"
sx={{
mb: 2,
fontWeight: 'bold',
bgcolor: alpha(theme.palette.primary.main, 0.1),
color: 'primary.main',
}}
/>
<Typography variant="h4" sx={{ fontWeight: '800', mb: 2, lineHeight: 1.2 }}>
{flavor.name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<RatingBadge score={flavor.average_rating || 0} size="large" />
<Typography
variant="body2"
color="text.secondary"
sx={{ fontWeight: 'bold' }}
>
{flavor.ratings.length} {t('common.reviews')}
</Typography>
</Box>
{flavor.ratings.length > 0 && (
<Box sx={{ mb: 3 }}>
<RatingDistribution
distribution={flavor.rating_distribution}
total={flavor.ratings.length}
/>
</Box>
)}
<Typography
variant="body2"
sx={{ lineHeight: 1.7, color: 'text.secondary', mb: 4 }}
>
<RichText text={flavor.description} />
</Typography>
</>
)} )}
{flavor.shop_url && !isAdminEditing && ( <Typography variant="body2" sx={{ lineHeight: 1.7, color: 'text.secondary', mb: 4 }}>
<RichText text={flavor.description} />
</Typography>
{flavor.shop_url && (
<Button <Button
variant="contained" variant="contained"
fullWidth fullWidth
@@ -761,6 +604,15 @@ const FlavorDetail: React.FC = () => {
</Grid> </Grid>
<SimilarFlavorsSection flavorId={flavor.id} /> <SimilarFlavorsSection flavorId={flavor.id} />
{isSuperuser && id && (
<AdminFlavorEditDialog
open={adminDialogOpen}
onClose={() => setAdminDialogOpen(false)}
flavorId={id}
flavor={flavor}
/>
)}
</PageShell> </PageShell>
); );
}; };
@@ -0,0 +1,154 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
Stack,
FormControlLabel,
Switch,
} from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import { useUpdateFlavor } from '../../../api/mutations/useFlavorMutations';
import { useToast } from '../../../hooks/useToast';
import type { FlavorDetailData } from '../../../api/queries/useFlavorDetail';
export interface AdminFlavorEditDialogProps {
open: boolean;
onClose: () => void;
flavorId: string;
flavor: FlavorDetailData;
}
/**
* Superuser-only product editor. Lifted out of the product card (UX audit #6) so
* the read view stays clean — admin affordances live in a focused dialog instead
* of swapping the card body inline.
*/
const AdminFlavorEditDialog: React.FC<AdminFlavorEditDialogProps> = ({
open,
onClose,
flavorId,
flavor,
}) => {
const { t } = useTranslation();
const { notify } = useToast();
const updateFlavor = useUpdateFlavor();
const [uploadingImage, setUploadingImage] = useState(false);
const [data, setData] = useState({
name: flavor.name,
description: flavor.description,
shop_url: flavor.shop_url || '',
is_available: flavor.is_available,
is_legacy: flavor.is_legacy,
});
// Re-sync form when the dialog reopens or the underlying flavor changes.
useEffect(() => {
if (open) {
setData({
name: flavor.name,
description: flavor.description,
shop_url: flavor.shop_url || '',
is_available: flavor.is_available,
is_legacy: flavor.is_legacy,
});
}
}, [open, flavor]);
const handleSave = async () => {
try {
await updateFlavor.mutateAsync({ id: flavorId, data });
onClose();
} catch {
notify({ message: t('flavorDetail.adminUpdateFailed'), severity: 'error' });
}
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('image', file);
try {
setUploadingImage(true);
await updateFlavor.mutateAsync({ id: flavorId, data: formData, isFormData: true });
} catch {
notify({ message: t('flavorDetail.imageUploadFailed'), severity: 'error' });
} finally {
setUploadingImage(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{t('flavorDetail.editProductAdmin')}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
label={t('flavorDetail.nameLabel')}
fullWidth
size="small"
value={data.name}
onChange={(e) => setData({ ...data, name: e.target.value })}
/>
<TextField
label={t('flavorDetail.descriptionLabel')}
fullWidth
size="small"
multiline
rows={4}
value={data.description}
onChange={(e) => setData({ ...data, description: e.target.value })}
/>
<TextField
label={t('flavorDetail.shopUrlLabel')}
fullWidth
size="small"
value={data.shop_url}
onChange={(e) => setData({ ...data, shop_url: e.target.value })}
/>
<Button variant="outlined" component="label" size="small" disabled={uploadingImage}>
{uploadingImage ? t('flavorDetail.uploading') : t('flavorDetail.uploadImage')}
<input type="file" hidden accept="image/*" onChange={handleImageUpload} />
</Button>
<FormControlLabel
control={
<Switch
checked={data.is_available}
onChange={(e) => setData({ ...data, is_available: e.target.checked })}
/>
}
label={t('flavorDetail.inStock')}
/>
<FormControlLabel
control={
<Switch
checked={data.is_legacy}
onChange={(e) => setData({ ...data, is_legacy: e.target.checked })}
/>
}
label={t('flavorDetail.legacyLimited')}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button
variant="contained"
color="success"
startIcon={<SaveIcon />}
onClick={handleSave}
disabled={updateFlavor.isPending}
>
{t('common.save')}
</Button>
</DialogActions>
</Dialog>
);
};
export default AdminFlavorEditDialog;
+2 -2
View File
@@ -15,12 +15,12 @@ Tracking doc from the frontend/UX audit. Items grouped by priority. Check off as
- [ ] **Deferred (admin-only, superuser surface):** `AdminUserDetail.tsx`, `admin/*` (Banners, Tickets, Overview, Config, Jobs, Users), `AdminRatingDetail.tsx`, `AdminReplyDetail.tsx`, `Settings.tsx` theme-list subheaders. Lower UX priority — only the operator sees these. - [ ] **Deferred (admin-only, superuser surface):** `AdminUserDetail.tsx`, `admin/*` (Banners, Tickets, Overview, Config, Jobs, Users), `AdminRatingDetail.tsx`, `AdminReplyDetail.tsx`, `Settings.tsx` theme-list subheaders. Lower UX priority — only the operator sees these.
- [x] **#2 Delete dead code** — `FlavorList.tsx` (240 LOC) + `AdminDashboard.tsx` (873 LOC). Zero imports, not in router (replaced by `admin/` folder + `flavors/FlavorDetail`). - [x] **#2 Delete dead code** — `FlavorList.tsx` (240 LOC) + `AdminDashboard.tsx` (873 LOC). Zero imports, not in router (replaced by `admin/` folder + `flavors/FlavorDetail`).
- [ ] **#3 Add brand/logo** — no app identity anchor in sidebar or mobile appbar. - [ ] **#3 Add brand/logo** — no app identity anchor in sidebar or mobile appbar.
- [ ] **#4 Categories discoverable** — collapsed-by-default accordion hides primary browse axis. - [x] **#4 Categories discoverable** — accordion now open by default (`NavSidebar.tsx`); primary browse axis visible without an extra click.
## 🟡 Medium ## 🟡 Medium
- [ ] **#5 Hall of Fame carousel** — no swipe/auto-advance; arrows far from card on mobile; featured quote is arbitrary (first-with-comment). - [ ] **#5 Hall of Fame carousel** — no swipe/auto-advance; arrows far from card on mobile; featured quote is arbitrary (first-with-comment).
- [ ] **#6 Admin edit leaks into product card** (`FlavorDetail.tsx`) — move to dialog/drawer. - [x] **#6 Admin edit leaks into product card** (`FlavorDetail.tsx`) — extracted to `components/AdminFlavorEditDialog.tsx`; card keeps a clean read view, edit opens a focused dialog.
- [ ] **#7 CommunityFeed duplication** — in-page notifications + following duplicate the nav/global menus. - [ ] **#7 CommunityFeed duplication** — in-page notifications + following duplicate the nav/global menus.
- [ ] **#8 Reply Enter-to-submit on multiline** — surprising; blocks newlines. - [ ] **#8 Reply Enter-to-submit on multiline** — surprising; blocks newlines.
- [ ] **#9 Login password UX** — no show/hide toggle, no strength hint, no inline validation. - [ ] **#9 Login password UX** — no show/hide toggle, no strength hint, no inline validation.