From 34fdb3e2cebd0a59cb90be5c8fc556e25149cf31 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 16 Jun 2026 14:08:24 +0200 Subject: [PATCH] admin edit button + hall of fame --- frontend/src/components/layout/NavSidebar.tsx | 4 +- frontend/src/pages/MainPage.tsx | 94 ++++++- frontend/src/pages/flavors/FlavorDetail.tsx | 248 ++++-------------- .../components/AdminFlavorEditDialog.tsx | 154 +++++++++++ frontend/ux-improvements.md | 4 +- 5 files changed, 299 insertions(+), 205 deletions(-) create mode 100644 frontend/src/pages/flavors/components/AdminFlavorEditDialog.tsx diff --git a/frontend/src/components/layout/NavSidebar.tsx b/frontend/src/components/layout/NavSidebar.tsx index 111e5cb..0b433c4 100644 --- a/frontend/src/components/layout/NavSidebar.tsx +++ b/frontend/src/components/layout/NavSidebar.tsx @@ -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); diff --git a/frontend/src/pages/MainPage.tsx b/frontend/src/pages/MainPage.tsx index 0d6798f..edaba08 100644 --- a/frontend/src/pages/MainPage.tsx +++ b/frontend/src/pages/MainPage.tsx @@ -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 }) => ( @@ -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(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 ? ( - + { <> 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 && ( + <> + + + + + + + + )} { setActiveIndex(i)} + onClick={() => goTo(i)} aria-label={t('home.goToFlavor', { n: i + 1 })} aria-current={safeIndex === i ? 'true' : undefined} sx={{ diff --git a/frontend/src/pages/flavors/FlavorDetail.tsx b/frontend/src/pages/flavors/FlavorDetail.tsx index bd2f8f9..2f9db3a 100644 --- a/frontend/src/pages/flavors/FlavorDetail.tsx +++ b/frontend/src/pages/flavors/FlavorDetail.tsx @@ -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(null); @@ -97,49 +82,10 @@ const FlavorDetail: React.FC = () => { const [editingReplyId, setEditingReplyId] = useState(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) => { - 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,154 +187,51 @@ const FlavorDetail: React.FC = () => { {isSuperuser && ( - {!isAdminEditing ? ( - - ) : ( - - - - - )} + )} - {isAdminEditing ? ( - - - setEditFlavorData({ ...editFlavorData, name: e.target.value }) - } - /> - - setEditFlavorData({ ...editFlavorData, description: e.target.value }) - } - /> - - setEditFlavorData({ ...editFlavorData, shop_url: e.target.value }) - } - /> + + + {flavor.name} + - - - + + + + {flavor.ratings.length} {t('common.reviews')} + + - - setEditFlavorData({ ...editFlavorData, is_available: e.target.checked }) - } - /> - } - label={t('flavorDetail.inStock')} + {flavor.ratings.length > 0 && ( + + - - setEditFlavorData({ ...editFlavorData, is_legacy: e.target.checked }) - } - /> - } - label={t('flavorDetail.legacyLimited')} - /> - - - ) : ( - <> - - - {flavor.name} - - - - - - {flavor.ratings.length} {t('common.reviews')} - - - - {flavor.ratings.length > 0 && ( - - - - )} - - - - - + )} - {flavor.shop_url && !isAdminEditing && ( + + + + + {flavor.shop_url && ( + setData({ ...data, is_available: e.target.checked })} + /> + } + label={t('flavorDetail.inStock')} + /> + setData({ ...data, is_legacy: e.target.checked })} + /> + } + label={t('flavorDetail.legacyLimited')} + /> + + + + + + + + ); +}; + +export default AdminFlavorEditDialog; diff --git a/frontend/ux-improvements.md b/frontend/ux-improvements.md index 77ed9ea..a3cd9fc 100644 --- a/frontend/ux-improvements.md +++ b/frontend/ux-improvements.md @@ -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.