diff --git a/backend/api/views.py b/backend/api/views.py index ebce8eb..fd23395 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -26,18 +26,46 @@ class FlavorViewSet(viewsets.ModelViewSet): @action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny]) def search(self, request): - # Unpaginated list of all flavors for the search autocomplete + # Global unpaginated search for autocomplete + results = [] + + # 1. Flavors flavors = Flavor.objects.select_related('category').order_by('name').distinct() - data = [ - { + for f in flavors: + results.append({ 'id': f.id, 'name': f.name, + 'type': 'flavor', + 'subtitle': f.category.name, 'image_url': request.build_absolute_uri(f.image.url) if f.image else f.image_url, - 'category_name': f.category.name - } - for f in flavors - ] - return Response(data) + 'slug': None + }) + + # 2. Categories + categories = Category.objects.all().order_by('name') + for c in categories: + results.append({ + 'id': c.id, + 'name': c.name, + 'type': 'category', + 'subtitle': 'Category', + 'image_url': None, + 'slug': c.slug + }) + + # 3. Users + users = User.objects.filter(is_active=True).order_by('username') + for u in users: + results.append({ + 'id': u.id, + 'name': u.username, + 'type': 'user', + 'subtitle': 'Community Member', + 'image_url': request.build_absolute_uri(u.avatar.url) if u.avatar else None, + 'slug': u.username + }) + + return Response(results) @action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny]) def top(self, request): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 96835ae..f427c52 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,33 +41,36 @@ const FlavorDetail = lazy(() => import('./pages/flavors/FlavorDetail')); const Dashboard = lazy(() => import('./pages/Dashboard')); const PublicProfile = lazy(() => import('./pages/PublicProfile')); const CommunityFeed = lazy(() => import('./pages/CommunityFeed')); +const About = lazy(() => import('./pages/About')); const Login = lazy(() => import('./pages/Login')); const Settings = lazy(() => import('./pages/Settings')); -interface SearchFlavor { +interface SearchResult { id: number; name: string; + type: 'flavor' | 'category' | 'user'; + subtitle: string; image_url: string | null; - category_name: string; + slug: string | null; } const GlobalSearch = () => { const [query, setQuery] = useState(''); - const [options, setOptions] = useState([]); + const [options, setOptions] = useState([]); const navigate = useNavigate(); const location = useLocation(); useEffect(() => { - const fetchFlavors = async () => { + const fetchSearchOptions = async () => { try { - // Fetch from unpaginated search endpoint for full catalog coverage + // Fetch unified results: flavors, categories, and users const res = await api.get('flavors/search/'); setOptions(res.data); } catch (err) { console.error('Failed to fetch search options'); } }; - fetchFlavors(); + fetchSearchOptions(); }, []); // Only fetch once to avoid excessive API calls useEffect(() => { @@ -93,13 +96,15 @@ const GlobalSearch = () => { options={options} getOptionLabel={(option) => { if (typeof option === 'string') return option; - return option.category_name ? `${option.name} (${option.category_name})` : (option.name || ''); + return option.name || ''; }} inputValue={query} onInputChange={(_, newValue) => setQuery(typeof newValue === 'string' ? newValue : '')} onChange={(_, newValue) => { if (newValue && typeof newValue !== 'string') { - navigate(`/flavor/${newValue.id}`); + if (newValue.type === 'flavor') navigate(`/flavor/${newValue.id}`); + if (newValue.type === 'category') navigate(`/category/${newValue.slug}`); + if (newValue.type === 'user') navigate(`/profile/${newValue.slug}`); } }} onKeyDown={(e) => { @@ -111,7 +116,7 @@ const GlobalSearch = () => { { const { key, ...optionProps } = props as any; return ( - {option.image_url && ( - + {option.image_url ? ( + + ) : ( + + {option.name.charAt(0).toUpperCase()} + )} {option.name} - {option.category_name} + {option.subtitle} ); @@ -243,6 +252,12 @@ const App: React.FC = () => { + + + setDrawerOpen(false)}> + + + setCatOpen(!catOpen)}> @@ -343,6 +358,14 @@ const App: React.FC = () => { > Community + + + + About This Project + + Holy Flavors Archive is a community-driven project dedicated to documenting and rating + the vast world of Holy Energy products. Our goal is to create a definitive, high-density catalog of every + flavor ever released—including legacy and limited editions. + + + + What you can do here: + +
  • Explore: Find details on any Holy Energy, Iced Tea, or Milkshake product.
  • +
  • Rate: Share your honest opinion and score flavors from 1 to 10.
  • +
  • Community: Follow other fans, see their latest tests, and discover new favorites together.
  • +
  • Taste Profile: Build your own personal tiered leaderboard to share with the world.
  • +
    +
    + + + This website is a fan project and is not officially affiliated with, authorized, maintained, sponsored, + or endorsed by HOLY Energy GmbH. All product names, logos, and brands are property of their respective owners. + + + + + Impressum + + Angaben gemäß § 5 TMG:
    + [Your Name or Project Name]
    + [Your Street Address]
    + [Postal Code and City]
    + Germany +
    + + + Kontakt: + + E-Mail: [Your Support Email Address]
    + Internet: holy.narl.io +
    +
    + + + Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV: + + [Your Name]
    + [Your Street Address]
    + [Postal Code and City] +
    +
    + + + Haftungsausschluss (Disclaimer) + + Haftung für Inhalte: Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte + auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter + jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen. + + + Urheberrecht: Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten + unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der + Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors. + + +
    + + ); +}; + +export default About; diff --git a/frontend/src/pages/CommunityFeed.tsx b/frontend/src/pages/CommunityFeed.tsx index 2c4de79..57ba052 100644 --- a/frontend/src/pages/CommunityFeed.tsx +++ b/frontend/src/pages/CommunityFeed.tsx @@ -15,7 +15,9 @@ import { ListItem, ListItemAvatar, ListItemText, - ListItemButton + ListItemButton, + Pagination, + Paper as MuiPaper } from '@mui/material'; import StarIcon from '@mui/icons-material/Star'; import { Link } from 'react-router-dom'; @@ -48,53 +50,52 @@ const CommunityFeed: React.FC = () => { const [ratings, setRatings] = useState([]); const [following, setFollowing] = useState([]); const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [nextPage, setNextPage] = useState(null); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); - const fetchInitialData = async () => { + const fetchFeedData = async (pageNum: number) => { setLoading(true); try { const [feedRes, followingRes] = await Promise.all([ - api.get('ratings/feed/'), + api.get(`ratings/feed/?page=${pageNum}`), api.get('users/following_list/') ]); - const feedData = feedRes.data.results || feedRes.data; - setRatings(Array.isArray(feedData) ? feedData : []); - setNextPage(feedRes.data.next || null); + const feedData = feedRes.data.results || []; + setRatings(feedData); + + // Calculate total pages (DRF returns total count) + const count = feedRes.data.count || 0; + setTotalPages(Math.ceil(count / 10)); // 10 is our page_size + setFollowing(followingRes.data); } catch (err) { - console.error('Failed to fetch initial feed data', err); + console.error('Failed to fetch feed data', err); } finally { setLoading(false); } }; - const handleLoadMore = async () => { - if (!nextPage || loadingMore) return; - setLoadingMore(true); - try { - // nextPage is a full URL from DRF, we can use axios directly or parse it - const res = await api.get(nextPage); - const newData = res.data.results || res.data; - setRatings(prev => [...prev, ...(Array.isArray(newData) ? newData : [])]); - setNextPage(res.data.next || null); - } catch (err) { - console.error('Failed to load more feed items', err); - } finally { - setLoadingMore(false); - } + useEffect(() => { + fetchFeedData(page); + }, [page]); + + const handlePageChange = (_: React.ChangeEvent, value: number) => { + setPage(value); + window.scrollTo({ top: 0, behavior: 'smooth' }); }; - useEffect(() => { - fetchInitialData(); - }, []); - - if (loading) return ; + if (loading && ratings.length === 0) return ; return ( - Community Feed + + Community Activity + + Discover which Holy Energy flavors your circle is currently testing. + See their latest ratings, read their reviews, and find your next favorite together. + + {/* Main Feed */} @@ -111,87 +112,95 @@ const CommunityFeed: React.FC = () => {
    ) : ( - {ratings.map(rating => ( - - - - - - {!rating.user_avatar && rating.user.charAt(0).toUpperCase()} - - - - {rating.user} - - - {new Date(rating.created_at).toLocaleDateString()} - - - - - - - - - - - - - - - - - {rating.flavor_name} - - - - {rating.category_name} - - - - {/* Compact rating for mobile, stars for desktop */} - - - {rating.score}/10 + {loading ? ( + + ) : ( + <> + {ratings.map(rating => ( + + + + + + {!rating.user_avatar && rating.user.charAt(0).toUpperCase()} + + + + {rating.user} + + + {new Date(rating.created_at).toLocaleDateString()} + + + - - - {rating.score}/10 + + + + + + + + + + + + + {rating.flavor_name} + + + + {rating.category_name} + + + + {/* Compact rating for mobile, stars for desktop */} + + + {rating.score}/10 + + + + {rating.score}/10 + + + + {rating.comment && ( + + "{rating.comment}" + + )} + - + + + ))} + + )} - {rating.comment && ( - - "{rating.comment}" - - )} - - - - - ))} - - {nextPage && ( - - + {totalPages > 1 && ( + + )} diff --git a/frontend/src/pages/MainPage.tsx b/frontend/src/pages/MainPage.tsx index b277d67..579596f 100644 --- a/frontend/src/pages/MainPage.tsx +++ b/frontend/src/pages/MainPage.tsx @@ -210,153 +210,245 @@ const MainPage: React.FC = () => { const currentTop = topFlavors[activeIndex]; const featuredReview = currentTop?.ratings?.find(r => r.comment) || null; - return ( - - {/* Hero Section / Activity Feed */} - {isLoggedIn ? ( - theme.palette.mode === 'dark' - ? 'linear-gradient(180deg, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0) 100%)' - : 'linear-gradient(180deg, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0) 100%)', - borderBottom: '1px solid', - borderColor: 'divider', - mb: 4 - }}> - - - - Community Activity - Latest reviews from people you follow - - - + return ( + + + + {/* 1. Hero Section / Activity Feed */} + + {isLoggedIn ? ( + + - Your feed is a bit quiet - Follow more users to see their activity here! - - - ) : ( - - {feedRatings.map(rating => ( - - - - - - {!rating.user_avatar && rating.user.charAt(0).toUpperCase()} - - {rating.user} - - rated - - - {new Date(rating.created_at).toLocaleDateString()} - - - - - - - - - {rating.flavor_name} - - - - {/* Compact rating for anything below Large desktop to prevent cutoffs */} - - - {rating.score}/10 - - - - {rating.score}/10 - - - - - - - - - ))} - - )} - - - ) : ( - theme.palette.mode === 'dark' - ? 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)' - : 'linear-gradient(135deg, #fdf6f7 0%, #f8e1e5 100%)', - textAlign: 'center', - borderBottom: '1px solid rgba(255,255,255,0.1)', - width: '100%', - boxSizing: 'border-box', - overflow: 'hidden' - }} - > - - The Ultimate Holy Archive - - - Browse every flavor ever released, discover new favorites, and share your ratings with the community. - - - - - - - )} - - - {/* Categories Grid - MOVED UP */} - - Browse by Category - - {categories.map(category => ( - - - - - {category.name} - - - - - ))} + ? 'linear-gradient(180deg, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0) 100%)' + + : 'linear-gradient(180deg, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0) 100%)', + + borderBottom: '1px solid', + + borderColor: 'divider', + + mb: 4 + + }}> + + + + + + + + Community Activity + + Latest reviews from people you follow + + + + + + + + + + {feedRatings.length === 0 ? ( + + + + Your feed is a bit quiet + + Follow more users to see their activity here! + + + + + + ) : ( + + + + {feedRatings.map(rating => ( + + + + + + + + + + + + {!rating.user_avatar && rating.user.charAt(0).toUpperCase()} + + + + {rating.user} + + + + rated + + + + + + {new Date(rating.created_at).toLocaleDateString()} + + + + + + + + + + + + + + + + {rating.flavor_name} + + + + + + + + {/* Compact rating for anything below Large desktop to prevent cutoffs */} + + + + + + {rating.score}/10 + + + + + + + + {rating.score}/10 + + + + + + + + + + + + + + ))} + + + + )} + + + - + + ) : ( + + theme.palette.mode === 'dark' + + ? 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)' + + : 'linear-gradient(135deg, #fdf6f7 0%, #f8e1e5 100%)', + + textAlign: 'center', + + borderBottom: '1px solid rgba(255,255,255,0.1)', + + width: '100%', + + boxSizing: 'border-box', + + overflow: 'hidden' + + }} + + > + + + + The Ultimate Holy Archive + + + + + + Browse every flavor ever released, discover new favorites, and share your ratings with the community. + + + + + + + + + + + + + + )} + + + + + + + + + + + + {/* Hall of Fame Carousel - SIDE BY SIDE */}