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
+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);
|
||||||
|
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user