mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
fixed deletion
This commit is contained in:
1 parent
d0aff29a71
commit
1fb318f6a1
7 files changed
+493
-212
No files matched your search
@@ -52,6 +52,27 @@ async fn ensure_schema(conn: &DatabaseConnection) -> anyhow::Result<()> {
|
|||||||
"ALTER TABLE api_flavor ADD COLUMN aliases TEXT",
|
"ALTER TABLE api_flavor ADD COLUMN aliases TEXT",
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
// Account-deletion (GDPR "right to erasure") flow gets its own dedicated,
|
||||||
|
// DB-persisted code + expiry. Previously it reused `email_confirmation_code`
|
||||||
|
// and an in-process TTL, so a concurrent email-confirm/password-reset flow
|
||||||
|
// could silently clobber the deletion code (the column ended up empty) and a
|
||||||
|
// backend restart dropped the expiry — both could block a user from deleting
|
||||||
|
// their account. Dedicated, persisted columns make the flow restart-safe,
|
||||||
|
// multi-instance-safe, and immune to cross-flow collisions.
|
||||||
|
add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"api_user",
|
||||||
|
"deletion_code",
|
||||||
|
"ALTER TABLE api_user ADD COLUMN deletion_code TEXT",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"api_user",
|
||||||
|
"deletion_code_expires",
|
||||||
|
"ALTER TABLE api_user ADD COLUMN deletion_code_expires DATETIME",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ pub struct Model {
|
|||||||
pub language: String,
|
pub language: String,
|
||||||
pub selected_banner_id: Option<i32>,
|
pub selected_banner_id: Option<i32>,
|
||||||
pub drawer_anchor: String,
|
pub drawer_anchor: String,
|
||||||
|
/// Dedicated account-deletion confirmation code + its expiry. Kept separate
|
||||||
|
/// from `email_confirmation_code` so other code-driven flows (email confirm,
|
||||||
|
/// password reset) can never clobber an in-flight deletion code, and
|
||||||
|
/// persisted so the 15-min TTL survives restarts / spans instances.
|
||||||
|
pub deletion_code: Option<String>,
|
||||||
|
pub deletion_code_expires: Option<ChronoDateTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use axum::extract::{ConnectInfo, Multipart, Path, Query, State};
|
|||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::routing::{get, patch, post};
|
use axum::routing::{get, patch, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use chrono::Datelike;
|
use chrono::{Datelike, Duration};
|
||||||
use sea_orm::ActiveValue::Set;
|
use sea_orm::ActiveValue::Set;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, PaginatorTrait,
|
ActiveModelTrait, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, PaginatorTrait,
|
||||||
@@ -147,7 +147,11 @@ fn err_json(status: StatusCode, body: Value) -> ApiError {
|
|||||||
/// to be constant-time to avoid leaking a correct prefix.
|
/// to be constant-time to avoid leaking a correct prefix.
|
||||||
fn code_matches(stored: Option<&str>, provided: Option<&str>) -> bool {
|
fn code_matches(stored: Option<&str>, provided: Option<&str>) -> bool {
|
||||||
match (stored, provided) {
|
match (stored, provided) {
|
||||||
(Some(s), Some(p)) if s.len() == p.len() => s.as_bytes().ct_eq(p.as_bytes()).into(),
|
// An empty stored code is never a valid secret: it means the column was
|
||||||
|
// cleared/never set, so it must not be satisfiable by an empty input.
|
||||||
|
(Some(s), Some(p)) if !s.is_empty() && s.len() == p.len() => {
|
||||||
|
s.as_bytes().ct_eq(p.as_bytes()).into()
|
||||||
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -995,10 +999,15 @@ async fn request_account_deletion(
|
|||||||
let code = gen_code();
|
let code = gen_code();
|
||||||
let uname = user.username.clone();
|
let uname = user.username.clone();
|
||||||
let email = user.email.clone();
|
let email = user.email.clone();
|
||||||
|
let expires = crate::datetime::now_micros()
|
||||||
|
+ Duration::from_std(throttle::CODE_TTL).expect("CODE_TTL fits in chrono::Duration");
|
||||||
let mut active: user::ActiveModel = user.into();
|
let mut active: user::ActiveModel = user.into();
|
||||||
active.email_confirmation_code = Set(Some(code.clone()));
|
// Dedicated, persisted columns — never touch `email_confirmation_code`, so a
|
||||||
|
// concurrent email/password flow can't clobber the in-flight deletion code,
|
||||||
|
// and the TTL survives restarts instead of living only in process memory.
|
||||||
|
active.deletion_code = Set(Some(code.clone()));
|
||||||
|
active.deletion_code_expires = Set(Some(expires));
|
||||||
active.update(&state.db).await?;
|
active.update(&state.db).await?;
|
||||||
state.security.record_code(&del_code_key(uid));
|
|
||||||
|
|
||||||
crate::email::spawn_mail(
|
crate::email::spawn_mail(
|
||||||
state.config.email.clone(),
|
state.config.email.clone(),
|
||||||
@@ -1018,26 +1027,33 @@ async fn confirm_account_deletion(
|
|||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> ApiResult<Json<Value>> {
|
) -> ApiResult<Json<Value>> {
|
||||||
let code_key = del_code_key(uid);
|
// Per-process attempt throttle (defence-in-depth; nginx limits sit in front).
|
||||||
state
|
state.security.check_rate(
|
||||||
.security
|
&format!("attempt:{}", del_code_key(uid)),
|
||||||
.check_rate(&format!("attempt:{code_key}"), throttle::CODE_ATTEMPTS)?;
|
throttle::CODE_ATTEMPTS,
|
||||||
if state.security.code_expired(&code_key) {
|
)?;
|
||||||
|
let user = load_user(&state, uid).await?;
|
||||||
|
let data = parse_body(&body);
|
||||||
|
let code = data.get("code").and_then(|v| v.as_str());
|
||||||
|
|
||||||
|
// Validate the dedicated, DB-persisted deletion code + expiry. Both checks
|
||||||
|
// read from the DB so they're restart-safe and consistent across instances.
|
||||||
|
let expired = user
|
||||||
|
.deletion_code_expires
|
||||||
|
.is_none_or(|exp| exp < crate::datetime::now_micros());
|
||||||
|
if expired {
|
||||||
return Err(err_json(
|
return Err(err_json(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
json!({ "error": "Deletion code has expired. Please request a new one." }),
|
json!({ "error": "Deletion code has expired. Please request a new one." }),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let user = load_user(&state, uid).await?;
|
if !code_matches(user.deletion_code.as_deref(), code) {
|
||||||
let data = parse_body(&body);
|
|
||||||
let code = data.get("code").and_then(|v| v.as_str());
|
|
||||||
if !code_matches(user.email_confirmation_code.as_deref(), code) {
|
|
||||||
return Err(err_json(
|
return Err(err_json(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
json!({ "error": "Invalid or expired code" }),
|
json!({ "error": "Invalid or expired code" }),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
state.security.clear_code(&code_key);
|
// delete_user_cascade removes the user row (and the code with it) atomically.
|
||||||
delete_user_cascade(&state, uid).await?;
|
delete_user_cascade(&state, uid).await?;
|
||||||
Ok(Json(json!({ "status": "Account deleted" })))
|
Ok(Json(json!({ "status": "Account deleted" })))
|
||||||
}
|
}
|
||||||
@@ -1548,3 +1564,37 @@ mod dashboard_stats_tests {
|
|||||||
assert_eq!(fav.slug, "apple");
|
assert_eq!(fav.slug, "apple");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod deletion_code_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// The account-deletion confirm endpoint gates on `code_matches`. Guard the
|
||||||
|
// exact failure that once blocked the GDPR erasure flow: an empty stored
|
||||||
|
// code (column was clobbered/never set) must never satisfy any input.
|
||||||
|
#[test]
|
||||||
|
fn empty_or_missing_stored_code_never_matches() {
|
||||||
|
assert!(!code_matches(None, Some("123456")));
|
||||||
|
assert!(!code_matches(Some(""), Some("")));
|
||||||
|
assert!(!code_matches(Some("123456"), None));
|
||||||
|
assert!(!code_matches(None, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_exact_equal_length_code_matches() {
|
||||||
|
assert!(code_matches(Some("123456"), Some("123456")));
|
||||||
|
assert!(!code_matches(Some("123456"), Some("123450")));
|
||||||
|
assert!(!code_matches(Some("123456"), Some("12345"))); // length mismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror the inline expiry guard in `confirm_account_deletion`: a missing or
|
||||||
|
// past expiry is treated as expired; a future expiry is valid.
|
||||||
|
#[test]
|
||||||
|
fn expiry_guard_treats_missing_or_past_as_expired() {
|
||||||
|
let now = crate::datetime::now_micros();
|
||||||
|
let is_expired = |exp: Option<chrono::NaiveDateTime>| exp.is_none_or(|e| e < now);
|
||||||
|
assert!(is_expired(None));
|
||||||
|
assert!(is_expired(Some(now - Duration::minutes(1))));
|
||||||
|
assert!(!is_expired(Some(now + Duration::minutes(1))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Community Tab Redesign
|
||||||
|
|
||||||
|
The Community tab is the oldest part of the site and doesn't fit the robust,
|
||||||
|
professional design of the rest. This document tracks the redesign.
|
||||||
|
|
||||||
|
## Starting state
|
||||||
|
|
||||||
|
`frontend/src/pages/CommunityFeed.tsx` — a paginated feed of ratings **from
|
||||||
|
followed users only**, with a single sidebar card ("Circle's Top Rated"). Replies
|
||||||
|
are hidden behind a per-card collapse. Loading is a bare center spinner.
|
||||||
|
|
||||||
|
### Core problems
|
||||||
|
|
||||||
|
- **Cold start is dead.** No follows → empty feed → no reason to return. The
|
||||||
|
backend `ratings/feed/` is gated on the follow graph and returns nothing for
|
||||||
|
users who follow no one.
|
||||||
|
- **Single content type.** Only ratings. No new-flavor drops, profile comments,
|
||||||
|
follow events, or milestones.
|
||||||
|
- **No engagement loop.** No reactions/likes. Replies buried in a collapse. The
|
||||||
|
feed is a read-only wall.
|
||||||
|
- **No filtering/sort.** Can't sort recent/top or filter by category, even though
|
||||||
|
the payload already carries `category_name`/`category_slug`.
|
||||||
|
- **Sparse sidebar.** One card wastes the 4-column rail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — frontend-only (DONE)
|
||||||
|
|
||||||
|
No backend changes. Reuses the existing `GET /api/ratings/recent/` endpoint
|
||||||
|
(global, AllowAny, 10 newest ratings with a comment).
|
||||||
|
|
||||||
|
- **Following | Discover tabs.** Discover surfaces the whole community's newest
|
||||||
|
ratings, so the page is useful even when you follow nobody — fixes the
|
||||||
|
cold-start hole. Discover is fetched lazily (`enabled: tab === 'discover'`).
|
||||||
|
- **Skeleton loader** (`FeedSkeleton`) replaces the center spinner — matches the
|
||||||
|
professional look of the rest of the app.
|
||||||
|
- **Inline reply preview.** The latest reply shows compact under each card; click
|
||||||
|
expands the full thread + compose box. Replies feel like conversation, not
|
||||||
|
archive.
|
||||||
|
- **Smarter empty states.** Empty Following nudges to Discover (button switches
|
||||||
|
tab); empty Discover nudges to explore flavors.
|
||||||
|
- **i18n** EN + DE keys added under `community.*`.
|
||||||
|
|
||||||
|
### Files touched
|
||||||
|
|
||||||
|
- `frontend/src/api/queries/useCommunityFeed.ts` — added `useRecentRatings`.
|
||||||
|
- `frontend/src/pages/CommunityFeed.tsx` — tabs, skeleton, inline reply preview,
|
||||||
|
empty-state logic.
|
||||||
|
- `frontend/src/i18n.ts` — `tabFollowing`, `tabDiscover`, `discoverSubtitle`,
|
||||||
|
`discoverEmpty`, `discoverEmptyHint`, `browseDiscover` (EN + DE).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — needs backend
|
||||||
|
|
||||||
|
### 1. Reactions (highest engagement lever)
|
||||||
|
|
||||||
|
Lightweight 👍 / emoji on ratings. Social proof, drives return visits.
|
||||||
|
|
||||||
|
- New `rating_reaction` table (`user_id`, `rating_id`, `kind`, `created_at`;
|
||||||
|
unique on `user_id + rating_id + kind`).
|
||||||
|
- Endpoints: `POST /api/ratings/{id}/react/`, `DELETE /api/ratings/{id}/react/`.
|
||||||
|
- Include reaction counts + the caller's own reaction in `RatingOut`.
|
||||||
|
- Frontend: reaction bar on each feed card; optimistic toggle.
|
||||||
|
|
||||||
|
### 2. Discover feed with sort + pagination
|
||||||
|
|
||||||
|
`recent/` is fixed at 10 and unpaginated. Promote Discover to a real feed.
|
||||||
|
|
||||||
|
- Extend `ratings/feed/` (or a new `ratings/discover/`) to accept
|
||||||
|
`?sort=recent|top` and `?category=<slug>` and `?page=`.
|
||||||
|
- Frontend: sticky filter bar with `Recent | Top | <category chips>`. Reuse the
|
||||||
|
`category_slug` already in the payload.
|
||||||
|
|
||||||
|
### 3. Who-to-follow
|
||||||
|
|
||||||
|
Sidebar card suggesting active raters the user doesn't follow yet.
|
||||||
|
|
||||||
|
- `GET /api/users/suggested/` — most-active raters excluding self + already-followed.
|
||||||
|
- Frontend: avatar + name + follow button; optimistic follow.
|
||||||
|
|
||||||
|
### 4. Richer activity types
|
||||||
|
|
||||||
|
Make the feed alive when friends are quiet.
|
||||||
|
|
||||||
|
- New-flavor drops (from the `sync_flavors` job) and milestones
|
||||||
|
("X hit 100 ratings") as feed items.
|
||||||
|
- Likely a unified activity payload or a separate `activity/` endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested order
|
||||||
|
|
||||||
|
Phase 2.1 (reactions) and 2.2 (Discover sort/filter) give the most UX lift per
|
||||||
|
unit of backend work. 2.3 and 2.4 follow.
|
||||||
@@ -60,6 +60,16 @@ export const useCommunityFeed = (page: number) =>
|
|||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const useRecentRatings = (enabled = true) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['recentRatings'],
|
||||||
|
queryFn: async (): Promise<FeedRating[]> => {
|
||||||
|
const res = await api.get<FeedRating[] | { results: FeedRating[] }>('ratings/recent/');
|
||||||
|
return Array.isArray(res.data) ? res.data : (res.data.results ?? []);
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
|
||||||
export const useFollowingList = () =>
|
export const useFollowingList = () =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['followingList'],
|
queryKey: ['followingList'],
|
||||||
|
|||||||
@@ -293,6 +293,12 @@ const resources = {
|
|||||||
searchFriends: 'Search friends...',
|
searchFriends: 'Search friends...',
|
||||||
writeReply: 'Add a reply...',
|
writeReply: 'Add a reply...',
|
||||||
rated: 'rated',
|
rated: 'rated',
|
||||||
|
tabFollowing: 'Following',
|
||||||
|
tabDiscover: 'Discover',
|
||||||
|
discoverSubtitle: 'See what the whole community is rating right now.',
|
||||||
|
discoverEmpty: 'No recent activity yet.',
|
||||||
|
discoverEmptyHint: 'Be the first to rate a flavor!',
|
||||||
|
browseDiscover: 'Browse Discover',
|
||||||
},
|
},
|
||||||
support: {
|
support: {
|
||||||
title: 'Help & Support',
|
title: 'Help & Support',
|
||||||
@@ -830,6 +836,12 @@ const resources = {
|
|||||||
searchFriends: 'Freunde suchen...',
|
searchFriends: 'Freunde suchen...',
|
||||||
writeReply: 'Eine Antwort hinzufügen...',
|
writeReply: 'Eine Antwort hinzufügen...',
|
||||||
rated: 'bewertete',
|
rated: 'bewertete',
|
||||||
|
tabFollowing: 'Folge ich',
|
||||||
|
tabDiscover: 'Entdecken',
|
||||||
|
discoverSubtitle: 'Sieh, was die ganze Community gerade bewertet.',
|
||||||
|
discoverEmpty: 'Noch keine Aktivität.',
|
||||||
|
discoverEmptyHint: 'Sei der Erste, der einen Geschmack bewertet!',
|
||||||
|
browseDiscover: 'Entdecken durchstöbern',
|
||||||
},
|
},
|
||||||
support: {
|
support: {
|
||||||
title: 'Hilfe & Support',
|
title: 'Hilfe & Support',
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
CardContent,
|
CardContent,
|
||||||
Avatar,
|
Avatar,
|
||||||
CircularProgress,
|
|
||||||
Divider,
|
Divider,
|
||||||
Button,
|
Button,
|
||||||
Grid,
|
Grid,
|
||||||
@@ -15,14 +14,20 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
Collapse,
|
Collapse,
|
||||||
Link as MuiLink,
|
Link as MuiLink,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Skeleton,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import CommentIcon from '@mui/icons-material/Comment';
|
import CommentIcon from '@mui/icons-material/Comment';
|
||||||
import SendIcon from '@mui/icons-material/Send';
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
import WhatshotIcon from '@mui/icons-material/Whatshot';
|
import WhatshotIcon from '@mui/icons-material/Whatshot';
|
||||||
|
import PeopleAltIcon from '@mui/icons-material/PeopleAlt';
|
||||||
|
import ExploreIcon from '@mui/icons-material/Explore';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
useCommunityFeed,
|
useCommunityFeed,
|
||||||
useFollowedTopFlavors,
|
useFollowedTopFlavors,
|
||||||
|
useRecentRatings,
|
||||||
type FeedRating,
|
type FeedRating,
|
||||||
} from '../api/queries/useCommunityFeed';
|
} from '../api/queries/useCommunityFeed';
|
||||||
import { useCreateReply } from '../api/mutations/useRatingMutations';
|
import { useCreateReply } from '../api/mutations/useRatingMutations';
|
||||||
@@ -44,6 +49,8 @@ import {
|
|||||||
} from '../components/ui';
|
} from '../components/ui';
|
||||||
import { useToast } from '../hooks/useToast';
|
import { useToast } from '../hooks/useToast';
|
||||||
|
|
||||||
|
type FeedTab = 'following' | 'discover';
|
||||||
|
|
||||||
const SidebarCard: React.FC<{
|
const SidebarCard: React.FC<{
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
@@ -69,11 +76,41 @@ const SidebarCard: React.FC<{
|
|||||||
</GlassCard>
|
</GlassCard>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const FeedSkeleton: React.FC = () => (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<GlassCard key={i} intensity="default">
|
||||||
|
<CardContent sx={{ p: { xs: 2, sm: 3 } }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
|
<Skeleton variant="circular" width={48} height={48} />
|
||||||
|
<Box>
|
||||||
|
<Skeleton variant="text" width={120} height={24} />
|
||||||
|
<Skeleton variant="text" width={80} height={16} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Skeleton variant="rounded" width={48} height={28} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Skeleton variant="text" width="40%" />
|
||||||
|
<Skeleton variant="text" />
|
||||||
|
<Skeleton variant="text" width="80%" />
|
||||||
|
</Box>
|
||||||
|
<Skeleton variant="rounded" width={70} height={70} />
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</GlassCard>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
const CommunityFeed: React.FC = () => {
|
const CommunityFeed: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useTitle(t('community.title'));
|
useTitle(t('community.title'));
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { notify } = useToast();
|
const { notify } = useToast();
|
||||||
|
const [tab, setTab] = useState<FeedTab>('following');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [replyInputs, setReplyInputs] = useState<Record<number, string>>({});
|
const [replyInputs, setReplyInputs] = useState<Record<number, string>>({});
|
||||||
const [expandedReplies, setExpandedReplies] = useState<Record<number, boolean>>({});
|
const [expandedReplies, setExpandedReplies] = useState<Record<number, boolean>>({});
|
||||||
@@ -81,21 +118,29 @@ const CommunityFeed: React.FC = () => {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isAuthed = !!user;
|
const isAuthed = !!user;
|
||||||
const { data: feed, isLoading: feedLoading, error: feedError } = useCommunityFeed(page);
|
const { data: feed, isLoading: feedLoading, error: feedError } = useCommunityFeed(page);
|
||||||
|
const {
|
||||||
|
data: discover,
|
||||||
|
isLoading: discoverLoading,
|
||||||
|
error: discoverError,
|
||||||
|
} = useRecentRatings(tab === 'discover');
|
||||||
const { data: topFollowed = [] } = useFollowedTopFlavors();
|
const { data: topFollowed = [] } = useFollowedTopFlavors();
|
||||||
const createReply = useCreateReply();
|
const createReply = useCreateReply();
|
||||||
|
|
||||||
const ratings: FeedRating[] = feed?.ratings ?? [];
|
const followingRatings: FeedRating[] = feed?.ratings ?? [];
|
||||||
|
const discoverRatings: FeedRating[] = discover ?? [];
|
||||||
|
const ratings = tab === 'following' ? followingRatings : discoverRatings;
|
||||||
const totalPages = feed?.totalPages ?? 1;
|
const totalPages = feed?.totalPages ?? 1;
|
||||||
const loading = feedLoading;
|
const loading = tab === 'following' ? feedLoading : discoverLoading;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthed) navigate('/login');
|
if (!isAuthed) navigate('/login');
|
||||||
}, [isAuthed, navigate]);
|
}, [isAuthed, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const status = (feedError as { response?: { status?: number } } | null)?.response?.status;
|
const err = tab === 'following' ? feedError : discoverError;
|
||||||
|
const status = (err as { response?: { status?: number } } | null)?.response?.status;
|
||||||
if (status === 401) navigate('/login');
|
if (status === 401) navigate('/login');
|
||||||
}, [feedError, navigate]);
|
}, [feedError, discoverError, tab, navigate]);
|
||||||
|
|
||||||
const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => {
|
const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => {
|
||||||
setPage(value);
|
setPage(value);
|
||||||
@@ -117,211 +162,253 @@ const CommunityFeed: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading && ratings.length === 0)
|
const renderCard = (rating: FeedRating) => {
|
||||||
|
const expanded = !!expandedReplies[rating.id];
|
||||||
|
const latestReply = rating.replies[rating.replies.length - 1];
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<GlassCard key={rating.id} intensity="default">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
<CardContent sx={{ p: { xs: 2, sm: 3 } }}>
|
||||||
<CircularProgress />
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||||
</Box>
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
</PageShell>
|
<Link to={`/profile/${rating.user}`} style={{ textDecoration: 'none' }}>
|
||||||
|
<Avatar
|
||||||
|
src={rating.user_avatar || undefined}
|
||||||
|
sx={{
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!rating.user_avatar && rating.user.charAt(0).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
</Link>
|
||||||
|
<Box>
|
||||||
|
<Link
|
||||||
|
to={`/profile/${rating.user}`}
|
||||||
|
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
|
||||||
|
{rating.user}
|
||||||
|
</Typography>
|
||||||
|
</Link>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||||
|
{formatDate(rating.created_at)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<RatingBadge score={rating.score} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{user?.is_superuser && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
onClick={() => navigate(`/admin-panel/rating/${rating.id}`)}
|
||||||
|
sx={{ mb: 2, borderRadius: 2, textTransform: 'none', fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
{t('admin.manageRating')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
{t('community.rated')}{' '}
|
||||||
|
<MuiLink
|
||||||
|
component={Link}
|
||||||
|
to={`/flavor/${rating.flavor}`}
|
||||||
|
sx={{
|
||||||
|
color: 'primary.main',
|
||||||
|
fontWeight: 700,
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rating.flavor_name}
|
||||||
|
</MuiLink>
|
||||||
|
</Typography>
|
||||||
|
{rating.comment ? (
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
sx={{ lineHeight: 1.6, fontStyle: 'italic', color: 'text.primary' }}
|
||||||
|
>
|
||||||
|
<RichText text={rating.comment} />
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||||
|
{t('common.noComment')}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Link to={`/flavor/${rating.flavor}`} style={{ flexShrink: 0 }}>
|
||||||
|
<FlavorThumb src={rating.flavor_image} name={rating.flavor_name} size={70} radius={1} />
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<CommentIcon fontSize="small" />}
|
||||||
|
onClick={() => handleReplyToggle(rating.id)}
|
||||||
|
sx={{ textTransform: 'none', color: 'text.secondary' }}
|
||||||
|
>
|
||||||
|
{rating.replies.length > 0
|
||||||
|
? `${rating.replies.length} ${t('common.replies')}`
|
||||||
|
: t('common.reply')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!expanded && latestReply && (
|
||||||
|
<Box
|
||||||
|
onClick={() => handleReplyToggle(rating.id)}
|
||||||
|
sx={{
|
||||||
|
mt: 1,
|
||||||
|
px: 1.5,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1,
|
||||||
|
alignItems: 'baseline',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8rem', flexShrink: 0 }}>
|
||||||
|
{latestReply.user}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" noWrap sx={{ minWidth: 0 }}>
|
||||||
|
<RichText text={latestReply.text} />
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Collapse in={expanded}>
|
||||||
|
<Box sx={{ mt: 2, pl: 2, borderLeft: '2px solid', borderColor: 'divider' }}>
|
||||||
|
{rating.replies.map((reply) => (
|
||||||
|
<Box key={reply.id} sx={{ mb: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8rem' }}>
|
||||||
|
{reply.user}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{formatDate(reply.created_at)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<RichText text={reply.text} />
|
||||||
|
</Typography>
|
||||||
|
{user?.is_superuser && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
sx={{ minWidth: 0, py: 0, fontSize: '0.7rem' }}
|
||||||
|
onClick={() => navigate(`/admin-panel/reply/${reply.id}`)}
|
||||||
|
>
|
||||||
|
{t('admin.manageReply')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
|
||||||
|
<MentionTextField
|
||||||
|
placeholder={t('community.writeReply')}
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={replyInputs[rating.id] || ''}
|
||||||
|
onChange={(val) => setReplyInputs((prev) => ({ ...prev, [rating.id]: val }))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||||
|
handleReplySubmit(rating.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
color="primary"
|
||||||
|
onClick={() => handleReplySubmit(rating.id)}
|
||||||
|
disabled={!replyInputs[rating.id]}
|
||||||
|
>
|
||||||
|
<SendIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</CardContent>
|
||||||
|
</GlassCard>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitle =
|
||||||
|
tab === 'discover' ? t('community.discoverSubtitle') : t('community.subtitle');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell hero={<HeroBackdrop variant="minimal" />}>
|
<PageShell hero={<HeroBackdrop variant="minimal" />}>
|
||||||
<BackButton />
|
<BackButton />
|
||||||
|
|
||||||
<SectionHeader title={t('community.title')} subtitle={t('community.subtitle')} />
|
<SectionHeader title={t('community.title')} subtitle={subtitle} />
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onChange={(_, v: FeedTab) => setTab(v)}
|
||||||
|
sx={{ mb: 3, '& .MuiTab-root': { textTransform: 'none', fontWeight: 700 } }}
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
value="following"
|
||||||
|
icon={<PeopleAltIcon fontSize="small" />}
|
||||||
|
iconPosition="start"
|
||||||
|
label={t('community.tabFollowing')}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
value="discover"
|
||||||
|
icon={<ExploreIcon fontSize="small" />}
|
||||||
|
iconPosition="start"
|
||||||
|
label={t('community.tabDiscover')}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<Grid container spacing={4}>
|
<Grid container spacing={4}>
|
||||||
<Grid size={{ xs: 12, lg: 8 }}>
|
<Grid size={{ xs: 12, lg: 8 }}>
|
||||||
{ratings.length === 0 ? (
|
{loading && ratings.length === 0 ? (
|
||||||
<EmptyState
|
<FeedSkeleton />
|
||||||
title={t('community.quietFeed')}
|
) : ratings.length === 0 ? (
|
||||||
subtitle={t('community.followMore')}
|
tab === 'following' ? (
|
||||||
action={
|
<EmptyState
|
||||||
<Button variant="contained" component={Link} to="/" sx={{ borderRadius: 2 }}>
|
title={t('community.quietFeed')}
|
||||||
{t('home.exploreFlavors')}
|
subtitle={t('community.followMore')}
|
||||||
</Button>
|
action={
|
||||||
}
|
<Button
|
||||||
/>
|
variant="contained"
|
||||||
|
startIcon={<ExploreIcon />}
|
||||||
|
onClick={() => setTab('discover')}
|
||||||
|
sx={{ borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
{t('community.browseDiscover')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title={t('community.discoverEmpty')}
|
||||||
|
subtitle={t('community.discoverEmptyHint')}
|
||||||
|
action={
|
||||||
|
<Button variant="contained" component={Link} to="/" sx={{ borderRadius: 2 }}>
|
||||||
|
{t('home.exploreFlavors')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
{ratings.map((rating) => (
|
{ratings.map(renderCard)}
|
||||||
<GlassCard key={rating.id} intensity="default">
|
{tab === 'following' && totalPages > 1 && (
|
||||||
<CardContent sx={{ p: { xs: 2, sm: 3 } }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
|
||||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
|
||||||
<Link to={`/profile/${rating.user}`} style={{ textDecoration: 'none' }}>
|
|
||||||
<Avatar
|
|
||||||
src={rating.user_avatar || undefined}
|
|
||||||
sx={{
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
border: '2px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!rating.user_avatar && rating.user.charAt(0).toUpperCase()}
|
|
||||||
</Avatar>
|
|
||||||
</Link>
|
|
||||||
<Box>
|
|
||||||
<Link
|
|
||||||
to={`/profile/${rating.user}`}
|
|
||||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="subtitle1"
|
|
||||||
sx={{ fontWeight: 700, lineHeight: 1.2 }}
|
|
||||||
>
|
|
||||||
{rating.user}
|
|
||||||
</Typography>
|
|
||||||
</Link>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ display: 'block' }}
|
|
||||||
>
|
|
||||||
{formatDate(rating.created_at)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<RatingBadge score={rating.score} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{user?.is_superuser && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
color="secondary"
|
|
||||||
onClick={() => navigate(`/admin-panel/rating/${rating.id}`)}
|
|
||||||
sx={{ mb: 2, borderRadius: 2, textTransform: 'none', fontWeight: 700 }}
|
|
||||||
>
|
|
||||||
{t('admin.manageRating')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
|
||||||
<Box sx={{ flex: 1 }}>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
|
||||||
{t('community.rated')}{' '}
|
|
||||||
<MuiLink
|
|
||||||
component={Link}
|
|
||||||
to={`/flavor/${rating.flavor}`}
|
|
||||||
sx={{
|
|
||||||
color: 'primary.main',
|
|
||||||
fontWeight: 700,
|
|
||||||
textDecoration: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{rating.flavor_name}
|
|
||||||
</MuiLink>
|
|
||||||
</Typography>
|
|
||||||
{rating.comment ? (
|
|
||||||
<Typography
|
|
||||||
variant="body1"
|
|
||||||
sx={{ lineHeight: 1.6, fontStyle: 'italic', color: 'text.primary' }}
|
|
||||||
>
|
|
||||||
<RichText text={rating.comment} />
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ fontStyle: 'italic' }}
|
|
||||||
>
|
|
||||||
{t('common.noComment')}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Link to={`/flavor/${rating.flavor}`} style={{ flexShrink: 0 }}>
|
|
||||||
<FlavorThumb
|
|
||||||
src={rating.flavor_image}
|
|
||||||
name={rating.flavor_name}
|
|
||||||
size={70}
|
|
||||||
radius={1}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ my: 2 }} />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
startIcon={<CommentIcon fontSize="small" />}
|
|
||||||
onClick={() => handleReplyToggle(rating.id)}
|
|
||||||
sx={{ textTransform: 'none', color: 'text.secondary' }}
|
|
||||||
>
|
|
||||||
{rating.replies.length > 0
|
|
||||||
? `${rating.replies.length} ${t('common.replies')}`
|
|
||||||
: t('common.reply')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Collapse in={expandedReplies[rating.id]}>
|
|
||||||
<Box sx={{ mt: 2, pl: 2, borderLeft: '2px solid', borderColor: 'divider' }}>
|
|
||||||
{rating.replies.map((reply) => (
|
|
||||||
<Box key={reply.id} sx={{ mb: 1.5 }}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
|
||||||
<Typography
|
|
||||||
variant="subtitle2"
|
|
||||||
sx={{ fontWeight: 700, fontSize: '0.8rem' }}
|
|
||||||
>
|
|
||||||
{reply.user}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{formatDate(reply.created_at)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2">
|
|
||||||
<RichText text={reply.text} />
|
|
||||||
</Typography>
|
|
||||||
{user?.is_superuser && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
sx={{ minWidth: 0, py: 0, fontSize: '0.7rem' }}
|
|
||||||
onClick={() => navigate(`/admin-panel/reply/${reply.id}`)}
|
|
||||||
>
|
|
||||||
{t('admin.manageReply')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
|
|
||||||
<MentionTextField
|
|
||||||
placeholder={t('community.writeReply')}
|
|
||||||
multiline
|
|
||||||
rows={2}
|
|
||||||
value={replyInputs[rating.id] || ''}
|
|
||||||
onChange={(val) =>
|
|
||||||
setReplyInputs((prev) => ({ ...prev, [rating.id]: val }))
|
|
||||||
}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
||||||
handleReplySubmit(rating.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
color="primary"
|
|
||||||
onClick={() => handleReplySubmit(rating.id)}
|
|
||||||
disabled={!replyInputs[rating.id]}
|
|
||||||
>
|
|
||||||
<SendIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Collapse>
|
|
||||||
</CardContent>
|
|
||||||
</GlassCard>
|
|
||||||
))}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
|
||||||
<Pagination
|
<Pagination
|
||||||
count={totalPages}
|
count={totalPages}
|
||||||
|
|||||||
Reference in new issue
Block a user