mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
added explore page on frontpage
This commit is contained in:
1 parent
8aaf4fc3d7
commit
3e6d08ee16
6 files changed
+174
-2
No files matched your search
@@ -9,13 +9,13 @@ use sea_orm::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::auth::{AuthUser, OptionalUser};
|
use crate::auth::{AuthUser, OptionalUser};
|
||||||
use crate::dto::{FlavorOut, SearchHit, SimilarFlavorOut};
|
use crate::dto::{FlavorOut, RecommendationOut, SearchHit, SimilarFlavorOut};
|
||||||
use crate::entities::prelude::*;
|
use crate::entities::prelude::*;
|
||||||
use crate::entities::{category, flavor, rating};
|
use crate::entities::{category, flavor, rating};
|
||||||
use crate::error::{ApiError, ApiResult};
|
use crate::error::{ApiError, ApiResult};
|
||||||
use crate::media::image_field_url;
|
use crate::media::image_field_url;
|
||||||
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
|
use crate::pagination::{parse_page, Paginated, DEFAULT_PAGE_SIZE};
|
||||||
use crate::recommend::{similar_flavors, RatingInput};
|
use crate::recommend::{recommend, similar_flavors, RatingInput};
|
||||||
use crate::search::{extract_category_slug, query_words, score_relevance};
|
use crate::search::{extract_category_slug, query_words, score_relevance};
|
||||||
use crate::service::build_flavors;
|
use crate::service::build_flavors;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -24,12 +24,21 @@ use crate::web::RequestCtx;
|
|||||||
/// Max "similar flavors" returned by `/flavors/{id}/similar/`.
|
/// Max "similar flavors" returned by `/flavors/{id}/similar/`.
|
||||||
const SIMILAR_LIMIT: usize = 8;
|
const SIMILAR_LIMIT: usize = 8;
|
||||||
|
|
||||||
|
/// Max "popular picks" returned by `/flavors/popular/`.
|
||||||
|
const POPULAR_LIMIT: usize = 12;
|
||||||
|
|
||||||
|
/// Sentinel target for the public popularity ranking: a user id that owns no
|
||||||
|
/// ratings, so the recommender cold-starts straight into its Bayesian-popularity
|
||||||
|
/// fallback over the whole catalog (nothing excluded as "already rated").
|
||||||
|
const POPULAR_SENTINEL: i32 = -1;
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/flavors/", get(list))
|
.route("/flavors/", get(list))
|
||||||
.route("/flavors/search/", get(search))
|
.route("/flavors/search/", get(search))
|
||||||
.route("/flavors/top/", get(top))
|
.route("/flavors/top/", get(top))
|
||||||
.route("/flavors/newest/", get(newest))
|
.route("/flavors/newest/", get(newest))
|
||||||
|
.route("/flavors/popular/", get(popular))
|
||||||
.route("/flavors/followed_top/", get(followed_top))
|
.route("/flavors/followed_top/", get(followed_top))
|
||||||
.route("/flavors/{id}/", get(retrieve))
|
.route("/flavors/{id}/", get(retrieve))
|
||||||
.route("/flavors/{id}/similar/", get(similar))
|
.route("/flavors/{id}/similar/", get(similar))
|
||||||
@@ -238,6 +247,64 @@ async fn newest(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/flavors/popular/ — public algorithmic "popular picks" for the front
|
||||||
|
/// page (logged-out discovery). Runs the recommender against a ratingless sentinel
|
||||||
|
/// user so it returns the Bayesian-popularity ranking over the whole catalog — the
|
||||||
|
/// same engine + payload shape as `/users/recommendations/`, so the frontend reuses
|
||||||
|
/// the recommendation card. All rows carry `reason: "popular"`.
|
||||||
|
async fn popular(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
ctx: RequestCtx,
|
||||||
|
OptionalUser(viewer): OptionalUser,
|
||||||
|
) -> ApiResult<Json<Vec<RecommendationOut>>> {
|
||||||
|
// Whole rating matrix (hundreds of rows at current scale) → in-process scoring.
|
||||||
|
let all = Rating::find().all(&state.db).await?;
|
||||||
|
let inputs: Vec<RatingInput> = all
|
||||||
|
.iter()
|
||||||
|
.map(|r| RatingInput {
|
||||||
|
user_id: r.user_id,
|
||||||
|
flavor_id: r.flavor_id,
|
||||||
|
score: r.score as f64,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let recs = recommend(POPULAR_SENTINEL, &inputs, POPULAR_LIMIT);
|
||||||
|
if recs.is_empty() {
|
||||||
|
return Ok(Json(Vec::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the recommended flavor ids to card payloads, then re-zip with the
|
||||||
|
// recommendation metadata in the engine's ranked order.
|
||||||
|
let rec_ids: Vec<i32> = recs.iter().map(|r| r.flavor_id).collect();
|
||||||
|
let models = Flavor::find()
|
||||||
|
.filter(flavor::Column::Id.is_in(rec_ids))
|
||||||
|
.all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let flavors = build_flavors(&state, &ctx, models, viewer, &[], false, false).await?;
|
||||||
|
let by_id: std::collections::HashMap<i32, _> = flavors.into_iter().map(|f| (f.id, f)).collect();
|
||||||
|
|
||||||
|
let out: Vec<RecommendationOut> = recs
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|rec| {
|
||||||
|
by_id.get(&rec.flavor_id).map(|f| RecommendationOut {
|
||||||
|
id: f.id,
|
||||||
|
name: f.name.clone(),
|
||||||
|
image_url: f.image_url.clone(),
|
||||||
|
category_name: f.category_name.clone(),
|
||||||
|
category_slug: f.category_slug.clone(),
|
||||||
|
average_rating: f.average_rating,
|
||||||
|
is_legacy: f.is_legacy,
|
||||||
|
is_available: f.is_available,
|
||||||
|
predicted_score: rec.predicted_score,
|
||||||
|
contributing_neighbours: rec.contributing_neighbours,
|
||||||
|
reason: rec.source.as_str().to_string(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(out))
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/flavors/followed_top/ — top 10 among flavors rated by people the
|
/// GET /api/flavors/followed_top/ — top 10 among flavors rated by people the
|
||||||
/// caller follows. Requires authentication.
|
/// caller follows. Requires authentication.
|
||||||
async fn followed_top(
|
async fn followed_top(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const queryKeys = {
|
|||||||
categories: () => ['categories'] as const,
|
categories: () => ['categories'] as const,
|
||||||
dashboard: () => ['dashboard'] as const,
|
dashboard: () => ['dashboard'] as const,
|
||||||
recommendations: () => ['recommendations'] as const,
|
recommendations: () => ['recommendations'] as const,
|
||||||
|
popularRecommendations: () => ['recommendations', 'popular'] as const,
|
||||||
publicProfile: (username: string) => ['publicProfile', username] as const,
|
publicProfile: (username: string) => ['publicProfile', username] as const,
|
||||||
publicProfileAll: () => ['publicProfile'] as const,
|
publicProfileAll: () => ['publicProfile'] as const,
|
||||||
flavors: (params: Record<string, unknown>) => ['flavors', params] as const,
|
flavors: (params: Record<string, unknown>) => ['flavors', params] as const,
|
||||||
|
|||||||
@@ -18,3 +18,20 @@ export const useRecommendations = (enabled = true) =>
|
|||||||
enabled,
|
enabled,
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public "popular picks" — the recommender's Bayesian-popularity ranking over the
|
||||||
|
* whole catalog (backend `GET /flavors/popular/`), used for logged-out front-page
|
||||||
|
* discovery. Same payload shape as {@link useRecommendations}; every row is
|
||||||
|
* `reason: "popular"`. Popularity shifts slowly, so a long staleTime is fine.
|
||||||
|
*/
|
||||||
|
export const usePopularRecommendations = (enabled = true) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: queryKeys.popularRecommendations(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<Recommendation[]>('flavors/popular/');
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
@@ -107,6 +107,10 @@ const resources = {
|
|||||||
exploreFlavors: 'Explore Flavors',
|
exploreFlavors: 'Explore Flavors',
|
||||||
hallOfFame: 'Hall of Fame',
|
hallOfFame: 'Hall of Fame',
|
||||||
hallOfFameSubtitle: 'Highest-rated flavors in the archive',
|
hallOfFameSubtitle: 'Highest-rated flavors in the archive',
|
||||||
|
recForYou: 'Recommended for You',
|
||||||
|
recForYouSubtitle: 'Picked by tasters who share your palate',
|
||||||
|
popularPicks: 'Popular Picks',
|
||||||
|
popularPicksSubtitle: 'Crowd favorites worth discovering',
|
||||||
communityVoice: 'Community Voice',
|
communityVoice: 'Community Voice',
|
||||||
newArrivals: 'New Arrivals',
|
newArrivals: 'New Arrivals',
|
||||||
newArrivalsSubtitle: 'The latest additions to the catalog',
|
newArrivalsSubtitle: 'The latest additions to the catalog',
|
||||||
@@ -425,6 +429,10 @@ const resources = {
|
|||||||
exploreFlavors: 'Sorten entdecken',
|
exploreFlavors: 'Sorten entdecken',
|
||||||
hallOfFame: 'Hall of Fame',
|
hallOfFame: 'Hall of Fame',
|
||||||
hallOfFameSubtitle: 'Die bestbewerteten Sorten im Archiv',
|
hallOfFameSubtitle: 'Die bestbewerteten Sorten im Archiv',
|
||||||
|
recForYou: 'Für dich empfohlen',
|
||||||
|
recForYouSubtitle: 'Ausgewählt von Testern mit ähnlichem Geschmack',
|
||||||
|
popularPicks: 'Beliebte Sorten',
|
||||||
|
popularPicksSubtitle: 'Favoriten der Community zum Entdecken',
|
||||||
communityVoice: 'Community-Stimmen',
|
communityVoice: 'Community-Stimmen',
|
||||||
newArrivals: 'Neuheiten',
|
newArrivals: 'Neuheiten',
|
||||||
newArrivalsSubtitle: 'Die neuesten Zugänge im Katalog',
|
newArrivalsSubtitle: 'Die neuesten Zugänge im Katalog',
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
FlavorCard,
|
FlavorCard,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
} from '../components/ui';
|
} from '../components/ui';
|
||||||
|
import MainPageRecommendations from './MainPageRecommendations';
|
||||||
|
|
||||||
const TOP_LIMIT = 5;
|
const TOP_LIMIT = 5;
|
||||||
|
|
||||||
@@ -459,6 +460,9 @@ const MainPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Algorithmic discovery: personalized (members) or popular picks (visitors) */}
|
||||||
|
<MainPageRecommendations isLoggedIn={isLoggedIn} />
|
||||||
|
|
||||||
{/* Newest Arrivals */}
|
{/* Newest Arrivals */}
|
||||||
<Box>
|
<Box>
|
||||||
<SectionHeader title={t('home.newArrivals')} subtitle={t('home.newArrivalsSubtitle')} />
|
<SectionHeader title={t('home.newArrivals')} subtitle={t('home.newArrivalsSubtitle')} />
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Skeleton } from '@mui/material';
|
||||||
|
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useRecommendations, usePopularRecommendations } from '../api/queries/useRecommendations';
|
||||||
|
import RecommendationCard from './dashboard/RecommendationCard';
|
||||||
|
import { SectionHeader } from '../components/ui';
|
||||||
|
|
||||||
|
export interface MainPageRecommendationsProps {
|
||||||
|
/** Logged-in → personalized CF recs; logged-out → public popularity picks. */
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GRID_SX = {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: {
|
||||||
|
xs: 'repeat(2, minmax(0, 1fr))',
|
||||||
|
sm: 'repeat(3, minmax(0, 1fr))',
|
||||||
|
md: 'repeat(4, minmax(0, 1fr))',
|
||||||
|
lg: 'repeat(6, minmax(0, 1fr))',
|
||||||
|
},
|
||||||
|
gap: 2,
|
||||||
|
width: '100%',
|
||||||
|
minWidth: 0,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Algorithmic discovery row for the front page. Members get "Recommended for you"
|
||||||
|
* (collaborative filtering); visitors get "Popular Picks" (Bayesian-popularity
|
||||||
|
* ranking over the whole catalog). Both reuse {@link RecommendationCard}. The
|
||||||
|
* section hides itself entirely when the engine returns nothing.
|
||||||
|
*/
|
||||||
|
const MainPageRecommendations: React.FC<MainPageRecommendationsProps> = ({ isLoggedIn }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const personalized = useRecommendations(isLoggedIn);
|
||||||
|
const popular = usePopularRecommendations(!isLoggedIn);
|
||||||
|
const { data, isLoading } = isLoggedIn ? personalized : popular;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Skeleton variant="text" width={260} height={36} sx={{ mb: 2 }} />
|
||||||
|
<Box sx={GRID_SX}>
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} variant="rounded" height={240} sx={{ borderRadius: 2 }} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<SectionHeader
|
||||||
|
title={
|
||||||
|
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<AutoAwesomeIcon color="primary" sx={{ fontSize: '1.75rem' }} />
|
||||||
|
<span>{isLoggedIn ? t('home.recForYou') : t('home.popularPicks')}</span>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
subtitle={isLoggedIn ? t('home.recForYouSubtitle') : t('home.popularPicksSubtitle')}
|
||||||
|
/>
|
||||||
|
<Box sx={GRID_SX}>
|
||||||
|
{data.map((rec) => (
|
||||||
|
<RecommendationCard key={rec.id} rec={rec} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainPageRecommendations;
|
||||||
Reference in new issue
Block a user