translation and back button

This commit is contained in:
nvrl committed 2026-06-16 13:55:24 +02:00
1 parent 3e6d08ee16
commit fccac10e64
13 files changed
+317 -1254

No files matched your search

+2 -2
View File
@@ -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>
}
+58
View File
@@ -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;
+1
View File
@@ -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';
+120
View File
@@ -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',
-873
View File
@@ -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;
+11 -17
View File
@@ -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>
-240
View File
@@ -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;
+29 -32
View File
@@ -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}>
+19 -19
View File
@@ -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' });
}
};
+22 -41
View File
@@ -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>
+38
View File
@@ -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.