mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
translation and back button
This commit is contained in:
1 parent
3e6d08ee16
commit
fccac10e64
13 files changed
+317
-1254
No files matched your search
@@ -252,9 +252,9 @@ const App: React.FC = () => {
|
||||
path="*"
|
||||
element={
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h5">404 - Page Not Found</Typography>
|
||||
<Typography variant="h5">{t('common.notFoundTitle')}</Typography>
|
||||
<Button component={Link} to="/" sx={{ mt: 2 }}>
|
||||
Back to Home
|
||||
{t('common.backToHome')}
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@mui/material';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface BackButtonProps {
|
||||
/**
|
||||
* Fixed destination. When set, the button always navigates here (rendered as a
|
||||
* router Link). When omitted, it goes back in history, falling back to `/`.
|
||||
*/
|
||||
to?: string;
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
const baseSx: SxProps<Theme> = {
|
||||
alignSelf: 'flex-start',
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared "go back" button. Replaces the per-page copies that drifted in styling
|
||||
* and label logic. With `to`, it's a static link; otherwise it pops history and
|
||||
* falls back to home, with the label reflecting which it'll do.
|
||||
*/
|
||||
export const BackButton: React.FC<BackButtonProps> = ({ to, sx }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const canGoBack = typeof window !== 'undefined' && window.history.length > 1;
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (canGoBack) navigate(-1);
|
||||
else navigate('/');
|
||||
};
|
||||
|
||||
const label = to || !canGoBack ? t('common.backToHome') : t('common.back');
|
||||
|
||||
const linkProps = to
|
||||
? { component: Link as React.ElementType, to }
|
||||
: { onClick: handleGoBack };
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ ...baseSx, ...sx }}
|
||||
{...linkProps}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackButton;
|
||||
@@ -7,6 +7,7 @@ export { ReviewRow, type ReviewRowProps, type ReviewRowData } from './ReviewRow'
|
||||
export { FlavorCard, type FlavorCardProps, type FlavorCardData } from './FlavorCard';
|
||||
export { FormCard, type FormCardProps } from './FormCard';
|
||||
export { EmptyState, type EmptyStateProps } from './EmptyState';
|
||||
export { BackButton, type BackButtonProps } from './BackButton';
|
||||
export { default as ScoreInput, type ScoreInputProps } from './ScoreInput';
|
||||
export { default as HeroGallery, type HeroGalleryProps } from './HeroGallery';
|
||||
export { default as RatingDistribution, type RatingDistributionProps } from './RatingDistribution';
|
||||
@@ -129,6 +129,9 @@ const resources = {
|
||||
previousFlavor: 'Previous flavor',
|
||||
nextFlavor: 'Next flavor',
|
||||
goToFlavor: 'Go to flavor {{n}}',
|
||||
noCategoryFlavors: 'No flavors here yet',
|
||||
noCategoryFlavorsSub: 'Check back soon.',
|
||||
flavorsFallback: 'Flavors',
|
||||
},
|
||||
common: {
|
||||
search: 'Search...',
|
||||
@@ -147,6 +150,13 @@ const resources = {
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
all: 'All',
|
||||
noComment: 'No comment provided.',
|
||||
replyFailed: 'Failed to send reply',
|
||||
actionFailed: 'Something went wrong. Please try again.',
|
||||
confirmDeleteReview: 'Delete this review?',
|
||||
confirmDeleteReply: 'Delete this reply?',
|
||||
confirmDeleteComment: 'Delete this comment?',
|
||||
notFoundTitle: '404 — Page Not Found',
|
||||
},
|
||||
status: {
|
||||
limited: 'Limited',
|
||||
@@ -174,6 +184,38 @@ const resources = {
|
||||
noImage: 'No Image',
|
||||
ratingDistribution: 'Rating distribution',
|
||||
flavorNotFound: 'Flavor not found',
|
||||
editProductAdmin: 'Edit Product (Admin)',
|
||||
nameLabel: 'Name',
|
||||
descriptionLabel: 'Description',
|
||||
shopUrlLabel: 'Shop URL',
|
||||
uploadImage: 'Upload Custom Image',
|
||||
uploading: 'Uploading...',
|
||||
inStock: 'In Stock',
|
||||
legacyLimited: 'Legacy / Limited',
|
||||
adminUpdateFailed: 'Admin update failed',
|
||||
imageUploadFailed: 'Image upload failed',
|
||||
replyUpdateFailed: 'Failed to update reply',
|
||||
replyDeleteFailed: 'Failed to delete reply',
|
||||
reviewUpdateFailed: 'Failed to update review',
|
||||
reviewDeleteFailed: 'Failed to delete review',
|
||||
},
|
||||
profile: {
|
||||
notFound: 'Profile not found.',
|
||||
tabRatings: 'Ratings',
|
||||
tabGuestbook: 'Guestbook',
|
||||
tabFollowers: 'Followers',
|
||||
tabFollowing: 'Following',
|
||||
masterCollector: 'Master Collector',
|
||||
noRatingsCategory: 'No ratings in this category',
|
||||
leaveMessage: 'Leave a message',
|
||||
guestbookPlaceholder: 'Say something about their taste...',
|
||||
guestbookEmpty: 'No entries in the guestbook yet.',
|
||||
guestbookEmptySub: 'Be the first to leave a message!',
|
||||
noFollowers: 'No followers yet.',
|
||||
noFollowing: 'Not following anyone yet.',
|
||||
followFailed: 'Failed to update follow status. Please login.',
|
||||
commentAddFailed: 'Failed to add comment',
|
||||
commentDeleteFailed: 'Failed to delete comment',
|
||||
},
|
||||
auth: {
|
||||
loginTitle: 'Login',
|
||||
@@ -188,6 +230,24 @@ const resources = {
|
||||
signupSuccess: 'Signup successful! Please enter the verification code sent to your email.',
|
||||
forgotPassword: 'Forgot Password?',
|
||||
backToAuth: 'Back to Signup/Login',
|
||||
usernameLabel: 'Username',
|
||||
passwordLabel: 'Password',
|
||||
emailLabel: 'Email',
|
||||
newPasswordLabel: 'New Password',
|
||||
verificationCodeLabel: 'Verification Code',
|
||||
resetCodeLabel: 'Reset Code',
|
||||
verifyButton: 'Verify',
|
||||
resetTitle: 'Reset Password',
|
||||
sendResetCode: 'Send Reset Code',
|
||||
resetButton: 'Reset Password',
|
||||
backToLogin: 'Back to Login',
|
||||
resetRequested: 'If an account exists, a reset code has been sent.',
|
||||
resetSuccess: 'Password reset successful! You can now login.',
|
||||
resetFailed: 'Reset failed',
|
||||
signupFailed: 'Signup failed',
|
||||
verifyFailed: 'Verification failed',
|
||||
resendFailed: 'Failed to resend code',
|
||||
genericError: 'An error occurred. Please try again later.',
|
||||
},
|
||||
community: {
|
||||
title: 'Community Activity',
|
||||
@@ -451,6 +511,9 @@ const resources = {
|
||||
previousFlavor: 'Vorherige Sorte',
|
||||
nextFlavor: 'Nächste Sorte',
|
||||
goToFlavor: 'Zu Sorte {{n}}',
|
||||
noCategoryFlavors: 'Hier gibt es noch keine Sorten',
|
||||
noCategoryFlavorsSub: 'Schau bald wieder vorbei.',
|
||||
flavorsFallback: 'Sorten',
|
||||
},
|
||||
common: {
|
||||
search: 'Suchen...',
|
||||
@@ -469,6 +532,13 @@ const resources = {
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
all: 'Alle',
|
||||
noComment: 'Kein Kommentar abgegeben.',
|
||||
replyFailed: 'Antwort konnte nicht gesendet werden',
|
||||
actionFailed: 'Etwas ist schiefgelaufen. Bitte versuche es erneut.',
|
||||
confirmDeleteReview: 'Diese Bewertung löschen?',
|
||||
confirmDeleteReply: 'Diese Antwort löschen?',
|
||||
confirmDeleteComment: 'Diesen Kommentar löschen?',
|
||||
notFoundTitle: '404 — Seite nicht gefunden',
|
||||
},
|
||||
status: {
|
||||
limited: 'Limitiert',
|
||||
@@ -496,6 +566,38 @@ const resources = {
|
||||
noImage: 'Kein Bild',
|
||||
ratingDistribution: 'Bewertungsverteilung',
|
||||
flavorNotFound: 'Sorte nicht gefunden',
|
||||
editProductAdmin: 'Produkt bearbeiten (Admin)',
|
||||
nameLabel: 'Name',
|
||||
descriptionLabel: 'Beschreibung',
|
||||
shopUrlLabel: 'Shop-URL',
|
||||
uploadImage: 'Eigenes Bild hochladen',
|
||||
uploading: 'Wird hochgeladen...',
|
||||
inStock: 'Auf Lager',
|
||||
legacyLimited: 'Legacy / Limitiert',
|
||||
adminUpdateFailed: 'Admin-Update fehlgeschlagen',
|
||||
imageUploadFailed: 'Bild-Upload fehlgeschlagen',
|
||||
replyUpdateFailed: 'Antwort konnte nicht aktualisiert werden',
|
||||
replyDeleteFailed: 'Antwort konnte nicht gelöscht werden',
|
||||
reviewUpdateFailed: 'Bewertung konnte nicht aktualisiert werden',
|
||||
reviewDeleteFailed: 'Bewertung konnte nicht gelöscht werden',
|
||||
},
|
||||
profile: {
|
||||
notFound: 'Profil nicht gefunden.',
|
||||
tabRatings: 'Bewertungen',
|
||||
tabGuestbook: 'Gästebuch',
|
||||
tabFollowers: 'Follower',
|
||||
tabFollowing: 'Folge ich',
|
||||
masterCollector: 'Meister-Sammler',
|
||||
noRatingsCategory: 'Keine Bewertungen in dieser Kategorie',
|
||||
leaveMessage: 'Nachricht hinterlassen',
|
||||
guestbookPlaceholder: 'Sag etwas über ihren Geschmack...',
|
||||
guestbookEmpty: 'Noch keine Einträge im Gästebuch.',
|
||||
guestbookEmptySub: 'Sei der Erste, der eine Nachricht hinterlässt!',
|
||||
noFollowers: 'Noch keine Follower.',
|
||||
noFollowing: 'Folgt noch niemandem.',
|
||||
followFailed: 'Follow-Status konnte nicht aktualisiert werden. Bitte anmelden.',
|
||||
commentAddFailed: 'Kommentar konnte nicht hinzugefügt werden',
|
||||
commentDeleteFailed: 'Kommentar konnte nicht gelöscht werden',
|
||||
},
|
||||
auth: {
|
||||
loginTitle: 'Anmelden',
|
||||
@@ -513,6 +615,24 @@ const resources = {
|
||||
'Registrierung erfolgreich! Bitte gib den Verifizierungscode ein, der an deine E-Mail gesendet wurde.',
|
||||
forgotPassword: 'Passwort vergessen?',
|
||||
backToAuth: 'Zurück zur Anmeldung',
|
||||
usernameLabel: 'Benutzername',
|
||||
passwordLabel: 'Passwort',
|
||||
emailLabel: 'E-Mail',
|
||||
newPasswordLabel: 'Neues Passwort',
|
||||
verificationCodeLabel: 'Verifizierungscode',
|
||||
resetCodeLabel: 'Reset-Code',
|
||||
verifyButton: 'Verifizieren',
|
||||
resetTitle: 'Passwort zurücksetzen',
|
||||
sendResetCode: 'Reset-Code senden',
|
||||
resetButton: 'Passwort zurücksetzen',
|
||||
backToLogin: 'Zurück zur Anmeldung',
|
||||
resetRequested: 'Falls ein Konto existiert, wurde ein Reset-Code gesendet.',
|
||||
resetSuccess: 'Passwort erfolgreich zurückgesetzt! Du kannst dich jetzt anmelden.',
|
||||
resetFailed: 'Zurücksetzen fehlgeschlagen',
|
||||
signupFailed: 'Registrierung fehlgeschlagen',
|
||||
verifyFailed: 'Verifizierung fehlgeschlagen',
|
||||
resendFailed: 'Code konnte nicht erneut gesendet werden',
|
||||
genericError: 'Ein Fehler ist aufgetreten. Bitte versuche es später erneut.',
|
||||
},
|
||||
community: {
|
||||
title: 'Community-Aktivität',
|
||||
|
||||
@@ -1,873 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Grid,
|
||||
CardContent,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Stack,
|
||||
alpha,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
TextField,
|
||||
IconButton,
|
||||
useMediaQuery,
|
||||
Tabs,
|
||||
Tab,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Switch,
|
||||
Divider,
|
||||
Alert,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
} from '@mui/material';
|
||||
import { PageShell, GlassCard, GlassPaper, EmptyState } from '../components/ui';
|
||||
import { useToast } from '../hooks/useToast';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import PeopleIcon from '@mui/icons-material/People';
|
||||
import WallpaperIcon from '@mui/icons-material/Wallpaper';
|
||||
import TerminalIcon from '@mui/icons-material/Terminal';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
useAdminConfig,
|
||||
useAdminJobs,
|
||||
useAdminStats,
|
||||
useAdminUsers,
|
||||
} from '../api/queries/useAdminQueries';
|
||||
import { useBannersList } from '../api/queries/useBanners';
|
||||
import {
|
||||
useActivateBanner,
|
||||
useSendTestEmail,
|
||||
useToggleBannerEnabled,
|
||||
useTriggerJob,
|
||||
useUpdateAdminConfig,
|
||||
useUpdateJobSchedule,
|
||||
} from '../api/mutations/useAdminMutations';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { formatDate } from '../utils/date';
|
||||
import BannerSettingsDialog from '../components/BannerSettingsDialog';
|
||||
|
||||
interface Stats {
|
||||
total_users: number;
|
||||
total_ratings: number;
|
||||
total_replies: number;
|
||||
open_tickets: number;
|
||||
email_config: {
|
||||
host: string;
|
||||
port: number;
|
||||
use_tls: boolean;
|
||||
use_ssl: boolean;
|
||||
skip_verify: boolean;
|
||||
};
|
||||
server_info: {
|
||||
debug: boolean;
|
||||
allowed_hosts: string[];
|
||||
frontend_url: string;
|
||||
media_root: string;
|
||||
static_root: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
date_joined: string;
|
||||
last_login: string;
|
||||
ips: string[];
|
||||
}
|
||||
|
||||
interface BannerSettingSchema {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'number' | 'slider' | 'text' | 'boolean';
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Banner {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
is_enabled: boolean;
|
||||
settings: Record<string, unknown>;
|
||||
schema: BannerSettingSchema[];
|
||||
}
|
||||
|
||||
interface Job {
|
||||
id: number;
|
||||
name: string;
|
||||
name_display: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
last_run: string | null;
|
||||
next_run: string | null;
|
||||
interval_hours: number;
|
||||
last_output: string;
|
||||
error_message: string;
|
||||
}
|
||||
|
||||
interface SystemConfig {
|
||||
site_name: string;
|
||||
maintenance_mode: boolean;
|
||||
allow_new_signups: boolean;
|
||||
require_email_verification: boolean;
|
||||
}
|
||||
|
||||
const AdminDashboard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useMediaQuery('(max-width:900px)');
|
||||
useTitle(t('admin.title'));
|
||||
const qc = useQueryClient();
|
||||
const { data: stats = null, isLoading: statsLoading } = useAdminStats() as unknown as {
|
||||
data: Stats | null | undefined;
|
||||
isLoading: boolean;
|
||||
};
|
||||
const { data: users = [], isLoading: usersLoading } = useAdminUsers() as unknown as {
|
||||
data: AdminUser[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
const { data: banners = [], isLoading: bannersLoading } = useBannersList() as unknown as {
|
||||
data: Banner[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
const { data: jobs = [], isLoading: jobsLoading } = useAdminJobs() as unknown as {
|
||||
data: Job[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
const { data: config = null, isLoading: configLoading } = useAdminConfig() as unknown as {
|
||||
data: SystemConfig | null | undefined;
|
||||
isLoading: boolean;
|
||||
};
|
||||
const updateConfigMutation = useUpdateAdminConfig();
|
||||
const triggerJobMutation = useTriggerJob();
|
||||
const updateJobScheduleMutation = useUpdateJobSchedule();
|
||||
const activateBannerMutation = useActivateBanner();
|
||||
const toggleBannerEnabledMutation = useToggleBannerEnabled();
|
||||
const sendTestEmailMutation = useSendTestEmail();
|
||||
|
||||
const loading = statsLoading || usersLoading || bannersLoading || jobsLoading || configLoading;
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [currentTab, setCurrentTab] = useState(0);
|
||||
const [selectedBanner, setSelectedBanner] = useState<Banner | null>(null);
|
||||
const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false);
|
||||
const { notify } = useToast();
|
||||
|
||||
// Poll the jobs query faster while anything is running on the Jobs tab.
|
||||
useEffect(() => {
|
||||
const anyRunning = jobs.some((j) => j.status === 'running' || j.status === 'pending');
|
||||
if (anyRunning && currentTab === 3) {
|
||||
const interval = setInterval(() => {
|
||||
qc.invalidateQueries({ queryKey: ['adminJobs'] });
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [jobs, currentTab, qc]);
|
||||
|
||||
const handleUpdateConfig = async (key: string, value: unknown) => {
|
||||
try {
|
||||
await updateConfigMutation.mutateAsync({ key, value });
|
||||
} catch {
|
||||
notify({ message: 'Failed to update configuration', severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerJob = async (id: number) => {
|
||||
try {
|
||||
await triggerJobMutation.mutateAsync(id);
|
||||
} catch {
|
||||
notify({ message: 'Failed to trigger job', severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateJobSchedule = async (id: number, interval: number, nextRun?: string) => {
|
||||
try {
|
||||
const data: Record<string, unknown> = { interval_hours: interval };
|
||||
if (nextRun) data.next_run = nextRun;
|
||||
await updateJobScheduleMutation.mutateAsync({ id, data });
|
||||
} catch {
|
||||
notify({ message: 'Failed to update schedule', severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivateBanner = async (id: number) => {
|
||||
try {
|
||||
await activateBannerMutation.mutateAsync(id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (id: number) => {
|
||||
try {
|
||||
await toggleBannerEnabledMutation.mutateAsync(id);
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
notify({
|
||||
message: error.response?.data?.error || 'Failed to toggle visibility',
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendTestEmail = async () => {
|
||||
try {
|
||||
await sendTestEmailMutation.mutateAsync();
|
||||
notify({ message: t('admin.testEmailSuccess'), severity: 'success' });
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
notify({
|
||||
message: `Error: ${error.response?.data?.error || 'Failed to send'}`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(u) =>
|
||||
u.username.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 3, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Typography variant={isMobile ? 'h4' : 'h3'} sx={{ fontWeight: 'bold', mb: 4 }}>
|
||||
{t('admin.title')}
|
||||
</Typography>
|
||||
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onChange={(_, v) => setCurrentTab(v)}
|
||||
sx={{ mb: 4, borderBottom: 1, borderColor: 'divider' }}
|
||||
variant={isMobile ? 'scrollable' : 'standard'}
|
||||
>
|
||||
<Tab icon={<SettingsIcon />} label={t('admin.overview')} iconPosition="start" />
|
||||
<Tab icon={<PeopleIcon />} label={t('admin.users')} iconPosition="start" />
|
||||
<Tab icon={<WallpaperIcon />} label={t('admin.banners')} iconPosition="start" />
|
||||
<Tab icon={<TerminalIcon />} label="Jobs & Workers" iconPosition="start" />
|
||||
</Tabs>
|
||||
|
||||
{currentTab === 0 && (
|
||||
<Box>
|
||||
<Grid container spacing={isMobile ? 2 : 3} sx={{ mb: 6 }}>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<GlassCard
|
||||
intensity="subtle"
|
||||
sx={{ bgcolor: (theme) => alpha(theme.palette.primary.main, 0.1) }}
|
||||
>
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>
|
||||
{t('admin.totalUsers')}
|
||||
</Typography>
|
||||
<Typography variant={isMobile ? 'h5' : 'h4'} sx={{ fontWeight: 'bold' }}>
|
||||
{stats?.total_users}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</GlassCard>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<GlassCard
|
||||
intensity="subtle"
|
||||
sx={{ bgcolor: (theme) => alpha(theme.palette.error.main, 0.1) }}
|
||||
>
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>
|
||||
{t('admin.openTickets')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant={isMobile ? 'h5' : 'h4'}
|
||||
sx={{ fontWeight: 'bold', color: 'error.main' }}
|
||||
>
|
||||
{stats?.open_tickets}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</GlassCard>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<GlassCard intensity="subtle">
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Stack
|
||||
direction={isMobile ? 'column' : 'row'}
|
||||
justifyContent="space-between"
|
||||
alignItems={isMobile ? 'flex-start' : 'center'}
|
||||
spacing={2}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
{t('admin.config')}: SMTP
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block' }}
|
||||
>
|
||||
{stats?.email_config.host}:{stats?.email_config.port} • TLS:{' '}
|
||||
{String(stats?.email_config.use_tls)} • Insecure:{' '}
|
||||
{String(stats?.email_config.skip_verify)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth={isMobile}
|
||||
startIcon={<EmailIcon />}
|
||||
onClick={handleSendTestEmail}
|
||||
size="small"
|
||||
>
|
||||
{t('admin.sendTestEmail')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</GlassCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={4}>
|
||||
{/* Live Config */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 2 }}>
|
||||
System Configuration (Live)
|
||||
</Typography>
|
||||
<GlassPaper intensity="subtle" sx={{ p: 0, overflow: 'hidden' }}>
|
||||
<List disablePadding>
|
||||
{[
|
||||
{ key: 'site_name', label: 'Site Name', type: 'text' },
|
||||
{ key: 'maintenance_mode', label: 'Maintenance Mode', type: 'switch' },
|
||||
{ key: 'allow_new_signups', label: 'Allow Signups', type: 'switch' },
|
||||
{
|
||||
key: 'require_email_verification',
|
||||
label: 'Require Email Verify',
|
||||
type: 'switch',
|
||||
},
|
||||
].map((item, idx) => (
|
||||
<ListItem key={item.key} divider={idx < 3} sx={{ py: 2 }}>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
secondary={
|
||||
item.type === 'text'
|
||||
? (config as unknown as Record<string, string>)?.[item.key]
|
||||
: null
|
||||
}
|
||||
/>
|
||||
{item.type === 'switch' ? (
|
||||
<Switch
|
||||
checked={
|
||||
(config as unknown as Record<string, boolean>)?.[item.key] || false
|
||||
}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
handleUpdateConfig(item.key, e.target.checked)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const val = prompt(
|
||||
`Enter new value for ${item.label}:`,
|
||||
(config as unknown as Record<string, string>)?.[item.key],
|
||||
);
|
||||
if (val !== null) handleUpdateConfig(item.key, val);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</GlassPaper>
|
||||
</Grid>
|
||||
|
||||
{/* Environment Info */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 2 }}>
|
||||
Environment Info (.env)
|
||||
</Typography>
|
||||
<GlassPaper intensity="subtle" sx={{ p: 3 }}>
|
||||
<Stack spacing={2.5}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}
|
||||
>
|
||||
API Status
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
DEBUG={String(stats?.server_info.debug)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}
|
||||
>
|
||||
Frontend URL
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
{stats?.server_info.frontend_url}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}
|
||||
>
|
||||
Allowed Hosts
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
{stats?.server_info.allowed_hosts.join(', ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||
<InfoIcon fontSize="small" color="primary" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
These values are read from your server environment or .env file. To change
|
||||
them, you must update the file and restart the backend container.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</GlassPaper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{currentTab === 1 && (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Stack
|
||||
direction={isMobile ? 'column' : 'row'}
|
||||
justifyContent="space-between"
|
||||
alignItems={isMobile ? 'flex-start' : 'center'}
|
||||
sx={{ mb: 2 }}
|
||||
spacing={2}
|
||||
>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
{t('admin.users')}
|
||||
</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search..."
|
||||
fullWidth={isMobile}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ width: isMobile ? '100%' : 300 }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{isMobile ? (
|
||||
<Stack spacing={2}>
|
||||
{filteredUsers.map((user) => (
|
||||
<GlassCard key={user.id} intensity="subtle" sx={{ borderRadius: 3 }}>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
{user.username}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => navigate(`/admin-panel/user/${user.id}`)}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{user.email}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||
<Chip
|
||||
label={user.is_active ? 'Active' : 'Inactive'}
|
||||
color={user.is_active ? 'success' : 'default'}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.65rem' }}
|
||||
/>
|
||||
{user.is_superuser && (
|
||||
<Chip
|
||||
label="Admin"
|
||||
color="secondary"
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.65rem' }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</GlassCard>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<TableContainer component={GlassPaper} sx={{ borderRadius: 3 }}>
|
||||
<Table>
|
||||
<TableHead sx={{ bgcolor: 'action.hover' }}>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>ID</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>User</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Email</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Status</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Joined</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>IP History</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }} align="right">
|
||||
Actions
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredUsers.map((user) => (
|
||||
<TableRow key={user.id} hover>
|
||||
<TableCell>{user.id}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{user.username}
|
||||
</Typography>
|
||||
{user.is_superuser && (
|
||||
<Chip
|
||||
label="Superuser"
|
||||
size="small"
|
||||
color="secondary"
|
||||
sx={{ height: 16, fontSize: '0.6rem' }}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={user.is_active ? 'Active' : 'Inactive'}
|
||||
color={user.is_active ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(user.date_joined)}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
||||
{user.ips.slice(0, 3).map((ip) => (
|
||||
<Chip
|
||||
key={ip}
|
||||
label={ip}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.7rem' }}
|
||||
/>
|
||||
))}
|
||||
{user.ips.length > 3 && (
|
||||
<Typography variant="caption">+{user.ips.length - 3}</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => navigate(`/admin-panel/user/${user.id}`)}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{currentTab === 2 && (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold', mb: 3 }}>
|
||||
{t('admin.banners')} ({banners.length})
|
||||
</Typography>
|
||||
|
||||
{banners.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No banner models found"
|
||||
subtitle="No banners registered in the database."
|
||||
action={
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => qc.invalidateQueries({ queryKey: ['banners'] })}
|
||||
>
|
||||
Retry Loading
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
{banners.map((banner) => (
|
||||
<GlassCard
|
||||
key={banner.id}
|
||||
sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<CardContent>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{banner.name} (Slug: {banner.slug})
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{!banner.is_enabled && (
|
||||
<Chip
|
||||
label="Hidden from Users"
|
||||
color="warning"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
{banner.is_active && (
|
||||
<Chip label="Global Default" color="success" size="small" />
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
{banner.description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ p: 2, bgcolor: 'action.hover', borderRadius: 2, mb: 2 }}>
|
||||
<pre style={{ margin: 0, fontSize: '0.8rem' }}>
|
||||
{JSON.stringify(banner.settings, null, 2)}
|
||||
</pre>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setSelectedBanner(banner);
|
||||
setIsSettingsDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Edit Settings
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
color={banner.is_enabled ? 'warning' : 'success'}
|
||||
onClick={() => handleToggleEnabled(banner.id)}
|
||||
disabled={banner.is_active}
|
||||
>
|
||||
{banner.is_enabled ? 'Disable for Users' : 'Enable for Users'}
|
||||
</Button>
|
||||
{!banner.is_active && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => handleActivateBanner(banner.id)}
|
||||
>
|
||||
Set as Default
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</GlassCard>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{currentTab === 3 && (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold', mb: 3 }}>
|
||||
Background Worker Jobs
|
||||
</Typography>
|
||||
<Stack spacing={3}>
|
||||
{jobs.map((job) => (
|
||||
<GlassCard
|
||||
key={job.id}
|
||||
sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<CardContent>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{job.name_display}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={job.status === 'pending' ? 'QUEUED' : job.status.toUpperCase()}
|
||||
size="small"
|
||||
color={
|
||||
job.status === 'completed'
|
||||
? 'success'
|
||||
: job.status === 'running'
|
||||
? 'info'
|
||||
: job.status === 'failed'
|
||||
? 'error'
|
||||
: 'default'
|
||||
}
|
||||
variant={job.status === 'running' ? 'filled' : 'outlined'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Last Run
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{job.last_run ? formatDate(job.last_run) : 'Never'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Next Run
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{job.next_run
|
||||
? formatDate(job.next_run)
|
||||
: job.interval_hours > 0
|
||||
? 'Queued...'
|
||||
: 'Disabled'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
display="block"
|
||||
sx={{ mb: 0.5 }}
|
||||
>
|
||||
Schedule
|
||||
</Typography>
|
||||
<FormControl size="small" fullWidth>
|
||||
<Select
|
||||
value={job.interval_hours}
|
||||
onChange={(e) => handleUpdateJobSchedule(job.id, Number(e.target.value))}
|
||||
sx={{ height: 32, fontSize: '0.875rem' }}
|
||||
>
|
||||
<MenuItem value={0}>Disabled</MenuItem>
|
||||
<MenuItem value={1}>Hourly</MenuItem>
|
||||
<MenuItem value={6}>Every 6 Hours</MenuItem>
|
||||
<MenuItem value={12}>Every 12 Hours</MenuItem>
|
||||
<MenuItem value={24}>Daily</MenuItem>
|
||||
<MenuItem value={168}>Weekly</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{job.last_output && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
display="block"
|
||||
gutterBottom
|
||||
>
|
||||
Last Run Output
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
bgcolor: '#1e1e1e',
|
||||
color: '#d4d4d4',
|
||||
borderRadius: 2,
|
||||
maxHeight: 150,
|
||||
overflowY: 'auto',
|
||||
border: '1px solid #333',
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{job.last_output}
|
||||
</pre>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{job.error_message && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="error" display="block" gutterBottom>
|
||||
Last Error
|
||||
</Typography>
|
||||
<Alert severity="error" sx={{ py: 0 }}>
|
||||
{job.error_message}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
disabled={job.status === 'running'}
|
||||
onClick={() => handleTriggerJob(job.id)}
|
||||
>
|
||||
Trigger Now
|
||||
</Button>
|
||||
</CardContent>
|
||||
</GlassCard>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<BannerSettingsDialog
|
||||
open={isSettingsDialogOpen}
|
||||
onClose={() => {
|
||||
setIsSettingsDialogOpen(false);
|
||||
setSelectedBanner(null);
|
||||
}}
|
||||
banner={selectedBanner}
|
||||
onSave={() => qc.invalidateQueries({ queryKey: ['banners'] })}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
@@ -24,7 +24,6 @@ import SendIcon from '@mui/icons-material/Send';
|
||||
import WhatshotIcon from '@mui/icons-material/Whatshot';
|
||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import PeopleIcon from '@mui/icons-material/People';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
useCommunityFeed,
|
||||
@@ -41,7 +40,14 @@ import { formatDate } from '../utils/date';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MentionTextField from '../components/MentionTextField';
|
||||
import RatingBadge from '../components/RatingBadge';
|
||||
import { PageShell, HeroBackdrop, SectionHeader, GlassCard, EmptyState } from '../components/ui';
|
||||
import {
|
||||
PageShell,
|
||||
HeroBackdrop,
|
||||
SectionHeader,
|
||||
GlassCard,
|
||||
EmptyState,
|
||||
BackButton,
|
||||
} from '../components/ui';
|
||||
import { useToast } from '../hooks/useToast';
|
||||
|
||||
const SidebarCard: React.FC<{
|
||||
@@ -96,11 +102,6 @@ const CommunityFeed: React.FC = () => {
|
||||
user.username.toLowerCase().includes(followingSearch.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (window.history.length > 1) navigate(-1);
|
||||
else navigate('/');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthed) navigate('/login');
|
||||
}, [isAuthed, navigate]);
|
||||
@@ -126,7 +127,7 @@ const CommunityFeed: React.FC = () => {
|
||||
await createReply.mutateAsync({ ratingId, text });
|
||||
setReplyInputs((prev) => ({ ...prev, [ratingId]: '' }));
|
||||
} catch {
|
||||
notify({ message: 'Failed to send reply', severity: 'error' });
|
||||
notify({ message: t('common.replyFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -141,14 +142,7 @@ const CommunityFeed: React.FC = () => {
|
||||
|
||||
return (
|
||||
<PageShell hero={<HeroBackdrop variant="minimal" />}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ alignSelf: 'flex-start', borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
</Button>
|
||||
<BackButton />
|
||||
|
||||
<SectionHeader title={t('community.title')} subtitle={t('community.subtitle')} />
|
||||
|
||||
@@ -249,7 +243,7 @@ const CommunityFeed: React.FC = () => {
|
||||
color="text.secondary"
|
||||
sx={{ fontStyle: 'italic' }}
|
||||
>
|
||||
No comment provided.
|
||||
{t('common.noComment')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
CardContent,
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Rating as MuiRating,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { useCategories, useFlavorsList } from '../api/queries/useFlavorsList';
|
||||
import { useCreateRating } from '../api/mutations/useRatingMutations';
|
||||
import RatingBadge from '../components/RatingBadge';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { PageShell, HeroBackdrop, GlassCard, SectionHeader } from '../components/ui';
|
||||
import { useToast } from '../hooks/useToast';
|
||||
|
||||
interface RatingInput {
|
||||
score: number;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
const FlavorList: React.FC = () => {
|
||||
const { data: categories = [], isLoading: catLoading } = useCategories();
|
||||
const { data: flavors = [], isLoading: flavLoading } = useFlavorsList();
|
||||
const createRating = useCreateRating();
|
||||
const [ratingInput, setRatingInput] = useState<Record<number, RatingInput>>({});
|
||||
const { notify } = useToast();
|
||||
|
||||
const loading = catLoading || flavLoading;
|
||||
|
||||
const handleRate = async (flavorId: number) => {
|
||||
const input = ratingInput[flavorId];
|
||||
if (!input) return;
|
||||
try {
|
||||
await createRating.mutateAsync({
|
||||
flavor: flavorId,
|
||||
score: input.score,
|
||||
comment: input.comment,
|
||||
});
|
||||
notify({ message: 'Rating submitted!', severity: 'success' });
|
||||
} catch {
|
||||
notify({
|
||||
message: "Failed to submit rating. Make sure you are logged in and haven't rated this yet.",
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<PageShell>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell hero={<HeroBackdrop variant="mesh" />}>
|
||||
<SectionHeader title="Holy Flavors" />
|
||||
|
||||
{categories.map((category) => (
|
||||
<Box key={category.id}>
|
||||
<SectionHeader title={category.name} compact />
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
},
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
{flavors
|
||||
.filter((f) => f.category_name === category.name)
|
||||
.map((flavor) => (
|
||||
<GlassCard
|
||||
key={flavor.id}
|
||||
intensity="subtle"
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 1 }}>
|
||||
<StatusBadge
|
||||
isLegacy={flavor.is_legacy}
|
||||
isAvailable={flavor.is_available}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{flavor.image_url && (
|
||||
<Box
|
||||
component="img"
|
||||
src={flavor.image_url}
|
||||
alt={flavor.name}
|
||||
loading="lazy"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 200,
|
||||
objectFit: 'contain',
|
||||
p: 2,
|
||||
bgcolor: (theme) => theme.tokens.surface.sunken,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CardContent
|
||||
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 1 }}
|
||||
>
|
||||
<Typography variant="h6">{flavor.name}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{flavor.description}
|
||||
</Typography>
|
||||
{flavor.shop_url && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
component="a"
|
||||
href={flavor.shop_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
sx={{ alignSelf: 'flex-start', fontSize: '0.7rem' }}
|
||||
>
|
||||
View in Shop
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2">Avg:</Typography>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="small" />
|
||||
</Box>
|
||||
|
||||
{flavor.ratings && flavor.ratings.length > 0 && (
|
||||
<Accordion variant="outlined" sx={{ mt: 1 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="caption">
|
||||
Comments ({flavor.ratings.filter((r) => r.comment).length})
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ maxHeight: 200, overflow: 'auto', p: 1 }}>
|
||||
{flavor.ratings
|
||||
.filter((r) => r.comment)
|
||||
.map((r) => (
|
||||
<Box
|
||||
key={r.id}
|
||||
sx={{
|
||||
mb: 1,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
pb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 'bold', display: 'block' }}
|
||||
>
|
||||
{r.user} ({r.score}/10):
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
|
||||
{r.comment}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{flavor.user_rating ? (
|
||||
<Typography variant="body2" color="secondary">
|
||||
Your Rating: {flavor.user_rating}/10
|
||||
</Typography>
|
||||
) : (
|
||||
<Accordion variant="outlined" sx={{ mt: 1 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="body2">Rate this flavor</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<MuiRating
|
||||
max={10}
|
||||
value={ratingInput[flavor.id]?.score || 0}
|
||||
onChange={(_, val) =>
|
||||
setRatingInput({
|
||||
...ratingInput,
|
||||
[flavor.id]: {
|
||||
...(ratingInput[flavor.id] || { comment: '' }),
|
||||
score: val || 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="Comment (Optional)"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
multiline
|
||||
value={ratingInput[flavor.id]?.comment || ''}
|
||||
onChange={(e) =>
|
||||
setRatingInput({
|
||||
...ratingInput,
|
||||
[flavor.id]: {
|
||||
...(ratingInput[flavor.id] || { score: 0 }),
|
||||
comment: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => handleRate(flavor.id)}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
</CardContent>
|
||||
</GlassCard>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlavorList;
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Typography, TextField, Button, Tab, Tabs, Alert, Stack, Box } from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../lib/api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageShell, GlassCard, FormCard } from '../components/ui';
|
||||
import { PageShell, GlassCard, FormCard, BackButton } from '../components/ui';
|
||||
|
||||
interface ApiError {
|
||||
response?: { data?: { error?: string; non_field_errors?: string[] } };
|
||||
@@ -63,7 +62,10 @@ const Login: React.FC = () => {
|
||||
setIsVerifying(true);
|
||||
setMessage({ type: 'success', text: t('auth.signupSuccess') });
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: readError(err).response?.data?.error || 'Signup failed' });
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: readError(err).response?.data?.error || t('auth.signupFailed'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,7 +81,7 @@ const Login: React.FC = () => {
|
||||
} catch (err) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: readError(err).response?.data?.error || 'Verification failed',
|
||||
text: readError(err).response?.data?.error || t('auth.verifyFailed'),
|
||||
});
|
||||
setShowResend(true);
|
||||
}
|
||||
@@ -94,7 +96,7 @@ const Login: React.FC = () => {
|
||||
} catch (err) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: readError(err).response?.data?.error || 'Failed to resend code',
|
||||
text: readError(err).response?.data?.error || t('auth.resendFailed'),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -105,9 +107,9 @@ const Login: React.FC = () => {
|
||||
try {
|
||||
await api.post('users/request_password_reset/', { email });
|
||||
setResetStep(2);
|
||||
setMessage({ type: 'success', text: 'If an account exists, a reset code has been sent.' });
|
||||
setMessage({ type: 'success', text: t('auth.resetRequested') });
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'An error occurred. Please try again later.' });
|
||||
setMessage({ type: 'error', text: t('auth.genericError') });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,14 +122,17 @@ const Login: React.FC = () => {
|
||||
code: verificationCode,
|
||||
password,
|
||||
});
|
||||
setMessage({ type: 'success', text: 'Password reset successful! You can now login.' });
|
||||
setMessage({ type: 'success', text: t('auth.resetSuccess') });
|
||||
setIsResetting(false);
|
||||
setResetStep(1);
|
||||
setTab(0);
|
||||
setPassword('');
|
||||
setVerificationCode('');
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: readError(err).response?.data?.error || 'Reset failed' });
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: readError(err).response?.data?.error || t('auth.resetFailed'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -158,7 +163,7 @@ const Login: React.FC = () => {
|
||||
{t('auth.backToAuth')}
|
||||
</Button>
|
||||
<Button variant="contained" type="submit">
|
||||
Verify
|
||||
{t('auth.verifyButton')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
@@ -170,7 +175,7 @@ const Login: React.FC = () => {
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Verification Code"
|
||||
label={t('auth.verificationCodeLabel')}
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
autoFocus
|
||||
@@ -182,7 +187,7 @@ const Login: React.FC = () => {
|
||||
if (isResetting) {
|
||||
return (
|
||||
<FormCard
|
||||
title="Reset Password"
|
||||
title={t('auth.resetTitle')}
|
||||
onSubmit={resetStep === 1 ? handleRequestReset : handleCompleteReset}
|
||||
actions={
|
||||
<>
|
||||
@@ -193,17 +198,17 @@ const Login: React.FC = () => {
|
||||
setMessage(null);
|
||||
}}
|
||||
>
|
||||
Back to Login
|
||||
{t('auth.backToLogin')}
|
||||
</Button>
|
||||
<Button variant="contained" type="submit">
|
||||
{resetStep === 1 ? 'Send Reset Code' : 'Reset Password'}
|
||||
{resetStep === 1 ? t('auth.sendResetCode') : t('auth.resetButton')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
label={t('auth.emailLabel')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={resetStep === 2}
|
||||
@@ -212,13 +217,13 @@ const Login: React.FC = () => {
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Reset Code"
|
||||
label={t('auth.resetCodeLabel')}
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="New Password"
|
||||
label={t('auth.newPasswordLabel')}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -254,13 +259,13 @@ const Login: React.FC = () => {
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
label={t('auth.usernameLabel')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
label={t('auth.passwordLabel')}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -281,20 +286,20 @@ const Login: React.FC = () => {
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
label={t('auth.usernameLabel')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
label={t('auth.emailLabel')}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
label={t('auth.passwordLabel')}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -307,15 +312,7 @@ const Login: React.FC = () => {
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component={Link}
|
||||
to="/"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ alignSelf: 'flex-start', textTransform: 'none', borderRadius: 2 }}
|
||||
>
|
||||
Back to Home
|
||||
</Button>
|
||||
<BackButton to="/" />
|
||||
|
||||
<Box sx={{ maxWidth: 480, width: '100%', mx: 'auto' }}>
|
||||
<Stack spacing={2}>
|
||||
|
||||
@@ -110,7 +110,7 @@ const PublicProfile: React.FC = () => {
|
||||
});
|
||||
refetchFollowing();
|
||||
} catch {
|
||||
notify({ message: 'Failed to update follow status. Please login.', severity: 'error' });
|
||||
notify({ message: t('profile.followFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -122,7 +122,7 @@ const PublicProfile: React.FC = () => {
|
||||
});
|
||||
refetchFollowing();
|
||||
} catch {
|
||||
notify({ message: 'Failed', severity: 'error' });
|
||||
notify({ message: t('common.actionFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -133,17 +133,17 @@ const PublicProfile: React.FC = () => {
|
||||
await addComment.mutateAsync({ userId: data.id, text: newComment });
|
||||
setNewComment('');
|
||||
} catch {
|
||||
notify({ message: 'Failed to add comment', severity: 'error' });
|
||||
notify({ message: t('profile.commentAddFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteComment = async (commentId: number) => {
|
||||
if (!data) return;
|
||||
if (!(await confirm({ message: 'Delete this comment?', danger: true }))) return;
|
||||
if (!(await confirm({ message: t('common.confirmDeleteComment'), danger: true }))) return;
|
||||
try {
|
||||
await deleteCommentMutation.mutateAsync({ userId: data.id, commentId });
|
||||
} catch {
|
||||
notify({ message: 'Failed to delete comment', severity: 'error' });
|
||||
notify({ message: t('profile.commentDeleteFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -160,7 +160,7 @@ const PublicProfile: React.FC = () => {
|
||||
if (!data)
|
||||
return (
|
||||
<PageShell>
|
||||
<Typography>Profile not found.</Typography>
|
||||
<Typography>{t('profile.notFound')}</Typography>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -344,7 +344,7 @@ const PublicProfile: React.FC = () => {
|
||||
{!data.avatar && data.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
{data.ratings.length > 50 && (
|
||||
<Tooltip title="Master Collector">
|
||||
<Tooltip title={t('profile.masterCollector')}>
|
||||
<VerifiedIcon
|
||||
color="primary"
|
||||
sx={{
|
||||
@@ -482,10 +482,10 @@ const PublicProfile: React.FC = () => {
|
||||
'& .Mui-selected': { color: 'primary.main' },
|
||||
}}
|
||||
>
|
||||
<Tab label="Ratings" />
|
||||
<Tab label="Guestbook" />
|
||||
<Tab label="Followers" />
|
||||
<Tab label="Following" />
|
||||
<Tab label={t('profile.tabRatings')} />
|
||||
<Tab label={t('profile.tabGuestbook')} />
|
||||
<Tab label={t('profile.tabFollowers')} />
|
||||
<Tab label={t('profile.tabFollowing')} />
|
||||
</Tabs>
|
||||
</GlassSurface>
|
||||
|
||||
@@ -515,7 +515,7 @@ const PublicProfile: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
{currentRatings.length === 0 ? (
|
||||
<EmptyState title="No ratings in this category" />
|
||||
<EmptyState title={t('profile.noRatingsCategory')} />
|
||||
) : (
|
||||
<Stack spacing={10}>
|
||||
{tiers.map((tier) => {
|
||||
@@ -676,7 +676,7 @@ const PublicProfile: React.FC = () => {
|
||||
{currentUser && (
|
||||
<Box sx={{ mb: 5 }}>
|
||||
<FormCard
|
||||
title="Leave a message"
|
||||
title={t('profile.leaveMessage')}
|
||||
onSubmit={handleAddComment}
|
||||
actions={
|
||||
<Button
|
||||
@@ -685,7 +685,7 @@ const PublicProfile: React.FC = () => {
|
||||
endIcon={<SendIcon />}
|
||||
sx={{ borderRadius: 2, px: 5, fontWeight: '900', textTransform: 'none' }}
|
||||
>
|
||||
Send Message
|
||||
{t('support.reply')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
@@ -693,7 +693,7 @@ const PublicProfile: React.FC = () => {
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
placeholder="Say something about their taste..."
|
||||
placeholder={t('profile.guestbookPlaceholder')}
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
/>
|
||||
@@ -704,8 +704,8 @@ const PublicProfile: React.FC = () => {
|
||||
<Stack spacing={2.5}>
|
||||
{data.comments.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No entries in the guestbook yet."
|
||||
subtitle="Be the first to leave a message!"
|
||||
title={t('profile.guestbookEmpty')}
|
||||
subtitle={t('profile.guestbookEmptySub')}
|
||||
/>
|
||||
) : (
|
||||
data.comments.map((comment) => (
|
||||
@@ -786,7 +786,7 @@ const PublicProfile: React.FC = () => {
|
||||
<Grid container spacing={3}>
|
||||
{data.followers.length === 0 ? (
|
||||
<Grid size={12}>
|
||||
<EmptyState title="No followers yet." />
|
||||
<EmptyState title={t('profile.noFollowers')} />
|
||||
</Grid>
|
||||
) : (
|
||||
data.followers.map(renderUserCard)
|
||||
@@ -798,7 +798,7 @@ const PublicProfile: React.FC = () => {
|
||||
<Grid container spacing={3}>
|
||||
{data.following.length === 0 ? (
|
||||
<Grid size={12}>
|
||||
<EmptyState title="Not following anyone yet." />
|
||||
<EmptyState title={t('profile.noFollowing')} />
|
||||
</Grid>
|
||||
) : (
|
||||
data.following.map(renderUserCard)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Box, CircularProgress, Button } from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Box, CircularProgress } from '@mui/material';
|
||||
import { useTitle } from '../../hooks/useTitle';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCategoryFlavors } from '../../api/queries/useCategoryFlavors';
|
||||
@@ -11,24 +10,16 @@ import {
|
||||
SectionHeader,
|
||||
FlavorCard,
|
||||
EmptyState,
|
||||
BackButton,
|
||||
} from '../../components/ui';
|
||||
|
||||
const CategoryFlavors: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: flavors = [], isLoading: loading } = useCategoryFlavors(slug);
|
||||
const categoryName = flavors[0]?.category_name || '';
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
useTitle(categoryName || 'Flavors');
|
||||
useTitle(categoryName || t('home.flavorsFallback'));
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
@@ -41,19 +32,15 @@ const CategoryFlavors: React.FC = () => {
|
||||
|
||||
return (
|
||||
<PageShell hero={<HeroBackdrop variant="mesh" />}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ alignSelf: 'flex-start', borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
</Button>
|
||||
<BackButton />
|
||||
|
||||
<SectionHeader title={categoryName || 'Flavors'} />
|
||||
<SectionHeader title={categoryName || t('home.flavorsFallback')} />
|
||||
|
||||
{flavors.length === 0 ? (
|
||||
<EmptyState title="No flavors here yet" subtitle="Check back soon." />
|
||||
<EmptyState
|
||||
title={t('home.noCategoryFlavors')}
|
||||
subtitle={t('home.noCategoryFlavorsSub')}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -71,7 +58,7 @@ const CategoryFlavors: React.FC = () => {
|
||||
key={flavor.id}
|
||||
flavor={flavor}
|
||||
showCategory={false}
|
||||
caption={`${flavor.ratings.length} reviews`}
|
||||
caption={`${flavor.ratings.length} ${t('common.reviews')}`}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -73,16 +73,16 @@ const MyReviewCard: React.FC<MyReviewCardProps> = ({ rating, currentUsername })
|
||||
await updateRating.mutateAsync({ id: rating.id, score: editScore, comment: editComment });
|
||||
setIsEditing(false);
|
||||
} catch {
|
||||
notify({ message: 'Failed to update review', severity: 'error' });
|
||||
notify({ message: t('flavorDetail.reviewUpdateFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!(await confirm({ message: 'Delete this review?', danger: true }))) return;
|
||||
if (!(await confirm({ message: t('common.confirmDeleteReview'), danger: true }))) return;
|
||||
try {
|
||||
await deleteRating.mutateAsync(rating.id);
|
||||
} catch {
|
||||
notify({ message: 'Failed to delete review', severity: 'error' });
|
||||
notify({ message: t('flavorDetail.reviewDeleteFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -93,16 +93,16 @@ const MyReviewCard: React.FC<MyReviewCardProps> = ({ rating, currentUsername })
|
||||
setReplyInput('');
|
||||
setExpanded(true);
|
||||
} catch {
|
||||
notify({ message: 'Failed to send reply', severity: 'error' });
|
||||
notify({ message: t('common.replyFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteReply = async (replyId: number) => {
|
||||
if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return;
|
||||
if (!(await confirm({ message: t('common.confirmDeleteReply'), danger: true }))) return;
|
||||
try {
|
||||
await deleteReply.mutateAsync(replyId);
|
||||
} catch {
|
||||
notify({ message: 'Failed to delete reply', severity: 'error' });
|
||||
notify({ message: t('flavorDetail.replyDeleteFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
} from '../../api/mutations/useRatingMutations';
|
||||
import { useUpdateReply } from '../../api/mutations/useReplyMutations';
|
||||
import { useTitle } from '../../hooks/useTitle';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import VerifiedIcon from '@mui/icons-material/Verified';
|
||||
import CommentIcon from '@mui/icons-material/Comment';
|
||||
@@ -51,6 +50,7 @@ import {
|
||||
HeroGallery,
|
||||
ScoreInput,
|
||||
RatingDistribution,
|
||||
BackButton,
|
||||
} from '../../components/ui';
|
||||
import RatingForm from './components/RatingForm';
|
||||
import SimilarFlavorsSection from './components/SimilarFlavorsSection';
|
||||
@@ -119,7 +119,7 @@ const FlavorDetail: React.FC = () => {
|
||||
await updateFlavor.mutateAsync({ id, data: editFlavorData });
|
||||
setIsAdminEditing(false);
|
||||
} catch {
|
||||
notify({ message: 'Admin update failed', severity: 'error' });
|
||||
notify({ message: t('flavorDetail.adminUpdateFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -134,7 +134,7 @@ const FlavorDetail: React.FC = () => {
|
||||
setUploadingImage(true);
|
||||
await updateFlavor.mutateAsync({ id, data: formData, isFormData: true });
|
||||
} catch {
|
||||
notify({ message: 'Image upload failed', severity: 'error' });
|
||||
notify({ message: t('flavorDetail.imageUploadFailed'), severity: 'error' });
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
@@ -153,7 +153,7 @@ const FlavorDetail: React.FC = () => {
|
||||
setReplyInputs({ ...replyInputs, [ratingId]: '' });
|
||||
setExpandedReplies((prev) => ({ ...prev, [ratingId]: true }));
|
||||
} catch {
|
||||
notify({ message: 'Failed to submit reply', severity: 'error' });
|
||||
notify({ message: t('common.replyFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -163,25 +163,25 @@ const FlavorDetail: React.FC = () => {
|
||||
await updateReply.mutateAsync({ replyId, text: editReplyText });
|
||||
setEditingReplyId(null);
|
||||
} catch {
|
||||
notify({ message: 'Failed to update reply', severity: 'error' });
|
||||
notify({ message: t('flavorDetail.replyUpdateFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteReply = async (replyId: number) => {
|
||||
if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return;
|
||||
if (!(await confirm({ message: t('common.confirmDeleteReply'), danger: true }))) return;
|
||||
try {
|
||||
await deleteReplyMutation.mutateAsync(replyId);
|
||||
} catch {
|
||||
notify({ message: 'Failed to delete reply', severity: 'error' });
|
||||
notify({ message: t('flavorDetail.replyDeleteFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRating = async (ratingId: number) => {
|
||||
if (!(await confirm({ message: 'Delete this review?', danger: true }))) return;
|
||||
if (!(await confirm({ message: t('common.confirmDeleteReview'), danger: true }))) return;
|
||||
try {
|
||||
await deleteRatingMutation.mutateAsync(ratingId);
|
||||
} catch {
|
||||
notify({ message: 'Failed to delete review', severity: 'error' });
|
||||
notify({ message: t('flavorDetail.reviewDeleteFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -196,15 +196,7 @@ const FlavorDetail: React.FC = () => {
|
||||
await updateRating.mutateAsync({ id: ratingId, score: editScore, comment: editComment });
|
||||
setEditMode(null);
|
||||
} catch {
|
||||
notify({ message: 'Failed to update review', severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
notify({ message: t('flavorDetail.reviewUpdateFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -225,20 +217,7 @@ const FlavorDetail: React.FC = () => {
|
||||
|
||||
return (
|
||||
<PageShell hero={<HeroBackdrop variant="minimal" />}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{
|
||||
alignSelf: 'flex-start',
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
</Button>
|
||||
<BackButton />
|
||||
|
||||
<Grid container spacing={4}>
|
||||
{/* Left: Product Info Card */}
|
||||
@@ -268,7 +247,7 @@ const FlavorDetail: React.FC = () => {
|
||||
size="small"
|
||||
onClick={() => setIsAdminEditing(true)}
|
||||
>
|
||||
Edit Product (Admin)
|
||||
{t('flavorDetail.editProductAdmin')}
|
||||
</Button>
|
||||
) : (
|
||||
<Stack direction="row" spacing={1}>
|
||||
@@ -279,14 +258,14 @@ const FlavorDetail: React.FC = () => {
|
||||
variant="contained"
|
||||
onClick={handleAdminUpdate}
|
||||
>
|
||||
Save
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
size="small"
|
||||
onClick={() => setIsAdminEditing(false)}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
@@ -296,7 +275,7 @@ const FlavorDetail: React.FC = () => {
|
||||
{isAdminEditing ? (
|
||||
<Stack spacing={2} sx={{ mb: 2 }}>
|
||||
<TextField
|
||||
label="Name"
|
||||
label={t('flavorDetail.nameLabel')}
|
||||
fullWidth
|
||||
size="small"
|
||||
value={editFlavorData.name}
|
||||
@@ -305,7 +284,7 @@ const FlavorDetail: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="Description"
|
||||
label={t('flavorDetail.descriptionLabel')}
|
||||
fullWidth
|
||||
size="small"
|
||||
multiline
|
||||
@@ -316,7 +295,7 @@ const FlavorDetail: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="Shop URL"
|
||||
label={t('flavorDetail.shopUrlLabel')}
|
||||
fullWidth
|
||||
size="small"
|
||||
value={editFlavorData.shop_url}
|
||||
@@ -333,7 +312,9 @@ const FlavorDetail: React.FC = () => {
|
||||
fullWidth
|
||||
disabled={uploadingImage}
|
||||
>
|
||||
{uploadingImage ? 'Uploading...' : 'Upload Custom Image'}
|
||||
{uploadingImage
|
||||
? t('flavorDetail.uploading')
|
||||
: t('flavorDetail.uploadImage')}
|
||||
<input type="file" hidden accept="image/*" onChange={handleImageUpload} />
|
||||
</Button>
|
||||
</Box>
|
||||
@@ -347,7 +328,7 @@ const FlavorDetail: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="In Stock"
|
||||
label={t('flavorDetail.inStock')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
@@ -358,7 +339,7 @@ const FlavorDetail: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Legacy / Limited"
|
||||
label={t('flavorDetail.legacyLimited')}
|
||||
/>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# UX & Design Improvements
|
||||
|
||||
Tracking doc from the frontend/UX audit. Items grouped by priority. Check off as done.
|
||||
|
||||
## 🔴 High impact
|
||||
|
||||
- [ ] **#1 i18n sweep** — app claims EN/DE but ~30+ user-facing strings are English-only.
|
||||
- [x] `App.tsx` 404 page
|
||||
- [x] `CategoryFlavors.tsx` empty state + review caption
|
||||
- [x] `CommunityFeed.tsx` "No comment provided.", reply-failed toast
|
||||
- [x] `FlavorDetail.tsx` admin edit labels, confirm dialogs, error toasts
|
||||
- [x] `Login.tsx` reset/verify flow labels, buttons, messages
|
||||
- [x] `MyReviewCard.tsx` confirm dialogs, error toasts
|
||||
- [x] `PublicProfile.tsx` tabs, empty states, confirm dialogs, error toasts
|
||||
- [ ] **Deferred (admin-only, superuser surface):** `AdminUserDetail.tsx`, `admin/*` (Banners, Tickets, Overview, Config, Jobs, Users), `AdminRatingDetail.tsx`, `AdminReplyDetail.tsx`, `Settings.tsx` theme-list subheaders. Lower UX priority — only the operator sees these.
|
||||
- [x] **#2 Delete dead code** — `FlavorList.tsx` (240 LOC) + `AdminDashboard.tsx` (873 LOC). Zero imports, not in router (replaced by `admin/` folder + `flavors/FlavorDetail`).
|
||||
- [ ] **#3 Add brand/logo** — no app identity anchor in sidebar or mobile appbar.
|
||||
- [ ] **#4 Categories discoverable** — collapsed-by-default accordion hides primary browse axis.
|
||||
|
||||
## 🟡 Medium
|
||||
|
||||
- [ ] **#5 Hall of Fame carousel** — no swipe/auto-advance; arrows far from card on mobile; featured quote is arbitrary (first-with-comment).
|
||||
- [ ] **#6 Admin edit leaks into product card** (`FlavorDetail.tsx`) — move to dialog/drawer.
|
||||
- [ ] **#7 CommunityFeed duplication** — in-page notifications + following duplicate the nav/global menus.
|
||||
- [ ] **#8 Reply Enter-to-submit on multiline** — surprising; blocks newlines.
|
||||
- [ ] **#9 Login password UX** — no show/hide toggle, no strength hint, no inline validation.
|
||||
|
||||
## 🟢 Design-system cleanup
|
||||
|
||||
- [ ] **#10 Unify grid breakpoints** — 3 different responsive card grids across browse surfaces.
|
||||
- [x] **#11 Extract `<BackButton>`** — repeated with drifting `sx` on 5+ pages.
|
||||
- [ ] **#12 Font-weight scale** — 600/700/800/900/'bold' scattered; enforce via theme.
|
||||
- [ ] **#13 Avatar fallback casing** — inconsistent `.toUpperCase()`.
|
||||
- [ ] **#14 Image placeholder** — `CommunityFeed`/`MyReviewCard` raw `<img>` can show broken-image icon; reuse `FlavorCard` pattern.
|
||||
- [ ] **#15 Standardize empty states** — `EmptyState` component vs inline `<Typography>`.
|
||||
|
||||
## ✅ Good (keep)
|
||||
ScoreInput a11y (radiogroup, arrow/number keys). Glass design system + skeletons. Optimistic mutations. Lazy routes. Status/Rating badges.
|
||||
Reference in new issue
Block a user