admin translations and brand

This commit is contained in:
nvrl committed 2026-06-16 15:32:04 +02:00
1 parent 97a17e516d
commit 12b973caac
46 files changed
+795 -408

No files matched your search

+5
View File
@@ -23,6 +23,7 @@ import { useAuth } from './hooks/useAuth';
import { useTheme } from './hooks/useTheme'; import { useTheme } from './hooks/useTheme';
import { useDrawerAnchor } from './hooks/useDrawerAnchor'; import { useDrawerAnchor } from './hooks/useDrawerAnchor';
import { NavSidebar, NAV_SIDEBAR_WIDTH } from './components/layout/NavSidebar'; import { NavSidebar, NAV_SIDEBAR_WIDTH } from './components/layout/NavSidebar';
import { BrandMark } from './components/layout/BrandMark';
import { useCategories } from './api/queries/useCategories'; import { useCategories } from './api/queries/useCategories';
import { NotificationMenu } from './components/layout/NotificationMenu'; import { NotificationMenu } from './components/layout/NotificationMenu';
import { RequireSuperuser } from './components/auth/RequireSuperuser'; import { RequireSuperuser } from './components/auth/RequireSuperuser';
@@ -114,6 +115,10 @@ const App: React.FC = () => {
<GlassAppBar> <GlassAppBar>
{mobileAnchor === 'left' && hamburger} {mobileAnchor === 'left' && hamburger}
<Box sx={{ mr: 1, display: 'flex', alignItems: 'center' }}>
<BrandMark compact />
</Box>
<GlobalSearch /> <GlobalSearch />
<Box sx={{ display: 'flex', alignItems: 'center' }}> <Box sx={{ display: 'flex', alignItems: 'center' }}>
+1 -1
View File
@@ -111,7 +111,7 @@ export const GlobalSearch = ({ compact = false }: GlobalSearchProps) => {
}} }}
/> />
<Box> <Box>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}> <Typography variant="body2" sx={{ fontWeight: 700 }}>
{option.name} {option.name}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
@@ -91,7 +91,7 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
return ( return (
<Box key={schema.key} sx={{ mb: 3 }}> <Box key={schema.key} sx={{ mb: 3 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}> <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}> <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{schema.label} {schema.label}
</Typography> </Typography>
{schema.description && ( {schema.description && (
@@ -143,7 +143,7 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 'bold' }}>Banner Settings: {banner?.name}</DialogTitle> <DialogTitle sx={{ fontWeight: 700 }}>Banner Settings: {banner?.name}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
{error && ( {error && (
<Alert severity="error" sx={{ mb: 3 }}> <Alert severity="error" sx={{ mb: 3 }}>
+4 -4
View File
@@ -56,7 +56,7 @@ const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme
}, },
}} }}
> >
<DialogTitle sx={{ fontWeight: 'bold', fontSize: '1.5rem', pt: 3 }}> <DialogTitle sx={{ fontWeight: 700, fontSize: '1.5rem', pt: 3 }}>
{t('cookieBanner.title')} 🍪 {t('cookieBanner.title')} 🍪
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
@@ -76,7 +76,7 @@ const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
sx={{ fontWeight: 'bold', display: 'block', mb: 1, textTransform: 'uppercase' }} sx={{ fontWeight: 700, display: 'block', mb: 1, textTransform: 'uppercase' }}
> >
Technical Details: Technical Details:
</Typography> </Typography>
@@ -94,7 +94,7 @@ const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme
variant="outlined" variant="outlined"
color="primary" color="primary"
onClick={handleToggleTheme} onClick={handleToggleTheme}
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', m: '0 !important' }} sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 700, m: '0 !important' }}
> >
{currentTheme === 'holy_light' {currentTheme === 'holy_light'
? t('cookieBanner.switchToDark') ? t('cookieBanner.switchToDark')
@@ -108,7 +108,7 @@ const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
textTransform: 'none', textTransform: 'none',
fontWeight: 'bold', fontWeight: 700,
px: 4, px: 4,
m: '0 !important', m: '0 !important',
}} }}
+1 -1
View File
@@ -30,7 +30,7 @@ const Footer: React.FC = () => {
mb: 2, mb: 2,
}} }}
> >
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}> <Typography variant="body2" color="text.secondary" sx={{ fontWeight: 700 }}>
© {year} Holy Flavors Archive © {year} Holy Flavors Archive
</Typography> </Typography>
+1 -1
View File
@@ -194,7 +194,7 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary={user.username} primary={user.username}
primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold' }} primaryTypographyProps={{ variant: 'body2', fontWeight: 700 }}
/> />
</MenuItem> </MenuItem>
))} ))}
+1 -1
View File
@@ -24,7 +24,7 @@ const RichText: React.FC<RichTextProps> = ({ text }) => {
style={{ style={{
color: theme.palette.primary.main, color: theme.palette.primary.main,
textDecoration: 'none', textDecoration: 'none',
fontWeight: 'bold', fontWeight: 700,
backgroundColor: alpha(theme.palette.primary.main, 0.1), backgroundColor: alpha(theme.palette.primary.main, 0.1),
padding: '0 4px', padding: '0 4px',
borderRadius: '4px', borderRadius: '4px',
@@ -0,0 +1,65 @@
import { Box, Typography } from '@mui/material';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export interface BrandMarkProps {
/** Fired on click — e.g. close the mobile drawer. */
onNavigate?: () => void;
/** Icon-only (no wordmark) — for tight surfaces like the mobile app bar. */
compact?: boolean;
}
/**
* App identity anchor: a gradient "H" monogram + wordmark, linking home. Gives the
* sidebar and mobile app bar a consistent brand presence (UX audit #3).
*/
export const BrandMark = ({ onNavigate, compact = false }: BrandMarkProps) => {
const { t } = useTranslation();
return (
<Box
component={Link}
to="/"
onClick={onNavigate}
aria-label={t('brand.name')}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.25,
minWidth: 0,
color: 'inherit',
textDecoration: 'none',
}}
>
<Box
sx={{
width: 36,
height: 36,
flexShrink: 0,
borderRadius: 1.5,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: (theme) => theme.tokens.accent.gradient,
boxShadow: (theme) => theme.tokens.elevation.sm,
}}
>
<Typography
component="span"
sx={{ fontWeight: 900, fontSize: '1.15rem', lineHeight: 1, color: '#fff' }}
>
H
</Typography>
</Box>
{!compact && (
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 800, lineHeight: 1.1, letterSpacing: '-0.01em' }} noWrap>
{t('brand.name')}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1 }} noWrap>
{t('brand.tagline')}
</Typography>
</Box>
)}
</Box>
);
};
@@ -32,6 +32,7 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../../hooks/useAuth'; import { useAuth } from '../../hooks/useAuth';
import { GlobalSearch } from '../../app/GlobalSearch'; import { GlobalSearch } from '../../app/GlobalSearch';
import { NotificationMenu } from './NotificationMenu'; import { NotificationMenu } from './NotificationMenu';
import { BrandMark } from './BrandMark';
export const NAV_SIDEBAR_WIDTH = 280; export const NAV_SIDEBAR_WIDTH = 280;
@@ -73,6 +74,16 @@ export const NavSidebar = ({
}} }}
role="presentation" role="presentation"
> >
<Box
sx={{
p: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<BrandMark onNavigate={close} />
</Box>
{user ? ( {user ? (
<Box <Box
component={Link} component={Link}
@@ -88,7 +88,7 @@ export const NotificationMenu = ({
borderColor: 'divider', borderColor: 'divider',
}} }}
> >
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}> <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('community.notifications')} {t('community.notifications')}
</Typography> </Typography>
{unreadCount > 0 && ( {unreadCount > 0 && (
+1 -1
View File
@@ -18,7 +18,7 @@ const baseSx: SxProps<Theme> = {
alignSelf: 'flex-start', alignSelf: 'flex-start',
borderRadius: 2, borderRadius: 2,
textTransform: 'none', textTransform: 'none',
fontWeight: 'bold', fontWeight: 700,
color: 'text.secondary', color: 'text.secondary',
}; };
+42
View File
@@ -0,0 +1,42 @@
import type { ReactNode } from 'react';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
export interface FlavorGridProps {
children: ReactNode;
/**
* `false` (default) — roomy auto-fill grid for primary browse surfaces (1 col on
* phones, ~240px tracks above). `true` — dense fixed 2→6 column grid for secondary
* strips (newest, similar).
*/
compact?: boolean;
sx?: SxProps<Theme>;
}
/**
* The single source of truth for flavor-card grids. Replaces the three divergent
* inline `display: grid` definitions that had drifted across the browse surfaces
* (CategoryFlavors, MainPage search/newest, SimilarFlavors).
*/
export const FlavorGrid = ({ children, compact = false, sx }: FlavorGridProps) => (
<Box
sx={{
display: 'grid',
gridTemplateColumns: compact
? {
xs: 'repeat(2, minmax(0, 1fr))',
sm: 'repeat(3, minmax(0, 1fr))',
md: 'repeat(4, minmax(0, 1fr))',
lg: 'repeat(5, minmax(0, 1fr))',
xl: 'repeat(6, minmax(0, 1fr))',
}
: { xs: '1fr', sm: 'repeat(auto-fill, minmax(240px, 1fr))' },
gap: compact ? 2 : 3,
width: '100%',
minWidth: 0,
...sx,
}}
>
{children}
</Box>
);
@@ -0,0 +1,72 @@
import { useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { alpha, useTheme } from '@mui/material/styles';
import type { SxProps, Theme } from '@mui/material/styles';
export interface FlavorThumbProps {
src?: string | null;
/** Used for alt text and the initial-letter fallback. */
name: string;
/** Pixel size, or a responsive map (passed straight to sx width/height). */
size?: number | Record<string, number>;
/** sx border-radius (theme spacing units). */
radius?: number;
sx?: SxProps<Theme>;
}
/**
* Square flavor thumbnail with a graceful fallback: when there is no image — or the
* image fails to load — it renders the flavor's initial instead of the browser's
* broken-image glyph. Mirrors the placeholder treatment in `FlavorCard`.
*/
export const FlavorThumb = ({ src, name, size = 64, radius = 1.5, sx }: FlavorThumbProps) => {
const theme = useTheme();
const [failed, setFailed] = useState(false);
const showImg = !!src && !failed;
return (
<Box
sx={{
width: size,
height: size,
flexShrink: 0,
borderRadius: radius,
overflow: 'hidden',
bgcolor: 'background.default',
border: '1px solid',
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...sx,
}}
>
{showImg ? (
<Box
component="img"
src={src || undefined}
alt={name}
loading="lazy"
onError={() => setFailed(true)}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<Box
sx={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: alpha(theme.palette.primary.main, 0.04),
}}
>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
{name.charAt(0).toUpperCase()}
</Typography>
</Box>
)}
</Box>
);
};
+2
View File
@@ -5,6 +5,8 @@ export { SectionHeader, type SectionHeaderProps } from './SectionHeader';
export { HeroBackdrop, type HeroBackdropProps } from './HeroBackdrop'; export { HeroBackdrop, type HeroBackdropProps } from './HeroBackdrop';
export { ReviewRow, type ReviewRowProps, type ReviewRowData } from './ReviewRow'; 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 { FlavorThumb, type FlavorThumbProps } from './FlavorThumb';
export { FlavorGrid, type FlavorGridProps } from './FlavorGrid';
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 { BackButton, type BackButtonProps } from './BackButton';
+1 -1
View File
@@ -50,7 +50,7 @@ export const ConfirmProvider = ({ children }: { children: ReactNode }) => {
fullWidth fullWidth
PaperProps={{ sx: { borderRadius: 3 } }} PaperProps={{ sx: { borderRadius: 3 } }}
> >
{opts?.title && <DialogTitle sx={{ fontWeight: 'bold' }}>{opts.title}</DialogTitle>} {opts?.title && <DialogTitle sx={{ fontWeight: 700 }}>{opts.title}</DialogTitle>}
<DialogContent> <DialogContent>
<DialogContentText>{opts?.message}</DialogContentText> <DialogContentText>{opts?.message}</DialogContentText>
</DialogContent> </DialogContent>
+287 -4
View File
@@ -17,6 +17,10 @@ const resources = {
following: 'Following', following: 'Following',
categories: 'Categories', categories: 'Categories',
}, },
brand: {
name: 'Holy Archive',
tagline: 'Flavor Rater',
},
categories: { categories: {
energy: 'Energy', energy: 'Energy',
hydration: 'Hydration', hydration: 'Hydration',
@@ -60,6 +64,9 @@ const resources = {
subtitle: 'Manage your account and preferences', subtitle: 'Manage your account and preferences',
appearanceTitle: 'Appearance & Language', appearanceTitle: 'Appearance & Language',
themeLabel: 'Theme', themeLabel: 'Theme',
themeGroupHoly: 'Holy Archive',
themeGroupLight: 'Light Themes',
themeGroupDark: 'Dark Themes',
langLabel: 'Language', langLabel: 'Language',
bannerLabel: 'Interactive Banner', bannerLabel: 'Interactive Banner',
bannerDefault: 'Site Default', bannerDefault: 'Site Default',
@@ -157,6 +164,10 @@ const resources = {
confirmDeleteReply: 'Delete this reply?', confirmDeleteReply: 'Delete this reply?',
confirmDeleteComment: 'Delete this comment?', confirmDeleteComment: 'Delete this comment?',
notFoundTitle: '404 — Page Not Found', notFoundTitle: '404 — Page Not Found',
home: 'Home',
processing: 'Processing...',
active: 'Active',
inactive: 'Inactive',
}, },
status: { status: {
limited: 'Limited', limited: 'Limited',
@@ -316,6 +327,135 @@ const resources = {
activate: 'Activate Account', activate: 'Activate Account',
dangerZone: 'Danger Zone', dangerZone: 'Danger Zone',
deleteUser: 'Delete User Permanently', deleteUser: 'Delete User Permanently',
sections: {
overview: 'Overview',
users: 'Users',
flavors: 'Flavors',
banners: 'Banners',
jobs: 'Jobs',
tickets: 'Tickets',
config: 'Config',
},
updateFailed: 'Update failed',
deleteFailed: 'Delete failed',
actionFailed: 'Action failed',
retryLoading: 'Retry Loading',
userNum: 'User #{{n}}',
ratingNum: 'Rating #{{n}}',
replyNum: 'Reply #{{n}}',
noBanners: 'No banner models found',
noBannersSub: 'No banners registered in the database.',
bannerSetDefault: 'Banner set as default',
bannerActivateFailed: 'Failed to activate banner',
bannerToggleFailed: 'Failed to toggle visibility',
bannerHidden: 'Hidden from Users',
bannerGlobalDefault: 'Global Default',
bannerSlug: 'Slug',
editSettings: 'Edit Settings',
disableForUsers: 'Disable for Users',
enableForUsers: 'Enable for Users',
setAsDefault: 'Set as Default',
configSaved: 'Configuration saved',
configSaveFailed: 'Failed to save configuration',
configLiveHint: 'Live values applied without restart. Form posts the full config on save.',
siteName: 'Site Name',
siteNameHint: 'Displayed in the browser tab and various headers.',
maintenanceMode: 'Maintenance Mode',
maintenanceModeHint: 'Block non-admin users from interacting with the site.',
allowSignups: 'Allow New Signups',
allowSignupsHint: 'When off, the registration endpoint rejects new accounts.',
requireVerification: 'Require Email Verification',
requireVerificationHint: 'New accounts must confirm their email before logging in.',
saving: 'Saving…',
saveChanges: 'Save changes',
discard: 'Discard',
jobsTitle: 'Background Worker Jobs',
jobsFilter: 'Filter jobs…',
jobQueued: 'Job queued',
jobTriggerFailed: 'Failed to trigger job',
jobScheduleFailed: 'Failed to update schedule',
jobStatusQueued: 'QUEUED',
jobLastRun: 'Last Run',
jobNextRun: 'Next Run',
jobNever: 'Never',
jobQueuedShort: 'Queued…',
jobDisabled: 'Disabled',
jobSchedule: 'Schedule',
jobLastOutput: 'Last Run Output',
jobLastError: 'Last Error',
jobTriggerNow: 'Trigger Now',
schedHourly: 'Hourly',
sched6h: 'Every 6 Hours',
sched12h: 'Every 12 Hours',
schedDaily: 'Daily',
schedWeekly: 'Weekly',
testEmailFailed: 'Failed to send',
envInfo: 'Environment Info (.env)',
apiStatus: 'API Status',
frontendUrl: 'Frontend URL',
allowedHosts: 'Allowed Hosts',
envHint:
'These values are read from your server environment or .env file. To change them, you must update the file and restart the backend container.',
colId: 'ID',
colUser: 'User',
colEmail: 'Email',
colStatus: 'Status',
colJoined: 'Joined',
colIp: 'IP History',
colActions: 'Actions',
roleAdmin: 'Admin',
roleSuperuser: 'Superuser',
ticketMessageFailed: 'Failed to send message',
ticketStatusSet: 'Status set to {{status}}',
ticketStatusFailed: 'Failed to update status',
ticketDeleteConfirm: 'Delete this ticket?',
ticketDeleted: 'Ticket deleted',
ticketDeleteFailed: 'Failed to delete ticket',
viewProfile: 'View Profile',
supportTag: 'Support',
userNotFound: 'User not found',
userMgmtTitle: 'User Management',
joinedLabel: 'Joined',
lastLoginLabel: 'Last Login',
tabContent: 'Content',
tabSecurity: 'Security & IP',
tabDanger: 'Danger Zone',
ratingsDiscussion: 'User Ratings & Discussion',
scoreLabel: 'Score',
noCommentShort: 'No comment',
noRatingsYet: 'No ratings yet.',
noIpHistory: 'No IP history available.',
deleteUserTitle: 'Delete user',
deleteUserConfirm: 'PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.',
deleteForever: 'Delete forever',
deleteUserWarning:
'Deleting this user will permanently remove their profile, all their ratings, replies, and IP history. This action is irreversible.',
deleteUserNote:
'Proceed only if the user has been reviewed for policy violations or requested deletion.',
deleteRatingConfirm: 'Delete this rating?',
deleteReplyConfirm: 'Delete this reply?',
ratingMgmtTitle: 'Rating Management',
replyMgmtTitle: 'Reply Management',
ratingUpdated: 'Rating updated!',
replyUpdated: 'Reply updated!',
deleteRatingRepliesConfirm: 'Delete this rating and all replies?',
userLabel: 'User: {{user}}',
replyByLabel: 'Reply by {{user}}',
manageFlavors: 'Manage Flavors',
mergeFlavors: 'Merge Flavors',
mergeFlavorsDesc:
'Combine two flavors into one. Ratings from the source flavor will be moved to the target flavor. If a user rated both, the more detailed review is kept.',
flavorToRemove: 'Flavor to Remove (Source)',
flavorToRemoveHint: 'This flavor will be DELETED',
flavorToKeep: 'Flavor to Keep (Target)',
flavorToKeepHint: 'This flavor will be PRESERVED',
startMerge: 'Start Merge',
confirmMergeTitle: 'Confirm Flavor Merge',
confirmMergeText:
'Are you sure you want to merge "{{remove}}" into "{{keep}}"? This action is irreversible. "{{remove}}" will be deleted, and all its ratings will be transferred.',
confirmMerge: 'Merge Flavors',
mergeSuccess: 'Flavors merged successfully',
errorPrefix: 'Error: {{message}}',
}, },
about: { about: {
title: 'About This Project', title: 'About This Project',
@@ -408,6 +548,10 @@ const resources = {
following: 'Folge ich', following: 'Folge ich',
categories: 'Kategorien', categories: 'Kategorien',
}, },
brand: {
name: 'Holy-Archiv',
tagline: 'Geschmacks-Rating',
},
categories: { categories: {
energy: 'Energy', energy: 'Energy',
hydration: 'Hydration', hydration: 'Hydration',
@@ -451,6 +595,9 @@ const resources = {
subtitle: 'Verwalte dein Konto und deine Einstellungen', subtitle: 'Verwalte dein Konto und deine Einstellungen',
appearanceTitle: 'Erscheinungsbild & Sprache', appearanceTitle: 'Erscheinungsbild & Sprache',
themeLabel: 'Design', themeLabel: 'Design',
themeGroupHoly: 'Holy Archiv',
themeGroupLight: 'Helle Designs',
themeGroupDark: 'Dunkle Designs',
langLabel: 'Sprache', langLabel: 'Sprache',
bannerLabel: 'Interaktiver Banner', bannerLabel: 'Interaktiver Banner',
bannerDefault: 'Seiten-Standard', bannerDefault: 'Seiten-Standard',
@@ -548,6 +695,10 @@ const resources = {
confirmDeleteReply: 'Diese Antwort löschen?', confirmDeleteReply: 'Diese Antwort löschen?',
confirmDeleteComment: 'Diesen Kommentar löschen?', confirmDeleteComment: 'Diesen Kommentar löschen?',
notFoundTitle: '404 — Seite nicht gefunden', notFoundTitle: '404 — Seite nicht gefunden',
home: 'Startseite',
processing: 'Wird verarbeitet...',
active: 'Aktiv',
inactive: 'Inaktiv',
}, },
status: { status: {
limited: 'Limitiert', limited: 'Limitiert',
@@ -704,13 +855,145 @@ const resources = {
sendTestEmail: 'Test-E-Mail senden', sendTestEmail: 'Test-E-Mail senden',
testEmailSuccess: 'Test-E-Mail erfolgreich gesendet!', testEmailSuccess: 'Test-E-Mail erfolgreich gesendet!',
ipHistory: 'Login-IP-Verlauf', ipHistory: 'Login-IP-Verlauf',
manageUser: 'Manage User', manageUser: 'Benutzer verwalten',
manageRating: 'Manage Rating', manageRating: 'Bewertung verwalten',
manageReply: 'Manage Reply', manageReply: 'Antwort verwalten',
deactivate: 'Deactivate Account', deactivate: 'Konto deaktivieren',
activate: 'Konto aktivieren', activate: 'Konto aktivieren',
dangerZone: 'Gefahrenzone', dangerZone: 'Gefahrenzone',
deleteUser: 'Benutzer dauerhaft löschen', deleteUser: 'Benutzer dauerhaft löschen',
sections: {
overview: 'Übersicht',
users: 'Benutzer',
flavors: 'Sorten',
banners: 'Banner',
jobs: 'Jobs',
tickets: 'Tickets',
config: 'Konfiguration',
},
updateFailed: 'Aktualisierung fehlgeschlagen',
deleteFailed: 'Löschen fehlgeschlagen',
actionFailed: 'Aktion fehlgeschlagen',
retryLoading: 'Erneut laden',
userNum: 'Benutzer #{{n}}',
ratingNum: 'Bewertung #{{n}}',
replyNum: 'Antwort #{{n}}',
noBanners: 'Keine Banner-Modelle gefunden',
noBannersSub: 'Keine Banner in der Datenbank registriert.',
bannerSetDefault: 'Banner als Standard festgelegt',
bannerActivateFailed: 'Banner konnte nicht aktiviert werden',
bannerToggleFailed: 'Sichtbarkeit konnte nicht umgeschaltet werden',
bannerHidden: 'Für Nutzer ausgeblendet',
bannerGlobalDefault: 'Globaler Standard',
bannerSlug: 'Slug',
editSettings: 'Einstellungen bearbeiten',
disableForUsers: 'Für Nutzer deaktivieren',
enableForUsers: 'Für Nutzer aktivieren',
setAsDefault: 'Als Standard festlegen',
configSaved: 'Konfiguration gespeichert',
configSaveFailed: 'Konfiguration konnte nicht gespeichert werden',
configLiveHint:
'Live-Werte werden ohne Neustart angewendet. Beim Speichern wird die vollständige Konfiguration gesendet.',
siteName: 'Seitenname',
siteNameHint: 'Wird im Browser-Tab und in verschiedenen Kopfzeilen angezeigt.',
maintenanceMode: 'Wartungsmodus',
maintenanceModeHint: 'Nicht-Admin-Nutzer von der Interaktion mit der Seite ausschließen.',
allowSignups: 'Neue Registrierungen erlauben',
allowSignupsHint: 'Wenn aus, lehnt der Registrierungs-Endpunkt neue Konten ab.',
requireVerification: 'E-Mail-Verifizierung erforderlich',
requireVerificationHint:
'Neue Konten müssen ihre E-Mail bestätigen, bevor sie sich anmelden können.',
saving: 'Wird gespeichert…',
saveChanges: 'Änderungen speichern',
discard: 'Verwerfen',
jobsTitle: 'Hintergrund-Worker-Jobs',
jobsFilter: 'Jobs filtern…',
jobQueued: 'Job eingereiht',
jobTriggerFailed: 'Job konnte nicht ausgelöst werden',
jobScheduleFailed: 'Zeitplan konnte nicht aktualisiert werden',
jobStatusQueued: 'EINGEREIHT',
jobLastRun: 'Letzter Lauf',
jobNextRun: 'Nächster Lauf',
jobNever: 'Nie',
jobQueuedShort: 'Eingereiht…',
jobDisabled: 'Deaktiviert',
jobSchedule: 'Zeitplan',
jobLastOutput: 'Letzte Ausgabe',
jobLastError: 'Letzter Fehler',
jobTriggerNow: 'Jetzt auslösen',
schedHourly: 'Stündlich',
sched6h: 'Alle 6 Stunden',
sched12h: 'Alle 12 Stunden',
schedDaily: 'Täglich',
schedWeekly: 'Wöchentlich',
testEmailFailed: 'Senden fehlgeschlagen',
envInfo: 'Umgebungsinfo (.env)',
apiStatus: 'API-Status',
frontendUrl: 'Frontend-URL',
allowedHosts: 'Erlaubte Hosts',
envHint:
'Diese Werte werden aus der Server-Umgebung oder der .env-Datei gelesen. Um sie zu ändern, musst du die Datei aktualisieren und den Backend-Container neu starten.',
colId: 'ID',
colUser: 'Benutzer',
colEmail: 'E-Mail',
colStatus: 'Status',
colJoined: 'Beigetreten',
colIp: 'IP-Verlauf',
colActions: 'Aktionen',
roleAdmin: 'Admin',
roleSuperuser: 'Superuser',
ticketMessageFailed: 'Nachricht konnte nicht gesendet werden',
ticketStatusSet: 'Status auf {{status}} gesetzt',
ticketStatusFailed: 'Status konnte nicht aktualisiert werden',
ticketDeleteConfirm: 'Dieses Ticket löschen?',
ticketDeleted: 'Ticket gelöscht',
ticketDeleteFailed: 'Ticket konnte nicht gelöscht werden',
viewProfile: 'Profil ansehen',
supportTag: 'Support',
userNotFound: 'Benutzer nicht gefunden',
userMgmtTitle: 'Benutzerverwaltung',
joinedLabel: 'Beigetreten',
lastLoginLabel: 'Letzte Anmeldung',
tabContent: 'Inhalt',
tabSecurity: 'Sicherheit & IP',
tabDanger: 'Gefahrenzone',
ratingsDiscussion: 'Bewertungen & Diskussion des Benutzers',
scoreLabel: 'Bewertung',
noCommentShort: 'Kein Kommentar',
noRatingsYet: 'Noch keine Bewertungen.',
noIpHistory: 'Kein IP-Verlauf verfügbar.',
deleteUserTitle: 'Benutzer löschen',
deleteUserConfirm:
'DIESEN BENUTZER UND ALLE DATEN DAUERHAFT LÖSCHEN? Dies kann nicht rückgängig gemacht werden.',
deleteForever: 'Endgültig löschen',
deleteUserWarning:
'Das Löschen dieses Benutzers entfernt dauerhaft sein Profil, alle Bewertungen, Antworten und den IP-Verlauf. Diese Aktion ist unwiderruflich.',
deleteUserNote:
'Fahre nur fort, wenn der Benutzer auf Richtlinienverstöße geprüft wurde oder die Löschung beantragt hat.',
deleteRatingConfirm: 'Diese Bewertung löschen?',
deleteReplyConfirm: 'Diese Antwort löschen?',
ratingMgmtTitle: 'Bewertungsverwaltung',
replyMgmtTitle: 'Antwortverwaltung',
ratingUpdated: 'Bewertung aktualisiert!',
replyUpdated: 'Antwort aktualisiert!',
deleteRatingRepliesConfirm: 'Diese Bewertung und alle Antworten löschen?',
userLabel: 'Benutzer: {{user}}',
replyByLabel: 'Antwort von {{user}}',
manageFlavors: 'Sorten verwalten',
mergeFlavors: 'Sorten zusammenführen',
mergeFlavorsDesc:
'Kombiniere zwei Sorten zu einer. Bewertungen der Quellsorte werden zur Zielsorte verschoben. Hat ein Nutzer beide bewertet, wird die ausführlichere Bewertung behalten.',
flavorToRemove: 'Zu entfernende Sorte (Quelle)',
flavorToRemoveHint: 'Diese Sorte wird GELÖSCHT',
flavorToKeep: 'Zu behaltende Sorte (Ziel)',
flavorToKeepHint: 'Diese Sorte wird BEHALTEN',
startMerge: 'Zusammenführen starten',
confirmMergeTitle: 'Sorten-Zusammenführung bestätigen',
confirmMergeText:
'Möchtest du „{{remove}}" wirklich in „{{keep}}" zusammenführen? Diese Aktion ist unwiderruflich. „{{remove}}" wird gelöscht und alle Bewertungen werden übertragen.',
confirmMerge: 'Sorten zusammenführen',
mergeSuccess: 'Sorten erfolgreich zusammengeführt',
errorPrefix: 'Fehler: {{message}}',
}, },
about: { about: {
title: 'Über dieses Projekt', title: 'Über dieses Projekt',
+2 -2
View File
@@ -68,7 +68,7 @@ const About: React.FC = () => {
</Typography> </Typography>
<Box sx={{ mt: 3 }}> <Box sx={{ mt: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}> <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('about.contact')}: {t('about.contact')}:
</Typography> </Typography>
<Typography variant="body2"> <Typography variant="body2">
@@ -85,7 +85,7 @@ const About: React.FC = () => {
</Box> </Box>
<Box sx={{ mt: 3 }}> <Box sx={{ mt: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}> <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('about.responsible')} {t('about.responsible')}
</Typography> </Typography>
<Typography variant="body2"> <Typography variant="body2">
+9 -7
View File
@@ -36,27 +36,29 @@ const AdminRatingDetail: React.FC = () => {
if (error) navigate('/admin-panel'); if (error) navigate('/admin-panel');
}, [error, navigate]); }, [error, navigate]);
useTitle(rating ? `${t('admin.manageRating')}: ${rating.flavor_name}` : 'Rating Management'); useTitle(
rating ? `${t('admin.manageRating')}: ${rating.flavor_name}` : t('admin.ratingMgmtTitle'),
);
const handleUpdate = async (e: React.FormEvent) => { const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!id) return; if (!id) return;
try { try {
await updateRating.mutateAsync({ id: Number(id), score, comment }); await updateRating.mutateAsync({ id: Number(id), score, comment });
notify({ message: 'Rating updated!', severity: 'success' }); notify({ message: t('admin.ratingUpdated'), severity: 'success' });
} catch { } catch {
notify({ message: 'Update failed', severity: 'error' }); notify({ message: t('admin.updateFailed'), severity: 'error' });
} }
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!id) return; if (!id) return;
if (!(await confirm({ message: 'Delete this rating and all replies?', danger: true }))) return; if (!(await confirm({ message: t('admin.deleteRatingRepliesConfirm'), danger: true }))) return;
try { try {
await deleteRating.mutateAsync(Number(id)); await deleteRating.mutateAsync(Number(id));
navigate('/admin-panel'); navigate('/admin-panel');
} catch { } catch {
notify({ message: 'Delete failed', severity: 'error' }); notify({ message: t('admin.deleteFailed'), severity: 'error' });
} }
}; };
@@ -87,7 +89,7 @@ const AdminRatingDetail: React.FC = () => {
<Box sx={{ maxWidth: 560, width: '100%' }}> <Box sx={{ maxWidth: 560, width: '100%' }}>
<FormCard <FormCard
title={rating.flavor_name} title={rating.flavor_name}
subtitle={`User: ${rating.user}`} subtitle={t('admin.userLabel', { user: rating.user })}
onSubmit={handleUpdate} onSubmit={handleUpdate}
actions={ actions={
<Stack direction="row" spacing={2} sx={{ width: '100%' }}> <Stack direction="row" spacing={2} sx={{ width: '100%' }}>
@@ -107,7 +109,7 @@ const AdminRatingDetail: React.FC = () => {
} }
> >
<TextField <TextField
label="Score" label={t('admin.scoreLabel')}
type="number" type="number"
slotProps={{ input: { inputProps: { min: 1, max: 10 } } }} slotProps={{ input: { inputProps: { min: 1, max: 10 } } }}
value={score} value={score}
+6 -6
View File
@@ -33,27 +33,27 @@ const AdminReplyDetail: React.FC = () => {
if (error) navigate('/admin-panel'); if (error) navigate('/admin-panel');
}, [error, navigate]); }, [error, navigate]);
useTitle(reply ? `${t('admin.manageReply')}` : 'Reply Management'); useTitle(reply ? `${t('admin.manageReply')}` : t('admin.replyMgmtTitle'));
const handleUpdate = async (e: React.FormEvent) => { const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!id) return; if (!id) return;
try { try {
await updateReply.mutateAsync({ replyId: Number(id), text }); await updateReply.mutateAsync({ replyId: Number(id), text });
notify({ message: 'Reply updated!', severity: 'success' }); notify({ message: t('admin.replyUpdated'), severity: 'success' });
} catch { } catch {
notify({ message: 'Update failed', severity: 'error' }); notify({ message: t('admin.updateFailed'), severity: 'error' });
} }
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!id) return; if (!id) return;
if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return; if (!(await confirm({ message: t('admin.deleteReplyConfirm'), danger: true }))) return;
try { try {
await deleteReply.mutateAsync(Number(id)); await deleteReply.mutateAsync(Number(id));
navigate('/admin-panel'); navigate('/admin-panel');
} catch { } catch {
notify({ message: 'Delete failed', severity: 'error' }); notify({ message: t('admin.deleteFailed'), severity: 'error' });
} }
}; };
@@ -83,7 +83,7 @@ const AdminReplyDetail: React.FC = () => {
<Box sx={{ maxWidth: 560, width: '100%' }}> <Box sx={{ maxWidth: 560, width: '100%' }}>
<FormCard <FormCard
title={`Reply by ${reply.user}`} title={t('admin.replyByLabel', { user: reply.user })}
onSubmit={handleUpdate} onSubmit={handleUpdate}
actions={ actions={
<Stack direction="row" spacing={2} sx={{ width: '100%' }}> <Stack direction="row" spacing={2} sx={{ width: '100%' }}>
+43 -40
View File
@@ -110,14 +110,14 @@ const AdminUserDetail: React.FC = () => {
if (error) navigate('/admin-panel'); if (error) navigate('/admin-panel');
}, [error, navigate]); }, [error, navigate]);
useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : 'User Management'); useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : t('admin.userMgmtTitle'));
const handleToggleActive = async () => { const handleToggleActive = async () => {
if (!user || !id) return; if (!user || !id) return;
try { try {
await toggleActive.mutateAsync({ id, isActive: user.is_active }); await toggleActive.mutateAsync({ id, isActive: user.is_active });
} catch { } catch {
notify({ message: 'Action failed', severity: 'error' }); notify({ message: t('admin.actionFailed'), severity: 'error' });
} }
}; };
@@ -130,16 +130,16 @@ const AdminUserDetail: React.FC = () => {
}); });
setEditingRatingId(null); setEditingRatingId(null);
} catch { } catch {
notify({ message: 'Update failed', severity: 'error' }); notify({ message: t('admin.updateFailed'), severity: 'error' });
} }
}; };
const handleDeleteRating = async (ratingId: number) => { const handleDeleteRating = async (ratingId: number) => {
if (!(await confirm({ message: 'Delete this rating?', danger: true }))) return; if (!(await confirm({ message: t('admin.deleteRatingConfirm'), danger: true }))) return;
try { try {
await deleteRatingMutation.mutateAsync(ratingId); await deleteRatingMutation.mutateAsync(ratingId);
} catch { } catch {
notify({ message: 'Delete failed', severity: 'error' }); notify({ message: t('admin.deleteFailed'), severity: 'error' });
} }
}; };
@@ -148,25 +148,25 @@ const AdminUserDetail: React.FC = () => {
await updateReplyMutation.mutateAsync({ replyId, text: editReplyText }); await updateReplyMutation.mutateAsync({ replyId, text: editReplyText });
setEditingReplyId(null); setEditingReplyId(null);
} catch { } catch {
notify({ message: 'Update failed', severity: 'error' }); notify({ message: t('admin.updateFailed'), 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('admin.deleteReplyConfirm'), danger: true }))) return;
try { try {
await deleteReplyMutation.mutateAsync(replyId); await deleteReplyMutation.mutateAsync(replyId);
} catch { } catch {
notify({ message: 'Delete failed', severity: 'error' }); notify({ message: t('admin.deleteFailed'), severity: 'error' });
} }
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!id) return; if (!id) return;
const ok = await confirm({ const ok = await confirm({
title: 'Delete user', title: t('admin.deleteUserTitle'),
message: 'PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.', message: t('admin.deleteUserConfirm'),
confirmLabel: 'Delete forever', confirmLabel: t('admin.deleteForever'),
danger: true, danger: true,
}); });
if (!ok) return; if (!ok) return;
@@ -174,7 +174,7 @@ const AdminUserDetail: React.FC = () => {
await deleteUserMutation.mutateAsync(id); await deleteUserMutation.mutateAsync(id);
navigate('/admin-panel'); navigate('/admin-panel');
} catch { } catch {
notify({ message: 'Delete failed', severity: 'error' }); notify({ message: t('admin.deleteFailed'), severity: 'error' });
} }
}; };
@@ -184,7 +184,7 @@ const AdminUserDetail: React.FC = () => {
<CircularProgress /> <CircularProgress />
</Box> </Box>
); );
if (!user) return <Typography>User not found</Typography>; if (!user) return <Typography>{t('admin.userNotFound')}</Typography>;
return ( return (
<PageShell> <PageShell>
@@ -215,7 +215,7 @@ const AdminUserDetail: React.FC = () => {
</Grid> </Grid>
<Grid size={{ xs: 12, sm: 8, md: 9 }}> <Grid size={{ xs: 12, sm: 8, md: 9 }}>
<Box sx={{ textAlign: { xs: 'center', sm: 'left' } }}> <Box sx={{ textAlign: { xs: 'center', sm: 'left' } }}>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}> <Typography variant="h4" sx={{ fontWeight: 700 }}>
{user.username} {user.username}
</Typography> </Typography>
<Typography variant="body1" color="text.secondary"> <Typography variant="body1" color="text.secondary">
@@ -227,7 +227,8 @@ const AdminUserDetail: React.FC = () => {
display="block" display="block"
sx={{ mt: 0.5 }} sx={{ mt: 0.5 }}
> >
Joined: {formatDate(user.date_joined)} • Last Login: {formatDate(user.last_login)} {t('admin.joinedLabel')}: {formatDate(user.date_joined)} •{' '}
{t('admin.lastLoginLabel')}: {formatDate(user.last_login)}
</Typography> </Typography>
<Stack <Stack
direction="row" direction="row"
@@ -235,11 +236,13 @@ const AdminUserDetail: React.FC = () => {
sx={{ mt: 1.5, justifyContent: { xs: 'center', sm: 'flex-start' } }} sx={{ mt: 1.5, justifyContent: { xs: 'center', sm: 'flex-start' } }}
> >
<Chip <Chip
label={user.is_active ? 'Active' : 'Inactive'} label={user.is_active ? t('common.active') : t('common.inactive')}
color={user.is_active ? 'success' : 'default'} color={user.is_active ? 'success' : 'default'}
size="small" size="small"
/> />
{user.is_superuser && <Chip label="Superuser" color="secondary" size="small" />} {user.is_superuser && (
<Chip label={t('admin.roleSuperuser')} color="secondary" size="small" />
)}
</Stack> </Stack>
</Box> </Box>
</Grid> </Grid>
@@ -249,7 +252,7 @@ const AdminUserDetail: React.FC = () => {
variant="contained" variant="contained"
color={user.is_active ? 'warning' : 'success'} color={user.is_active ? 'warning' : 'success'}
onClick={handleToggleActive} onClick={handleToggleActive}
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }} sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 700 }}
> >
{user.is_active ? t('admin.deactivate') : t('admin.activate')} {user.is_active ? t('admin.deactivate') : t('admin.activate')}
</Button> </Button>
@@ -262,14 +265,14 @@ const AdminUserDetail: React.FC = () => {
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} variant="fullWidth"> <Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} variant="fullWidth">
<Tab <Tab
icon={<RateReviewIcon />} icon={<RateReviewIcon />}
label={`Content (${user.ratings.length})`} label={`${t('admin.tabContent')} (${user.ratings.length})`}
sx={{ fontWeight: 'bold' }} sx={{ fontWeight: 700 }}
/> />
<Tab icon={<HistoryIcon />} label="Security & IP" sx={{ fontWeight: 'bold' }} /> <Tab icon={<HistoryIcon />} label={t('admin.tabSecurity')} sx={{ fontWeight: 700 }} />
<Tab <Tab
icon={<SecurityIcon />} icon={<SecurityIcon />}
label="Danger Zone" label={t('admin.tabDanger')}
sx={{ fontWeight: 'bold', color: 'error.main' }} sx={{ fontWeight: 700, color: 'error.main' }}
/> />
</Tabs> </Tabs>
<Divider /> <Divider />
@@ -277,7 +280,7 @@ const AdminUserDetail: React.FC = () => {
{activeTab === 0 && ( {activeTab === 0 && (
<Box> <Box>
<SectionHeader title="User Ratings & Discussion" compact /> <SectionHeader title={t('admin.ratingsDiscussion')} compact />
<Stack spacing={2}> <Stack spacing={2}>
{user.ratings.map((rating) => ( {user.ratings.map((rating) => (
<GlassCard key={rating.id} intensity="subtle"> <GlassCard key={rating.id} intensity="subtle">
@@ -285,7 +288,7 @@ const AdminUserDetail: React.FC = () => {
{editingRatingId === rating.id ? ( {editingRatingId === rating.id ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Stack direction="row" spacing={2} alignItems="center"> <Stack direction="row" spacing={2} alignItems="center">
<Typography variant="body2">Score:</Typography> <Typography variant="body2">{t('admin.scoreLabel')}:</Typography>
<TextField <TextField
type="number" type="number"
size="small" size="small"
@@ -326,7 +329,7 @@ const AdminUserDetail: React.FC = () => {
alignItems="flex-start" alignItems="flex-start"
sx={{ mb: 1 }} sx={{ mb: 1 }}
> >
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}> <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{rating.flavor_name} {rating.flavor_name}
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}> <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
@@ -334,7 +337,7 @@ const AdminUserDetail: React.FC = () => {
label={`${rating.score}/10`} label={`${rating.score}/10`}
size="small" size="small"
color="primary" color="primary"
sx={{ fontWeight: '900' }} sx={{ fontWeight: 900 }}
/> />
<Button <Button
size="small" size="small"
@@ -344,14 +347,14 @@ const AdminUserDetail: React.FC = () => {
setEditComment(rating.comment || ''); setEditComment(rating.comment || '');
}} }}
> >
Edit {t('common.edit')}
</Button> </Button>
<Button <Button
size="small" size="small"
color="error" color="error"
onClick={() => handleDeleteRating(rating.id)} onClick={() => handleDeleteRating(rating.id)}
> >
Delete {t('common.delete')}
</Button> </Button>
</Box> </Box>
</Stack> </Stack>
@@ -366,7 +369,7 @@ const AdminUserDetail: React.FC = () => {
color="text.secondary" color="text.secondary"
sx={{ fontStyle: 'italic' }} sx={{ fontStyle: 'italic' }}
> >
No comment {t('admin.noCommentShort')}
</Typography> </Typography>
)} )}
</Box> </Box>
@@ -408,7 +411,7 @@ const AdminUserDetail: React.FC = () => {
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
fontWeight: 'bold', fontWeight: 700,
color: color:
reply.user === user.username reply.user === user.username
? 'primary.main' ? 'primary.main'
@@ -426,7 +429,7 @@ const AdminUserDetail: React.FC = () => {
setEditReplyText(reply.text); setEditReplyText(reply.text);
}} }}
> >
Edit {t('common.edit')}
</Button> </Button>
<Button <Button
size="small" size="small"
@@ -434,7 +437,7 @@ const AdminUserDetail: React.FC = () => {
sx={{ minWidth: 0, p: 0, fontSize: '0.7rem' }} sx={{ minWidth: 0, p: 0, fontSize: '0.7rem' }}
onClick={() => handleDeleteReply(reply.id)} onClick={() => handleDeleteReply(reply.id)}
> >
Delete {t('common.delete')}
</Button> </Button>
</Box> </Box>
</Stack> </Stack>
@@ -452,7 +455,7 @@ const AdminUserDetail: React.FC = () => {
</CardContent> </CardContent>
</GlassCard> </GlassCard>
))} ))}
{user.ratings.length === 0 && <EmptyState title="No ratings yet." />} {user.ratings.length === 0 && <EmptyState title={t('admin.noRatingsYet')} />}
</Stack> </Stack>
</Box> </Box>
)} )}
@@ -462,7 +465,7 @@ const AdminUserDetail: React.FC = () => {
<Typography <Typography
variant="h6" variant="h6"
gutterBottom gutterBottom
sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }} sx={{ fontWeight: 700, display: 'flex', alignItems: 'center', gap: 1 }}
> >
<SecurityIcon /> {t('admin.ipHistory')} <SecurityIcon /> {t('admin.ipHistory')}
</Typography> </Typography>
@@ -471,12 +474,12 @@ const AdminUserDetail: React.FC = () => {
<ListItem key={idx} divider={idx < user.ips.length - 1}> <ListItem key={idx} divider={idx < user.ips.length - 1}>
<ListItemText <ListItemText
primary={ip} primary={ip}
primaryTypographyProps={{ sx: { fontWeight: 'bold', fontFamily: 'monospace' } }} primaryTypographyProps={{ sx: { fontWeight: 700, fontFamily: 'monospace' } }}
/> />
</ListItem> </ListItem>
))} ))}
{user.ips.length === 0 && ( {user.ips.length === 0 && (
<Typography variant="caption">No IP history available.</Typography> <Typography variant="caption">{t('admin.noIpHistory')}</Typography>
)} )}
</List> </List>
</GlassPaper> </GlassPaper>
@@ -485,7 +488,7 @@ const AdminUserDetail: React.FC = () => {
{activeTab === 2 && ( {activeTab === 2 && (
<FormCard <FormCard
title={t('admin.dangerZone')} title={t('admin.dangerZone')}
subtitle="Deleting this user will permanently remove their profile, all their ratings, replies, and IP history. This action is irreversible." subtitle={t('admin.deleteUserWarning')}
danger danger
asForm={false} asForm={false}
actions={ actions={
@@ -495,14 +498,14 @@ const AdminUserDetail: React.FC = () => {
size="large" size="large"
startIcon={<DeleteIcon />} startIcon={<DeleteIcon />}
onClick={handleDelete} onClick={handleDelete}
sx={{ borderRadius: 2, fontWeight: 'bold', px: 4 }} sx={{ borderRadius: 2, fontWeight: 700, px: 4 }}
> >
{t('admin.deleteUser')} {t('admin.deleteUser')}
</Button> </Button>
} }
> >
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Proceed only if the user has been reviewed for policy violations or requested deletion. {t('admin.deleteUserNote')}
</Typography> </Typography>
</FormCard> </FormCard>
)} )}
+21 -45
View File
@@ -38,6 +38,7 @@ import {
HeroBackdrop, HeroBackdrop,
SectionHeader, SectionHeader,
GlassCard, GlassCard,
FlavorThumb,
EmptyState, EmptyState,
BackButton, BackButton,
} from '../components/ui'; } from '../components/ui';
@@ -60,7 +61,7 @@ const SidebarCard: React.FC<{
}} }}
> >
{icon} {icon}
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}> <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title} {title}
</Typography> </Typography>
</Box> </Box>
@@ -170,7 +171,7 @@ const CommunityFeed: React.FC = () => {
> >
<Typography <Typography
variant="subtitle1" variant="subtitle1"
sx={{ fontWeight: 'bold', lineHeight: 1.2 }} sx={{ fontWeight: 700, lineHeight: 1.2 }}
> >
{rating.user} {rating.user}
</Typography> </Typography>
@@ -193,7 +194,7 @@ const CommunityFeed: React.FC = () => {
variant="outlined" variant="outlined"
color="secondary" color="secondary"
onClick={() => navigate(`/admin-panel/rating/${rating.id}`)} onClick={() => navigate(`/admin-panel/rating/${rating.id}`)}
sx={{ mb: 2, borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }} sx={{ mb: 2, borderRadius: 2, textTransform: 'none', fontWeight: 700 }}
> >
{t('admin.manageRating')} {t('admin.manageRating')}
</Button> </Button>
@@ -208,7 +209,7 @@ const CommunityFeed: React.FC = () => {
to={`/flavor/${rating.flavor}`} to={`/flavor/${rating.flavor}`}
sx={{ sx={{
color: 'primary.main', color: 'primary.main',
fontWeight: 'bold', fontWeight: 700,
textDecoration: 'none', textDecoration: 'none',
}} }}
> >
@@ -233,24 +234,12 @@ const CommunityFeed: React.FC = () => {
)} )}
</Box> </Box>
<Link to={`/flavor/${rating.flavor}`} style={{ flexShrink: 0 }}> <Link to={`/flavor/${rating.flavor}`} style={{ flexShrink: 0 }}>
<Box <FlavorThumb
sx={{ src={rating.flavor_image}
width: 70, name={rating.flavor_name}
height: 70, size={70}
borderRadius: 1, radius={1}
overflow: 'hidden', />
bgcolor: 'background.default',
border: '1px solid',
borderColor: 'divider',
}}
>
<Box
component="img"
src={rating.flavor_image || undefined}
alt={rating.flavor_name}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
</Link> </Link>
</Box> </Box>
@@ -274,7 +263,7 @@ const CommunityFeed: React.FC = () => {
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography <Typography
variant="subtitle2" variant="subtitle2"
sx={{ fontWeight: 'bold', fontSize: '0.8rem' }} sx={{ fontWeight: 700, fontSize: '0.8rem' }}
> >
{reply.user} {reply.user}
</Typography> </Typography>
@@ -355,34 +344,21 @@ const CommunityFeed: React.FC = () => {
<List disablePadding> <List disablePadding>
{topFollowed.map((flavor, idx) => ( {topFollowed.map((flavor, idx) => (
<ListItemButton key={flavor.id} component={Link} to={`/flavor/${flavor.id}`}> <ListItemButton key={flavor.id} component={Link} to={`/flavor/${flavor.id}`}>
<Box sx={{ mr: 2, fontWeight: 'bold', color: 'text.secondary', width: 15 }}> <Box sx={{ mr: 2, fontWeight: 700, color: 'text.secondary', width: 15 }}>
{idx + 1} {idx + 1}
</Box> </Box>
<Box <FlavorThumb
sx={{ src={flavor.image_url}
width: 32, name={flavor.name}
height: 32, size={32}
mr: 2, radius={0.5}
bgcolor: 'background.default', sx={{ mr: 2 }}
border: '1px solid', />
borderColor: 'divider',
borderRadius: 0.5,
overflow: 'hidden',
flexShrink: 0,
}}
>
<Box
component="img"
src={flavor.image_url || undefined}
alt={flavor.name}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
<ListItemText <ListItemText
primary={flavor.name} primary={flavor.name}
primaryTypographyProps={{ primaryTypographyProps={{
variant: 'body2', variant: 'body2',
fontWeight: 'bold', fontWeight: 700,
noWrap: true, noWrap: true,
}} }}
/> />
+1 -1
View File
@@ -63,7 +63,7 @@ const Dashboard: React.FC = () => {
<RecommendationsSection enabled={!!data} /> <RecommendationsSection enabled={!!data} />
<Box> <Box>
<Typography variant="h6" sx={{ fontWeight: '900', mb: 2 }}> <Typography variant="h6" sx={{ fontWeight: 900, mb: 2 }}>
{t('dashboard.myReviews')} {t('dashboard.myReviews')}
</Typography> </Typography>
<MyReviewsTab <MyReviewsTab
+12 -58
View File
@@ -30,6 +30,7 @@ import {
SectionHeader, SectionHeader,
HeroBackdrop, HeroBackdrop,
FlavorCard, FlavorCard,
FlavorGrid,
EmptyState, EmptyState,
} from '../components/ui'; } from '../components/ui';
import MainPageRecommendations from './MainPageRecommendations'; import MainPageRecommendations from './MainPageRecommendations';
@@ -163,39 +164,19 @@ const MainPage: React.FC = () => {
} }
/> />
{searchLoading ? ( {searchLoading ? (
<Box <FlavorGrid>
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(auto-fill, minmax(280px, 1fr))',
lg: 'repeat(auto-fill, minmax(240px, 1fr))',
},
gap: 3,
}}
>
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
<FlavorCardSkeleton key={i} /> <FlavorCardSkeleton key={i} />
))} ))}
</Box> </FlavorGrid>
) : searchResults.length === 0 ? ( ) : searchResults.length === 0 ? (
<EmptyState title={t('home.noSearchResults', { query })} /> <EmptyState title={t('home.noSearchResults', { query })} />
) : ( ) : (
<Box <FlavorGrid>
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(auto-fill, minmax(280px, 1fr))',
lg: 'repeat(auto-fill, minmax(240px, 1fr))',
},
gap: 3,
}}
>
{searchResults.map((flavor) => ( {searchResults.map((flavor) => (
<FlavorCard key={flavor.id} flavor={flavor} /> <FlavorCard key={flavor.id} flavor={flavor} />
))} ))}
</Box> </FlavorGrid>
)} )}
</PageShell> </PageShell>
); );
@@ -423,7 +404,7 @@ const MainPage: React.FC = () => {
label={`${t('common.rank')} #${safeIndex + 1}`} label={`${t('common.rank')} #${safeIndex + 1}`}
color="primary" color="primary"
size="small" size="small"
sx={{ width: 'fit-content', mb: 1, fontWeight: 'bold' }} sx={{ width: 'fit-content', mb: 1, fontWeight: 700 }}
/> />
<Typography <Typography
variant="h3" variant="h3"
@@ -446,7 +427,7 @@ const MainPage: React.FC = () => {
}} }}
> >
<RatingBadge score={currentTop.average_rating || 0} size="large" /> <RatingBadge score={currentTop.average_rating || 0} size="large" />
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}> <Typography variant="body2" color="text.secondary" sx={{ fontWeight: 700 }}>
{currentTop.ratings.length} {t('common.reviews')} {currentTop.ratings.length} {t('common.reviews')}
</Typography> </Typography>
</Box> </Box>
@@ -486,7 +467,7 @@ const MainPage: React.FC = () => {
src={featuredReview.user_avatar || undefined} src={featuredReview.user_avatar || undefined}
sx={{ width: 22, height: 22, fontSize: '0.6rem' }} sx={{ width: 22, height: 22, fontSize: '0.6rem' }}
> >
{!featuredReview.user_avatar && featuredReview.user.charAt(0)} {!featuredReview.user_avatar && featuredReview.user.charAt(0).toUpperCase()}
</Avatar> </Avatar>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
— {featuredReview.user} — {featuredReview.user}
@@ -553,46 +534,19 @@ const MainPage: React.FC = () => {
<Box> <Box>
<SectionHeader title={t('home.newArrivals')} subtitle={t('home.newArrivalsSubtitle')} /> <SectionHeader title={t('home.newArrivals')} subtitle={t('home.newArrivalsSubtitle')} />
{newestLoading ? ( {newestLoading ? (
<Box <FlavorGrid compact>
sx={{
display: 'grid',
gridTemplateColumns: {
xs: 'repeat(2, minmax(0, 1fr))',
sm: 'repeat(3, minmax(0, 1fr))',
md: 'repeat(4, minmax(0, 1fr))',
lg: 'repeat(5, minmax(0, 1fr))',
xl: 'repeat(6, minmax(0, 1fr))',
},
gap: 2,
width: '100%',
}}
>
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<FlavorCardSkeleton key={i} compact /> <FlavorCardSkeleton key={i} compact />
))} ))}
</Box> </FlavorGrid>
) : newestFlavors.length === 0 ? ( ) : newestFlavors.length === 0 ? (
<EmptyState title={t('home.noNewest')} /> <EmptyState title={t('home.noNewest')} />
) : ( ) : (
<Box <FlavorGrid compact>
sx={{
display: 'grid',
gridTemplateColumns: {
xs: 'repeat(2, minmax(0, 1fr))',
sm: 'repeat(3, minmax(0, 1fr))',
md: 'repeat(4, minmax(0, 1fr))',
lg: 'repeat(5, minmax(0, 1fr))',
xl: 'repeat(6, minmax(0, 1fr))',
},
gap: 2,
width: '100%',
minWidth: 0,
}}
>
{newestFlavors.map((flavor) => ( {newestFlavors.map((flavor) => (
<FlavorCard key={flavor.id} flavor={flavor} compact /> <FlavorCard key={flavor.id} flavor={flavor} compact />
))} ))}
</Box> </FlavorGrid>
)} )}
</Box> </Box>
</PageShell> </PageShell>
+1 -1
View File
@@ -81,7 +81,7 @@ const Privacy: React.FC = () => {
] as const ] as const
).map(([titleKey, descKey]) => ( ).map(([titleKey, descKey]) => (
<Box key={titleKey} sx={{ mb: 2 }}> <Box key={titleKey} sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}> <Typography variant="body2" sx={{ fontWeight: 700 }}>
{t(`privacy.${titleKey}`)} {t(`privacy.${titleKey}`)}
</Typography> </Typography>
<Typography variant="body2" sx={{ lineHeight: 1.7 }}> <Typography variant="body2" sx={{ lineHeight: 1.7 }}>
+19 -19
View File
@@ -230,7 +230,7 @@ const PublicProfile: React.FC = () => {
borderColor: 'divider', borderColor: 'divider',
}} }}
> >
{user.username.charAt(0)} {user.username.charAt(0).toUpperCase()}
</Avatar> </Avatar>
<Box sx={{ flex: 1, minWidth: 0 }}> <Box sx={{ flex: 1, minWidth: 0 }}>
<Typography <Typography
@@ -238,7 +238,7 @@ const PublicProfile: React.FC = () => {
to={`/profile/${user.username}`} to={`/profile/${user.username}`}
variant="subtitle1" variant="subtitle1"
sx={{ sx={{
fontWeight: '900', fontWeight: 900,
color: 'text.primary', color: 'text.primary',
textDecoration: 'none', textDecoration: 'none',
'&:hover': { color: 'primary.main' }, '&:hover': { color: 'primary.main' },
@@ -249,7 +249,7 @@ const PublicProfile: React.FC = () => {
> >
{user.username} {user.username}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold' }}> <Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
Community Member Community Member
</Typography> </Typography>
</Box> </Box>
@@ -288,7 +288,7 @@ const PublicProfile: React.FC = () => {
alignSelf: 'flex-start', alignSelf: 'flex-start',
borderRadius: 2, borderRadius: 2,
textTransform: 'none', textTransform: 'none',
fontWeight: 'bold', fontWeight: 700,
color: 'text.secondary', color: 'text.secondary',
}} }}
> >
@@ -367,7 +367,7 @@ const PublicProfile: React.FC = () => {
<Typography <Typography
variant="h2" variant="h2"
sx={{ sx={{
fontWeight: '900', fontWeight: 900,
letterSpacing: -2, letterSpacing: -2,
fontSize: { xs: '2.8rem', sm: '4.2rem' }, fontSize: { xs: '2.8rem', sm: '4.2rem' },
lineHeight: 0.9, lineHeight: 0.9,
@@ -391,13 +391,13 @@ const PublicProfile: React.FC = () => {
].map((stat, i) => ( ].map((stat, i) => (
<React.Fragment key={stat.label}> <React.Fragment key={stat.label}>
<Box sx={{ py: 1.2, width: { xs: 90, sm: 120 }, textAlign: 'center' }}> <Box sx={{ py: 1.2, width: { xs: 90, sm: 120 }, textAlign: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: '900', lineHeight: 1 }}> <Typography variant="h6" sx={{ fontWeight: 900, lineHeight: 1 }}>
{stat.val} {stat.val}
</Typography> </Typography>
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
fontWeight: '900', fontWeight: 900,
textTransform: 'uppercase', textTransform: 'uppercase',
opacity: 0.5, opacity: 0.5,
fontSize: '0.6rem', fontSize: '0.6rem',
@@ -433,7 +433,7 @@ const PublicProfile: React.FC = () => {
sx={{ sx={{
borderRadius: 1, borderRadius: 1,
px: 4, px: 4,
fontWeight: '900', fontWeight: 900,
py: 1.2, py: 1.2,
textTransform: 'none', textTransform: 'none',
fontSize: '1rem', fontSize: '1rem',
@@ -447,7 +447,7 @@ const PublicProfile: React.FC = () => {
variant="outlined" variant="outlined"
color="secondary" color="secondary"
onClick={() => navigate(`/admin-panel/user/${data.id}`)} onClick={() => navigate(`/admin-panel/user/${data.id}`)}
sx={{ borderRadius: 1, px: 2.5, fontWeight: 'bold', textTransform: 'none' }} sx={{ borderRadius: 1, px: 2.5, fontWeight: 700, textTransform: 'none' }}
> >
{t('admin.manageUser')} {t('admin.manageUser')}
</Button> </Button>
@@ -474,7 +474,7 @@ const PublicProfile: React.FC = () => {
variant="fullWidth" variant="fullWidth"
sx={{ sx={{
'& .MuiTab-root': { '& .MuiTab-root': {
fontWeight: '900', fontWeight: 900,
textTransform: 'none', textTransform: 'none',
fontSize: '1rem', fontSize: '1rem',
borderRadius: 1, borderRadius: 1,
@@ -509,7 +509,7 @@ const PublicProfile: React.FC = () => {
onClick={() => setCategoryTab(idx)} onClick={() => setCategoryTab(idx)}
color={categoryTab === idx ? 'primary' : 'default'} color={categoryTab === idx ? 'primary' : 'default'}
variant={categoryTab === idx ? 'filled' : 'outlined'} variant={categoryTab === idx ? 'filled' : 'outlined'}
sx={{ fontWeight: '900', px: 1.5, borderRadius: 1 }} sx={{ fontWeight: 900, px: 1.5, borderRadius: 1 }}
/> />
))} ))}
</Box> </Box>
@@ -542,7 +542,7 @@ const PublicProfile: React.FC = () => {
justifyContent: 'center', justifyContent: 'center',
bgcolor: tier.color, bgcolor: tier.color,
color: 'black', color: 'black',
fontWeight: '900', fontWeight: 900,
fontSize: '2rem', fontSize: '2rem',
borderRadius: 0.5, borderRadius: 0.5,
boxShadow: `0 0 20px ${alpha(tier.color, 0.6)}`, boxShadow: `0 0 20px ${alpha(tier.color, 0.6)}`,
@@ -553,14 +553,14 @@ const PublicProfile: React.FC = () => {
<Box> <Box>
<Typography <Typography
variant="h4" variant="h4"
sx={{ fontWeight: '900', lineHeight: 1.1, letterSpacing: -0.5 }} sx={{ fontWeight: 900, lineHeight: 1.1, letterSpacing: -0.5 }}
> >
{tier.title} {tier.title}
</Typography> </Typography>
<Typography <Typography
variant="subtitle2" variant="subtitle2"
color="text.secondary" color="text.secondary"
sx={{ fontWeight: 'bold', opacity: 0.7 }} sx={{ fontWeight: 700, opacity: 0.7 }}
> >
{items.length} items {items.length} items
</Typography> </Typography>
@@ -622,7 +622,7 @@ const PublicProfile: React.FC = () => {
<Typography <Typography
variant="subtitle2" variant="subtitle2"
sx={{ sx={{
fontWeight: '900', fontWeight: 900,
fontSize: '0.85rem', fontSize: '0.85rem',
lineHeight: 1.2, lineHeight: 1.2,
display: '-webkit-box', display: '-webkit-box',
@@ -652,7 +652,7 @@ const PublicProfile: React.FC = () => {
textTransform: 'none', textTransform: 'none',
fontSize: '0.6rem', fontSize: '0.6rem',
py: 0, py: 0,
fontWeight: 'bold', fontWeight: 700,
}} }}
> >
Manage Manage
@@ -683,7 +683,7 @@ const PublicProfile: React.FC = () => {
variant="contained" variant="contained"
type="submit" type="submit"
endIcon={<SendIcon />} endIcon={<SendIcon />}
sx={{ borderRadius: 2, px: 5, fontWeight: '900', textTransform: 'none' }} sx={{ borderRadius: 2, px: 5, fontWeight: 900, textTransform: 'none' }}
> >
{t('support.reply')} {t('support.reply')}
</Button> </Button>
@@ -742,7 +742,7 @@ const PublicProfile: React.FC = () => {
> >
<Typography <Typography
variant="subtitle1" variant="subtitle1"
sx={{ fontWeight: '900', lineHeight: 1.2 }} sx={{ fontWeight: 900, lineHeight: 1.2 }}
> >
{comment.author_username} {comment.author_username}
</Typography> </Typography>
@@ -750,7 +750,7 @@ const PublicProfile: React.FC = () => {
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
sx={{ fontWeight: 'bold' }} sx={{ fontWeight: 700 }}
> >
{formatDate(comment.created_at)} {formatDate(comment.created_at)}
</Typography> </Typography>
+7 -7
View File
@@ -202,7 +202,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
variant="outlined" variant="outlined"
onClick={handleGoBack} onClick={handleGoBack}
startIcon={<ArrowBackIcon />} startIcon={<ArrowBackIcon />}
sx={{ alignSelf: 'flex-start', borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }} sx={{ alignSelf: 'flex-start', borderRadius: 2, textTransform: 'none', fontWeight: 700 }}
> >
{window.history.length > 1 ? t('common.back') : t('common.backToHome')} {window.history.length > 1 ? t('common.back') : t('common.backToHome')}
</Button> </Button>
@@ -215,7 +215,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
{!avatar && username.charAt(0).toUpperCase()} {!avatar && username.charAt(0).toUpperCase()}
</Avatar> </Avatar>
<Box> <Box>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}> <Typography variant="h4" sx={{ fontWeight: 700 }}>
{username} {username}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
@@ -235,7 +235,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
<Grid size={{ xs: 12, lg: 5 }}> <Grid size={{ xs: 12, lg: 5 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<GlassCard sx={{ p: 3 }}> <GlassCard sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}> <Typography variant="h6" gutterBottom sx={{ fontWeight: 700 }}>
{t('settings.appearanceTitle')} {t('settings.appearanceTitle')}
</Typography> </Typography>
<FormControl fullWidth margin="normal"> <FormControl fullWidth margin="normal">
@@ -245,11 +245,11 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
label={t('settings.themeLabel')} label={t('settings.themeLabel')}
onChange={(e) => onThemeChange(e.target.value as CatppuccinTheme)} onChange={(e) => onThemeChange(e.target.value as CatppuccinTheme)}
> >
<ListSubheader>Holy Archive</ListSubheader> <ListSubheader>{t('settings.themeGroupHoly')}</ListSubheader>
<MenuItem value="holy_light">Holy Light</MenuItem> <MenuItem value="holy_light">Holy Light</MenuItem>
<MenuItem value="holy_dark">Holy Dark</MenuItem> <MenuItem value="holy_dark">Holy Dark</MenuItem>
<ListSubheader>Light Themes</ListSubheader> <ListSubheader>{t('settings.themeGroupLight')}</ListSubheader>
<MenuItem value="latte">Catppuccin Latte</MenuItem> <MenuItem value="latte">Catppuccin Latte</MenuItem>
<MenuItem value="pink_pastel">Pink Pastel</MenuItem> <MenuItem value="pink_pastel">Pink Pastel</MenuItem>
<MenuItem value="mint_pastel">Mint Pastel</MenuItem> <MenuItem value="mint_pastel">Mint Pastel</MenuItem>
@@ -258,7 +258,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
<Divider /> <Divider />
<ListSubheader>Dark Themes</ListSubheader> <ListSubheader>{t('settings.themeGroupDark')}</ListSubheader>
<MenuItem value="mocha">Catppuccin Mocha</MenuItem> <MenuItem value="mocha">Catppuccin Mocha</MenuItem>
<MenuItem value="frappe">Catppuccin Frappé</MenuItem> <MenuItem value="frappe">Catppuccin Frappé</MenuItem>
<MenuItem value="macchiato">Catppuccin Macchiato</MenuItem> <MenuItem value="macchiato">Catppuccin Macchiato</MenuItem>
@@ -336,7 +336,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
{/* Avatar: NOT a FormCard — file input lives outside any form */} {/* Avatar: NOT a FormCard — file input lives outside any form */}
<GlassCard sx={{ p: 3 }}> <GlassCard sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}> <Typography variant="h6" gutterBottom sx={{ fontWeight: 700 }}>
{t('settings.avatarTitle')} {t('settings.avatarTitle')}
</Typography> </Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, my: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 3, my: 2 }}>
+4 -7
View File
@@ -214,7 +214,7 @@ const Support: React.FC = () => {
/> />
)} )}
<Box> <Box>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}> <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{ticket.subject} {ticket.subject}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
@@ -228,14 +228,14 @@ const Support: React.FC = () => {
label={t('support.unread')} label={t('support.unread')}
color="primary" color="primary"
size="small" size="small"
sx={{ fontWeight: 'bold', height: 20, fontSize: '0.6rem' }} sx={{ fontWeight: 700, height: 20, fontSize: '0.6rem' }}
/> />
)} )}
<Chip <Chip
label={t(`support.${ticket.status}`)} label={t(`support.${ticket.status}`)}
color={getChipColor(ticket.status)} color={getChipColor(ticket.status)}
size="small" size="small"
sx={{ fontWeight: 'bold' }} sx={{ fontWeight: 700 }}
/> />
</Stack> </Stack>
</Box> </Box>
@@ -276,10 +276,7 @@ const Support: React.FC = () => {
boxShadow: msg.is_admin ? 2 : 0, boxShadow: msg.is_admin ? 2 : 0,
}} }}
> >
<Typography <Typography variant="caption" sx={{ fontWeight: 700, display: 'block' }}>
variant="caption"
sx={{ fontWeight: 'bold', display: 'block' }}
>
{msg.username} {msg.is_admin && '(Support)'} {msg.username} {msg.is_admin && '(Support)'}
</Typography> </Typography>
<Typography variant="body2" sx={{ my: 0.5 }}> <Typography variant="body2" sx={{ my: 0.5 }}>
+3 -3
View File
@@ -58,7 +58,7 @@ const VerifyEmail: React.FC = () => {
{status === 'loading' && ( {status === 'loading' && (
<Box> <Box>
<CircularProgress size={60} sx={{ mb: 4 }} /> <CircularProgress size={60} sx={{ mb: 4 }} />
<Typography variant="h5" sx={{ fontWeight: 'bold' }}> <Typography variant="h5" sx={{ fontWeight: 700 }}>
Verifying your account... Verifying your account...
</Typography> </Typography>
<Typography color="text.secondary" sx={{ mt: 1 }}> <Typography color="text.secondary" sx={{ mt: 1 }}>
@@ -70,7 +70,7 @@ const VerifyEmail: React.FC = () => {
{status === 'success' && ( {status === 'success' && (
<Box> <Box>
<CheckCircleOutlineIcon color="success" sx={{ fontSize: 80, mb: 2 }} /> <CheckCircleOutlineIcon color="success" sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 2 }}> <Typography variant="h4" sx={{ fontWeight: 700, mb: 2 }}>
{t('auth.verifySuccess')} {t('auth.verifySuccess')}
</Typography> </Typography>
<Typography color="text.secondary" sx={{ mb: 4 }}> <Typography color="text.secondary" sx={{ mb: 4 }}>
@@ -91,7 +91,7 @@ const VerifyEmail: React.FC = () => {
{status === 'error' && ( {status === 'error' && (
<Box> <Box>
<ErrorOutlineIcon color="error" sx={{ fontSize: 80, mb: 2 }} /> <ErrorOutlineIcon color="error" sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 2 }}> <Typography variant="h4" sx={{ fontWeight: 700, mb: 2 }}>
Verification Failed Verification Failed
</Typography> </Typography>
<Alert severity="error" sx={{ mb: 4, textAlign: 'left' }}> <Alert severity="error" sx={{ mb: 4, textAlign: 'left' }}>
+14 -14
View File
@@ -46,10 +46,10 @@ const AdminBanners = () => {
const handleActivateBanner = async (id: number) => { const handleActivateBanner = async (id: number) => {
try { try {
await activateBannerMutation.mutateAsync(id); await activateBannerMutation.mutateAsync(id);
notify({ message: 'Banner set as default', severity: 'success' }); notify({ message: t('admin.bannerSetDefault'), severity: 'success' });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
notify({ message: 'Failed to activate banner', severity: 'error' }); notify({ message: t('admin.bannerActivateFailed'), severity: 'error' });
} }
}; };
@@ -59,7 +59,7 @@ const AdminBanners = () => {
} catch (err) { } catch (err) {
const error = err as { response?: { data?: { error?: string } } }; const error = err as { response?: { data?: { error?: string } } };
notify({ notify({
message: error.response?.data?.error || 'Failed to toggle visibility', message: error.response?.data?.error || t('admin.bannerToggleFailed'),
severity: 'error', severity: 'error',
}); });
} }
@@ -75,20 +75,20 @@ const AdminBanners = () => {
return ( return (
<Box> <Box>
<Typography variant="h5" sx={{ fontWeight: 'bold', mb: 3 }}> <Typography variant="h5" sx={{ fontWeight: 700, mb: 3 }}>
{t('admin.banners')} ({banners.length}) {t('admin.banners')} ({banners.length})
</Typography> </Typography>
{banners.length === 0 ? ( {banners.length === 0 ? (
<EmptyState <EmptyState
title="No banner models found" title={t('admin.noBanners')}
subtitle="No banners registered in the database." subtitle={t('admin.noBannersSub')}
action={ action={
<Button <Button
variant="outlined" variant="outlined"
onClick={() => qc.invalidateQueries({ queryKey: ['banners'] })} onClick={() => qc.invalidateQueries({ queryKey: ['banners'] })}
> >
Retry Loading {t('admin.retryLoading')}
</Button> </Button>
} }
/> />
@@ -108,20 +108,20 @@ const AdminBanners = () => {
mb: 2, mb: 2,
}} }}
> >
<Typography variant="h6" sx={{ fontWeight: 'bold' }}> <Typography variant="h6" sx={{ fontWeight: 700 }}>
{banner.name} (Slug: {banner.slug}) {banner.name} ({t('admin.bannerSlug')}: {banner.slug})
</Typography> </Typography>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
{!banner.is_enabled && ( {!banner.is_enabled && (
<Chip <Chip
label="Hidden from Users" label={t('admin.bannerHidden')}
color="warning" color="warning"
size="small" size="small"
variant="outlined" variant="outlined"
/> />
)} )}
{banner.is_active && ( {banner.is_active && (
<Chip label="Global Default" color="success" size="small" /> <Chip label={t('admin.bannerGlobalDefault')} color="success" size="small" />
)} )}
</Stack> </Stack>
</Box> </Box>
@@ -145,7 +145,7 @@ const AdminBanners = () => {
setIsSettingsDialogOpen(true); setIsSettingsDialogOpen(true);
}} }}
> >
Edit Settings {t('admin.editSettings')}
</Button> </Button>
<Button <Button
variant="outlined" variant="outlined"
@@ -154,7 +154,7 @@ const AdminBanners = () => {
onClick={() => handleToggleEnabled(banner.id)} onClick={() => handleToggleEnabled(banner.id)}
disabled={banner.is_active} disabled={banner.is_active}
> >
{banner.is_enabled ? 'Disable for Users' : 'Enable for Users'} {banner.is_enabled ? t('admin.disableForUsers') : t('admin.enableForUsers')}
</Button> </Button>
{!banner.is_active && ( {!banner.is_active && (
<Button <Button
@@ -162,7 +162,7 @@ const AdminBanners = () => {
size="small" size="small"
onClick={() => handleActivateBanner(banner.id)} onClick={() => handleActivateBanner(banner.id)}
> >
Set as Default {t('admin.setAsDefault')}
</Button> </Button>
)} )}
</Stack> </Stack>
+15 -15
View File
@@ -59,13 +59,13 @@ const AdminConfig = () => {
await api.patch('admin-custom/config/', draft); await api.patch('admin-custom/config/', draft);
qc.invalidateQueries({ queryKey: ['adminConfig'] }); qc.invalidateQueries({ queryKey: ['adminConfig'] });
notify({ notify({
message: t('admin.configSaved', { defaultValue: 'Configuration saved' }), message: t('admin.configSaved'),
severity: 'success', severity: 'success',
}); });
} catch (err) { } catch (err) {
const error = err as { response?: { data?: { error?: string } } }; const error = err as { response?: { data?: { error?: string } } };
notify({ notify({
message: error.response?.data?.error || 'Failed to save configuration', message: error.response?.data?.error || t('admin.configSaveFailed'),
severity: 'error', severity: 'error',
}); });
} finally { } finally {
@@ -87,21 +87,21 @@ const AdminConfig = () => {
return ( return (
<Box> <Box>
<Typography variant="h5" sx={{ fontWeight: 'bold', mb: 1 }}> <Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
{t('admin.sections.config', { defaultValue: 'System Configuration' })} {t('admin.config')}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Live values applied without restart. Form posts the full config on save. {t('admin.configLiveHint')}
</Typography> </Typography>
<GlassPaper intensity="subtle" sx={{ p: 3, maxWidth: 720 }}> <GlassPaper intensity="subtle" sx={{ p: 3, maxWidth: 720 }}>
<Stack spacing={3} component="form" onSubmit={(e) => e.preventDefault()}> <Stack spacing={3} component="form" onSubmit={(e) => e.preventDefault()}>
<TextField <TextField
label="Site Name" label={t('admin.siteName')}
value={draft.site_name} value={draft.site_name}
onChange={(e) => setDraft({ ...draft, site_name: e.target.value })} onChange={(e) => setDraft({ ...draft, site_name: e.target.value })}
fullWidth fullWidth
helperText="Displayed in the browser tab and various headers." helperText={t('admin.siteNameHint')}
/> />
<FormGroup> <FormGroup>
@@ -114,9 +114,9 @@ const AdminConfig = () => {
} }
label={ label={
<Box> <Box>
<Typography variant="body1">Maintenance Mode</Typography> <Typography variant="body1">{t('admin.maintenanceMode')}</Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Block non-admin users from interacting with the site. {t('admin.maintenanceModeHint')}
</Typography> </Typography>
</Box> </Box>
} }
@@ -130,9 +130,9 @@ const AdminConfig = () => {
} }
label={ label={
<Box> <Box>
<Typography variant="body1">Allow New Signups</Typography> <Typography variant="body1">{t('admin.allowSignups')}</Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
When off, the registration endpoint rejects new accounts. {t('admin.allowSignupsHint')}
</Typography> </Typography>
</Box> </Box>
} }
@@ -148,9 +148,9 @@ const AdminConfig = () => {
} }
label={ label={
<Box> <Box>
<Typography variant="body1">Require Email Verification</Typography> <Typography variant="body1">{t('admin.requireVerification')}</Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
New accounts must confirm their email before logging in. {t('admin.requireVerificationHint')}
</Typography> </Typography>
</Box> </Box>
} }
@@ -165,7 +165,7 @@ const AdminConfig = () => {
disabled={!dirty || saving} disabled={!dirty || saving}
onClick={handleSave} onClick={handleSave}
> >
{saving ? 'Saving…' : 'Save changes'} {saving ? t('admin.saving') : t('admin.saveChanges')}
</Button> </Button>
<Button <Button
variant="outlined" variant="outlined"
@@ -173,7 +173,7 @@ const AdminConfig = () => {
onClick={handleReset} onClick={handleReset}
disabled={!dirty || saving} disabled={!dirty || saving}
> >
Discard {t('admin.discard')}
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>
+2 -2
View File
@@ -57,7 +57,7 @@ const AdminFlavors: React.FC = () => {
setConfirmOpen(false); setConfirmOpen(false);
} catch (err: any) { } catch (err: any) {
const errorMsg = err.response?.data?.error || err.message; const errorMsg = err.response?.data?.error || err.message;
notify({ message: `Error: ${errorMsg}`, severity: 'error' }); notify({ message: t('admin.errorPrefix', { message: errorMsg }), severity: 'error' });
} finally { } finally {
setIsMerging(false); setIsMerging(false);
} }
@@ -67,7 +67,7 @@ const AdminFlavors: React.FC = () => {
return ( return (
<Box> <Box>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold' }}> <Typography variant="h5" sx={{ mb: 3, fontWeight: 700 }}>
{t('admin.manageFlavors', { defaultValue: 'Manage Flavors' })} {t('admin.manageFlavors', { defaultValue: 'Manage Flavors' })}
</Typography> </Typography>
+27 -23
View File
@@ -16,6 +16,7 @@ import {
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import { useTranslation } from 'react-i18next';
import { GlassCard } from '../../components/ui'; import { GlassCard } from '../../components/ui';
import { useToast } from '../../hooks/useToast'; import { useToast } from '../../hooks/useToast';
import { formatDate } from '../../utils/date'; import { formatDate } from '../../utils/date';
@@ -35,6 +36,7 @@ interface Job {
} }
const AdminJobs = () => { const AdminJobs = () => {
const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const { notify } = useToast(); const { notify } = useToast();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -58,9 +60,9 @@ const AdminJobs = () => {
const handleTriggerJob = async (id: number) => { const handleTriggerJob = async (id: number) => {
try { try {
await triggerJobMutation.mutateAsync(id); await triggerJobMutation.mutateAsync(id);
notify({ message: 'Job queued', severity: 'success' }); notify({ message: t('admin.jobQueued'), severity: 'success' });
} catch { } catch {
notify({ message: 'Failed to trigger job', severity: 'error' }); notify({ message: t('admin.jobTriggerFailed'), severity: 'error' });
} }
}; };
@@ -68,7 +70,7 @@ const AdminJobs = () => {
try { try {
await updateJobScheduleMutation.mutateAsync({ id, data: { interval_hours: interval } }); await updateJobScheduleMutation.mutateAsync({ id, data: { interval_hours: interval } });
} catch { } catch {
notify({ message: 'Failed to update schedule', severity: 'error' }); notify({ message: t('admin.jobScheduleFailed'), severity: 'error' });
} }
}; };
@@ -95,12 +97,12 @@ const AdminJobs = () => {
sx={{ mb: 3 }} sx={{ mb: 3 }}
spacing={2} spacing={2}
> >
<Typography variant="h5" sx={{ fontWeight: 'bold' }}> <Typography variant="h5" sx={{ fontWeight: 700 }}>
Background Worker Jobs ({filtered.length}) {t('admin.jobsTitle')} ({filtered.length})
</Typography> </Typography>
<TextField <TextField
size="small" size="small"
placeholder="Filter jobs…" placeholder={t('admin.jobsFilter')}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
sx={{ width: { xs: '100%', md: 300 } }} sx={{ width: { xs: '100%', md: 300 } }}
@@ -122,11 +124,13 @@ const AdminJobs = () => {
mb: 2, mb: 2,
}} }}
> >
<Typography variant="h6" sx={{ fontWeight: 'bold' }}> <Typography variant="h6" sx={{ fontWeight: 700 }}>
{job.name_display} {job.name_display}
</Typography> </Typography>
<Chip <Chip
label={job.status === 'pending' ? 'QUEUED' : job.status.toUpperCase()} label={
job.status === 'pending' ? t('admin.jobStatusQueued') : job.status.toUpperCase()
}
size="small" size="small"
color={ color={
job.status === 'completed' job.status === 'completed'
@@ -144,22 +148,22 @@ const AdminJobs = () => {
<Grid container spacing={2} sx={{ mb: 2 }}> <Grid container spacing={2} sx={{ mb: 2 }}>
<Grid size={{ xs: 12, md: 4 }}> <Grid size={{ xs: 12, md: 4 }}>
<Typography variant="caption" color="text.secondary" display="block"> <Typography variant="caption" color="text.secondary" display="block">
Last Run {t('admin.jobLastRun')}
</Typography> </Typography>
<Typography variant="body2"> <Typography variant="body2">
{job.last_run ? formatDate(job.last_run) : 'Never'} {job.last_run ? formatDate(job.last_run) : t('admin.jobNever')}
</Typography> </Typography>
</Grid> </Grid>
<Grid size={{ xs: 12, md: 4 }}> <Grid size={{ xs: 12, md: 4 }}>
<Typography variant="caption" color="text.secondary" display="block"> <Typography variant="caption" color="text.secondary" display="block">
Next Run {t('admin.jobNextRun')}
</Typography> </Typography>
<Typography variant="body2"> <Typography variant="body2">
{job.next_run {job.next_run
? formatDate(job.next_run) ? formatDate(job.next_run)
: job.interval_hours > 0 : job.interval_hours > 0
? 'Queued…' ? t('admin.jobQueuedShort')
: 'Disabled'} : t('admin.jobDisabled')}
</Typography> </Typography>
</Grid> </Grid>
<Grid size={{ xs: 12, md: 4 }}> <Grid size={{ xs: 12, md: 4 }}>
@@ -169,7 +173,7 @@ const AdminJobs = () => {
display="block" display="block"
sx={{ mb: 0.5 }} sx={{ mb: 0.5 }}
> >
Schedule {t('admin.jobSchedule')}
</Typography> </Typography>
<FormControl size="small" fullWidth> <FormControl size="small" fullWidth>
<Select <Select
@@ -177,12 +181,12 @@ const AdminJobs = () => {
onChange={(e) => handleUpdateJobSchedule(job.id, Number(e.target.value))} onChange={(e) => handleUpdateJobSchedule(job.id, Number(e.target.value))}
sx={{ height: 32, fontSize: '0.875rem' }} sx={{ height: 32, fontSize: '0.875rem' }}
> >
<MenuItem value={0}>Disabled</MenuItem> <MenuItem value={0}>{t('admin.jobDisabled')}</MenuItem>
<MenuItem value={1}>Hourly</MenuItem> <MenuItem value={1}>{t('admin.schedHourly')}</MenuItem>
<MenuItem value={6}>Every 6 Hours</MenuItem> <MenuItem value={6}>{t('admin.sched6h')}</MenuItem>
<MenuItem value={12}>Every 12 Hours</MenuItem> <MenuItem value={12}>{t('admin.sched12h')}</MenuItem>
<MenuItem value={24}>Daily</MenuItem> <MenuItem value={24}>{t('admin.schedDaily')}</MenuItem>
<MenuItem value={168}>Weekly</MenuItem> <MenuItem value={168}>{t('admin.schedWeekly')}</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
</Grid> </Grid>
@@ -191,7 +195,7 @@ const AdminJobs = () => {
{job.last_output && ( {job.last_output && (
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary" display="block" gutterBottom> <Typography variant="caption" color="text.secondary" display="block" gutterBottom>
Last Run Output {t('admin.jobLastOutput')}
</Typography> </Typography>
<Box <Box
sx={{ sx={{
@@ -221,7 +225,7 @@ const AdminJobs = () => {
{job.error_message && ( {job.error_message && (
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<Typography variant="caption" color="error" display="block" gutterBottom> <Typography variant="caption" color="error" display="block" gutterBottom>
Last Error {t('admin.jobLastError')}
</Typography> </Typography>
<Alert severity="error" sx={{ py: 0 }}> <Alert severity="error" sx={{ py: 0 }}>
{job.error_message} {job.error_message}
@@ -235,7 +239,7 @@ const AdminJobs = () => {
disabled={job.status === 'running'} disabled={job.status === 'running'}
onClick={() => handleTriggerJob(job.id)} onClick={() => handleTriggerJob(job.id)}
> >
Trigger Now {t('admin.jobTriggerNow')}
</Button> </Button>
</CardContent> </CardContent>
</GlassCard> </GlassCard>
+5 -5
View File
@@ -53,11 +53,11 @@ const AdminLayout = () => {
const detailLabel = () => { const detailLabel = () => {
if (!detailSegment) return null; if (!detailSegment) return null;
if (sectionSlug === 'users' && params.id) return `User #${params.id}`; if (sectionSlug === 'users' && params.id) return t('admin.userNum', { n: params.id });
if (sectionSlug === 'ratings' || segments[1] === 'rating') if (sectionSlug === 'ratings' || segments[1] === 'rating')
return `Rating #${params.id ?? detailSegment}`; return t('admin.ratingNum', { n: params.id ?? detailSegment });
if (sectionSlug === 'replies' || segments[1] === 'reply') if (sectionSlug === 'replies' || segments[1] === 'reply')
return `Reply #${params.id ?? detailSegment}`; return t('admin.replyNum', { n: params.id ?? detailSegment });
return decodeURIComponent(detailSegment); return decodeURIComponent(detailSegment);
}; };
@@ -67,7 +67,7 @@ const AdminLayout = () => {
<MuiLink component={RouterLink} to="/" underline="hover" color="inherit"> <MuiLink component={RouterLink} to="/" underline="hover" color="inherit">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<HomeOutlinedIcon fontSize="small" /> <HomeOutlinedIcon fontSize="small" />
{t('common.home', { defaultValue: 'Home' })} {t('common.home')}
</Box> </Box>
</MuiLink> </MuiLink>
<MuiLink component={RouterLink} to="/admin-panel" underline="hover" color="inherit"> <MuiLink component={RouterLink} to="/admin-panel" underline="hover" color="inherit">
@@ -86,7 +86,7 @@ const AdminLayout = () => {
{detailLabel() && <Typography color="text.primary">{detailLabel()}</Typography>} {detailLabel() && <Typography color="text.primary">{detailLabel()}</Typography>}
</Breadcrumbs> </Breadcrumbs>
<Typography variant={isMobile ? 'h4' : 'h3'} sx={{ fontWeight: 'bold', mb: 3 }}> <Typography variant={isMobile ? 'h4' : 'h3'} sx={{ fontWeight: 700, mb: 3 }}>
{t('admin.title')} {t('admin.title')}
</Typography> </Typography>
+15 -14
View File
@@ -58,7 +58,9 @@ const AdminOverview = () => {
} catch (err) { } catch (err) {
const error = err as { response?: { data?: { error?: string } } }; const error = err as { response?: { data?: { error?: string } } };
notify({ notify({
message: `Error: ${error.response?.data?.error || 'Failed to send'}`, message: t('admin.errorPrefix', {
message: error.response?.data?.error || t('admin.testEmailFailed'),
}),
severity: 'error', severity: 'error',
}); });
} }
@@ -84,7 +86,7 @@ const AdminOverview = () => {
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}> <Typography variant="overline" sx={{ fontSize: '0.6rem' }}>
{t('admin.totalUsers')} {t('admin.totalUsers')}
</Typography> </Typography>
<Typography variant={isMobile ? 'h5' : 'h4'} sx={{ fontWeight: 'bold' }}> <Typography variant={isMobile ? 'h5' : 'h4'} sx={{ fontWeight: 700 }}>
{stats.total_users} {stats.total_users}
</Typography> </Typography>
</CardContent> </CardContent>
@@ -98,7 +100,7 @@ const AdminOverview = () => {
</Typography> </Typography>
<Typography <Typography
variant={isMobile ? 'h5' : 'h4'} variant={isMobile ? 'h5' : 'h4'}
sx={{ fontWeight: 'bold', color: 'error.main' }} sx={{ fontWeight: 700, color: 'error.main' }}
> >
{stats.open_tickets} {stats.open_tickets}
</Typography> </Typography>
@@ -115,7 +117,7 @@ const AdminOverview = () => {
spacing={2} spacing={2}
> >
<Box> <Box>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}> <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('admin.config')}: SMTP {t('admin.config')}: SMTP
</Typography> </Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}> <Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
@@ -139,8 +141,8 @@ const AdminOverview = () => {
</Grid> </Grid>
</Grid> </Grid>
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 2 }}> <Typography variant="h6" sx={{ fontWeight: 700, mb: 2 }}>
Environment Info (.env) {t('admin.envInfo')}
</Typography> </Typography>
<GlassPaper intensity="subtle" sx={{ p: 3 }}> <GlassPaper intensity="subtle" sx={{ p: 3 }}>
<Stack spacing={2.5}> <Stack spacing={2.5}>
@@ -148,9 +150,9 @@ const AdminOverview = () => {
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
sx={{ fontWeight: 'bold', textTransform: 'uppercase' }} sx={{ fontWeight: 700, textTransform: 'uppercase' }}
> >
API Status {t('admin.apiStatus')}
</Typography> </Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}> <Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
DEBUG={String(stats.server_info.debug)} DEBUG={String(stats.server_info.debug)}
@@ -160,9 +162,9 @@ const AdminOverview = () => {
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
sx={{ fontWeight: 'bold', textTransform: 'uppercase' }} sx={{ fontWeight: 700, textTransform: 'uppercase' }}
> >
Frontend URL {t('admin.frontendUrl')}
</Typography> </Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}> <Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
{stats.server_info.frontend_url} {stats.server_info.frontend_url}
@@ -172,9 +174,9 @@ const AdminOverview = () => {
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
sx={{ fontWeight: 'bold', textTransform: 'uppercase' }} sx={{ fontWeight: 700, textTransform: 'uppercase' }}
> >
Allowed Hosts {t('admin.allowedHosts')}
</Typography> </Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}> <Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
{stats.server_info.allowed_hosts.join(', ')} {stats.server_info.allowed_hosts.join(', ')}
@@ -184,8 +186,7 @@ const AdminOverview = () => {
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}> <Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<InfoIcon fontSize="small" color="primary" /> <InfoIcon fontSize="small" color="primary" />
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
These values are read from your server environment or .env file. To change them, you {t('admin.envHint')}
must update the file and restart the backend container.
</Typography> </Typography>
</Box> </Box>
</Stack> </Stack>
+17 -17
View File
@@ -111,26 +111,29 @@ const AdminTickets: React.FC = () => {
await addMessage.mutateAsync({ ticketId, text }); await addMessage.mutateAsync({ ticketId, text });
setReplyText({ ...replyText, [ticketId]: '' }); setReplyText({ ...replyText, [ticketId]: '' });
} catch { } catch {
notify({ message: 'Failed to send message', severity: 'error' }); notify({ message: t('admin.ticketMessageFailed'), severity: 'error' });
} }
}; };
const handleUpdateStatus = async (ticketId: number, status: string) => { const handleUpdateStatus = async (ticketId: number, status: string) => {
try { try {
await updateStatusMutation.mutateAsync({ ticketId, status }); await updateStatusMutation.mutateAsync({ ticketId, status });
notify({ message: `Status set to ${t(`support.${status}`)}`, severity: 'success' }); notify({
message: t('admin.ticketStatusSet', { status: t(`support.${status}`) }),
severity: 'success',
});
} catch { } catch {
notify({ message: 'Failed to update status', severity: 'error' }); notify({ message: t('admin.ticketStatusFailed'), severity: 'error' });
} }
}; };
const handleDeleteTicket = async (ticketId: number) => { const handleDeleteTicket = async (ticketId: number) => {
if (!(await confirm({ message: 'Delete this ticket?', danger: true }))) return; if (!(await confirm({ message: t('admin.ticketDeleteConfirm'), danger: true }))) return;
try { try {
await deleteTicketMutation.mutateAsync(ticketId); await deleteTicketMutation.mutateAsync(ticketId);
notify({ message: 'Ticket deleted', severity: 'success' }); notify({ message: t('admin.ticketDeleted'), severity: 'success' });
} catch { } catch {
notify({ message: 'Failed to delete ticket', severity: 'error' }); notify({ message: t('admin.ticketDeleteFailed'), severity: 'error' });
} }
}; };
@@ -151,7 +154,7 @@ const AdminTickets: React.FC = () => {
spacing={2} spacing={2}
sx={{ mb: 3 }} sx={{ mb: 3 }}
> >
<Typography variant="h5" sx={{ fontWeight: 'bold' }}> <Typography variant="h5" sx={{ fontWeight: 700 }}>
{t('support.title')} ({filtered.length}) {t('support.title')} ({filtered.length})
</Typography> </Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap> <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
@@ -204,7 +207,7 @@ const AdminTickets: React.FC = () => {
/> />
)} )}
<Box> <Box>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}> <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{ticket.subject} {ticket.subject}
<Typography <Typography
component="span" component="span"
@@ -225,14 +228,14 @@ const AdminTickets: React.FC = () => {
label={t('support.unread')} label={t('support.unread')}
color="primary" color="primary"
size="small" size="small"
sx={{ fontWeight: 'bold', height: 20, fontSize: '0.6rem' }} sx={{ fontWeight: 700, height: 20, fontSize: '0.6rem' }}
/> />
)} )}
<Chip <Chip
label={t(`support.${ticket.status}`)} label={t(`support.${ticket.status}`)}
color={getChipColor(ticket.status)} color={getChipColor(ticket.status)}
size="small" size="small"
sx={{ fontWeight: 'bold' }} sx={{ fontWeight: 700 }}
/> />
<IconButton <IconButton
size="small" size="small"
@@ -268,7 +271,7 @@ const AdminTickets: React.FC = () => {
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
fontWeight: 'bold', fontWeight: 700,
display: 'block', display: 'block',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
@@ -286,7 +289,7 @@ const AdminTickets: React.FC = () => {
}} }}
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }} sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
> >
View Profile {t('admin.viewProfile')}
</Button> </Button>
<Button <Button
size="small" size="small"
@@ -354,11 +357,8 @@ const AdminTickets: React.FC = () => {
boxShadow: msg.is_admin ? 2 : 0, boxShadow: msg.is_admin ? 2 : 0,
}} }}
> >
<Typography <Typography variant="caption" sx={{ fontWeight: 700, display: 'block' }}>
variant="caption" {msg.username} {msg.is_admin && `(${t('admin.supportTag')})`}
sx={{ fontWeight: 'bold', display: 'block' }}
>
{msg.username} {msg.is_admin && '(Support)'}
</Typography> </Typography>
<Typography variant="body2" sx={{ my: 0.5 }}> <Typography variant="body2" sx={{ my: 0.5 }}>
{msg.text} {msg.text}
+15 -15
View File
@@ -69,7 +69,7 @@ const AdminUsers = () => {
sx={{ mb: 2 }} sx={{ mb: 2 }}
spacing={2} spacing={2}
> >
<Typography variant="h5" sx={{ fontWeight: 'bold' }}> <Typography variant="h5" sx={{ fontWeight: 700 }}>
{t('admin.users')} ({filtered.length}) {t('admin.users')} ({filtered.length})
</Typography> </Typography>
<TextField <TextField
@@ -88,7 +88,7 @@ const AdminUsers = () => {
<GlassCard key={user.id} intensity="subtle" sx={{ borderRadius: 3 }}> <GlassCard key={user.id} intensity="subtle" sx={{ borderRadius: 3 }}>
<CardContent sx={{ p: 2 }}> <CardContent sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}> <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{user.username} {user.username}
</Typography> </Typography>
<IconButton <IconButton
@@ -103,14 +103,14 @@ const AdminUsers = () => {
</Typography> </Typography>
<Stack direction="row" spacing={1} sx={{ mt: 1 }}> <Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Chip <Chip
label={user.is_active ? 'Active' : 'Inactive'} label={user.is_active ? t('common.active') : t('common.inactive')}
color={user.is_active ? 'success' : 'default'} color={user.is_active ? 'success' : 'default'}
size="small" size="small"
sx={{ height: 20, fontSize: '0.65rem' }} sx={{ height: 20, fontSize: '0.65rem' }}
/> />
{user.is_superuser && ( {user.is_superuser && (
<Chip <Chip
label="Admin" label={t('admin.roleAdmin')}
color="secondary" color="secondary"
size="small" size="small"
sx={{ height: 20, fontSize: '0.65rem' }} sx={{ height: 20, fontSize: '0.65rem' }}
@@ -126,14 +126,14 @@ const AdminUsers = () => {
<Table> <Table>
<TableHead sx={{ bgcolor: 'action.hover' }}> <TableHead sx={{ bgcolor: 'action.hover' }}>
<TableRow> <TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>ID</TableCell> <TableCell sx={{ fontWeight: 700 }}>{t('admin.colId')}</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>User</TableCell> <TableCell sx={{ fontWeight: 700 }}>{t('admin.colUser')}</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Email</TableCell> <TableCell sx={{ fontWeight: 700 }}>{t('admin.colEmail')}</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Status</TableCell> <TableCell sx={{ fontWeight: 700 }}>{t('admin.colStatus')}</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Joined</TableCell> <TableCell sx={{ fontWeight: 700 }}>{t('admin.colJoined')}</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>IP History</TableCell> <TableCell sx={{ fontWeight: 700 }}>{t('admin.colIp')}</TableCell>
<TableCell sx={{ fontWeight: 'bold' }} align="right"> <TableCell sx={{ fontWeight: 700 }} align="right">
Actions {t('admin.colActions')}
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
@@ -142,12 +142,12 @@ const AdminUsers = () => {
<TableRow key={user.id} hover> <TableRow key={user.id} hover>
<TableCell>{user.id}</TableCell> <TableCell>{user.id}</TableCell>
<TableCell> <TableCell>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}> <Typography variant="body2" sx={{ fontWeight: 700 }}>
{user.username} {user.username}
</Typography> </Typography>
{user.is_superuser && ( {user.is_superuser && (
<Chip <Chip
label="Superuser" label={t('admin.roleSuperuser')}
size="small" size="small"
color="secondary" color="secondary"
sx={{ height: 16, fontSize: '0.6rem' }} sx={{ height: 16, fontSize: '0.6rem' }}
@@ -157,7 +157,7 @@ const AdminUsers = () => {
<TableCell>{user.email}</TableCell> <TableCell>{user.email}</TableCell>
<TableCell> <TableCell>
<Chip <Chip
label={user.is_active ? 'Active' : 'Inactive'} label={user.is_active ? t('common.active') : t('common.inactive')}
color={user.is_active ? 'success' : 'default'} color={user.is_active ? 'success' : 'default'}
size="small" size="small"
/> />
@@ -9,6 +9,7 @@ import {
HeroBackdrop, HeroBackdrop,
SectionHeader, SectionHeader,
FlavorCard, FlavorCard,
FlavorGrid,
EmptyState, EmptyState,
BackButton, BackButton,
} from '../../components/ui'; } from '../../components/ui';
@@ -39,17 +40,7 @@ const CategoryFlavors: React.FC = () => {
{flavors.length === 0 ? ( {flavors.length === 0 ? (
<EmptyState title={t('home.noCategoryFlavors')} subtitle={t('home.noCategoryFlavorsSub')} /> <EmptyState title={t('home.noCategoryFlavors')} subtitle={t('home.noCategoryFlavorsSub')} />
) : ( ) : (
<Box <FlavorGrid>
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(auto-fill, minmax(260px, 1fr))',
lg: 'repeat(auto-fill, minmax(240px, 1fr))',
},
gap: 3,
}}
>
{flavors.map((flavor) => ( {flavors.map((flavor) => (
<FlavorCard <FlavorCard
key={flavor.id} key={flavor.id}
@@ -58,7 +49,7 @@ const CategoryFlavors: React.FC = () => {
caption={`${flavor.ratings.length} ${t('common.reviews')}`} caption={`${flavor.ratings.length} ${t('common.reviews')}`}
/> />
))} ))}
</Box> </FlavorGrid>
)} )}
</PageShell> </PageShell>
); );
@@ -70,12 +70,12 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
variant="outlined" variant="outlined"
onClick={onBack} onClick={onBack}
startIcon={<ArrowBackIcon />} startIcon={<ArrowBackIcon />}
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }} sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 700 }}
> >
{t('common.back')} {t('common.back')}
</Button> </Button>
)} )}
<Typography variant="h5" sx={{ fontWeight: '900', display: { xs: 'none', md: 'block' } }}> <Typography variant="h5" sx={{ fontWeight: 900, display: { xs: 'none', md: 'block' } }}>
{t('dashboard.title')} {t('dashboard.title')}
</Typography> </Typography>
</Box> </Box>
@@ -96,7 +96,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
variant="contained" variant="contained"
onClick={onShare} onClick={onShare}
startIcon={<ShareIcon />} startIcon={<ShareIcon />}
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }} sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 700 }}
> >
{t('dashboard.shareProfile')} {t('dashboard.shareProfile')}
</Button> </Button>
@@ -177,7 +177,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
<Typography <Typography
variant={isXs ? 'h5' : 'h3'} variant={isXs ? 'h5' : 'h3'}
sx={{ sx={{
fontWeight: '900', fontWeight: 900,
mb: 0.25, mb: 0.25,
letterSpacing: -1, letterSpacing: -1,
overflow: 'hidden', overflow: 'hidden',
@@ -222,14 +222,14 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
> >
<Typography <Typography
variant="h6" variant="h6"
sx={{ fontWeight: '900', lineHeight: 1, color: 'text.primary' }} sx={{ fontWeight: 900, lineHeight: 1, color: 'text.primary' }}
> >
{stat.val} {stat.val}
</Typography> </Typography>
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
fontWeight: '900', fontWeight: 900,
textTransform: 'uppercase', textTransform: 'uppercase',
opacity: 0.5, opacity: 0.5,
fontSize: '0.6rem', fontSize: '0.6rem',
+12 -29
View File
@@ -27,7 +27,7 @@ import StatusBadge from '../../components/StatusBadge';
import MentionTextField from '../../components/MentionTextField'; import MentionTextField from '../../components/MentionTextField';
import RichText from '../../components/RichText'; import RichText from '../../components/RichText';
import { formatDate } from '../../utils/date'; import { formatDate } from '../../utils/date';
import { GlassCard } from '../../components/ui'; import { GlassCard, FlavorThumb } from '../../components/ui';
import { useToast } from '../../hooks/useToast'; import { useToast } from '../../hooks/useToast';
import { useConfirm } from '../../hooks/useConfirm'; import { useConfirm } from '../../hooks/useConfirm';
@@ -111,7 +111,7 @@ const MyReviewCard: React.FC<MyReviewCardProps> = ({ rating, currentUsername })
<CardContent sx={{ p: { xs: 2, sm: 3 } }}> <CardContent sx={{ p: { xs: 2, sm: 3 } }}>
{isEditing ? ( {isEditing ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}> <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('dashboard.editingReview', { flavor: rating.flavor_name })} {t('dashboard.editingReview', { flavor: rating.flavor_name })}
</Typography> </Typography>
<Stack direction="row" spacing={2} alignItems="center"> <Stack direction="row" spacing={2} alignItems="center">
@@ -141,29 +141,15 @@ const MyReviewCard: React.FC<MyReviewCardProps> = ({ rating, currentUsername })
}} }}
> >
<Box sx={{ display: 'flex', gap: 2, minWidth: 0, flex: 1 }}> <Box sx={{ display: 'flex', gap: 2, minWidth: 0, flex: 1 }}>
<Box <FlavorThumb
sx={{ src={rating.flavor_image}
width: { xs: 56, sm: 64 }, name={rating.flavor_name}
height: { xs: 56, sm: 64 }, size={{ xs: 56, sm: 64 }}
bgcolor: 'background.default', />
border: '1px solid',
borderColor: 'divider',
borderRadius: 1.5,
overflow: 'hidden',
flexShrink: 0,
}}
>
<Box
component="img"
src={rating.flavor_image || undefined}
alt={rating.flavor_name}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}> <Box sx={{ minWidth: 0, flex: 1 }}>
<Typography <Typography
variant={isXs ? 'subtitle1' : 'h6'} variant={isXs ? 'subtitle1' : 'h6'}
sx={{ fontWeight: 'bold', lineHeight: 1.2, wordBreak: 'break-word' }} sx={{ fontWeight: 700, lineHeight: 1.2, wordBreak: 'break-word' }}
> >
<Link <Link
to={`/flavor/${rating.flavor}`} to={`/flavor/${rating.flavor}`}
@@ -223,15 +209,15 @@ const MyReviewCard: React.FC<MyReviewCardProps> = ({ rating, currentUsername })
size="small" size="small"
startIcon={<CommentIcon fontSize="small" />} startIcon={<CommentIcon fontSize="small" />}
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
sx={{ textTransform: 'none', fontWeight: 'bold', color: 'text.secondary' }} sx={{ textTransform: 'none', fontWeight: 700, color: 'text.secondary' }}
> >
{rating.replies.length} {t('common.replies')} {rating.replies.length} {t('common.replies')}
</Button> </Button>
<Box sx={{ flexGrow: 1 }} /> <Box sx={{ flexGrow: 1 }} />
<Button size="small" sx={{ fontWeight: 'bold' }} onClick={startEdit}> <Button size="small" sx={{ fontWeight: 700 }} onClick={startEdit}>
{t('common.edit')} {t('common.edit')}
</Button> </Button>
<Button size="small" color="error" sx={{ fontWeight: 'bold' }} onClick={handleDelete}> <Button size="small" color="error" sx={{ fontWeight: 700 }} onClick={handleDelete}>
{t('common.delete')} {t('common.delete')}
</Button> </Button>
</Stack> </Stack>
@@ -251,10 +237,7 @@ const MyReviewCard: React.FC<MyReviewCardProps> = ({ rating, currentUsername })
mb: 0.5, mb: 0.5,
}} }}
> >
<Typography <Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.85rem' }}>
variant="subtitle2"
sx={{ fontWeight: 'bold', fontSize: '0.85rem' }}
>
{reply.user} {reply.user}
{reply.user === currentUsername && ( {reply.user === currentUsername && (
<VerifiedIcon <VerifiedIcon
@@ -88,7 +88,7 @@ const MyReviewsTab: React.FC<MyReviewsTabProps> = ({ ratings, currentUsername, o
<SortIcon fontSize="small" /> <SortIcon fontSize="small" />
</InputAdornment> </InputAdornment>
} }
sx={{ minWidth: { lg: 140 }, fontWeight: 'bold' }} sx={{ minWidth: { lg: 140 }, fontWeight: 700 }}
> >
<MenuItem value="date">{t('dashboard.date')}</MenuItem> <MenuItem value="date">{t('dashboard.date')}</MenuItem>
<MenuItem value="rating">{t('dashboard.rating')}</MenuItem> <MenuItem value="rating">{t('dashboard.rating')}</MenuItem>
@@ -125,7 +125,7 @@ const MyReviewsTab: React.FC<MyReviewsTabProps> = ({ ratings, currentUsername, o
onClick={() => setCategory(cat)} onClick={() => setCategory(cat)}
color={category === cat ? 'primary' : 'default'} color={category === cat ? 'primary' : 'default'}
variant={category === cat ? 'filled' : 'outlined'} variant={category === cat ? 'filled' : 'outlined'}
sx={{ fontWeight: 'bold', flexShrink: 0 }} sx={{ fontWeight: 700, flexShrink: 0 }}
/> />
))} ))}
</Box> </Box>
@@ -93,7 +93,7 @@ const RecommendationCard: React.FC<RecommendationCardProps> = ({ rec }) => {
<Typography <Typography
variant="subtitle2" variant="subtitle2"
sx={{ sx={{
fontWeight: 'bold', fontWeight: 700,
mb: 0.5, mb: 0.5,
display: '-webkit-box', display: '-webkit-box',
WebkitLineClamp: 1, WebkitLineClamp: 1,
@@ -112,7 +112,7 @@ const RecommendationCard: React.FC<RecommendationCardProps> = ({ rec }) => {
label={reasonLabel} label={reasonLabel}
color={rec.reason === 'cf' ? 'primary' : 'default'} color={rec.reason === 'cf' ? 'primary' : 'default'}
variant="outlined" variant="outlined"
sx={{ fontWeight: 'bold', maxWidth: '100%' }} sx={{ fontWeight: 700, maxWidth: '100%' }}
/> />
</CardContent> </CardContent>
</Link> </Link>
@@ -40,7 +40,7 @@ const RecommendationsSection: React.FC<RecommendationsSectionProps> = ({ enabled
<Box> <Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}> <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
<AutoAwesomeIcon color="primary" /> <AutoAwesomeIcon color="primary" />
<Typography variant="h6" sx={{ fontWeight: '900' }}> <Typography variant="h6" sx={{ fontWeight: 900 }}>
{t('dashboard.recHeading')} {t('dashboard.recHeading')}
</Typography> </Typography>
</Stack> </Stack>
+14 -14
View File
@@ -202,18 +202,18 @@ const FlavorDetail: React.FC = () => {
size="small" size="small"
sx={{ sx={{
mb: 2, mb: 2,
fontWeight: 'bold', fontWeight: 700,
bgcolor: alpha(theme.palette.primary.main, 0.1), bgcolor: alpha(theme.palette.primary.main, 0.1),
color: 'primary.main', color: 'primary.main',
}} }}
/> />
<Typography variant="h4" sx={{ fontWeight: '800', mb: 2, lineHeight: 1.2 }}> <Typography variant="h4" sx={{ fontWeight: 800, mb: 2, lineHeight: 1.2 }}>
{flavor.name} {flavor.name}
</Typography> </Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<RatingBadge score={flavor.average_rating || 0} size="large" /> <RatingBadge score={flavor.average_rating || 0} size="large" />
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}> <Typography variant="body2" color="text.secondary" sx={{ fontWeight: 700 }}>
{flavor.ratings.length} {t('common.reviews')} {flavor.ratings.length} {t('common.reviews')}
</Typography> </Typography>
</Box> </Box>
@@ -248,7 +248,7 @@ const FlavorDetail: React.FC = () => {
borderRadius: 3, borderRadius: 3,
py: 1.5, py: 1.5,
fontSize: '1rem', fontSize: '1rem',
fontWeight: '900', fontWeight: 900,
boxShadow: (theme) => `0 8px 20px ${alpha(theme.palette.primary.main, 0.3)}`, boxShadow: (theme) => `0 8px 20px ${alpha(theme.palette.primary.main, 0.3)}`,
}} }}
> >
@@ -340,7 +340,7 @@ const FlavorDetail: React.FC = () => {
> >
<Typography <Typography
variant="subtitle2" variant="subtitle2"
sx={{ fontWeight: 'bold', fontSize: '1rem' }} sx={{ fontWeight: 700, fontSize: '1rem' }}
> >
{rating.user} {rating.user}
</Typography> </Typography>
@@ -358,7 +358,7 @@ const FlavorDetail: React.FC = () => {
variant="outlined" variant="outlined"
color="secondary" color="secondary"
onClick={() => navigate(`/admin-panel/rating/${rating.id}`)} onClick={() => navigate(`/admin-panel/rating/${rating.id}`)}
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }} sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 700 }}
> >
{t('admin.manageRating')} {t('admin.manageRating')}
</Button> </Button>
@@ -402,7 +402,7 @@ const FlavorDetail: React.FC = () => {
sx={{ sx={{
textTransform: 'none', textTransform: 'none',
color: 'text.secondary', color: 'text.secondary',
fontWeight: 'bold', fontWeight: 700,
}} }}
> >
{rating.replies.length > 0 {rating.replies.length > 0
@@ -415,7 +415,7 @@ const FlavorDetail: React.FC = () => {
<Button <Button
size="small" size="small"
onClick={() => startEdit(rating)} onClick={() => startEdit(rating)}
sx={{ minWidth: 0, mr: 1, fontWeight: 'bold' }} sx={{ minWidth: 0, mr: 1, fontWeight: 700 }}
> >
{t('common.edit')} {t('common.edit')}
</Button> </Button>
@@ -423,7 +423,7 @@ const FlavorDetail: React.FC = () => {
size="small" size="small"
color="error" color="error"
onClick={() => handleDeleteRating(rating.id)} onClick={() => handleDeleteRating(rating.id)}
sx={{ minWidth: 0, fontWeight: 'bold' }} sx={{ minWidth: 0, fontWeight: 700 }}
> >
{t('common.delete')} {t('common.delete')}
</Button> </Button>
@@ -461,7 +461,7 @@ const FlavorDetail: React.FC = () => {
> >
<Typography <Typography
variant="subtitle2" variant="subtitle2"
sx={{ fontWeight: 'bold', fontSize: '0.85rem' }} sx={{ fontWeight: 700, fontSize: '0.85rem' }}
> >
{reply.user} {reply.user}
{reply.user === rating.user && ( {reply.user === rating.user && (
@@ -492,7 +492,7 @@ const FlavorDetail: React.FC = () => {
minWidth: 0, minWidth: 0,
py: 0, py: 0,
fontSize: '0.7rem', fontSize: '0.7rem',
fontWeight: 'bold', fontWeight: 700,
}} }}
onClick={() => { onClick={() => {
setEditingReplyId(reply.id); setEditingReplyId(reply.id);
@@ -508,7 +508,7 @@ const FlavorDetail: React.FC = () => {
minWidth: 0, minWidth: 0,
py: 0, py: 0,
fontSize: '0.7rem', fontSize: '0.7rem',
fontWeight: 'bold', fontWeight: 700,
}} }}
onClick={() => handleDeleteReply(reply.id)} onClick={() => handleDeleteReply(reply.id)}
> >
@@ -526,7 +526,7 @@ const FlavorDetail: React.FC = () => {
minWidth: 0, minWidth: 0,
py: 0, py: 0,
fontSize: '0.7rem', fontSize: '0.7rem',
fontWeight: 'bold', fontWeight: 700,
}} }}
> >
{t('admin.manageReply')} {t('admin.manageReply')}
@@ -591,7 +591,7 @@ const FlavorDetail: React.FC = () => {
onClick={() => handleReplySubmit(rating.id)} onClick={() => handleReplySubmit(rating.id)}
sx={{ sx={{
px: 2, px: 2,
fontWeight: 'bold', fontWeight: 700,
height: 40, height: 40,
alignSelf: 'flex-start', alignSelf: 'flex-start',
}} }}
@@ -44,7 +44,7 @@ const RatingForm: React.FC<RatingFormProps> = ({ flavorId }) => {
return ( return (
<GlassCard intensity="default"> <GlassCard intensity="default">
<CardContent sx={{ p: 3 }}> <CardContent sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}> <Typography variant="h6" gutterBottom sx={{ fontWeight: 700 }}>
{t('flavorDetail.rateThisFlavor')} {t('flavorDetail.rateThisFlavor')}
</Typography> </Typography>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
@@ -67,7 +67,7 @@ const RatingForm: React.FC<RatingFormProps> = ({ flavorId }) => {
variant="contained" variant="contained"
type="submit" type="submit"
disabled={!score || createRating.isPending} disabled={!score || createRating.isPending}
sx={{ mt: 2, borderRadius: 2, fontWeight: 'bold' }} sx={{ mt: 2, borderRadius: 2, fontWeight: 700 }}
> >
{t('flavorDetail.submitReview')} {t('flavorDetail.submitReview')}
</Button> </Button>
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Box, CardContent, Chip, Grid, Skeleton, Stack, Tooltip, Typography } from '@mui/material'; import { Box, CardContent, Chip, Skeleton, Stack, Tooltip, Typography } from '@mui/material';
import RecommendIcon from '@mui/icons-material/Recommend'; import RecommendIcon from '@mui/icons-material/Recommend';
import GroupsIcon from '@mui/icons-material/Groups'; import GroupsIcon from '@mui/icons-material/Groups';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@@ -8,7 +8,7 @@ import { useSimilarFlavors } from '../../../api/queries/useSimilarFlavors';
import type { SimilarFlavor } from '../../../api/types'; import type { SimilarFlavor } from '../../../api/types';
import RatingBadge from '../../../components/RatingBadge'; import RatingBadge from '../../../components/RatingBadge';
import StatusBadge from '../../../components/StatusBadge'; import StatusBadge from '../../../components/StatusBadge';
import { GlassCard } from '../../../components/ui'; import { GlassCard, FlavorGrid } from '../../../components/ui';
export interface SimilarFlavorsSectionProps { export interface SimilarFlavorsSectionProps {
flavorId: number | undefined; flavorId: number | undefined;
@@ -68,7 +68,7 @@ const SimilarCard: React.FC<{ item: SimilarFlavor }> = ({ item }) => {
<Typography <Typography
variant="subtitle2" variant="subtitle2"
sx={{ sx={{
fontWeight: 'bold', fontWeight: 700,
mb: 0.5, mb: 0.5,
display: '-webkit-box', display: '-webkit-box',
WebkitLineClamp: 1, WebkitLineClamp: 1,
@@ -88,7 +88,7 @@ const SimilarCard: React.FC<{ item: SimilarFlavor }> = ({ item }) => {
label={t('flavorDetail.similarCoRaters', { count: item.co_raters })} label={t('flavorDetail.similarCoRaters', { count: item.co_raters })}
color="primary" color="primary"
variant="outlined" variant="outlined"
sx={{ fontWeight: 'bold', maxWidth: '100%' }} sx={{ fontWeight: 700, maxWidth: '100%' }}
/> />
</Tooltip> </Tooltip>
</CardContent> </CardContent>
@@ -110,13 +110,11 @@ const SimilarFlavorsSection: React.FC<SimilarFlavorsSectionProps> = ({ flavorId
return ( return (
<Box sx={{ mt: 4 }}> <Box sx={{ mt: 4 }}>
<Skeleton variant="text" width={280} height={36} sx={{ mb: 2 }} /> <Skeleton variant="text" width={280} height={36} sx={{ mb: 2 }} />
<Grid container spacing={2}> <FlavorGrid compact>
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<Grid key={i} size={{ xs: 6, sm: 4, md: 3 }}> <Skeleton key={i} variant="rounded" height={240} sx={{ borderRadius: 2 }} />
<Skeleton variant="rounded" height={240} sx={{ borderRadius: 2 }} />
</Grid>
))} ))}
</Grid> </FlavorGrid>
</Box> </Box>
); );
} }
@@ -127,17 +125,15 @@ const SimilarFlavorsSection: React.FC<SimilarFlavorsSectionProps> = ({ flavorId
<Box sx={{ mt: 4 }}> <Box sx={{ mt: 4 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}> <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
<RecommendIcon color="primary" /> <RecommendIcon color="primary" />
<Typography variant="h6" sx={{ fontWeight: '900' }}> <Typography variant="h6" sx={{ fontWeight: 900 }}>
{t('flavorDetail.similarHeading')} {t('flavorDetail.similarHeading')}
</Typography> </Typography>
</Stack> </Stack>
<Grid container spacing={2}> <FlavorGrid compact>
{data.map((item) => ( {data.map((item) => (
<Grid key={item.id} size={{ xs: 6, sm: 4, md: 3 }}> <SimilarCard key={item.id} item={item} />
<SimilarCard item={item} />
</Grid>
))} ))}
</Grid> </FlavorGrid>
</Box> </Box>
); );
}; };
+7 -7
View File
@@ -12,9 +12,9 @@ Tracking doc from the frontend/UX audit. Items grouped by priority. Check off as
- [x] `Login.tsx` reset/verify flow labels, buttons, messages - [x] `Login.tsx` reset/verify flow labels, buttons, messages
- [x] `MyReviewCard.tsx` confirm dialogs, error toasts - [x] `MyReviewCard.tsx` confirm dialogs, error toasts
- [x] `PublicProfile.tsx` tabs, empty states, 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] **Admin-only, superuser surface:** swept `AdminUserDetail.tsx`, `admin/*` (Banners, Tickets, Overview, Config, Jobs, Users, Layout, Flavors), `AdminRatingDetail.tsx`, `AdminReplyDetail.tsx`, `Settings.tsx` theme-list subheaders. Added ~120 `admin.*` keys + `common.{home,processing,active,inactive}` (EN+DE), fixed DE leftovers in the existing admin block (manageUser/manageRating/manageReply/deactivate), added `react-i18next` to `AdminJobs` (was missing). Toasts/confirms/labels/table headers/menu items all keyed.
- [x] **#2 Delete dead code** — `FlavorList.tsx` (240 LOC) + `AdminDashboard.tsx` (873 LOC). Zero imports, not in router (replaced by `admin/` folder + `flavors/FlavorDetail`). - [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. - [x] **#3 Add brand/logo** — new `BrandMark` (`components/layout/BrandMark.tsx`): gradient "H" monogram + wordmark, links home. Anchored at the top of `NavSidebar` (full) and the mobile `GlassAppBar` (compact, icon-only). New `brand.{name,tagline}` i18n keys (EN+DE).
- [x] **#4 Categories discoverable** — accordion now open by default (`NavSidebar.tsx`); primary browse axis visible without an extra click. - [x] **#4 Categories discoverable** — accordion now open by default (`NavSidebar.tsx`); primary browse axis visible without an extra click.
## 🟡 Medium ## 🟡 Medium
@@ -27,12 +27,12 @@ Tracking doc from the frontend/UX audit. Items grouped by priority. Check off as
## 🟢 Design-system cleanup ## 🟢 Design-system cleanup
- [ ] **#10 Unify grid breakpoints** — 3 different responsive card grids across browse surfaces. - [x] **#10 Unify grid breakpoints** — new `<FlavorGrid>` (`components/ui/FlavorGrid.tsx`) is the single source of truth for flavor-card grids: default roomy auto-fill mode + `compact` 2→6 fixed mode. Replaced the 3 divergent inline `display:grid` defs (CategoryFlavors, MainPage search/newest) and converted SimilarFlavors off MUI `<Grid>`.
- [x] **#11 Extract `<BackButton>`** — repeated with drifting `sx` on 5+ pages. - [x] **#11 Extract `<BackButton>`** — repeated with drifting `sx` on 5+ pages.
- [ ] **#12 Font-weight scale** — 600/700/800/900/'bold' scattered; enforce via theme. - [x] **#12 Font-weight scale** — normalized all inline weights to a numeric scale (`'bold'`→700, `'900'`→900, `'800'`→800) across 35 files; theme typography already owns h1–h6/button/tab weights. No visual change (`'bold'`===700).
- [ ] **#13 Avatar fallback casing** — inconsistent `.toUpperCase()`. - [x] **#13 Avatar fallback casing** — all avatar/initial fallbacks now `.charAt(0).toUpperCase()`; fixed the 2 outliers missing `.toUpperCase()` (`MainPage` featured review, `PublicProfile` header).
- [ ] **#14 Image placeholder** — `CommunityFeed`/`MyReviewCard` raw `<img>` can show broken-image icon; reuse `FlavorCard` pattern. - [x] **#14 Image placeholder** — new `<FlavorThumb>` (`components/ui/FlavorThumb.tsx`) renders the flavor initial on missing **or** failed image (`onError` fallback), no broken-image glyph. Adopted in `CommunityFeed` (feed + top-followed) and `MyReviewCard`.
- [ ] **#15 Standardize empty states** — `EmptyState` component vs inline `<Typography>`. - [x] **#15 Standardize empty states** — audited: all user-facing list/section empties already use `<EmptyState>`. Remaining inline `<Typography>` are intentional in-card comment placeholders (italic "no comment") + one admin-only caption (deferred under #1).
## ✅ Good (keep) ## ✅ Good (keep)
ScoreInput a11y (radiogroup, arrow/number keys). Glass design system + skeletons. Optimistic mutations. Lazy routes. Status/Rating badges. ScoreInput a11y (radiogroup, arrow/number keys). Glass design system + skeletons. Optimistic mutations. Lazy routes. Status/Rating badges.