mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
admin edit button + hall of fame
This commit is contained in:
1 parent
fccac10e64
commit
34fdb3e2ce
5 files changed
+266
-172
No files matched your search
@@ -51,7 +51,9 @@ export const NavSidebar = ({
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
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 [notifAnchorEl, setNotifAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Chip,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
useMediaQuery,
|
||||
} from '@mui/material';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -34,6 +35,8 @@ import {
|
||||
import MainPageRecommendations from './MainPageRecommendations';
|
||||
|
||||
const TOP_LIMIT = 5;
|
||||
const AUTO_ADVANCE_MS = 6000;
|
||||
const SWIPE_THRESHOLD = 50;
|
||||
|
||||
const FlavorCardSkeleton: React.FC<{ compact?: boolean }> = ({ compact }) => (
|
||||
<Box sx={{ height: '100%' }}>
|
||||
@@ -82,6 +85,12 @@ const MainPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
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 { user } = useAuth();
|
||||
@@ -96,18 +105,50 @@ const MainPage: React.FC = () => {
|
||||
.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;
|
||||
// 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');
|
||||
|
||||
const handlePrev = () => {
|
||||
if (topFlavors.length === 0) return;
|
||||
setAutoPlay(false);
|
||||
setActiveIndex((prev) => (prev - 1 + topFlavors.length) % topFlavors.length);
|
||||
};
|
||||
const handleNext = () => {
|
||||
if (topFlavors.length === 0) return;
|
||||
setAutoPlay(false);
|
||||
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 ---
|
||||
if (query) {
|
||||
@@ -258,7 +299,7 @@ const MainPage: React.FC = () => {
|
||||
subtitle={t('home.hallOfFameSubtitle')}
|
||||
action={
|
||||
topFlavors.length > 1 ? (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Box sx={{ display: { xs: 'none', md: 'flex' }, gap: 1 }}>
|
||||
<IconButton
|
||||
onClick={handlePrev}
|
||||
aria-label={t('home.previousFlavor')}
|
||||
@@ -284,6 +325,13 @@ const MainPage: React.FC = () => {
|
||||
<>
|
||||
<GlassCard
|
||||
intensity="strong"
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
aria-label={t('home.hallOfFame')}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
@@ -292,6 +340,44 @@ const MainPage: React.FC = () => {
|
||||
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
|
||||
component={Link}
|
||||
to={`/flavor/${currentTop.id}`}
|
||||
@@ -432,7 +518,7 @@ const MainPage: React.FC = () => {
|
||||
<Box
|
||||
key={i}
|
||||
component="button"
|
||||
onClick={() => setActiveIndex(i)}
|
||||
onClick={() => goTo(i)}
|
||||
aria-label={t('home.goToFlavor', { n: i + 1 })}
|
||||
aria-current={safeIndex === i ? 'true' : undefined}
|
||||
sx={{
|
||||
|
||||
@@ -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 { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -14,14 +14,9 @@ import {
|
||||
alpha,
|
||||
useTheme,
|
||||
Collapse,
|
||||
TextField,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import { useFlavorDetail, type FlavorDetailRating } from '../../api/queries/useFlavorDetail';
|
||||
import { useCurrentUserLite } from '../../api/queries/useCurrentUserLite';
|
||||
import { useUpdateFlavor } from '../../api/mutations/useFlavorMutations';
|
||||
import {
|
||||
useDeleteRating,
|
||||
useCreateReply,
|
||||
@@ -34,8 +29,6 @@ import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import VerifiedIcon from '@mui/icons-material/Verified';
|
||||
import CommentIcon from '@mui/icons-material/Comment';
|
||||
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 MentionTextField from '../../components/MentionTextField';
|
||||
import RichText from '../../components/RichText';
|
||||
@@ -54,6 +47,7 @@ import {
|
||||
} from '../../components/ui';
|
||||
import RatingForm from './components/RatingForm';
|
||||
import SimilarFlavorsSection from './components/SimilarFlavorsSection';
|
||||
import AdminFlavorEditDialog from './components/AdminFlavorEditDialog';
|
||||
import { useToast } from '../../hooks/useToast';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
|
||||
@@ -68,7 +62,6 @@ const FlavorDetail: React.FC = () => {
|
||||
const { data: meData } = useCurrentUserLite();
|
||||
const currentUser = meData?.username ?? null;
|
||||
const isSuperuser = !!meData?.is_superuser;
|
||||
const updateFlavor = useUpdateFlavor();
|
||||
const deleteRatingMutation = useDeleteRating();
|
||||
const updateRating = useUpdateRating();
|
||||
const createReply = useCreateReply();
|
||||
@@ -77,16 +70,8 @@ const FlavorDetail: React.FC = () => {
|
||||
const [replyInputs, setReplyInputs] = useState<{ [key: number]: string }>({});
|
||||
const [expandedReplies, setExpandedReplies] = useState<{ [key: number]: boolean }>({});
|
||||
|
||||
// Product Edit state (Admin)
|
||||
const [isAdminEditing, setIsAdminEditing] = useState(false);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [editFlavorData, setEditFlavorData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
shop_url: '',
|
||||
is_available: true,
|
||||
is_legacy: false,
|
||||
});
|
||||
// Product Edit dialog (Admin) — form moved out of the card (UX audit #6).
|
||||
const [adminDialogOpen, setAdminDialogOpen] = useState(false);
|
||||
|
||||
// Edit state
|
||||
const [editMode, setEditMode] = useState<number | null>(null);
|
||||
@@ -97,49 +82,10 @@ const FlavorDetail: React.FC = () => {
|
||||
const [editingReplyId, setEditingReplyId] = useState<number | null>(null);
|
||||
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'));
|
||||
|
||||
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 text = replyInputs[ratingId];
|
||||
if (!text) return;
|
||||
@@ -241,110 +187,16 @@ const FlavorDetail: React.FC = () => {
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
{isSuperuser && (
|
||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
{!isAdminEditing ? (
|
||||
<Button
|
||||
startIcon={<EditIcon />}
|
||||
size="small"
|
||||
onClick={() => setIsAdminEditing(true)}
|
||||
onClick={() => setAdminDialogOpen(true)}
|
||||
>
|
||||
{t('flavorDetail.editProductAdmin')}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{isAdminEditing ? (
|
||||
<Stack spacing={2} sx={{ mb: 2 }}>
|
||||
<TextField
|
||||
label={t('flavorDetail.nameLabel')}
|
||||
fullWidth
|
||||
size="small"
|
||||
value={editFlavorData.name}
|
||||
onChange={(e) =>
|
||||
setEditFlavorData({ ...editFlavorData, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label={t('flavorDetail.descriptionLabel')}
|
||||
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>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="label"
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={uploadingImage}
|
||||
>
|
||||
{uploadingImage
|
||||
? t('flavorDetail.uploading')
|
||||
: t('flavorDetail.uploadImage')}
|
||||
<input type="file" hidden accept="image/*" onChange={handleImageUpload} />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={editFlavorData.is_available}
|
||||
onChange={(e) =>
|
||||
setEditFlavorData({ ...editFlavorData, is_available: e.target.checked })
|
||||
}
|
||||
/>
|
||||
}
|
||||
label={t('flavorDetail.inStock')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
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"
|
||||
@@ -361,11 +213,7 @@ const FlavorDetail: React.FC = () => {
|
||||
|
||||
<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' }}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
|
||||
{flavor.ratings.length} {t('common.reviews')}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -379,16 +227,11 @@ const FlavorDetail: React.FC = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ lineHeight: 1.7, color: 'text.secondary', mb: 4 }}
|
||||
>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7, color: 'text.secondary', mb: 4 }}>
|
||||
<RichText text={flavor.description} />
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
{flavor.shop_url && !isAdminEditing && (
|
||||
{flavor.shop_url && (
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
@@ -761,6 +604,15 @@ const FlavorDetail: React.FC = () => {
|
||||
</Grid>
|
||||
|
||||
<SimilarFlavorsSection flavorId={flavor.id} />
|
||||
|
||||
{isSuperuser && id && (
|
||||
<AdminFlavorEditDialog
|
||||
open={adminDialogOpen}
|
||||
onClose={() => setAdminDialogOpen(false)}
|
||||
flavorId={id}
|
||||
flavor={flavor}
|
||||
/>
|
||||
)}
|
||||
</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;
|
||||
@@ -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.
|
||||
- [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.
|
||||
- [ ] **#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
|
||||
|
||||
- [ ] **#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.
|
||||
- [ ] **#8 Reply Enter-to-submit on multiline** — surprising; blocks newlines.
|
||||
- [ ] **#9 Login password UX** — no show/hide toggle, no strength hint, no inline validation.
|
||||
|
||||
Reference in new issue
Block a user