From 6a8e7d53f9c3de9cbadedcbbde946e69e12a9a4c Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 15 Jun 2026 19:58:58 +0200 Subject: [PATCH] fixed translation --- audit.md | 92 +++++++++++ backend-rs/src/routes/flavors.rs | 17 +- backend-rs/src/routes/users.rs | 15 +- backend-rs/src/service.rs | 133 ++++++++------- frontend/src/App.tsx | 103 +----------- frontend/src/components/layout/NavSidebar.tsx | 107 +------------ .../components/layout/NotificationMenu.tsx | 151 ++++++++++++++++++ frontend/src/hooks/useNotificationClick.ts | 33 ++++ frontend/src/i18n.ts | 8 + 9 files changed, 399 insertions(+), 260 deletions(-) create mode 100644 audit.md create mode 100644 frontend/src/components/layout/NotificationMenu.tsx create mode 100644 frontend/src/hooks/useNotificationClick.ts diff --git a/audit.md b/audit.md new file mode 100644 index 0000000..be5d494 --- /dev/null +++ b/audit.md @@ -0,0 +1,92 @@ +# Holy Flavor Rater — Audit + +Scope: `backend-rs/` (Rust/Axum/SeaORM) + `frontend/` (React 19/MUI 7). Django backend excluded (legacy data dir). Findings ordered by impact within each area. + +## Robustness + +### R1. Notification text is hardcoded English (DE locale shows mixed languages) +`App.tsx:183-189` and `NavSidebar.tsx:445-451` hardcode `replied to your review on`, `mentioned you on`, `left a message on your guestbook` as template literals while sibling notif types use `t(...)`. German users get a mixed-language menu. +**Fix:** move to i18n keys with interpolation, e.g. `t('community.notifReply', { flavor: n.flavor_name })`. + +### R2. Full page reload on notification click +`App.tsx:104` and `NavSidebar.tsx:83` use `window.location.href = '/flavor/${id}'` instead of `navigate()`. Kills SPA state, refetches everything, white flash. Other branches use `navigate()` — inconsistent. +**Fix:** `navigate(path)`; if same-route remount is needed, reset via key/scroll. + +### R3. Rate limiter fails open and is per-process +`throttle.rs` is in-memory: login/signup limits reset on restart and don't span instances. Acceptable for the single Rust process today. If ever scaled to 2 instances or frequent deploys, the brute-force window resets each time. +**Fix (future):** DB/Redis-backed counters, or rely on strict nginx limits in front. + +### R4. `refetchUser` 60s poll also re-pulls the full following list +`AuthContext.tsx:48` — every minute it re-fetches `following_list` just to refresh the unread badge, which is already on `/users/me/`. +**Fix:** poll only `users/me/`; fetch following on login + after follow/unfollow mutations. + +## Performance + +### P1. (Biggest) List endpoints ship full nested ratings the UI never reads +`build_flavors` (`service.rs:230`) always populates `FlavorOut.ratings` with full `RatingOut` objects (comment, avatar URL, category, score…). `/flavors/` page size = 50 (`pagination.rs:9`). `FlavorCard` (`FlavorCard.tsx:10-18`) consumes only id/name/image/avg/flags/category — never `ratings`. Category + main list pages thus transfer 50 flavors × all their ratings as dead JSON. +- Detail view needs ratings. `top` needs them (`MainPage.tsx:98,363`). `list`/`newest`/`category` do **not**. +**Fix:** add `include_ratings: bool` (mirrors existing `include_replies`); skip per-rating `RatingOut` build for list/newest/category while keeping the cheap aggregate pass (avg/distribution). For `top`'s review-count + featured review, keep ratings on `top` only or add slim `review_count`/`featured_comment` fields. + +### P2. Double average computation in `list` +`flavors.rs:97` orders by `AVG(score)` in SQL, then `build_flavors` recomputes avg in Rust. Ordering forces the SQL avg; the win is the P1 payload trim (ratings get fully loaded regardless today). + +## Code quality + +### Q1. Notification menu duplicated +Full render + `handleNotificationClick` logic is copy-pasted in `App.tsx:94-205` and `NavSidebar.tsx`. Two places to fix every notif bug (already bit us: R1 + R2 live in both). +**Fix:** extract `` + `useNotificationClick()`. + +### Q2. Categories fetched raw, outside react-query +`App.tsx:75-83` uses raw `api.get('categories/')` in `useEffect` while everything else uses query hooks. No cache, refetch on every mount, manual array/results juggling. +**Fix:** `useCategories()` query hook. + +### Q3. Stale docs / comment mismatch +`api.ts:4-7` correctly describes httpOnly-cookie auth, but CLAUDE.md frontend Auth still describes the retired localStorage `access`/`refresh` scheme. +**Fix:** update CLAUDE.md frontend Auth paragraph. + +### Q4. `as object` cast hack +`AuthContext.tsx:40-42` does `...({ skipAuthRedirect: true } as object)`; the field is already typed in `RetriableConfig` (`api.ts:24`). +**Fix:** export that type, drop the cast. + +## Extendability + +### E1. Hand-rolled per-field validation +`ratings.rs:107-178` (and update `:258-321`) manually build DRF-shaped field errors. Every new field/endpoint repeats the pattern. +**Fix:** small validation helper (required/int-range/string) returning the field-keyed map. + +### E2. App-level CASCADE hand-coded per handler +`ratings.rs:352-373` collects replies→notifications→replies→rating in a txn. Correct but error-prone as relations grow. +**Fix (long-term):** migration adding `ON DELETE CASCADE` FKs; short-term centralize collectors in helpers. + +## UX flow + +### U1. No optimistic UI on rating/reply submit +Verify mutations `invalidateQueries`; consider optimistic insert so reviews appear instantly. + +### U2. Login redirect is a hard reload +`Login.tsx:35` + `Settings.tsx:185` use `window.location.href='/'`, losing warm react-query cache. +**Fix:** `navigate('/')` + `refetchUser()`. + +### U3. Mark-all-read fails silently +`NotificationContext.tsx:44-51` swallows errors; badge clears then reappears on next poll if the request failed. +**Fix:** toast on failure (ToastContext exists). + +--- + +## Implementation status +- [x] R1 + R2 + Q1 — notif menu dedup + i18n + SPA nav. Extracted shared + `` (`components/layout/NotificationMenu.tsx`) + `useNotificationClick()` + hook (`hooks/useNotificationClick.ts`); `App.tsx` and `NavSidebar.tsx` now both render + the one component. R1: hardcoded English notif strings moved to i18n keys + (`community.notifReplyOn`/`notifMentionOn`/`notifProfileComment`/`markAllRead`, EN+DE, + flavor interpolated). R2: `window.location.href` → `navigate()` (no full reload). `tsc`, + ESLint (0 errors), 27 tests, `vite build` all clean. +- [x] P1 — list payload trim (`include_ratings` flag). `build_flavors` now takes + `include_ratings`; the aggregate rollup (avg/followed-avg/distribution/viewer + score) always runs, but the per-rating `RatingOut` vec + rating-author user + lookup are skipped when false. Set false on `newest`, `followed_top`, and the + dashboard's "missing flavors" (loads *every* unrated flavor — biggest win); + kept true on `list`/`top`/detail because their UI reads the nested reviews. No + frontend change required. `cargo fmt`/`clippy`/`test` clean (20 passed). + + diff --git a/backend-rs/src/routes/flavors.rs b/backend-rs/src/routes/flavors.rs index db86b4f..8541c99 100644 --- a/backend-rs/src/routes/flavors.rs +++ b/backend-rs/src/routes/flavors.rs @@ -100,7 +100,9 @@ async fn list( .all(&state.db) .await?; - let results = build_flavors(&state, &ctx, models, viewer, &[], false).await?; + // List cards show review counts + comment previews, so they need the nested + // ratings (but not replies). + let results = build_flavors(&state, &ctx, models, viewer, &[], true, false).await?; Ok(Json(Paginated::build(&ctx, results, count, page, size))) } @@ -115,7 +117,8 @@ async fn retrieve( .one(&state.db) .await? .ok_or(ApiError::NotFound)?; - let mut dtos = build_flavors(&state, &ctx, vec![model], viewer, &[], true).await?; + // Detail view: full nested ratings + their replies. + let mut dtos = build_flavors(&state, &ctx, vec![model], viewer, &[], true, true).await?; dtos.pop().map(Json).ok_or(ApiError::NotFound) } @@ -143,8 +146,10 @@ async fn top( .limit(10) .all(&state.db) .await?; + // The home "Hall of Fame" reads each top flavor's review count + featured + // review, so it needs the nested ratings. Ok(Json( - build_flavors(&state, &ctx, models, viewer, &[], false).await?, + build_flavors(&state, &ctx, models, viewer, &[], true, false).await?, )) } @@ -159,8 +164,9 @@ async fn newest( .limit(10) .all(&state.db) .await?; + // Rendered as compact cards (no nested ratings read). Ok(Json( - build_flavors(&state, &ctx, models, viewer, &[], false).await?, + build_flavors(&state, &ctx, models, viewer, &[], false, false).await?, )) } @@ -194,8 +200,9 @@ async fn followed_top( .limit(10) .all(&state.db) .await?; + // Compact cards (id/name/image/average only) — skip the nested ratings. Ok(Json( - build_flavors(&state, &ctx, models, Some(uid), &[], false).await?, + build_flavors(&state, &ctx, models, Some(uid), &[], false, false).await?, )) } diff --git a/backend-rs/src/routes/users.rs b/backend-rs/src/routes/users.rs index b8d0ac0..ab6f6a5 100644 --- a/backend-rs/src/routes/users.rs +++ b/backend-rs/src/routes/users.rs @@ -1363,8 +1363,19 @@ async fn dashboard( }); let missing_count = all_flavors.len() as i64; - let missing_flavors = - build_flavors(&state, &ctx, all_flavors, Some(uid), &followed, false).await?; + // The dashboard "explore" grid sorts these by community / followed average + // but never reads the nested ratings, and this query loads *every* unrated + // flavor — so skip building a RatingOut per rating (large payload win). + let missing_flavors = build_flavors( + &state, + &ctx, + all_flavors, + Some(uid), + &followed, + false, + false, + ) + .await?; let user = build_user(&state, &ctx, me, Some(uid)).await?; Ok(Json(DashboardOut { diff --git a/backend-rs/src/service.rs b/backend-rs/src/service.rs index afc6fe8..c6b1295 100644 --- a/backend-rs/src/service.rs +++ b/backend-rs/src/service.rs @@ -223,16 +223,28 @@ impl RatingAgg { } } -/// Build `FlavorOut`s. `include_replies` controls whether each nested rating -/// carries its replies: the flavor *detail* view needs them, but card/list -/// callers (list/top/newest/followed_top) don't — skipping the reply query + -/// reply-author lookups avoids shipping data the list UI never reads. +/// Build `FlavorOut`s. +/// +/// `include_ratings` controls whether each flavor carries its full nested +/// `ratings` array. The aggregate rollup (average / followed-average / +/// distribution / the viewer's own score) is always computed cheaply in one +/// pass over the loaded rows, so callers that only render those fields +/// (`newest`, `followed_top`, the dashboard's "missing flavors" — which loads +/// *every* unrated flavor) pass `false` and skip allocating a `RatingOut` per +/// rating plus the rating-author user lookup. `list`/`top`/detail pass `true` +/// because their UI reads the nested reviews (counts, comment previews, the +/// featured review). +/// +/// `include_replies` further controls whether each nested rating carries its +/// replies: only the flavor *detail* view needs them, so it alone pays for the +/// reply query + reply-author lookups. It implies `include_ratings`. pub async fn build_flavors( state: &AppState, ctx: &RequestCtx, flavors: Vec, viewer_id: Option, followed_ids: &[i32], + include_ratings: bool, include_replies: bool, ) -> ApiResult> { let media = &state.config.media_url; @@ -259,6 +271,7 @@ pub async fn build_flavors( .await?; // Replies (order: created_at asc) — only loaded for the detail view. + let include_replies = include_replies && include_ratings; let replies = if include_replies { let rating_ids: Vec = ratings.iter().map(|r| r.id).collect(); if rating_ids.is_empty() { @@ -274,20 +287,28 @@ pub async fn build_flavors( Vec::new() }; - // Users referenced by ratings + replies - let mut user_ids: Vec = ratings.iter().map(|r| r.user_id).collect(); - user_ids.extend(replies.iter().map(|r| r.user_id)); - user_ids.sort_unstable(); - user_ids.dedup(); - let users = if user_ids.is_empty() { - Vec::new() + // Users referenced by ratings + replies — only needed to fill the nested + // rating/reply author fields, so skip the lookup entirely when the caller + // doesn't want the nested ratings. + let user_map: HashMap = if include_ratings { + let mut user_ids: Vec = ratings.iter().map(|r| r.user_id).collect(); + user_ids.extend(replies.iter().map(|r| r.user_id)); + user_ids.sort_unstable(); + user_ids.dedup(); + if user_ids.is_empty() { + HashMap::new() + } else { + User::find() + .filter(user::Column::Id.is_in(user_ids)) + .all(&state.db) + .await? + .into_iter() + .map(|u| (u.id, u)) + .collect() + } } else { - User::find() - .filter(user::Column::Id.is_in(user_ids)) - .all(&state.db) - .await? + HashMap::new() }; - let user_map: HashMap = users.into_iter().map(|u| (u.id, u)).collect(); // Replies grouped by rating id let mut replies_by_rating: HashMap> = HashMap::new(); @@ -318,47 +339,51 @@ pub async fn build_flavors( let mut ratings_by_flavor: HashMap> = HashMap::new(); let mut agg_by_flavor: HashMap = HashMap::new(); for r in &ratings { - let user = user_map.get(&r.user_id); - let username = user.map(|u| u.username.clone()).unwrap_or_default(); - let user_avatar = user - .and_then(|u| u.avatar.as_deref()) - .filter(|s| !s.is_empty()) - .map(|a| image_field_url(ctx, media, a)); + // Nested RatingOut: only built when the caller will ship it. The + // aggregate accumulation below always runs (it's the cheap part). + if include_ratings { + let user = user_map.get(&r.user_id); + let username = user.map(|u| u.username.clone()).unwrap_or_default(); + let user_avatar = user + .and_then(|u| u.avatar.as_deref()) + .filter(|s| !s.is_empty()) + .map(|a| image_field_url(ctx, media, a)); - let fl = flavor_map.get(&r.flavor_id); - let (cat_name, cat_slug) = fl - .and_then(|f| cat_map.get(&f.category_id)) - .cloned() - .unwrap_or_default(); - let flavor_image = fl.and_then(|f| { - if let Some(img) = f.image.as_deref().filter(|s| !s.is_empty()) { - Some(image_field_url(ctx, media, img)) - } else { - f.image_url.clone() - } - }); - - ratings_by_flavor - .entry(r.flavor_id) - .or_default() - .push(RatingOut { - id: r.id, - user: username, - user_id: r.user_id, - user_avatar, - flavor: r.flavor_id, - flavor_name: fl.map(|f| f.name.clone()).unwrap_or_default(), - flavor_image, - category_name: cat_name, - category_slug: cat_slug, - is_available: fl.map(|f| f.is_available).unwrap_or(false), - is_legacy: fl.map(|f| f.is_legacy).unwrap_or(false), - score: r.score, - comment: r.comment.clone(), - created_at: crate::datetime::drf_iso(&r.created_at), - replies: replies_by_rating.remove(&r.id).unwrap_or_default(), + let fl = flavor_map.get(&r.flavor_id); + let (cat_name, cat_slug) = fl + .and_then(|f| cat_map.get(&f.category_id)) + .cloned() + .unwrap_or_default(); + let flavor_image = fl.and_then(|f| { + if let Some(img) = f.image.as_deref().filter(|s| !s.is_empty()) { + Some(image_field_url(ctx, media, img)) + } else { + f.image_url.clone() + } }); + ratings_by_flavor + .entry(r.flavor_id) + .or_default() + .push(RatingOut { + id: r.id, + user: username, + user_id: r.user_id, + user_avatar, + flavor: r.flavor_id, + flavor_name: fl.map(|f| f.name.clone()).unwrap_or_default(), + flavor_image, + category_name: cat_name, + category_slug: cat_slug, + is_available: fl.map(|f| f.is_available).unwrap_or(false), + is_legacy: fl.map(|f| f.is_legacy).unwrap_or(false), + score: r.score, + comment: r.comment.clone(), + created_at: crate::datetime::drf_iso(&r.created_at), + replies: replies_by_rating.remove(&r.id).unwrap_or_default(), + }); + } + let a = agg_by_flavor.entry(r.flavor_id).or_default(); if (1..=10).contains(&r.score) { *a.dist.get_mut(&r.score.to_string()).unwrap() += 1; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 322fc8f..267d307 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,13 +1,10 @@ import React, { Suspense, lazy, useEffect, useState } from 'react'; -import { Routes, Route, Link, useNavigate, Navigate } from 'react-router-dom'; +import { Routes, Route, Link, Navigate } from 'react-router-dom'; import { Typography, Button, Box, IconButton, - Avatar, - Menu, - MenuItem, SwipeableDrawer, CircularProgress, Badge, @@ -22,15 +19,13 @@ import { useTranslation } from 'react-i18next'; import api from './lib/api'; import Footer from './components/Footer'; import CookieBanner from './components/CookieBanner'; -import { formatDate } from './utils/date'; import { GlobalSearch } from './app/GlobalSearch'; import { useAuth } from './hooks/useAuth'; import { useTheme } from './hooks/useTheme'; -import { useNotifications } from './hooks/useNotifications'; import { useDrawerAnchor } from './hooks/useDrawerAnchor'; import { NavSidebar, NAV_SIDEBAR_WIDTH } from './components/layout/NavSidebar'; +import { NotificationMenu } from './components/layout/NotificationMenu'; import { RequireSuperuser } from './components/auth/RequireSuperuser'; -import type { Notification } from './contexts/NotificationContext'; import type { CatppuccinTheme } from './theme'; const MainPage = lazy(() => import('./pages/MainPage')); @@ -60,11 +55,9 @@ const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail')); const App: React.FC = () => { const { t } = useTranslation(); - const navigate = useNavigate(); const muiTheme = useMuiTheme(); const { user, loadingUser } = useAuth(); const { themeName, handleThemeChange } = useTheme(); - const { notifications, markAllRead, markRead } = useNotifications(); const { anchor: mobileAnchor } = useDrawerAnchor(); const [notifAnchorEl, setNotifAnchorEl] = useState(null); @@ -91,20 +84,6 @@ const App: React.FC = () => { await handleThemeChange(name, !!user); }; - const handleNotificationClick = async (notif: Notification) => { - if (!notif.is_read) await markRead(notif.id); - setNotifAnchorEl(null); - if (notif.notification_type.startsWith('ticket')) { - navigate(user?.is_superuser ? '/admin-panel/tickets' : '/support'); - } else if (notif.notification_type === 'profile_comment') { - navigate(`/profile/${user?.username}`); - } else if (notif.notification_type === 'follow') { - navigate(`/profile/${notif.actor_username}`); - } else if (notif.flavor_id) { - window.location.href = `/flavor/${notif.flavor_id}`; - } - }; - const hamburger = ( { - setNotifAnchorEl(null)} - elevation={3} - sx={{ mt: 1 }} - PaperProps={{ sx: { width: 320, maxHeight: 400, borderRadius: 1 } }} - > - - - Notifications - - {(user?.unread_notifications_count || 0) > 0 && ( - - )} - - {notifications.length === 0 ? ( - - - No notifications yet - - - ) : ( - notifications.map((n) => ( - handleNotificationClick(n)} - sx={{ - py: 1.5, - px: 2, - borderBottom: '1px solid', - borderColor: 'divider', - bgcolor: n.is_read ? 'transparent' : alpha(muiTheme.palette.primary.main, 0.05), - whiteSpace: 'normal', - display: 'flex', - gap: 2, - alignItems: 'flex-start', - }} - > - - {n.actor_username.charAt(0).toUpperCase()} - - - - {n.actor_username}{' '} - {n.notification_type === 'reply' - ? `replied to your review on ${n.flavor_name}` - : n.notification_type === 'mention' - ? `mentioned you on ${n.flavor_name}` - : n.notification_type === 'follow' - ? t('community.notifFollow') - : n.notification_type === 'profile_comment' - ? `left a message on your guestbook` - : n.notification_type === 'ticket_new' - ? t('community.notifTicketNew') - : user?.is_superuser - ? t('community.notifTicketReplyAdmin') - : t('community.notifTicketReply')} - - - {formatDate(n.created_at)} - - - - )) - )} - + menuSx={{ mt: 1 }} + /> ); diff --git a/frontend/src/components/layout/NavSidebar.tsx b/frontend/src/components/layout/NavSidebar.tsx index a0907c5..111e5cb 100644 --- a/frontend/src/components/layout/NavSidebar.tsx +++ b/frontend/src/components/layout/NavSidebar.tsx @@ -1,10 +1,9 @@ import { useState } from 'react'; -import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import { Avatar, Badge, Box, - Button, Collapse, Divider, List, @@ -13,11 +12,7 @@ import { ListItemButton, ListItemIcon, ListItemText, - Menu, - MenuItem, Typography, - alpha, - useTheme, } from '@mui/material'; import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; @@ -35,10 +30,8 @@ import NotificationsOutlinedIcon from '@mui/icons-material/NotificationsOutlined import PeopleOutlineOutlinedIcon from '@mui/icons-material/PeopleOutlineOutlined'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../../hooks/useAuth'; -import { useNotifications } from '../../hooks/useNotifications'; import { GlobalSearch } from '../../app/GlobalSearch'; -import { formatDate } from '../../utils/date'; -import type { Notification } from '../../contexts/NotificationContext'; +import { NotificationMenu } from './NotificationMenu'; export const NAV_SIDEBAR_WIDTH = 280; @@ -57,10 +50,7 @@ export const NavSidebar = ({ }: NavSidebarProps) => { const { t } = useTranslation(); const location = useLocation(); - const navigate = useNavigate(); - const theme = useTheme(); const { user, following, logout } = useAuth(); - const { notifications, markAllRead, markRead } = useNotifications(); const [catOpen, setCatOpen] = useState(false); const [followingOpen, setFollowingOpen] = useState(false); const [notifAnchorEl, setNotifAnchorEl] = useState(null); @@ -69,21 +59,6 @@ export const NavSidebar = ({ const isActive = (path: string) => path === '/' ? location.pathname === '/' : location.pathname.startsWith(path); - const handleNotificationClick = async (notif: Notification) => { - if (!notif.is_read) await markRead(notif.id); - setNotifAnchorEl(null); - close(); - if (notif.notification_type.startsWith('ticket')) { - navigate(user?.is_superuser ? '/admin-panel/tickets' : '/support'); - } else if (notif.notification_type === 'profile_comment') { - navigate(`/profile/${user?.username}`); - } else if (notif.notification_type === 'follow') { - navigate(`/profile/${notif.actor_username}`); - } else if (notif.flavor_id) { - window.location.href = `/flavor/${notif.flavor_id}`; - } - }; - const unreadCount = user?.unread_notifications_count || 0; return ( @@ -384,85 +359,15 @@ export const NavSidebar = ({ )} {showNotifications && ( - setNotifAnchorEl(null)} + onItemNavigate={close} anchorOrigin={{ vertical: 'center', horizontal: 'right' }} transformOrigin={{ vertical: 'center', horizontal: 'left' }} - elevation={3} - PaperProps={{ sx: { width: 320, maxHeight: 400, borderRadius: 1, ml: 1 } }} - > - - - {t('nav.notifications', { defaultValue: 'Notifications' })} - - {unreadCount > 0 && ( - - )} - - {notifications.length === 0 ? ( - - - {t('community.noNotifications', { defaultValue: 'No notifications yet' })} - - - ) : ( - notifications.map((n) => ( - handleNotificationClick(n)} - sx={{ - py: 1.5, - px: 2, - borderBottom: '1px solid', - borderColor: 'divider', - bgcolor: n.is_read ? 'transparent' : alpha(theme.palette.primary.main, 0.05), - whiteSpace: 'normal', - display: 'flex', - gap: 2, - alignItems: 'flex-start', - }} - > - - {n.actor_username.charAt(0).toUpperCase()} - - - - {n.actor_username}{' '} - {n.notification_type === 'reply' - ? `replied to your review on ${n.flavor_name}` - : n.notification_type === 'mention' - ? `mentioned you on ${n.flavor_name}` - : n.notification_type === 'follow' - ? t('community.notifFollow') - : n.notification_type === 'profile_comment' - ? `left a message on your guestbook` - : n.notification_type === 'ticket_new' - ? t('community.notifTicketNew') - : user?.is_superuser - ? t('community.notifTicketReplyAdmin') - : t('community.notifTicketReply')} - - - {formatDate(n.created_at)} - - - - )) - )} - + paperSx={{ ml: 1 }} + /> )} ); diff --git a/frontend/src/components/layout/NotificationMenu.tsx b/frontend/src/components/layout/NotificationMenu.tsx new file mode 100644 index 0000000..45b6ac3 --- /dev/null +++ b/frontend/src/components/layout/NotificationMenu.tsx @@ -0,0 +1,151 @@ +import { + Avatar, + Box, + Button, + Menu, + MenuItem, + Typography, + alpha, + useTheme, +} from '@mui/material'; +import type { MenuProps, SxProps, Theme } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useAuth } from '../../hooks/useAuth'; +import { useNotifications } from '../../hooks/useNotifications'; +import { useNotificationClick } from '../../hooks/useNotificationClick'; +import { formatDate } from '../../utils/date'; +import type { Notification } from '../../contexts/NotificationContext'; + +interface NotificationMenuProps { + anchorEl: HTMLElement | null; + open: boolean; + onClose: () => void; + /** Extra cleanup after a notification is clicked (e.g. close a drawer). */ + onItemNavigate?: () => void; + anchorOrigin?: MenuProps['anchorOrigin']; + transformOrigin?: MenuProps['transformOrigin']; + menuSx?: SxProps; + paperSx?: SxProps; +} + +/** Human-readable line for a notification, fully i18n'd. */ +const NotifText = ({ n }: { n: Notification }) => { + const { t } = useTranslation(); + const { user } = useAuth(); + switch (n.notification_type) { + case 'reply': + return <>{t('community.notifReplyOn', { flavor: n.flavor_name })}; + case 'mention': + return <>{t('community.notifMentionOn', { flavor: n.flavor_name })}; + case 'follow': + return <>{t('community.notifFollow')}; + case 'profile_comment': + return <>{t('community.notifProfileComment')}; + case 'ticket_new': + return <>{t('community.notifTicketNew')}; + default: + return ( + <> + {user?.is_superuser + ? t('community.notifTicketReplyAdmin') + : t('community.notifTicketReply')} + + ); + } +}; + +/** + * The notification dropdown shared by the mobile AppBar and the desktop + * sidebar. Placement differs per consumer, so anchor/transform origins and + * paper styling are props. + */ +export const NotificationMenu = ({ + anchorEl, + open, + onClose, + onItemNavigate, + anchorOrigin, + transformOrigin, + menuSx, + paperSx, +}: NotificationMenuProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + const { user } = useAuth(); + const { notifications, markAllRead } = useNotifications(); + const handleClick = useNotificationClick(); + const unreadCount = user?.unread_notifications_count || 0; + + return ( + + + + {t('community.notifications')} + + {unreadCount > 0 && ( + + )} + + {notifications.length === 0 ? ( + + + {t('community.noNotifications')} + + + ) : ( + notifications.map((n) => ( + { + onClose(); + handleClick(n, onItemNavigate); + }} + sx={{ + py: 1.5, + px: 2, + borderBottom: '1px solid', + borderColor: 'divider', + bgcolor: n.is_read ? 'transparent' : alpha(theme.palette.primary.main, 0.05), + whiteSpace: 'normal', + display: 'flex', + gap: 2, + alignItems: 'flex-start', + }} + > + + {n.actor_username.charAt(0).toUpperCase()} + + + + {n.actor_username} + + + {formatDate(n.created_at)} + + + + )) + )} + + ); +}; diff --git a/frontend/src/hooks/useNotificationClick.ts b/frontend/src/hooks/useNotificationClick.ts new file mode 100644 index 0000000..f0ce7fb --- /dev/null +++ b/frontend/src/hooks/useNotificationClick.ts @@ -0,0 +1,33 @@ +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from './useAuth'; +import { useNotifications } from './useNotifications'; +import type { Notification } from '../contexts/NotificationContext'; + +/** + * Shared notification-click behaviour for the AppBar and sidebar menus: + * marks the notification read, runs an optional cleanup (close menu/drawer), + * then routes via the SPA router (no full page reload). + */ +export const useNotificationClick = () => { + const navigate = useNavigate(); + const { user } = useAuth(); + const { markRead } = useNotifications(); + + return useCallback( + async (notif: Notification, onDone?: () => void) => { + if (!notif.is_read) await markRead(notif.id); + onDone?.(); + if (notif.notification_type.startsWith('ticket')) { + navigate(user?.is_superuser ? '/admin-panel/tickets' : '/support'); + } else if (notif.notification_type === 'profile_comment') { + navigate(`/profile/${user?.username}`); + } else if (notif.notification_type === 'follow') { + navigate(`/profile/${notif.actor_username}`); + } else if (notif.flavor_id) { + navigate(`/flavor/${notif.flavor_id}`); + } + }, + [navigate, user, markRead], + ); +}; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index a7114df..1ba5529 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -182,12 +182,16 @@ const resources = { topRated: "Circle's Top Rated", notifications: 'Recent Notifications', notifReply: 'replied', + notifReplyOn: 'replied to your review on {{flavor}}', notifMention: 'mentioned you', + notifMentionOn: 'mentioned you on {{flavor}}', notifFollow: 'started following you', + notifProfileComment: 'left a message on your guestbook', notifTicketNew: 'opened a new support ticket', notifTicketReply: 'replied to your support ticket', notifTicketReplyAdmin: 'replied to a support ticket', noNotifications: 'No notifications.', + markAllRead: 'Mark all read', searchFriends: 'Search friends...', writeReply: 'Add a reply...', rated: 'rated', @@ -488,12 +492,16 @@ const resources = { topRated: 'Top-Bewertungen deines Kreises', notifications: 'Neueste Benachrichtigungen', notifReply: 'hat geantwortet', + notifReplyOn: 'hat auf deine Bewertung zu {{flavor}} geantwortet', notifMention: 'hat dich erwähnt', + notifMentionOn: 'hat dich bei {{flavor}} erwähnt', notifFollow: 'folgt dir jetzt', + notifProfileComment: 'hat dir einen Eintrag ins Gästebuch hinterlassen', notifTicketNew: 'hat ein neues Support-Ticket eröffnet', notifTicketReply: 'hat auf dein Support-Ticket geantwortet', notifTicketReplyAdmin: 'hat auf ein Support-Ticket geantwortet', noNotifications: 'Keine Benachrichtigungen.', + markAllRead: 'Alle als gelesen markieren', searchFriends: 'Freunde suchen...', writeReply: 'Eine Antwort hinzufügen...', rated: 'bewertete',