import React, { useState, useEffect, useMemo, Suspense, lazy } from 'react'; import { Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom'; import { ThemeProvider, CssBaseline, AppBar, Toolbar, Typography, Button, Box, IconButton, Avatar, Menu, MenuItem, Drawer, Divider, List, ListItem, ListItemButton, ListItemText, Collapse, TextField, InputAdornment, Autocomplete, CircularProgress, ListItemAvatar, ListSubheader, Badge, alpha, useMediaQuery } from '@mui/material'; import MenuIcon from '@mui/icons-material/Menu'; import SearchIcon from '@mui/icons-material/Search'; import NotificationsIcon from '@mui/icons-material/Notifications'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; import { getTheme, type CatppuccinTheme } from './theme'; import api from './api'; import './i18n'; import { useTranslation } from 'react-i18next'; import Footer from './components/Footer'; import { formatDate } from './utils/date'; import CookieBanner from './components/CookieBanner'; // Code splitting const MainPage = lazy(() => import('./pages/MainPage')); const CategoryFlavors = lazy(() => import('./pages/categories/CategoryFlavors')); 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 Privacy = lazy(() => import('./pages/Privacy')); const Login = lazy(() => import('./pages/Login')); const Settings = lazy(() => import('./pages/Settings')); const Support = lazy(() => import('./pages/Support')); const VerifyEmail = lazy(() => import('./pages/VerifyEmail')); const AdminDashboard = lazy(() => import('./pages/AdminDashboard')); const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail')); const AdminRatingDetail = lazy(() => import('./pages/AdminRatingDetail')); const AdminReplyDetail = lazy(() => import('./pages/AdminReplyDetail')); interface SearchResult { id: number; name: string; type: 'flavor' | 'category' | 'user'; subtitle: string; image_url: string | null; slug: string | null; } interface Notification { id: number; actor_username: string; actor_avatar: string | null; notification_type: 'reply' | 'mention' | 'ticket_new' | 'ticket_reply' | 'profile_comment'; rating: number | null; reply: number | null; is_read: boolean; created_at: string; flavor_name: string | null; flavor_id: number | null; profile_owner_username: string | null; } const GlobalSearch = () => { const { t } = useTranslation(); const [query, setQuery] = useState(''); const [options, setOptions] = useState([]); const [loading, setLoading] = useState(false); const navigate = useNavigate(); const location = useLocation(); const isMobile = useMediaQuery('(max-width:1150px)'); // Debounced search useEffect(() => { if (!query.trim()) { setOptions([]); return; } const timer = setTimeout(async () => { setLoading(true); try { const res = await api.get(`flavors/search/?q=${encodeURIComponent(query)}`); const results = Array.isArray(res.data) ? res.data : (res.data.results || []); setOptions(results); } catch (err) { console.error('Search failed'); } finally { setLoading(false); } }, 300); return () => clearTimeout(timer); }, [query]); useEffect(() => { const params = new URLSearchParams(location.search); const q = params.get('q'); if (q) setQuery(q); }, [location.search]); const handleSearch = (e: React.FormEvent) => { e.preventDefault(); if (query.trim()) { navigate(`/?q=${encodeURIComponent(query.trim())}`); } else { navigate('/'); } }; return ( x} getOptionLabel={(option) => { if (typeof option === 'string') return option; return option.name || ''; }} inputValue={query} onInputChange={(_, newValue) => setQuery(typeof newValue === 'string' ? newValue : '')} onChange={(_, newValue) => { if (newValue && typeof newValue !== 'string') { if (newValue.type === 'flavor') navigate(`/flavor/${newValue.id}`); } }} onKeyDown={(e) => { if (e.key === 'Enter') { handleSearch(e as any); } }} renderInput={(params) => ( ), endAdornment: ( {loading ? : null} {params.InputProps.endAdornment} ), }} /> )} renderOption={(props, option) => { const { key, ...optionProps } = props as any; return ( {option.name} {option.subtitle} ); }} /> ); }; const App: React.FC = () => { const { i18n, t } = useTranslation(); const navigate = useNavigate(); const [themeName, setThemeName] = useState((localStorage.getItem('theme') as CatppuccinTheme) || 'holy_light'); const [anchorEl, setAnchorEl] = useState(null); const [notifAnchorEl, setNotifAnchorEl] = useState(null); const [catAnchorEl, setCatAnchorEl] = useState(null); const [categories, setCategories] = useState<{name: string, slug: string}[]>([]); const [user, setUser] = useState<{username: string, avatar: string | null, unread_notifications_count: number, is_superuser: boolean} | null>(null); const [notifications, setNotifications] = useState([]); const [following, setFollowing] = useState<{id: number, username: string, avatar: string | null}[]>([]); const [loadingUser, setLoadingUser] = useState(true); const [drawerOpen, setDrawerOpen] = useState(false); const [catOpen, setCatOpen] = useState(false); const [adminMode, setAdminMode] = useState(localStorage.getItem('admin-mode') === 'true'); const isMobile = useMediaQuery('(max-width:1150px)'); const toggleAdminMode = () => { const newVal = !adminMode; setAdminMode(newVal); localStorage.setItem('admin-mode', String(newVal)); }; const fetchNotifications = async () => { try { const res = await api.get('notifications/'); const data = Array.isArray(res.data) ? res.data : (res.data.results || []); setNotifications(data); } catch (err) { console.error('Failed to fetch notifications'); } }; useEffect(() => { const fetchData = async () => { try { const token = localStorage.getItem('token'); const [userRes, catRes] = await Promise.all([ token ? api.get('users/me/') : Promise.resolve({ data: null }), api.get('categories/') ]); if (userRes.data && userRes.data.username) { setUser(userRes.data); if (userRes.data.theme) { setThemeName(userRes.data.theme); localStorage.setItem('theme', userRes.data.theme); } if (userRes.data.language) i18n.changeLanguage(userRes.data.language); try { const followRes = await api.get('users/following_list/'); const followData = Array.isArray(followRes.data) ? followRes.data : (followRes.data.results || []); setFollowing(followData); fetchNotifications(); } catch (err) { console.error('Failed to fetch following list'); } } else { setUser(null); } const catData = Array.isArray(catRes.data) ? catRes.data : (catRes.data.results || []); setCategories(catData); } catch (err) { setUser(null); } finally { setLoadingUser(false); } }; fetchData(); const interval = setInterval(() => { if (localStorage.getItem('token')) { fetchNotifications(); api.get('users/me/').then(res => setUser(res.data)).catch(() => {}); } }, 60000); return () => clearInterval(interval); }, []); const theme = useMemo(() => getTheme(themeName), [themeName]); useEffect(() => { // Update body styles to match theme immediately (overriding index.html blocking script) document.body.style.backgroundColor = theme.palette.background.default; document.body.style.color = theme.palette.text.primary; }, [theme]); const handleMarkAllRead = async () => { try { await api.post('notifications/mark_all_read/'); setNotifications(notifications.map(n => ({ ...n, is_read: true }))); if (user) setUser({ ...user, unread_notifications_count: 0 }); } catch (err) { console.error('Failed to mark all as read'); } }; const handleNotificationClick = async (notif: Notification) => { if (!notif.is_read) { try { await api.post(`notifications/${notif.id}/mark_read/`); setNotifications(notifications.map(n => n.id === notif.id ? { ...n, is_read: true } : n)); if (user) setUser({ ...user, unread_notifications_count: Math.max(0, user.unread_notifications_count - 1) }); } catch (err) { /* ignore */ } } setNotifAnchorEl(null); if (notif.flavor_id) { window.location.href = `/flavor/${notif.flavor_id}`; } }; const handleThemeChange = async (newTheme: CatppuccinTheme) => { setThemeName(newTheme); localStorage.setItem('theme', newTheme); if (user) { try { await api.patch('users/update_preferences/', { theme: newTheme }); } catch (err) { console.error('Failed to update theme on server'); } } setAnchorEl(null); }; const handleLogout = () => { localStorage.removeItem('token'); setUser(null); setFollowing([]); i18n.changeLanguage(navigator.language.split('-')[0] || 'en'); window.location.href = '/'; }; const drawer = ( setDrawerOpen(false)} style={{ color: 'inherit', textDecoration: 'none' }}> Holy Flavors Archive setDrawerOpen(false)}> {user && ( setDrawerOpen(false)}> )} setCatOpen(!catOpen)}> {catOpen ? : } {categories.map(cat => ( setDrawerOpen(false)} sx={{ pl: 4 }} > ))} {user ? ( <> setDrawerOpen(false)}> {user.username.charAt(0).toUpperCase()} {user.username} } /> setDrawerOpen(false)}> setDrawerOpen(false)}> setDrawerOpen(false)}> {user.is_superuser && ( )} {user.is_superuser && ( setDrawerOpen(false)}> )} {following.length > 0 && ( <> {t('nav.following')} {following.map(f => ( setDrawerOpen(false)}> {!f.avatar && f.username.charAt(0).toUpperCase()} ))} )} ) : ( setDrawerOpen(false)}> )} ); const notificationsMenu = ( <> setNotifAnchorEl(e.currentTarget)} sx={{ ml: 1 }}> setNotifAnchorEl(null)} elevation={3} sx={{ mt: 1 }} PaperProps={{ sx: { width: 320, maxHeight: 400, borderRadius: 1 } }} > Notifications {(user?.unread_notifications_count || 0) > 0 && ( )} {notifications.length === 0 ? ( No notifications yet ) : ( notifications.map(n => { const handleClick = () => { if (n.notification_type.startsWith('ticket')) { handleNotificationClick(n); navigate('/support'); } else if (n.notification_type === 'profile_comment') { handleNotificationClick(n); navigate(`/profile/${user?.username}`); } else if (n.notification_type === 'follow') { handleNotificationClick(n); navigate(`/profile/${n.actor_username}`); } else { handleNotificationClick(n); } }; return ( {n.actor_username.charAt(0).toUpperCase()} {n.actor_username} { n.notification_type === 'reply' ? `replied to your review on ${n.flavor_name}` : n.notification_type === 'mention' ? `mentioned you on ${n.flavor_name}` : n.notification_type === 'follow' ? t('community.notifFollow') : n.notification_type === 'profile_comment' ? `left a message on your guestbook` : n.notification_type === 'ticket_new' ? t('community.notifTicketNew') : user?.is_superuser ? t('community.notifTicketReplyAdmin') : t('community.notifTicketReply') } {formatDate(n.created_at)} ); }) )} ); return ( alpha(theme.palette.background.paper, 0.8), backdropFilter: 'blur(12px)', color: 'text.primary' }} > Holy Flavors Archive HFA {user && ( )} setCatAnchorEl(null)} elevation={3} sx={{ mt: 1 }} > {categories.map(cat => ( setCatAnchorEl(null)} > {t(`categories.${cat.slug}`, { defaultValue: cat.name })} ))} {!loadingUser && ( <> {user && notificationsMenu} {user ? ( setAnchorEl(e.currentTarget)}> {user.username.charAt(0).toUpperCase()} setAnchorEl(null)} elevation={3} sx={{ mt: 1 }} > setAnchorEl(null)}>{t('nav.profile')} setAnchorEl(null)}>{t('nav.dashboard')} setAnchorEl(null)}>{t('nav.settings')} setAnchorEl(null)}>{t('support.title')} {user.is_superuser && ( {t('admin.adminMode')}: {adminMode ? "ON" : "OFF"} )} {user.is_superuser && ( setAnchorEl(null)} sx={{ fontWeight: 'bold', color: 'primary.main' }}>Admin Panel )} {t('nav.logout')} ) : ( )} )} {isMobile && ( setDrawerOpen(true)} sx={{ ml: 1 }} > )} setDrawerOpen(false)} > {drawer} }> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> 404 - Page Not Found} />