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="*"
|
path="*"
|
||||||
element={
|
element={
|
||||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
<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 }}>
|
<Button component={Link} to="/" sx={{ mt: 2 }}>
|
||||||
Back to Home
|
{t('common.backToHome')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</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 { FlavorCard, type FlavorCardProps, type FlavorCardData } from './FlavorCard';
|
||||||
export { FormCard, type FormCardProps } from './FormCard';
|
export { FormCard, type FormCardProps } from './FormCard';
|
||||||
export { EmptyState, type EmptyStateProps } from './EmptyState';
|
export { EmptyState, type EmptyStateProps } from './EmptyState';
|
||||||
|
export { BackButton, type BackButtonProps } from './BackButton';
|
||||||
export { default as ScoreInput, type ScoreInputProps } from './ScoreInput';
|
export { default as ScoreInput, type ScoreInputProps } from './ScoreInput';
|
||||||
export { default as HeroGallery, type HeroGalleryProps } from './HeroGallery';
|
export { default as HeroGallery, type HeroGalleryProps } from './HeroGallery';
|
||||||
export { default as RatingDistribution, type RatingDistributionProps } from './RatingDistribution';
|
export { default as RatingDistribution, type RatingDistributionProps } from './RatingDistribution';
|
||||||
@@ -129,6 +129,9 @@ const resources = {
|
|||||||
previousFlavor: 'Previous flavor',
|
previousFlavor: 'Previous flavor',
|
||||||
nextFlavor: 'Next flavor',
|
nextFlavor: 'Next flavor',
|
||||||
goToFlavor: 'Go to flavor {{n}}',
|
goToFlavor: 'Go to flavor {{n}}',
|
||||||
|
noCategoryFlavors: 'No flavors here yet',
|
||||||
|
noCategoryFlavorsSub: 'Check back soon.',
|
||||||
|
flavorsFallback: 'Flavors',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
search: 'Search...',
|
search: 'Search...',
|
||||||
@@ -147,6 +150,13 @@ const resources = {
|
|||||||
save: 'Save',
|
save: 'Save',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
all: 'All',
|
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: {
|
status: {
|
||||||
limited: 'Limited',
|
limited: 'Limited',
|
||||||
@@ -174,6 +184,38 @@ const resources = {
|
|||||||
noImage: 'No Image',
|
noImage: 'No Image',
|
||||||
ratingDistribution: 'Rating distribution',
|
ratingDistribution: 'Rating distribution',
|
||||||
flavorNotFound: 'Flavor not found',
|
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: {
|
auth: {
|
||||||
loginTitle: 'Login',
|
loginTitle: 'Login',
|
||||||
@@ -188,6 +230,24 @@ const resources = {
|
|||||||
signupSuccess: 'Signup successful! Please enter the verification code sent to your email.',
|
signupSuccess: 'Signup successful! Please enter the verification code sent to your email.',
|
||||||
forgotPassword: 'Forgot Password?',
|
forgotPassword: 'Forgot Password?',
|
||||||
backToAuth: 'Back to Signup/Login',
|
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: {
|
community: {
|
||||||
title: 'Community Activity',
|
title: 'Community Activity',
|
||||||
@@ -451,6 +511,9 @@ const resources = {
|
|||||||
previousFlavor: 'Vorherige Sorte',
|
previousFlavor: 'Vorherige Sorte',
|
||||||
nextFlavor: 'Nächste Sorte',
|
nextFlavor: 'Nächste Sorte',
|
||||||
goToFlavor: 'Zu Sorte {{n}}',
|
goToFlavor: 'Zu Sorte {{n}}',
|
||||||
|
noCategoryFlavors: 'Hier gibt es noch keine Sorten',
|
||||||
|
noCategoryFlavorsSub: 'Schau bald wieder vorbei.',
|
||||||
|
flavorsFallback: 'Sorten',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
search: 'Suchen...',
|
search: 'Suchen...',
|
||||||
@@ -469,6 +532,13 @@ const resources = {
|
|||||||
save: 'Speichern',
|
save: 'Speichern',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
all: 'Alle',
|
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: {
|
status: {
|
||||||
limited: 'Limitiert',
|
limited: 'Limitiert',
|
||||||
@@ -496,6 +566,38 @@ const resources = {
|
|||||||
noImage: 'Kein Bild',
|
noImage: 'Kein Bild',
|
||||||
ratingDistribution: 'Bewertungsverteilung',
|
ratingDistribution: 'Bewertungsverteilung',
|
||||||
flavorNotFound: 'Sorte nicht gefunden',
|
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: {
|
auth: {
|
||||||
loginTitle: 'Anmelden',
|
loginTitle: 'Anmelden',
|
||||||
@@ -513,6 +615,24 @@ const resources = {
|
|||||||
'Registrierung erfolgreich! Bitte gib den Verifizierungscode ein, der an deine E-Mail gesendet wurde.',
|
'Registrierung erfolgreich! Bitte gib den Verifizierungscode ein, der an deine E-Mail gesendet wurde.',
|
||||||
forgotPassword: 'Passwort vergessen?',
|
forgotPassword: 'Passwort vergessen?',
|
||||||
backToAuth: 'Zurück zur Anmeldung',
|
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: {
|
community: {
|
||||||
title: 'Community-Aktivität',
|
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 WhatshotIcon from '@mui/icons-material/Whatshot';
|
||||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||||
import PeopleIcon from '@mui/icons-material/People';
|
import PeopleIcon from '@mui/icons-material/People';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
useCommunityFeed,
|
useCommunityFeed,
|
||||||
@@ -41,7 +40,14 @@ import { formatDate } from '../utils/date';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import MentionTextField from '../components/MentionTextField';
|
import MentionTextField from '../components/MentionTextField';
|
||||||
import RatingBadge from '../components/RatingBadge';
|
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';
|
import { useToast } from '../hooks/useToast';
|
||||||
|
|
||||||
const SidebarCard: React.FC<{
|
const SidebarCard: React.FC<{
|
||||||
@@ -96,11 +102,6 @@ const CommunityFeed: React.FC = () => {
|
|||||||
user.username.toLowerCase().includes(followingSearch.toLowerCase()),
|
user.username.toLowerCase().includes(followingSearch.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleGoBack = () => {
|
|
||||||
if (window.history.length > 1) navigate(-1);
|
|
||||||
else navigate('/');
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthed) navigate('/login');
|
if (!isAuthed) navigate('/login');
|
||||||
}, [isAuthed, navigate]);
|
}, [isAuthed, navigate]);
|
||||||
@@ -126,7 +127,7 @@ const CommunityFeed: React.FC = () => {
|
|||||||
await createReply.mutateAsync({ ratingId, text });
|
await createReply.mutateAsync({ ratingId, text });
|
||||||
setReplyInputs((prev) => ({ ...prev, [ratingId]: '' }));
|
setReplyInputs((prev) => ({ ...prev, [ratingId]: '' }));
|
||||||
} catch {
|
} 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 (
|
return (
|
||||||
<PageShell hero={<HeroBackdrop variant="minimal" />}>
|
<PageShell hero={<HeroBackdrop variant="minimal" />}>
|
||||||
<Button
|
<BackButton />
|
||||||
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>
|
|
||||||
|
|
||||||
<SectionHeader title={t('community.title')} subtitle={t('community.subtitle')} />
|
<SectionHeader title={t('community.title')} subtitle={t('community.subtitle')} />
|
||||||
|
|
||||||
@@ -249,7 +243,7 @@ const CommunityFeed: React.FC = () => {
|
|||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
sx={{ fontStyle: 'italic' }}
|
sx={{ fontStyle: 'italic' }}
|
||||||
>
|
>
|
||||||
No comment provided.
|
{t('common.noComment')}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</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 React, { useState } from 'react';
|
||||||
import { Typography, TextField, Button, Tab, Tabs, Alert, Stack, Box } from '@mui/material';
|
import { Typography, TextField, Button, Tab, Tabs, Alert, Stack, Box } from '@mui/material';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import api from '../lib/api';
|
import api from '../lib/api';
|
||||||
import { useTitle } from '../hooks/useTitle';
|
import { useTitle } from '../hooks/useTitle';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PageShell, GlassCard, FormCard } from '../components/ui';
|
import { PageShell, GlassCard, FormCard, BackButton } from '../components/ui';
|
||||||
|
|
||||||
interface ApiError {
|
interface ApiError {
|
||||||
response?: { data?: { error?: string; non_field_errors?: string[] } };
|
response?: { data?: { error?: string; non_field_errors?: string[] } };
|
||||||
@@ -63,7 +62,10 @@ const Login: React.FC = () => {
|
|||||||
setIsVerifying(true);
|
setIsVerifying(true);
|
||||||
setMessage({ type: 'success', text: t('auth.signupSuccess') });
|
setMessage({ type: 'success', text: t('auth.signupSuccess') });
|
||||||
} catch (err) {
|
} 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) {
|
} catch (err) {
|
||||||
setMessage({
|
setMessage({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
text: readError(err).response?.data?.error || 'Verification failed',
|
text: readError(err).response?.data?.error || t('auth.verifyFailed'),
|
||||||
});
|
});
|
||||||
setShowResend(true);
|
setShowResend(true);
|
||||||
}
|
}
|
||||||
@@ -94,7 +96,7 @@ const Login: React.FC = () => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage({
|
setMessage({
|
||||||
type: 'error',
|
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 {
|
try {
|
||||||
await api.post('users/request_password_reset/', { email });
|
await api.post('users/request_password_reset/', { email });
|
||||||
setResetStep(2);
|
setResetStep(2);
|
||||||
setMessage({ type: 'success', text: 'If an account exists, a reset code has been sent.' });
|
setMessage({ type: 'success', text: t('auth.resetRequested') });
|
||||||
} catch {
|
} 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,
|
code: verificationCode,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
setMessage({ type: 'success', text: 'Password reset successful! You can now login.' });
|
setMessage({ type: 'success', text: t('auth.resetSuccess') });
|
||||||
setIsResetting(false);
|
setIsResetting(false);
|
||||||
setResetStep(1);
|
setResetStep(1);
|
||||||
setTab(0);
|
setTab(0);
|
||||||
setPassword('');
|
setPassword('');
|
||||||
setVerificationCode('');
|
setVerificationCode('');
|
||||||
} catch (err) {
|
} 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')}
|
{t('auth.backToAuth')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" type="submit">
|
<Button variant="contained" type="submit">
|
||||||
Verify
|
{t('auth.verifyButton')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -170,7 +175,7 @@ const Login: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Verification Code"
|
label={t('auth.verificationCodeLabel')}
|
||||||
value={verificationCode}
|
value={verificationCode}
|
||||||
onChange={(e) => setVerificationCode(e.target.value)}
|
onChange={(e) => setVerificationCode(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -182,7 +187,7 @@ const Login: React.FC = () => {
|
|||||||
if (isResetting) {
|
if (isResetting) {
|
||||||
return (
|
return (
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Reset Password"
|
title={t('auth.resetTitle')}
|
||||||
onSubmit={resetStep === 1 ? handleRequestReset : handleCompleteReset}
|
onSubmit={resetStep === 1 ? handleRequestReset : handleCompleteReset}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
@@ -193,17 +198,17 @@ const Login: React.FC = () => {
|
|||||||
setMessage(null);
|
setMessage(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Back to Login
|
{t('auth.backToLogin')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" type="submit">
|
<Button variant="contained" type="submit">
|
||||||
{resetStep === 1 ? 'Send Reset Code' : 'Reset Password'}
|
{resetStep === 1 ? t('auth.sendResetCode') : t('auth.resetButton')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Email"
|
label={t('auth.emailLabel')}
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
disabled={resetStep === 2}
|
disabled={resetStep === 2}
|
||||||
@@ -212,13 +217,13 @@ const Login: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Reset Code"
|
label={t('auth.resetCodeLabel')}
|
||||||
value={verificationCode}
|
value={verificationCode}
|
||||||
onChange={(e) => setVerificationCode(e.target.value)}
|
onChange={(e) => setVerificationCode(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="New Password"
|
label={t('auth.newPasswordLabel')}
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
@@ -254,13 +259,13 @@ const Login: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Username"
|
label={t('auth.usernameLabel')}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Password"
|
label={t('auth.passwordLabel')}
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
@@ -281,20 +286,20 @@ const Login: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Username"
|
label={t('auth.usernameLabel')}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Email"
|
label={t('auth.emailLabel')}
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Password"
|
label={t('auth.passwordLabel')}
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
@@ -307,15 +312,7 @@ const Login: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<Button
|
<BackButton to="/" />
|
||||||
variant="outlined"
|
|
||||||
component={Link}
|
|
||||||
to="/"
|
|
||||||
startIcon={<ArrowBackIcon />}
|
|
||||||
sx={{ alignSelf: 'flex-start', textTransform: 'none', borderRadius: 2 }}
|
|
||||||
>
|
|
||||||
Back to Home
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Box sx={{ maxWidth: 480, width: '100%', mx: 'auto' }}>
|
<Box sx={{ maxWidth: 480, width: '100%', mx: 'auto' }}>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ const PublicProfile: React.FC = () => {
|
|||||||
});
|
});
|
||||||
refetchFollowing();
|
refetchFollowing();
|
||||||
} catch {
|
} 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();
|
refetchFollowing();
|
||||||
} catch {
|
} 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 });
|
await addComment.mutateAsync({ userId: data.id, text: newComment });
|
||||||
setNewComment('');
|
setNewComment('');
|
||||||
} catch {
|
} catch {
|
||||||
notify({ message: 'Failed to add comment', severity: 'error' });
|
notify({ message: t('profile.commentAddFailed'), severity: 'error' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteComment = async (commentId: number) => {
|
const handleDeleteComment = async (commentId: number) => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
if (!(await confirm({ message: 'Delete this comment?', danger: true }))) return;
|
if (!(await confirm({ message: t('common.confirmDeleteComment'), danger: true }))) return;
|
||||||
try {
|
try {
|
||||||
await deleteCommentMutation.mutateAsync({ userId: data.id, commentId });
|
await deleteCommentMutation.mutateAsync({ userId: data.id, commentId });
|
||||||
} catch {
|
} 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)
|
if (!data)
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<Typography>Profile not found.</Typography>
|
<Typography>{t('profile.notFound')}</Typography>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -344,7 +344,7 @@ const PublicProfile: React.FC = () => {
|
|||||||
{!data.avatar && data.username.charAt(0).toUpperCase()}
|
{!data.avatar && data.username.charAt(0).toUpperCase()}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
{data.ratings.length > 50 && (
|
{data.ratings.length > 50 && (
|
||||||
<Tooltip title="Master Collector">
|
<Tooltip title={t('profile.masterCollector')}>
|
||||||
<VerifiedIcon
|
<VerifiedIcon
|
||||||
color="primary"
|
color="primary"
|
||||||
sx={{
|
sx={{
|
||||||
@@ -482,10 +482,10 @@ const PublicProfile: React.FC = () => {
|
|||||||
'& .Mui-selected': { color: 'primary.main' },
|
'& .Mui-selected': { color: 'primary.main' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tab label="Ratings" />
|
<Tab label={t('profile.tabRatings')} />
|
||||||
<Tab label="Guestbook" />
|
<Tab label={t('profile.tabGuestbook')} />
|
||||||
<Tab label="Followers" />
|
<Tab label={t('profile.tabFollowers')} />
|
||||||
<Tab label="Following" />
|
<Tab label={t('profile.tabFollowing')} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
|
|
||||||
@@ -515,7 +515,7 @@ const PublicProfile: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{currentRatings.length === 0 ? (
|
{currentRatings.length === 0 ? (
|
||||||
<EmptyState title="No ratings in this category" />
|
<EmptyState title={t('profile.noRatingsCategory')} />
|
||||||
) : (
|
) : (
|
||||||
<Stack spacing={10}>
|
<Stack spacing={10}>
|
||||||
{tiers.map((tier) => {
|
{tiers.map((tier) => {
|
||||||
@@ -676,7 +676,7 @@ const PublicProfile: React.FC = () => {
|
|||||||
{currentUser && (
|
{currentUser && (
|
||||||
<Box sx={{ mb: 5 }}>
|
<Box sx={{ mb: 5 }}>
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Leave a message"
|
title={t('profile.leaveMessage')}
|
||||||
onSubmit={handleAddComment}
|
onSubmit={handleAddComment}
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
@@ -685,7 +685,7 @@ const PublicProfile: React.FC = () => {
|
|||||||
endIcon={<SendIcon />}
|
endIcon={<SendIcon />}
|
||||||
sx={{ borderRadius: 2, px: 5, fontWeight: '900', textTransform: 'none' }}
|
sx={{ borderRadius: 2, px: 5, fontWeight: '900', textTransform: 'none' }}
|
||||||
>
|
>
|
||||||
Send Message
|
{t('support.reply')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -693,7 +693,7 @@ const PublicProfile: React.FC = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Say something about their taste..."
|
placeholder={t('profile.guestbookPlaceholder')}
|
||||||
value={newComment}
|
value={newComment}
|
||||||
onChange={(e) => setNewComment(e.target.value)}
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -704,8 +704,8 @@ const PublicProfile: React.FC = () => {
|
|||||||
<Stack spacing={2.5}>
|
<Stack spacing={2.5}>
|
||||||
{data.comments.length === 0 ? (
|
{data.comments.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No entries in the guestbook yet."
|
title={t('profile.guestbookEmpty')}
|
||||||
subtitle="Be the first to leave a message!"
|
subtitle={t('profile.guestbookEmptySub')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
data.comments.map((comment) => (
|
data.comments.map((comment) => (
|
||||||
@@ -786,7 +786,7 @@ const PublicProfile: React.FC = () => {
|
|||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{data.followers.length === 0 ? (
|
{data.followers.length === 0 ? (
|
||||||
<Grid size={12}>
|
<Grid size={12}>
|
||||||
<EmptyState title="No followers yet." />
|
<EmptyState title={t('profile.noFollowers')} />
|
||||||
</Grid>
|
</Grid>
|
||||||
) : (
|
) : (
|
||||||
data.followers.map(renderUserCard)
|
data.followers.map(renderUserCard)
|
||||||
@@ -798,7 +798,7 @@ const PublicProfile: React.FC = () => {
|
|||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{data.following.length === 0 ? (
|
{data.following.length === 0 ? (
|
||||||
<Grid size={12}>
|
<Grid size={12}>
|
||||||
<EmptyState title="Not following anyone yet." />
|
<EmptyState title={t('profile.noFollowing')} />
|
||||||
</Grid>
|
</Grid>
|
||||||
) : (
|
) : (
|
||||||
data.following.map(renderUserCard)
|
data.following.map(renderUserCard)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Box, CircularProgress, Button } from '@mui/material';
|
import { Box, CircularProgress } from '@mui/material';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
|
||||||
import { useTitle } from '../../hooks/useTitle';
|
import { useTitle } from '../../hooks/useTitle';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useCategoryFlavors } from '../../api/queries/useCategoryFlavors';
|
import { useCategoryFlavors } from '../../api/queries/useCategoryFlavors';
|
||||||
@@ -11,24 +10,16 @@ import {
|
|||||||
SectionHeader,
|
SectionHeader,
|
||||||
FlavorCard,
|
FlavorCard,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
|
BackButton,
|
||||||
} from '../../components/ui';
|
} from '../../components/ui';
|
||||||
|
|
||||||
const CategoryFlavors: React.FC = () => {
|
const CategoryFlavors: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const navigate = useNavigate();
|
|
||||||
const { data: flavors = [], isLoading: loading } = useCategoryFlavors(slug);
|
const { data: flavors = [], isLoading: loading } = useCategoryFlavors(slug);
|
||||||
const categoryName = flavors[0]?.category_name || '';
|
const categoryName = flavors[0]?.category_name || '';
|
||||||
|
|
||||||
const handleGoBack = () => {
|
useTitle(categoryName || t('home.flavorsFallback'));
|
||||||
if (window.history.length > 1) {
|
|
||||||
navigate(-1);
|
|
||||||
} else {
|
|
||||||
navigate('/');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useTitle(categoryName || 'Flavors');
|
|
||||||
|
|
||||||
if (loading)
|
if (loading)
|
||||||
return (
|
return (
|
||||||
@@ -41,19 +32,15 @@ const CategoryFlavors: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell hero={<HeroBackdrop variant="mesh" />}>
|
<PageShell hero={<HeroBackdrop variant="mesh" />}>
|
||||||
<Button
|
<BackButton />
|
||||||
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>
|
|
||||||
|
|
||||||
<SectionHeader title={categoryName || 'Flavors'} />
|
<SectionHeader title={categoryName || t('home.flavorsFallback')} />
|
||||||
|
|
||||||
{flavors.length === 0 ? (
|
{flavors.length === 0 ? (
|
||||||
<EmptyState title="No flavors here yet" subtitle="Check back soon." />
|
<EmptyState
|
||||||
|
title={t('home.noCategoryFlavors')}
|
||||||
|
subtitle={t('home.noCategoryFlavorsSub')}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -71,7 +58,7 @@ const CategoryFlavors: React.FC = () => {
|
|||||||
key={flavor.id}
|
key={flavor.id}
|
||||||
flavor={flavor}
|
flavor={flavor}
|
||||||
showCategory={false}
|
showCategory={false}
|
||||||
caption={`${flavor.ratings.length} reviews`}
|
caption={`${flavor.ratings.length} ${t('common.reviews')}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -73,16 +73,16 @@ const MyReviewCard: React.FC<MyReviewCardProps> = ({ rating, currentUsername })
|
|||||||
await updateRating.mutateAsync({ id: rating.id, score: editScore, comment: editComment });
|
await updateRating.mutateAsync({ id: rating.id, score: editScore, comment: editComment });
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
} catch {
|
} catch {
|
||||||
notify({ message: 'Failed to update review', severity: 'error' });
|
notify({ message: t('flavorDetail.reviewUpdateFailed'), severity: 'error' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!(await confirm({ message: 'Delete this review?', danger: true }))) return;
|
if (!(await confirm({ message: t('common.confirmDeleteReview'), danger: true }))) return;
|
||||||
try {
|
try {
|
||||||
await deleteRating.mutateAsync(rating.id);
|
await deleteRating.mutateAsync(rating.id);
|
||||||
} catch {
|
} 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('');
|
setReplyInput('');
|
||||||
setExpanded(true);
|
setExpanded(true);
|
||||||
} catch {
|
} catch {
|
||||||
notify({ message: 'Failed to send reply', severity: 'error' });
|
notify({ message: t('common.replyFailed'), severity: 'error' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteReply = async (replyId: number) => {
|
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 {
|
try {
|
||||||
await deleteReply.mutateAsync(replyId);
|
await deleteReply.mutateAsync(replyId);
|
||||||
} catch {
|
} 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';
|
} from '../../api/mutations/useRatingMutations';
|
||||||
import { useUpdateReply } from '../../api/mutations/useReplyMutations';
|
import { useUpdateReply } from '../../api/mutations/useReplyMutations';
|
||||||
import { useTitle } from '../../hooks/useTitle';
|
import { useTitle } from '../../hooks/useTitle';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
|
||||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||||
import VerifiedIcon from '@mui/icons-material/Verified';
|
import VerifiedIcon from '@mui/icons-material/Verified';
|
||||||
import CommentIcon from '@mui/icons-material/Comment';
|
import CommentIcon from '@mui/icons-material/Comment';
|
||||||
@@ -51,6 +50,7 @@ import {
|
|||||||
HeroGallery,
|
HeroGallery,
|
||||||
ScoreInput,
|
ScoreInput,
|
||||||
RatingDistribution,
|
RatingDistribution,
|
||||||
|
BackButton,
|
||||||
} from '../../components/ui';
|
} from '../../components/ui';
|
||||||
import RatingForm from './components/RatingForm';
|
import RatingForm from './components/RatingForm';
|
||||||
import SimilarFlavorsSection from './components/SimilarFlavorsSection';
|
import SimilarFlavorsSection from './components/SimilarFlavorsSection';
|
||||||
@@ -119,7 +119,7 @@ const FlavorDetail: React.FC = () => {
|
|||||||
await updateFlavor.mutateAsync({ id, data: editFlavorData });
|
await updateFlavor.mutateAsync({ id, data: editFlavorData });
|
||||||
setIsAdminEditing(false);
|
setIsAdminEditing(false);
|
||||||
} catch {
|
} 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);
|
setUploadingImage(true);
|
||||||
await updateFlavor.mutateAsync({ id, data: formData, isFormData: true });
|
await updateFlavor.mutateAsync({ id, data: formData, isFormData: true });
|
||||||
} catch {
|
} catch {
|
||||||
notify({ message: 'Image upload failed', severity: 'error' });
|
notify({ message: t('flavorDetail.imageUploadFailed'), severity: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingImage(false);
|
setUploadingImage(false);
|
||||||
}
|
}
|
||||||
@@ -153,7 +153,7 @@ const FlavorDetail: React.FC = () => {
|
|||||||
setReplyInputs({ ...replyInputs, [ratingId]: '' });
|
setReplyInputs({ ...replyInputs, [ratingId]: '' });
|
||||||
setExpandedReplies((prev) => ({ ...prev, [ratingId]: true }));
|
setExpandedReplies((prev) => ({ ...prev, [ratingId]: true }));
|
||||||
} catch {
|
} 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 });
|
await updateReply.mutateAsync({ replyId, text: editReplyText });
|
||||||
setEditingReplyId(null);
|
setEditingReplyId(null);
|
||||||
} catch {
|
} catch {
|
||||||
notify({ message: 'Failed to update reply', severity: 'error' });
|
notify({ message: t('flavorDetail.replyUpdateFailed'), severity: 'error' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteReply = async (replyId: number) => {
|
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 {
|
try {
|
||||||
await deleteReplyMutation.mutateAsync(replyId);
|
await deleteReplyMutation.mutateAsync(replyId);
|
||||||
} catch {
|
} catch {
|
||||||
notify({ message: 'Failed to delete reply', severity: 'error' });
|
notify({ message: t('flavorDetail.replyDeleteFailed'), severity: 'error' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteRating = async (ratingId: number) => {
|
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 {
|
try {
|
||||||
await deleteRatingMutation.mutateAsync(ratingId);
|
await deleteRatingMutation.mutateAsync(ratingId);
|
||||||
} catch {
|
} 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 });
|
await updateRating.mutateAsync({ id: ratingId, score: editScore, comment: editComment });
|
||||||
setEditMode(null);
|
setEditMode(null);
|
||||||
} catch {
|
} catch {
|
||||||
notify({ message: 'Failed to update review', severity: 'error' });
|
notify({ message: t('flavorDetail.reviewUpdateFailed'), severity: 'error' });
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGoBack = () => {
|
|
||||||
if (window.history.length > 1) {
|
|
||||||
navigate(-1);
|
|
||||||
} else {
|
|
||||||
navigate('/');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -225,20 +217,7 @@ const FlavorDetail: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell hero={<HeroBackdrop variant="minimal" />}>
|
<PageShell hero={<HeroBackdrop variant="minimal" />}>
|
||||||
<Button
|
<BackButton />
|
||||||
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>
|
|
||||||
|
|
||||||
<Grid container spacing={4}>
|
<Grid container spacing={4}>
|
||||||
{/* Left: Product Info Card */}
|
{/* Left: Product Info Card */}
|
||||||
@@ -268,7 +247,7 @@ const FlavorDetail: React.FC = () => {
|
|||||||
size="small"
|
size="small"
|
||||||
onClick={() => setIsAdminEditing(true)}
|
onClick={() => setIsAdminEditing(true)}
|
||||||
>
|
>
|
||||||
Edit Product (Admin)
|
{t('flavorDetail.editProductAdmin')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction="row" spacing={1}>
|
||||||
@@ -279,14 +258,14 @@ const FlavorDetail: React.FC = () => {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleAdminUpdate}
|
onClick={handleAdminUpdate}
|
||||||
>
|
>
|
||||||
Save
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<CancelIcon />}
|
startIcon={<CancelIcon />}
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => setIsAdminEditing(false)}
|
onClick={() => setIsAdminEditing(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
@@ -296,7 +275,7 @@ const FlavorDetail: React.FC = () => {
|
|||||||
{isAdminEditing ? (
|
{isAdminEditing ? (
|
||||||
<Stack spacing={2} sx={{ mb: 2 }}>
|
<Stack spacing={2} sx={{ mb: 2 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Name"
|
label={t('flavorDetail.nameLabel')}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
value={editFlavorData.name}
|
value={editFlavorData.name}
|
||||||
@@ -305,7 +284,7 @@ const FlavorDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Description"
|
label={t('flavorDetail.descriptionLabel')}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
multiline
|
multiline
|
||||||
@@ -316,7 +295,7 @@ const FlavorDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Shop URL"
|
label={t('flavorDetail.shopUrlLabel')}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
value={editFlavorData.shop_url}
|
value={editFlavorData.shop_url}
|
||||||
@@ -333,7 +312,9 @@ const FlavorDetail: React.FC = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
disabled={uploadingImage}
|
disabled={uploadingImage}
|
||||||
>
|
>
|
||||||
{uploadingImage ? 'Uploading...' : 'Upload Custom Image'}
|
{uploadingImage
|
||||||
|
? t('flavorDetail.uploading')
|
||||||
|
: t('flavorDetail.uploadImage')}
|
||||||
<input type="file" hidden accept="image/*" onChange={handleImageUpload} />
|
<input type="file" hidden accept="image/*" onChange={handleImageUpload} />
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -347,7 +328,7 @@ const FlavorDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label="In Stock"
|
label={t('flavorDetail.inStock')}
|
||||||
/>
|
/>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
@@ -358,7 +339,7 @@ const FlavorDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label="Legacy / Limited"
|
label={t('flavorDetail.legacyLimited')}
|
||||||
/>
|
/>
|
||||||
<Divider sx={{ my: 1 }} />
|
<Divider sx={{ my: 1 }} />
|
||||||
</Stack>
|
</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