mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
fixed translation
This commit is contained in:
1 parent
e90b3b073c
commit
6a8e7d53f9
9 files changed
+399
-260
No files matched your search
@@ -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 `<NotificationMenu>` + `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
|
||||
`<NotificationMenu>` (`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).
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -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?,
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+79
-54
@@ -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<flavor::Model>,
|
||||
viewer_id: Option<i32>,
|
||||
followed_ids: &[i32],
|
||||
include_ratings: bool,
|
||||
include_replies: bool,
|
||||
) -> ApiResult<Vec<FlavorOut>> {
|
||||
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<i32> = 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<i32> = 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<i32, user::Model> = if include_ratings {
|
||||
let mut user_ids: Vec<i32> = 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<i32, user::Model> = users.into_iter().map(|u| (u.id, u)).collect();
|
||||
|
||||
// Replies grouped by rating id
|
||||
let mut replies_by_rating: HashMap<i32, Vec<ReplyOut>> = HashMap::new();
|
||||
@@ -318,47 +339,51 @@ pub async fn build_flavors(
|
||||
let mut ratings_by_flavor: HashMap<i32, Vec<RatingOut>> = HashMap::new();
|
||||
let mut agg_by_flavor: HashMap<i32, RatingAgg> = 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;
|
||||
|
||||
+5
-98
@@ -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 | HTMLElement>(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 = (
|
||||
<IconButton
|
||||
color="inherit"
|
||||
@@ -123,84 +102,12 @@ const App: React.FC = () => {
|
||||
<NotificationsIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
<Menu
|
||||
<NotificationMenu
|
||||
anchorEl={notifAnchorEl}
|
||||
open={Boolean(notifAnchorEl)}
|
||||
onClose={() => setNotifAnchorEl(null)}
|
||||
elevation={3}
|
||||
sx={{ mt: 1 }}
|
||||
PaperProps={{ sx: { width: 320, maxHeight: 400, borderRadius: 1 } }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
Notifications
|
||||
</Typography>
|
||||
{(user?.unread_notifications_count || 0) > 0 && (
|
||||
<Button size="small" onClick={markAllRead}>
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{notifications.length === 0 ? (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No notifications yet
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
notifications.map((n) => (
|
||||
<MenuItem
|
||||
key={n.id}
|
||||
onClick={() => 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',
|
||||
}}
|
||||
>
|
||||
<Avatar src={n.actor_avatar || undefined} sx={{ width: 32, height: 32 }}>
|
||||
{n.actor_username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.4 }}>
|
||||
<strong>{n.actor_username}</strong>{' '}
|
||||
{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')}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(n.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
</Menu>
|
||||
menuSx={{ mt: 1 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -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 | HTMLElement>(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 && (
|
||||
<Menu
|
||||
<NotificationMenu
|
||||
anchorEl={notifAnchorEl}
|
||||
open={Boolean(notifAnchorEl)}
|
||||
onClose={() => 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 } }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
{t('nav.notifications', { defaultValue: 'Notifications' })}
|
||||
</Typography>
|
||||
{unreadCount > 0 && (
|
||||
<Button size="small" onClick={markAllRead}>
|
||||
{t('community.markAllRead', { defaultValue: 'Mark all read' })}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{notifications.length === 0 ? (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('community.noNotifications', { defaultValue: 'No notifications yet' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
notifications.map((n) => (
|
||||
<MenuItem
|
||||
key={n.id}
|
||||
onClick={() => 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',
|
||||
}}
|
||||
>
|
||||
<Avatar src={n.actor_avatar || undefined} sx={{ width: 32, height: 32 }}>
|
||||
{n.actor_username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.4 }}>
|
||||
<strong>{n.actor_username}</strong>{' '}
|
||||
{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')}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(n.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
</Menu>
|
||||
paperSx={{ ml: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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<Theme>;
|
||||
paperSx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
anchorOrigin={anchorOrigin}
|
||||
transformOrigin={transformOrigin}
|
||||
elevation={3}
|
||||
sx={menuSx}
|
||||
slotProps={{ paper: { sx: { width: 320, maxHeight: 400, borderRadius: 1, ...paperSx } } }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
{t('community.notifications')}
|
||||
</Typography>
|
||||
{unreadCount > 0 && (
|
||||
<Button size="small" onClick={markAllRead}>
|
||||
{t('community.markAllRead')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{notifications.length === 0 ? (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('community.noNotifications')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
notifications.map((n) => (
|
||||
<MenuItem
|
||||
key={n.id}
|
||||
onClick={() => {
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<Avatar src={n.actor_avatar || undefined} sx={{ width: 32, height: 32 }}>
|
||||
{n.actor_username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.4 }}>
|
||||
<strong>{n.actor_username}</strong> <NotifText n={n} />
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(n.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user