mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
admin translations and brand
This commit is contained in:
1 parent
97a17e516d
commit
12b973caac
46 files changed
+795
-408
No files matched your search
@@ -23,6 +23,7 @@ import { useAuth } from './hooks/useAuth';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { useDrawerAnchor } from './hooks/useDrawerAnchor';
|
||||
import { NavSidebar, NAV_SIDEBAR_WIDTH } from './components/layout/NavSidebar';
|
||||
import { BrandMark } from './components/layout/BrandMark';
|
||||
import { useCategories } from './api/queries/useCategories';
|
||||
import { NotificationMenu } from './components/layout/NotificationMenu';
|
||||
import { RequireSuperuser } from './components/auth/RequireSuperuser';
|
||||
@@ -114,6 +115,10 @@ const App: React.FC = () => {
|
||||
<GlassAppBar>
|
||||
{mobileAnchor === 'left' && hamburger}
|
||||
|
||||
<Box sx={{ mr: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<BrandMark compact />
|
||||
</Box>
|
||||
|
||||
<GlobalSearch />
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
|
||||
@@ -111,7 +111,7 @@ export const GlobalSearch = ({ compact = false }: GlobalSearchProps) => {
|
||||
}}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{option.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
|
||||
@@ -91,7 +91,7 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
|
||||
return (
|
||||
<Box key={schema.key} sx={{ mb: 3 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{schema.label}
|
||||
</Typography>
|
||||
{schema.description && (
|
||||
@@ -143,7 +143,7 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
|
||||
|
||||
return (
|
||||
<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>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
|
||||
@@ -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')} 🍪
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
@@ -76,7 +76,7 @@ const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 'bold', display: 'block', mb: 1, textTransform: 'uppercase' }}
|
||||
sx={{ fontWeight: 700, display: 'block', mb: 1, textTransform: 'uppercase' }}
|
||||
>
|
||||
Technical Details:
|
||||
</Typography>
|
||||
@@ -94,7 +94,7 @@ const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
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'
|
||||
? t('cookieBanner.switchToDark')
|
||||
@@ -108,7 +108,7 @@ const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
px: 4,
|
||||
m: '0 !important',
|
||||
}}
|
||||
|
||||
@@ -30,7 +30,7 @@ const Footer: React.FC = () => {
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 700 }}>
|
||||
© {year} Holy Flavors Archive
|
||||
</Typography>
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={user.username}
|
||||
primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold' }}
|
||||
primaryTypographyProps={{ variant: 'body2', fontWeight: 700 }}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
@@ -24,7 +24,7 @@ const RichText: React.FC<RichTextProps> = ({ text }) => {
|
||||
style={{
|
||||
color: theme.palette.primary.main,
|
||||
textDecoration: 'none',
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||
padding: '0 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 { GlobalSearch } from '../../app/GlobalSearch';
|
||||
import { NotificationMenu } from './NotificationMenu';
|
||||
import { BrandMark } from './BrandMark';
|
||||
|
||||
export const NAV_SIDEBAR_WIDTH = 280;
|
||||
|
||||
@@ -73,6 +74,16 @@ export const NavSidebar = ({
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<BrandMark onNavigate={close} />
|
||||
</Box>
|
||||
|
||||
{user ? (
|
||||
<Box
|
||||
component={Link}
|
||||
|
||||
@@ -88,7 +88,7 @@ export const NotificationMenu = ({
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('community.notifications')}
|
||||
</Typography>
|
||||
{unreadCount > 0 && (
|
||||
|
||||
@@ -18,7 +18,7 @@ const baseSx: SxProps<Theme> = {
|
||||
alignSelf: 'flex-start',
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
color: 'text.secondary',
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,8 @@ export { SectionHeader, type SectionHeaderProps } from './SectionHeader';
|
||||
export { HeroBackdrop, type HeroBackdropProps } from './HeroBackdrop';
|
||||
export { ReviewRow, type ReviewRowProps, type ReviewRowData } from './ReviewRow';
|
||||
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 { EmptyState, type EmptyStateProps } from './EmptyState';
|
||||
export { BackButton, type BackButtonProps } from './BackButton';
|
||||
|
||||
@@ -50,7 +50,7 @@ export const ConfirmProvider = ({ children }: { children: ReactNode }) => {
|
||||
fullWidth
|
||||
PaperProps={{ sx: { borderRadius: 3 } }}
|
||||
>
|
||||
{opts?.title && <DialogTitle sx={{ fontWeight: 'bold' }}>{opts.title}</DialogTitle>}
|
||||
{opts?.title && <DialogTitle sx={{ fontWeight: 700 }}>{opts.title}</DialogTitle>}
|
||||
<DialogContent>
|
||||
<DialogContentText>{opts?.message}</DialogContentText>
|
||||
</DialogContent>
|
||||
|
||||
+287
-4
@@ -17,6 +17,10 @@ const resources = {
|
||||
following: 'Following',
|
||||
categories: 'Categories',
|
||||
},
|
||||
brand: {
|
||||
name: 'Holy Archive',
|
||||
tagline: 'Flavor Rater',
|
||||
},
|
||||
categories: {
|
||||
energy: 'Energy',
|
||||
hydration: 'Hydration',
|
||||
@@ -60,6 +64,9 @@ const resources = {
|
||||
subtitle: 'Manage your account and preferences',
|
||||
appearanceTitle: 'Appearance & Language',
|
||||
themeLabel: 'Theme',
|
||||
themeGroupHoly: 'Holy Archive',
|
||||
themeGroupLight: 'Light Themes',
|
||||
themeGroupDark: 'Dark Themes',
|
||||
langLabel: 'Language',
|
||||
bannerLabel: 'Interactive Banner',
|
||||
bannerDefault: 'Site Default',
|
||||
@@ -157,6 +164,10 @@ const resources = {
|
||||
confirmDeleteReply: 'Delete this reply?',
|
||||
confirmDeleteComment: 'Delete this comment?',
|
||||
notFoundTitle: '404 — Page Not Found',
|
||||
home: 'Home',
|
||||
processing: 'Processing...',
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
},
|
||||
status: {
|
||||
limited: 'Limited',
|
||||
@@ -316,6 +327,135 @@ const resources = {
|
||||
activate: 'Activate Account',
|
||||
dangerZone: 'Danger Zone',
|
||||
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: {
|
||||
title: 'About This Project',
|
||||
@@ -408,6 +548,10 @@ const resources = {
|
||||
following: 'Folge ich',
|
||||
categories: 'Kategorien',
|
||||
},
|
||||
brand: {
|
||||
name: 'Holy-Archiv',
|
||||
tagline: 'Geschmacks-Rating',
|
||||
},
|
||||
categories: {
|
||||
energy: 'Energy',
|
||||
hydration: 'Hydration',
|
||||
@@ -451,6 +595,9 @@ const resources = {
|
||||
subtitle: 'Verwalte dein Konto und deine Einstellungen',
|
||||
appearanceTitle: 'Erscheinungsbild & Sprache',
|
||||
themeLabel: 'Design',
|
||||
themeGroupHoly: 'Holy Archiv',
|
||||
themeGroupLight: 'Helle Designs',
|
||||
themeGroupDark: 'Dunkle Designs',
|
||||
langLabel: 'Sprache',
|
||||
bannerLabel: 'Interaktiver Banner',
|
||||
bannerDefault: 'Seiten-Standard',
|
||||
@@ -548,6 +695,10 @@ const resources = {
|
||||
confirmDeleteReply: 'Diese Antwort löschen?',
|
||||
confirmDeleteComment: 'Diesen Kommentar löschen?',
|
||||
notFoundTitle: '404 — Seite nicht gefunden',
|
||||
home: 'Startseite',
|
||||
processing: 'Wird verarbeitet...',
|
||||
active: 'Aktiv',
|
||||
inactive: 'Inaktiv',
|
||||
},
|
||||
status: {
|
||||
limited: 'Limitiert',
|
||||
@@ -704,13 +855,145 @@ const resources = {
|
||||
sendTestEmail: 'Test-E-Mail senden',
|
||||
testEmailSuccess: 'Test-E-Mail erfolgreich gesendet!',
|
||||
ipHistory: 'Login-IP-Verlauf',
|
||||
manageUser: 'Manage User',
|
||||
manageRating: 'Manage Rating',
|
||||
manageReply: 'Manage Reply',
|
||||
deactivate: 'Deactivate Account',
|
||||
manageUser: 'Benutzer verwalten',
|
||||
manageRating: 'Bewertung verwalten',
|
||||
manageReply: 'Antwort verwalten',
|
||||
deactivate: 'Konto deaktivieren',
|
||||
activate: 'Konto aktivieren',
|
||||
dangerZone: 'Gefahrenzone',
|
||||
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: {
|
||||
title: 'Über dieses Projekt',
|
||||
|
||||
@@ -68,7 +68,7 @@ const About: React.FC = () => {
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('about.contact')}:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
@@ -85,7 +85,7 @@ const About: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('about.responsible')}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
|
||||
@@ -36,27 +36,29 @@ const AdminRatingDetail: React.FC = () => {
|
||||
if (error) navigate('/admin-panel');
|
||||
}, [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) => {
|
||||
e.preventDefault();
|
||||
if (!id) return;
|
||||
try {
|
||||
await updateRating.mutateAsync({ id: Number(id), score, comment });
|
||||
notify({ message: 'Rating updated!', severity: 'success' });
|
||||
notify({ message: t('admin.ratingUpdated'), severity: 'success' });
|
||||
} catch {
|
||||
notify({ message: 'Update failed', severity: 'error' });
|
||||
notify({ message: t('admin.updateFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
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 {
|
||||
await deleteRating.mutateAsync(Number(id));
|
||||
navigate('/admin-panel');
|
||||
} 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%' }}>
|
||||
<FormCard
|
||||
title={rating.flavor_name}
|
||||
subtitle={`User: ${rating.user}`}
|
||||
subtitle={t('admin.userLabel', { user: rating.user })}
|
||||
onSubmit={handleUpdate}
|
||||
actions={
|
||||
<Stack direction="row" spacing={2} sx={{ width: '100%' }}>
|
||||
@@ -107,7 +109,7 @@ const AdminRatingDetail: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
label="Score"
|
||||
label={t('admin.scoreLabel')}
|
||||
type="number"
|
||||
slotProps={{ input: { inputProps: { min: 1, max: 10 } } }}
|
||||
value={score}
|
||||
|
||||
@@ -33,27 +33,27 @@ const AdminReplyDetail: React.FC = () => {
|
||||
if (error) navigate('/admin-panel');
|
||||
}, [error, navigate]);
|
||||
|
||||
useTitle(reply ? `${t('admin.manageReply')}` : 'Reply Management');
|
||||
useTitle(reply ? `${t('admin.manageReply')}` : t('admin.replyMgmtTitle'));
|
||||
|
||||
const handleUpdate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!id) return;
|
||||
try {
|
||||
await updateReply.mutateAsync({ replyId: Number(id), text });
|
||||
notify({ message: 'Reply updated!', severity: 'success' });
|
||||
notify({ message: t('admin.replyUpdated'), severity: 'success' });
|
||||
} catch {
|
||||
notify({ message: 'Update failed', severity: 'error' });
|
||||
notify({ message: t('admin.updateFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id) return;
|
||||
if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return;
|
||||
if (!(await confirm({ message: t('admin.deleteReplyConfirm'), danger: true }))) return;
|
||||
try {
|
||||
await deleteReply.mutateAsync(Number(id));
|
||||
navigate('/admin-panel');
|
||||
} 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%' }}>
|
||||
<FormCard
|
||||
title={`Reply by ${reply.user}`}
|
||||
title={t('admin.replyByLabel', { user: reply.user })}
|
||||
onSubmit={handleUpdate}
|
||||
actions={
|
||||
<Stack direction="row" spacing={2} sx={{ width: '100%' }}>
|
||||
|
||||
@@ -110,14 +110,14 @@ const AdminUserDetail: React.FC = () => {
|
||||
if (error) navigate('/admin-panel');
|
||||
}, [error, navigate]);
|
||||
|
||||
useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : 'User Management');
|
||||
useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : t('admin.userMgmtTitle'));
|
||||
|
||||
const handleToggleActive = async () => {
|
||||
if (!user || !id) return;
|
||||
try {
|
||||
await toggleActive.mutateAsync({ id, isActive: user.is_active });
|
||||
} catch {
|
||||
notify({ message: 'Action failed', severity: 'error' });
|
||||
notify({ message: t('admin.actionFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -130,16 +130,16 @@ const AdminUserDetail: React.FC = () => {
|
||||
});
|
||||
setEditingRatingId(null);
|
||||
} catch {
|
||||
notify({ message: 'Update failed', severity: 'error' });
|
||||
notify({ message: t('admin.updateFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
await deleteRatingMutation.mutateAsync(ratingId);
|
||||
} 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 });
|
||||
setEditingReplyId(null);
|
||||
} catch {
|
||||
notify({ message: 'Update failed', severity: 'error' });
|
||||
notify({ message: t('admin.updateFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
await deleteReplyMutation.mutateAsync(replyId);
|
||||
} catch {
|
||||
notify({ message: 'Delete failed', severity: 'error' });
|
||||
notify({ message: t('admin.deleteFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id) return;
|
||||
const ok = await confirm({
|
||||
title: 'Delete user',
|
||||
message: 'PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.',
|
||||
confirmLabel: 'Delete forever',
|
||||
title: t('admin.deleteUserTitle'),
|
||||
message: t('admin.deleteUserConfirm'),
|
||||
confirmLabel: t('admin.deleteForever'),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
@@ -174,7 +174,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
await deleteUserMutation.mutateAsync(id);
|
||||
navigate('/admin-panel');
|
||||
} catch {
|
||||
notify({ message: 'Delete failed', severity: 'error' });
|
||||
notify({ message: t('admin.deleteFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -184,7 +184,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
if (!user) return <Typography>User not found</Typography>;
|
||||
if (!user) return <Typography>{t('admin.userNotFound')}</Typography>;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -215,7 +215,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 8, md: 9 }}>
|
||||
<Box sx={{ textAlign: { xs: 'center', sm: 'left' } }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700 }}>
|
||||
{user.username}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
@@ -227,7 +227,8 @@ const AdminUserDetail: React.FC = () => {
|
||||
display="block"
|
||||
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>
|
||||
<Stack
|
||||
direction="row"
|
||||
@@ -235,11 +236,13 @@ const AdminUserDetail: React.FC = () => {
|
||||
sx={{ mt: 1.5, justifyContent: { xs: 'center', sm: 'flex-start' } }}
|
||||
>
|
||||
<Chip
|
||||
label={user.is_active ? 'Active' : 'Inactive'}
|
||||
label={user.is_active ? t('common.active') : t('common.inactive')}
|
||||
color={user.is_active ? 'success' : 'default'}
|
||||
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>
|
||||
</Box>
|
||||
</Grid>
|
||||
@@ -249,7 +252,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
variant="contained"
|
||||
color={user.is_active ? 'warning' : 'success'}
|
||||
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')}
|
||||
</Button>
|
||||
@@ -262,14 +265,14 @@ const AdminUserDetail: React.FC = () => {
|
||||
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} variant="fullWidth">
|
||||
<Tab
|
||||
icon={<RateReviewIcon />}
|
||||
label={`Content (${user.ratings.length})`}
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
label={`${t('admin.tabContent')} (${user.ratings.length})`}
|
||||
sx={{ fontWeight: 700 }}
|
||||
/>
|
||||
<Tab icon={<HistoryIcon />} label="Security & IP" sx={{ fontWeight: 'bold' }} />
|
||||
<Tab icon={<HistoryIcon />} label={t('admin.tabSecurity')} sx={{ fontWeight: 700 }} />
|
||||
<Tab
|
||||
icon={<SecurityIcon />}
|
||||
label="Danger Zone"
|
||||
sx={{ fontWeight: 'bold', color: 'error.main' }}
|
||||
label={t('admin.tabDanger')}
|
||||
sx={{ fontWeight: 700, color: 'error.main' }}
|
||||
/>
|
||||
</Tabs>
|
||||
<Divider />
|
||||
@@ -277,7 +280,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
|
||||
{activeTab === 0 && (
|
||||
<Box>
|
||||
<SectionHeader title="User Ratings & Discussion" compact />
|
||||
<SectionHeader title={t('admin.ratingsDiscussion')} compact />
|
||||
<Stack spacing={2}>
|
||||
{user.ratings.map((rating) => (
|
||||
<GlassCard key={rating.id} intensity="subtle">
|
||||
@@ -285,7 +288,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
{editingRatingId === rating.id ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<Typography variant="body2">Score:</Typography>
|
||||
<Typography variant="body2">{t('admin.scoreLabel')}:</Typography>
|
||||
<TextField
|
||||
type="number"
|
||||
size="small"
|
||||
@@ -326,7 +329,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
alignItems="flex-start"
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{rating.flavor_name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
@@ -334,7 +337,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
label={`${rating.score}/10`}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ fontWeight: '900' }}
|
||||
sx={{ fontWeight: 900 }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -344,14 +347,14 @@ const AdminUserDetail: React.FC = () => {
|
||||
setEditComment(rating.comment || '');
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteRating(rating.id)}
|
||||
>
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
@@ -366,7 +369,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
color="text.secondary"
|
||||
sx={{ fontStyle: 'italic' }}
|
||||
>
|
||||
No comment
|
||||
{t('admin.noCommentShort')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
@@ -408,7 +411,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
color:
|
||||
reply.user === user.username
|
||||
? 'primary.main'
|
||||
@@ -426,7 +429,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
setEditReplyText(reply.text);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -434,7 +437,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
sx={{ minWidth: 0, p: 0, fontSize: '0.7rem' }}
|
||||
onClick={() => handleDeleteReply(reply.id)}
|
||||
>
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
@@ -452,7 +455,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
</CardContent>
|
||||
</GlassCard>
|
||||
))}
|
||||
{user.ratings.length === 0 && <EmptyState title="No ratings yet." />}
|
||||
{user.ratings.length === 0 && <EmptyState title={t('admin.noRatingsYet')} />}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
@@ -462,7 +465,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
<Typography
|
||||
variant="h6"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}
|
||||
sx={{ fontWeight: 700, display: 'flex', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
<SecurityIcon /> {t('admin.ipHistory')}
|
||||
</Typography>
|
||||
@@ -471,12 +474,12 @@ const AdminUserDetail: React.FC = () => {
|
||||
<ListItem key={idx} divider={idx < user.ips.length - 1}>
|
||||
<ListItemText
|
||||
primary={ip}
|
||||
primaryTypographyProps={{ sx: { fontWeight: 'bold', fontFamily: 'monospace' } }}
|
||||
primaryTypographyProps={{ sx: { fontWeight: 700, fontFamily: 'monospace' } }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
{user.ips.length === 0 && (
|
||||
<Typography variant="caption">No IP history available.</Typography>
|
||||
<Typography variant="caption">{t('admin.noIpHistory')}</Typography>
|
||||
)}
|
||||
</List>
|
||||
</GlassPaper>
|
||||
@@ -485,7 +488,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
{activeTab === 2 && (
|
||||
<FormCard
|
||||
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
|
||||
asForm={false}
|
||||
actions={
|
||||
@@ -495,14 +498,14 @@ const AdminUserDetail: React.FC = () => {
|
||||
size="large"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
sx={{ borderRadius: 2, fontWeight: 'bold', px: 4 }}
|
||||
sx={{ borderRadius: 2, fontWeight: 700, px: 4 }}
|
||||
>
|
||||
{t('admin.deleteUser')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Proceed only if the user has been reviewed for policy violations or requested deletion.
|
||||
{t('admin.deleteUserNote')}
|
||||
</Typography>
|
||||
</FormCard>
|
||||
)}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
HeroBackdrop,
|
||||
SectionHeader,
|
||||
GlassCard,
|
||||
FlavorThumb,
|
||||
EmptyState,
|
||||
BackButton,
|
||||
} from '../components/ui';
|
||||
@@ -60,7 +61,7 @@ const SidebarCard: React.FC<{
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -170,7 +171,7 @@ const CommunityFeed: React.FC = () => {
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{ fontWeight: 'bold', lineHeight: 1.2 }}
|
||||
sx={{ fontWeight: 700, lineHeight: 1.2 }}
|
||||
>
|
||||
{rating.user}
|
||||
</Typography>
|
||||
@@ -193,7 +194,7 @@ const CommunityFeed: React.FC = () => {
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
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')}
|
||||
</Button>
|
||||
@@ -208,7 +209,7 @@ const CommunityFeed: React.FC = () => {
|
||||
to={`/flavor/${rating.flavor}`}
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
@@ -233,24 +234,12 @@ const CommunityFeed: React.FC = () => {
|
||||
)}
|
||||
</Box>
|
||||
<Link to={`/flavor/${rating.flavor}`} style={{ flexShrink: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 70,
|
||||
height: 70,
|
||||
borderRadius: 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>
|
||||
<FlavorThumb
|
||||
src={rating.flavor_image}
|
||||
name={rating.flavor_name}
|
||||
size={70}
|
||||
radius={1}
|
||||
/>
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
@@ -274,7 +263,7 @@ const CommunityFeed: React.FC = () => {
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 'bold', fontSize: '0.8rem' }}
|
||||
sx={{ fontWeight: 700, fontSize: '0.8rem' }}
|
||||
>
|
||||
{reply.user}
|
||||
</Typography>
|
||||
@@ -355,34 +344,21 @@ const CommunityFeed: React.FC = () => {
|
||||
<List disablePadding>
|
||||
{topFollowed.map((flavor, idx) => (
|
||||
<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}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
mr: 2,
|
||||
bgcolor: 'background.default',
|
||||
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>
|
||||
<FlavorThumb
|
||||
src={flavor.image_url}
|
||||
name={flavor.name}
|
||||
size={32}
|
||||
radius={0.5}
|
||||
sx={{ mr: 2 }}
|
||||
/>
|
||||
<ListItemText
|
||||
primary={flavor.name}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body2',
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
noWrap: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -63,7 +63,7 @@ const Dashboard: React.FC = () => {
|
||||
<RecommendationsSection enabled={!!data} />
|
||||
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: '900', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 2 }}>
|
||||
{t('dashboard.myReviews')}
|
||||
</Typography>
|
||||
<MyReviewsTab
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
SectionHeader,
|
||||
HeroBackdrop,
|
||||
FlavorCard,
|
||||
FlavorGrid,
|
||||
EmptyState,
|
||||
} from '../components/ui';
|
||||
import MainPageRecommendations from './MainPageRecommendations';
|
||||
@@ -163,39 +164,19 @@ const MainPage: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
{searchLoading ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
lg: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
},
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<FlavorGrid>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<FlavorCardSkeleton key={i} />
|
||||
))}
|
||||
</Box>
|
||||
</FlavorGrid>
|
||||
) : searchResults.length === 0 ? (
|
||||
<EmptyState title={t('home.noSearchResults', { query })} />
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
lg: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
},
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<FlavorGrid>
|
||||
{searchResults.map((flavor) => (
|
||||
<FlavorCard key={flavor.id} flavor={flavor} />
|
||||
))}
|
||||
</Box>
|
||||
</FlavorGrid>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
@@ -423,7 +404,7 @@ const MainPage: React.FC = () => {
|
||||
label={`${t('common.rank')} #${safeIndex + 1}`}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{ width: 'fit-content', mb: 1, fontWeight: 'bold' }}
|
||||
sx={{ width: 'fit-content', mb: 1, fontWeight: 700 }}
|
||||
/>
|
||||
<Typography
|
||||
variant="h3"
|
||||
@@ -446,7 +427,7 @@ const MainPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<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')}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -486,7 +467,7 @@ const MainPage: React.FC = () => {
|
||||
src={featuredReview.user_avatar || undefined}
|
||||
sx={{ width: 22, height: 22, fontSize: '0.6rem' }}
|
||||
>
|
||||
{!featuredReview.user_avatar && featuredReview.user.charAt(0)}
|
||||
{!featuredReview.user_avatar && featuredReview.user.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
— {featuredReview.user}
|
||||
@@ -553,46 +534,19 @@ const MainPage: React.FC = () => {
|
||||
<Box>
|
||||
<SectionHeader title={t('home.newArrivals')} subtitle={t('home.newArrivalsSubtitle')} />
|
||||
{newestLoading ? (
|
||||
<Box
|
||||
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%',
|
||||
}}
|
||||
>
|
||||
<FlavorGrid compact>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<FlavorCardSkeleton key={i} compact />
|
||||
))}
|
||||
</Box>
|
||||
</FlavorGrid>
|
||||
) : newestFlavors.length === 0 ? (
|
||||
<EmptyState title={t('home.noNewest')} />
|
||||
) : (
|
||||
<Box
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<FlavorGrid compact>
|
||||
{newestFlavors.map((flavor) => (
|
||||
<FlavorCard key={flavor.id} flavor={flavor} compact />
|
||||
))}
|
||||
</Box>
|
||||
</FlavorGrid>
|
||||
)}
|
||||
</Box>
|
||||
</PageShell>
|
||||
|
||||
@@ -81,7 +81,7 @@ const Privacy: React.FC = () => {
|
||||
] as const
|
||||
).map(([titleKey, descKey]) => (
|
||||
<Box key={titleKey} sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t(`privacy.${titleKey}`)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
|
||||
|
||||
@@ -230,7 +230,7 @@ const PublicProfile: React.FC = () => {
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{user.username.charAt(0)}
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
@@ -238,7 +238,7 @@ const PublicProfile: React.FC = () => {
|
||||
to={`/profile/${user.username}`}
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: '900',
|
||||
fontWeight: 900,
|
||||
color: 'text.primary',
|
||||
textDecoration: 'none',
|
||||
'&:hover': { color: 'primary.main' },
|
||||
@@ -249,7 +249,7 @@ const PublicProfile: React.FC = () => {
|
||||
>
|
||||
{user.username}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
|
||||
Community Member
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -288,7 +288,7 @@ const PublicProfile: React.FC = () => {
|
||||
alignSelf: 'flex-start',
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
@@ -367,7 +367,7 @@ const PublicProfile: React.FC = () => {
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontWeight: '900',
|
||||
fontWeight: 900,
|
||||
letterSpacing: -2,
|
||||
fontSize: { xs: '2.8rem', sm: '4.2rem' },
|
||||
lineHeight: 0.9,
|
||||
@@ -391,13 +391,13 @@ const PublicProfile: React.FC = () => {
|
||||
].map((stat, i) => (
|
||||
<React.Fragment key={stat.label}>
|
||||
<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}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: '900',
|
||||
fontWeight: 900,
|
||||
textTransform: 'uppercase',
|
||||
opacity: 0.5,
|
||||
fontSize: '0.6rem',
|
||||
@@ -433,7 +433,7 @@ const PublicProfile: React.FC = () => {
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
px: 4,
|
||||
fontWeight: '900',
|
||||
fontWeight: 900,
|
||||
py: 1.2,
|
||||
textTransform: 'none',
|
||||
fontSize: '1rem',
|
||||
@@ -447,7 +447,7 @@ const PublicProfile: React.FC = () => {
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
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')}
|
||||
</Button>
|
||||
@@ -474,7 +474,7 @@ const PublicProfile: React.FC = () => {
|
||||
variant="fullWidth"
|
||||
sx={{
|
||||
'& .MuiTab-root': {
|
||||
fontWeight: '900',
|
||||
fontWeight: 900,
|
||||
textTransform: 'none',
|
||||
fontSize: '1rem',
|
||||
borderRadius: 1,
|
||||
@@ -509,7 +509,7 @@ const PublicProfile: React.FC = () => {
|
||||
onClick={() => setCategoryTab(idx)}
|
||||
color={categoryTab === idx ? 'primary' : 'default'}
|
||||
variant={categoryTab === idx ? 'filled' : 'outlined'}
|
||||
sx={{ fontWeight: '900', px: 1.5, borderRadius: 1 }}
|
||||
sx={{ fontWeight: 900, px: 1.5, borderRadius: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
@@ -542,7 +542,7 @@ const PublicProfile: React.FC = () => {
|
||||
justifyContent: 'center',
|
||||
bgcolor: tier.color,
|
||||
color: 'black',
|
||||
fontWeight: '900',
|
||||
fontWeight: 900,
|
||||
fontSize: '2rem',
|
||||
borderRadius: 0.5,
|
||||
boxShadow: `0 0 20px ${alpha(tier.color, 0.6)}`,
|
||||
@@ -553,14 +553,14 @@ const PublicProfile: React.FC = () => {
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{ fontWeight: '900', lineHeight: 1.1, letterSpacing: -0.5 }}
|
||||
sx={{ fontWeight: 900, lineHeight: 1.1, letterSpacing: -0.5 }}
|
||||
>
|
||||
{tier.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 'bold', opacity: 0.7 }}
|
||||
sx={{ fontWeight: 700, opacity: 0.7 }}
|
||||
>
|
||||
{items.length} items
|
||||
</Typography>
|
||||
@@ -622,7 +622,7 @@ const PublicProfile: React.FC = () => {
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: '900',
|
||||
fontWeight: 900,
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.2,
|
||||
display: '-webkit-box',
|
||||
@@ -652,7 +652,7 @@ const PublicProfile: React.FC = () => {
|
||||
textTransform: 'none',
|
||||
fontSize: '0.6rem',
|
||||
py: 0,
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Manage
|
||||
@@ -683,7 +683,7 @@ const PublicProfile: React.FC = () => {
|
||||
variant="contained"
|
||||
type="submit"
|
||||
endIcon={<SendIcon />}
|
||||
sx={{ borderRadius: 2, px: 5, fontWeight: '900', textTransform: 'none' }}
|
||||
sx={{ borderRadius: 2, px: 5, fontWeight: 900, textTransform: 'none' }}
|
||||
>
|
||||
{t('support.reply')}
|
||||
</Button>
|
||||
@@ -742,7 +742,7 @@ const PublicProfile: React.FC = () => {
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{ fontWeight: '900', lineHeight: 1.2 }}
|
||||
sx={{ fontWeight: 900, lineHeight: 1.2 }}
|
||||
>
|
||||
{comment.author_username}
|
||||
</Typography>
|
||||
@@ -750,7 +750,7 @@ const PublicProfile: React.FC = () => {
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
sx={{ fontWeight: 700 }}
|
||||
>
|
||||
{formatDate(comment.created_at)}
|
||||
</Typography>
|
||||
|
||||
@@ -202,7 +202,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
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')}
|
||||
</Button>
|
||||
@@ -215,7 +215,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
{!avatar && username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700 }}>
|
||||
{username}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
@@ -235,7 +235,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
<Grid size={{ xs: 12, lg: 5 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<GlassCard sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 700 }}>
|
||||
{t('settings.appearanceTitle')}
|
||||
</Typography>
|
||||
<FormControl fullWidth margin="normal">
|
||||
@@ -245,11 +245,11 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
label={t('settings.themeLabel')}
|
||||
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_dark">Holy Dark</MenuItem>
|
||||
|
||||
<ListSubheader>Light Themes</ListSubheader>
|
||||
<ListSubheader>{t('settings.themeGroupLight')}</ListSubheader>
|
||||
<MenuItem value="latte">Catppuccin Latte</MenuItem>
|
||||
<MenuItem value="pink_pastel">Pink Pastel</MenuItem>
|
||||
<MenuItem value="mint_pastel">Mint Pastel</MenuItem>
|
||||
@@ -258,7 +258,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
|
||||
<Divider />
|
||||
|
||||
<ListSubheader>Dark Themes</ListSubheader>
|
||||
<ListSubheader>{t('settings.themeGroupDark')}</ListSubheader>
|
||||
<MenuItem value="mocha">Catppuccin Mocha</MenuItem>
|
||||
<MenuItem value="frappe">Catppuccin Frappé</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 */}
|
||||
<GlassCard sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 700 }}>
|
||||
{t('settings.avatarTitle')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, my: 2 }}>
|
||||
|
||||
@@ -214,7 +214,7 @@ const Support: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{ticket.subject}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
@@ -228,14 +228,14 @@ const Support: React.FC = () => {
|
||||
label={t('support.unread')}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{ fontWeight: 'bold', height: 20, fontSize: '0.6rem' }}
|
||||
sx={{ fontWeight: 700, height: 20, fontSize: '0.6rem' }}
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
label={t(`support.${ticket.status}`)}
|
||||
color={getChipColor(ticket.status)}
|
||||
size="small"
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
sx={{ fontWeight: 700 }}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -276,10 +276,7 @@ const Support: React.FC = () => {
|
||||
boxShadow: msg.is_admin ? 2 : 0,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 'bold', display: 'block' }}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, display: 'block' }}>
|
||||
{msg.username} {msg.is_admin && '(Support)'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ my: 0.5 }}>
|
||||
|
||||
@@ -58,7 +58,7 @@ const VerifyEmail: React.FC = () => {
|
||||
{status === 'loading' && (
|
||||
<Box>
|
||||
<CircularProgress size={60} sx={{ mb: 4 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
Verifying your account...
|
||||
</Typography>
|
||||
<Typography color="text.secondary" sx={{ mt: 1 }}>
|
||||
@@ -70,7 +70,7 @@ const VerifyEmail: React.FC = () => {
|
||||
{status === 'success' && (
|
||||
<Box>
|
||||
<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')}
|
||||
</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 4 }}>
|
||||
@@ -91,7 +91,7 @@ const VerifyEmail: React.FC = () => {
|
||||
{status === 'error' && (
|
||||
<Box>
|
||||
<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
|
||||
</Typography>
|
||||
<Alert severity="error" sx={{ mb: 4, textAlign: 'left' }}>
|
||||
|
||||
@@ -46,10 +46,10 @@ const AdminBanners = () => {
|
||||
const handleActivateBanner = async (id: number) => {
|
||||
try {
|
||||
await activateBannerMutation.mutateAsync(id);
|
||||
notify({ message: 'Banner set as default', severity: 'success' });
|
||||
notify({ message: t('admin.bannerSetDefault'), severity: 'success' });
|
||||
} catch (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) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
notify({
|
||||
message: error.response?.data?.error || 'Failed to toggle visibility',
|
||||
message: error.response?.data?.error || t('admin.bannerToggleFailed'),
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
@@ -75,20 +75,20 @@ const AdminBanners = () => {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold', mb: 3 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 3 }}>
|
||||
{t('admin.banners')} ({banners.length})
|
||||
</Typography>
|
||||
|
||||
{banners.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No banner models found"
|
||||
subtitle="No banners registered in the database."
|
||||
title={t('admin.noBanners')}
|
||||
subtitle={t('admin.noBannersSub')}
|
||||
action={
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => qc.invalidateQueries({ queryKey: ['banners'] })}
|
||||
>
|
||||
Retry Loading
|
||||
{t('admin.retryLoading')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -108,20 +108,20 @@ const AdminBanners = () => {
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{banner.name} (Slug: {banner.slug})
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
{banner.name} ({t('admin.bannerSlug')}: {banner.slug})
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{!banner.is_enabled && (
|
||||
<Chip
|
||||
label="Hidden from Users"
|
||||
label={t('admin.bannerHidden')}
|
||||
color="warning"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
{banner.is_active && (
|
||||
<Chip label="Global Default" color="success" size="small" />
|
||||
<Chip label={t('admin.bannerGlobalDefault')} color="success" size="small" />
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -145,7 +145,7 @@ const AdminBanners = () => {
|
||||
setIsSettingsDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Edit Settings
|
||||
{t('admin.editSettings')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
@@ -154,7 +154,7 @@ const AdminBanners = () => {
|
||||
onClick={() => handleToggleEnabled(banner.id)}
|
||||
disabled={banner.is_active}
|
||||
>
|
||||
{banner.is_enabled ? 'Disable for Users' : 'Enable for Users'}
|
||||
{banner.is_enabled ? t('admin.disableForUsers') : t('admin.enableForUsers')}
|
||||
</Button>
|
||||
{!banner.is_active && (
|
||||
<Button
|
||||
@@ -162,7 +162,7 @@ const AdminBanners = () => {
|
||||
size="small"
|
||||
onClick={() => handleActivateBanner(banner.id)}
|
||||
>
|
||||
Set as Default
|
||||
{t('admin.setAsDefault')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -59,13 +59,13 @@ const AdminConfig = () => {
|
||||
await api.patch('admin-custom/config/', draft);
|
||||
qc.invalidateQueries({ queryKey: ['adminConfig'] });
|
||||
notify({
|
||||
message: t('admin.configSaved', { defaultValue: 'Configuration saved' }),
|
||||
message: t('admin.configSaved'),
|
||||
severity: 'success',
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
notify({
|
||||
message: error.response?.data?.error || 'Failed to save configuration',
|
||||
message: error.response?.data?.error || t('admin.configSaveFailed'),
|
||||
severity: 'error',
|
||||
});
|
||||
} finally {
|
||||
@@ -87,21 +87,21 @@ const AdminConfig = () => {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold', mb: 1 }}>
|
||||
{t('admin.sections.config', { defaultValue: 'System Configuration' })}
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
{t('admin.config')}
|
||||
</Typography>
|
||||
<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>
|
||||
|
||||
<GlassPaper intensity="subtle" sx={{ p: 3, maxWidth: 720 }}>
|
||||
<Stack spacing={3} component="form" onSubmit={(e) => e.preventDefault()}>
|
||||
<TextField
|
||||
label="Site Name"
|
||||
label={t('admin.siteName')}
|
||||
value={draft.site_name}
|
||||
onChange={(e) => setDraft({ ...draft, site_name: e.target.value })}
|
||||
fullWidth
|
||||
helperText="Displayed in the browser tab and various headers."
|
||||
helperText={t('admin.siteNameHint')}
|
||||
/>
|
||||
|
||||
<FormGroup>
|
||||
@@ -114,9 +114,9 @@ const AdminConfig = () => {
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body1">Maintenance Mode</Typography>
|
||||
<Typography variant="body1">{t('admin.maintenanceMode')}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Block non-admin users from interacting with the site.
|
||||
{t('admin.maintenanceModeHint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
@@ -130,9 +130,9 @@ const AdminConfig = () => {
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body1">Allow New Signups</Typography>
|
||||
<Typography variant="body1">{t('admin.allowSignups')}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
When off, the registration endpoint rejects new accounts.
|
||||
{t('admin.allowSignupsHint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
@@ -148,9 +148,9 @@ const AdminConfig = () => {
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body1">Require Email Verification</Typography>
|
||||
<Typography variant="body1">{t('admin.requireVerification')}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
New accounts must confirm their email before logging in.
|
||||
{t('admin.requireVerificationHint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
@@ -165,7 +165,7 @@ const AdminConfig = () => {
|
||||
disabled={!dirty || saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
{saving ? t('admin.saving') : t('admin.saveChanges')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
@@ -173,7 +173,7 @@ const AdminConfig = () => {
|
||||
onClick={handleReset}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
Discard
|
||||
{t('admin.discard')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -57,7 +57,7 @@ const AdminFlavors: React.FC = () => {
|
||||
setConfirmOpen(false);
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.error || err.message;
|
||||
notify({ message: `Error: ${errorMsg}`, severity: 'error' });
|
||||
notify({ message: t('admin.errorPrefix', { message: errorMsg }), severity: 'error' });
|
||||
} finally {
|
||||
setIsMerging(false);
|
||||
}
|
||||
@@ -67,7 +67,7 @@ const AdminFlavors: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold' }}>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 700 }}>
|
||||
{t('admin.manageFlavors', { defaultValue: 'Manage Flavors' })}
|
||||
</Typography>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GlassCard } from '../../components/ui';
|
||||
import { useToast } from '../../hooks/useToast';
|
||||
import { formatDate } from '../../utils/date';
|
||||
@@ -35,6 +36,7 @@ interface Job {
|
||||
}
|
||||
|
||||
const AdminJobs = () => {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { notify } = useToast();
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -58,9 +60,9 @@ const AdminJobs = () => {
|
||||
const handleTriggerJob = async (id: number) => {
|
||||
try {
|
||||
await triggerJobMutation.mutateAsync(id);
|
||||
notify({ message: 'Job queued', severity: 'success' });
|
||||
notify({ message: t('admin.jobQueued'), severity: 'success' });
|
||||
} catch {
|
||||
notify({ message: 'Failed to trigger job', severity: 'error' });
|
||||
notify({ message: t('admin.jobTriggerFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -68,7 +70,7 @@ const AdminJobs = () => {
|
||||
try {
|
||||
await updateJobScheduleMutation.mutateAsync({ id, data: { interval_hours: interval } });
|
||||
} 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 }}
|
||||
spacing={2}
|
||||
>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
Background Worker Jobs ({filtered.length})
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
{t('admin.jobsTitle')} ({filtered.length})
|
||||
</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Filter jobs…"
|
||||
placeholder={t('admin.jobsFilter')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ width: { xs: '100%', md: 300 } }}
|
||||
@@ -122,11 +124,13 @@ const AdminJobs = () => {
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
{job.name_display}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={job.status === 'pending' ? 'QUEUED' : job.status.toUpperCase()}
|
||||
label={
|
||||
job.status === 'pending' ? t('admin.jobStatusQueued') : job.status.toUpperCase()
|
||||
}
|
||||
size="small"
|
||||
color={
|
||||
job.status === 'completed'
|
||||
@@ -144,22 +148,22 @@ const AdminJobs = () => {
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Last Run
|
||||
{t('admin.jobLastRun')}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{job.last_run ? formatDate(job.last_run) : 'Never'}
|
||||
{job.last_run ? formatDate(job.last_run) : t('admin.jobNever')}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Next Run
|
||||
{t('admin.jobNextRun')}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{job.next_run
|
||||
? formatDate(job.next_run)
|
||||
: job.interval_hours > 0
|
||||
? 'Queued…'
|
||||
: 'Disabled'}
|
||||
? t('admin.jobQueuedShort')
|
||||
: t('admin.jobDisabled')}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
@@ -169,7 +173,7 @@ const AdminJobs = () => {
|
||||
display="block"
|
||||
sx={{ mb: 0.5 }}
|
||||
>
|
||||
Schedule
|
||||
{t('admin.jobSchedule')}
|
||||
</Typography>
|
||||
<FormControl size="small" fullWidth>
|
||||
<Select
|
||||
@@ -177,12 +181,12 @@ const AdminJobs = () => {
|
||||
onChange={(e) => handleUpdateJobSchedule(job.id, Number(e.target.value))}
|
||||
sx={{ height: 32, fontSize: '0.875rem' }}
|
||||
>
|
||||
<MenuItem value={0}>Disabled</MenuItem>
|
||||
<MenuItem value={1}>Hourly</MenuItem>
|
||||
<MenuItem value={6}>Every 6 Hours</MenuItem>
|
||||
<MenuItem value={12}>Every 12 Hours</MenuItem>
|
||||
<MenuItem value={24}>Daily</MenuItem>
|
||||
<MenuItem value={168}>Weekly</MenuItem>
|
||||
<MenuItem value={0}>{t('admin.jobDisabled')}</MenuItem>
|
||||
<MenuItem value={1}>{t('admin.schedHourly')}</MenuItem>
|
||||
<MenuItem value={6}>{t('admin.sched6h')}</MenuItem>
|
||||
<MenuItem value={12}>{t('admin.sched12h')}</MenuItem>
|
||||
<MenuItem value={24}>{t('admin.schedDaily')}</MenuItem>
|
||||
<MenuItem value={168}>{t('admin.schedWeekly')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
@@ -191,7 +195,7 @@ const AdminJobs = () => {
|
||||
{job.last_output && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block" gutterBottom>
|
||||
Last Run Output
|
||||
{t('admin.jobLastOutput')}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -221,7 +225,7 @@ const AdminJobs = () => {
|
||||
{job.error_message && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="error" display="block" gutterBottom>
|
||||
Last Error
|
||||
{t('admin.jobLastError')}
|
||||
</Typography>
|
||||
<Alert severity="error" sx={{ py: 0 }}>
|
||||
{job.error_message}
|
||||
@@ -235,7 +239,7 @@ const AdminJobs = () => {
|
||||
disabled={job.status === 'running'}
|
||||
onClick={() => handleTriggerJob(job.id)}
|
||||
>
|
||||
Trigger Now
|
||||
{t('admin.jobTriggerNow')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</GlassCard>
|
||||
|
||||
@@ -53,11 +53,11 @@ const AdminLayout = () => {
|
||||
|
||||
const detailLabel = () => {
|
||||
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')
|
||||
return `Rating #${params.id ?? detailSegment}`;
|
||||
return t('admin.ratingNum', { n: params.id ?? detailSegment });
|
||||
if (sectionSlug === 'replies' || segments[1] === 'reply')
|
||||
return `Reply #${params.id ?? detailSegment}`;
|
||||
return t('admin.replyNum', { n: params.id ?? detailSegment });
|
||||
return decodeURIComponent(detailSegment);
|
||||
};
|
||||
|
||||
@@ -67,7 +67,7 @@ const AdminLayout = () => {
|
||||
<MuiLink component={RouterLink} to="/" underline="hover" color="inherit">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<HomeOutlinedIcon fontSize="small" />
|
||||
{t('common.home', { defaultValue: 'Home' })}
|
||||
{t('common.home')}
|
||||
</Box>
|
||||
</MuiLink>
|
||||
<MuiLink component={RouterLink} to="/admin-panel" underline="hover" color="inherit">
|
||||
@@ -86,7 +86,7 @@ const AdminLayout = () => {
|
||||
{detailLabel() && <Typography color="text.primary">{detailLabel()}</Typography>}
|
||||
</Breadcrumbs>
|
||||
|
||||
<Typography variant={isMobile ? 'h4' : 'h3'} sx={{ fontWeight: 'bold', mb: 3 }}>
|
||||
<Typography variant={isMobile ? 'h4' : 'h3'} sx={{ fontWeight: 700, mb: 3 }}>
|
||||
{t('admin.title')}
|
||||
</Typography>
|
||||
|
||||
|
||||
@@ -58,7 +58,9 @@ const AdminOverview = () => {
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
notify({
|
||||
message: `Error: ${error.response?.data?.error || 'Failed to send'}`,
|
||||
message: t('admin.errorPrefix', {
|
||||
message: error.response?.data?.error || t('admin.testEmailFailed'),
|
||||
}),
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
@@ -84,7 +86,7 @@ const AdminOverview = () => {
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>
|
||||
{t('admin.totalUsers')}
|
||||
</Typography>
|
||||
<Typography variant={isMobile ? 'h5' : 'h4'} sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant={isMobile ? 'h5' : 'h4'} sx={{ fontWeight: 700 }}>
|
||||
{stats.total_users}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
@@ -98,7 +100,7 @@ const AdminOverview = () => {
|
||||
</Typography>
|
||||
<Typography
|
||||
variant={isMobile ? 'h5' : 'h4'}
|
||||
sx={{ fontWeight: 'bold', color: 'error.main' }}
|
||||
sx={{ fontWeight: 700, color: 'error.main' }}
|
||||
>
|
||||
{stats.open_tickets}
|
||||
</Typography>
|
||||
@@ -115,7 +117,7 @@ const AdminOverview = () => {
|
||||
spacing={2}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('admin.config')}: SMTP
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
@@ -139,8 +141,8 @@ const AdminOverview = () => {
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 2 }}>
|
||||
Environment Info (.env)
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2 }}>
|
||||
{t('admin.envInfo')}
|
||||
</Typography>
|
||||
<GlassPaper intensity="subtle" sx={{ p: 3 }}>
|
||||
<Stack spacing={2.5}>
|
||||
@@ -148,9 +150,9 @@ const AdminOverview = () => {
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}
|
||||
sx={{ fontWeight: 700, textTransform: 'uppercase' }}
|
||||
>
|
||||
API Status
|
||||
{t('admin.apiStatus')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
DEBUG={String(stats.server_info.debug)}
|
||||
@@ -160,9 +162,9 @@ const AdminOverview = () => {
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}
|
||||
sx={{ fontWeight: 700, textTransform: 'uppercase' }}
|
||||
>
|
||||
Frontend URL
|
||||
{t('admin.frontendUrl')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
{stats.server_info.frontend_url}
|
||||
@@ -172,9 +174,9 @@ const AdminOverview = () => {
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}
|
||||
sx={{ fontWeight: 700, textTransform: 'uppercase' }}
|
||||
>
|
||||
Allowed Hosts
|
||||
{t('admin.allowedHosts')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
{stats.server_info.allowed_hosts.join(', ')}
|
||||
@@ -184,8 +186,7 @@ const AdminOverview = () => {
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
||||
<InfoIcon fontSize="small" color="primary" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
These values are read from your server environment or .env file. To change them, you
|
||||
must update the file and restart the backend container.
|
||||
{t('admin.envHint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
@@ -111,26 +111,29 @@ const AdminTickets: React.FC = () => {
|
||||
await addMessage.mutateAsync({ ticketId, text });
|
||||
setReplyText({ ...replyText, [ticketId]: '' });
|
||||
} catch {
|
||||
notify({ message: 'Failed to send message', severity: 'error' });
|
||||
notify({ message: t('admin.ticketMessageFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStatus = async (ticketId: number, status: string) => {
|
||||
try {
|
||||
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 {
|
||||
notify({ message: 'Failed to update status', severity: 'error' });
|
||||
notify({ message: t('admin.ticketStatusFailed'), severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
await deleteTicketMutation.mutateAsync(ticketId);
|
||||
notify({ message: 'Ticket deleted', severity: 'success' });
|
||||
notify({ message: t('admin.ticketDeleted'), severity: 'success' });
|
||||
} 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}
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
{t('support.title')} ({filtered.length})
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
@@ -204,7 +207,7 @@ const AdminTickets: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{ticket.subject}
|
||||
<Typography
|
||||
component="span"
|
||||
@@ -225,14 +228,14 @@ const AdminTickets: React.FC = () => {
|
||||
label={t('support.unread')}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{ fontWeight: 'bold', height: 20, fontSize: '0.6rem' }}
|
||||
sx={{ fontWeight: 700, height: 20, fontSize: '0.6rem' }}
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
label={t(`support.${ticket.status}`)}
|
||||
color={getChipColor(ticket.status)}
|
||||
size="small"
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
sx={{ fontWeight: 700 }}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -268,7 +271,7 @@ const AdminTickets: React.FC = () => {
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
@@ -286,7 +289,7 @@ const AdminTickets: React.FC = () => {
|
||||
}}
|
||||
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
|
||||
>
|
||||
View Profile
|
||||
{t('admin.viewProfile')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -354,11 +357,8 @@ const AdminTickets: React.FC = () => {
|
||||
boxShadow: msg.is_admin ? 2 : 0,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 'bold', display: 'block' }}
|
||||
>
|
||||
{msg.username} {msg.is_admin && '(Support)'}
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, display: 'block' }}>
|
||||
{msg.username} {msg.is_admin && `(${t('admin.supportTag')})`}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ my: 0.5 }}>
|
||||
{msg.text}
|
||||
|
||||
@@ -69,7 +69,7 @@ const AdminUsers = () => {
|
||||
sx={{ mb: 2 }}
|
||||
spacing={2}
|
||||
>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
{t('admin.users')} ({filtered.length})
|
||||
</Typography>
|
||||
<TextField
|
||||
@@ -88,7 +88,7 @@ const AdminUsers = () => {
|
||||
<GlassCard key={user.id} intensity="subtle" sx={{ borderRadius: 3 }}>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{user.username}
|
||||
</Typography>
|
||||
<IconButton
|
||||
@@ -103,14 +103,14 @@ const AdminUsers = () => {
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||
<Chip
|
||||
label={user.is_active ? 'Active' : 'Inactive'}
|
||||
label={user.is_active ? t('common.active') : t('common.inactive')}
|
||||
color={user.is_active ? 'success' : 'default'}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.65rem' }}
|
||||
/>
|
||||
{user.is_superuser && (
|
||||
<Chip
|
||||
label="Admin"
|
||||
label={t('admin.roleAdmin')}
|
||||
color="secondary"
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.65rem' }}
|
||||
@@ -126,14 +126,14 @@ const AdminUsers = () => {
|
||||
<Table>
|
||||
<TableHead sx={{ bgcolor: 'action.hover' }}>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>ID</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>User</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Email</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Status</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>Joined</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }}>IP History</TableCell>
|
||||
<TableCell sx={{ fontWeight: 'bold' }} align="right">
|
||||
Actions
|
||||
<TableCell sx={{ fontWeight: 700 }}>{t('admin.colId')}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 700 }}>{t('admin.colUser')}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 700 }}>{t('admin.colEmail')}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 700 }}>{t('admin.colStatus')}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 700 }}>{t('admin.colJoined')}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 700 }}>{t('admin.colIp')}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 700 }} align="right">
|
||||
{t('admin.colActions')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@@ -142,12 +142,12 @@ const AdminUsers = () => {
|
||||
<TableRow key={user.id} hover>
|
||||
<TableCell>{user.id}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{user.username}
|
||||
</Typography>
|
||||
{user.is_superuser && (
|
||||
<Chip
|
||||
label="Superuser"
|
||||
label={t('admin.roleSuperuser')}
|
||||
size="small"
|
||||
color="secondary"
|
||||
sx={{ height: 16, fontSize: '0.6rem' }}
|
||||
@@ -157,7 +157,7 @@ const AdminUsers = () => {
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={user.is_active ? 'Active' : 'Inactive'}
|
||||
label={user.is_active ? t('common.active') : t('common.inactive')}
|
||||
color={user.is_active ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
HeroBackdrop,
|
||||
SectionHeader,
|
||||
FlavorCard,
|
||||
FlavorGrid,
|
||||
EmptyState,
|
||||
BackButton,
|
||||
} from '../../components/ui';
|
||||
@@ -39,17 +40,7 @@ const CategoryFlavors: React.FC = () => {
|
||||
{flavors.length === 0 ? (
|
||||
<EmptyState title={t('home.noCategoryFlavors')} subtitle={t('home.noCategoryFlavorsSub')} />
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||
lg: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
},
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<FlavorGrid>
|
||||
{flavors.map((flavor) => (
|
||||
<FlavorCard
|
||||
key={flavor.id}
|
||||
@@ -58,7 +49,7 @@ const CategoryFlavors: React.FC = () => {
|
||||
caption={`${flavor.ratings.length} ${t('common.reviews')}`}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</FlavorGrid>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -70,12 +70,12 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
variant="outlined"
|
||||
onClick={onBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 700 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</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')}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -96,7 +96,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
variant="contained"
|
||||
onClick={onShare}
|
||||
startIcon={<ShareIcon />}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 700 }}
|
||||
>
|
||||
{t('dashboard.shareProfile')}
|
||||
</Button>
|
||||
@@ -177,7 +177,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
<Typography
|
||||
variant={isXs ? 'h5' : 'h3'}
|
||||
sx={{
|
||||
fontWeight: '900',
|
||||
fontWeight: 900,
|
||||
mb: 0.25,
|
||||
letterSpacing: -1,
|
||||
overflow: 'hidden',
|
||||
@@ -222,14 +222,14 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ fontWeight: '900', lineHeight: 1, color: 'text.primary' }}
|
||||
sx={{ fontWeight: 900, lineHeight: 1, color: 'text.primary' }}
|
||||
>
|
||||
{stat.val}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: '900',
|
||||
fontWeight: 900,
|
||||
textTransform: 'uppercase',
|
||||
opacity: 0.5,
|
||||
fontSize: '0.6rem',
|
||||
|
||||
@@ -27,7 +27,7 @@ import StatusBadge from '../../components/StatusBadge';
|
||||
import MentionTextField from '../../components/MentionTextField';
|
||||
import RichText from '../../components/RichText';
|
||||
import { formatDate } from '../../utils/date';
|
||||
import { GlassCard } from '../../components/ui';
|
||||
import { GlassCard, FlavorThumb } from '../../components/ui';
|
||||
import { useToast } from '../../hooks/useToast';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
|
||||
@@ -111,7 +111,7 @@ const MyReviewCard: React.FC<MyReviewCardProps> = ({ rating, currentUsername })
|
||||
<CardContent sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
{isEditing ? (
|
||||
<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 })}
|
||||
</Typography>
|
||||
<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={{
|
||||
width: { xs: 56, sm: 64 },
|
||||
height: { 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>
|
||||
<FlavorThumb
|
||||
src={rating.flavor_image}
|
||||
name={rating.flavor_name}
|
||||
size={{ xs: 56, sm: 64 }}
|
||||
/>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography
|
||||
variant={isXs ? 'subtitle1' : 'h6'}
|
||||
sx={{ fontWeight: 'bold', lineHeight: 1.2, wordBreak: 'break-word' }}
|
||||
sx={{ fontWeight: 700, lineHeight: 1.2, wordBreak: 'break-word' }}
|
||||
>
|
||||
<Link
|
||||
to={`/flavor/${rating.flavor}`}
|
||||
@@ -223,15 +209,15 @@ const MyReviewCard: React.FC<MyReviewCardProps> = ({ rating, currentUsername })
|
||||
size="small"
|
||||
startIcon={<CommentIcon fontSize="small" />}
|
||||
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')}
|
||||
</Button>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Button size="small" sx={{ fontWeight: 'bold' }} onClick={startEdit}>
|
||||
<Button size="small" sx={{ fontWeight: 700 }} onClick={startEdit}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button size="small" color="error" sx={{ fontWeight: 'bold' }} onClick={handleDelete}>
|
||||
<Button size="small" color="error" sx={{ fontWeight: 700 }} onClick={handleDelete}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Stack>
|
||||
@@ -251,10 +237,7 @@ const MyReviewCard: React.FC<MyReviewCardProps> = ({ rating, currentUsername })
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 'bold', fontSize: '0.85rem' }}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.85rem' }}>
|
||||
{reply.user}
|
||||
{reply.user === currentUsername && (
|
||||
<VerifiedIcon
|
||||
|
||||
@@ -88,7 +88,7 @@ const MyReviewsTab: React.FC<MyReviewsTabProps> = ({ ratings, currentUsername, o
|
||||
<SortIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
}
|
||||
sx={{ minWidth: { lg: 140 }, fontWeight: 'bold' }}
|
||||
sx={{ minWidth: { lg: 140 }, fontWeight: 700 }}
|
||||
>
|
||||
<MenuItem value="date">{t('dashboard.date')}</MenuItem>
|
||||
<MenuItem value="rating">{t('dashboard.rating')}</MenuItem>
|
||||
@@ -125,7 +125,7 @@ const MyReviewsTab: React.FC<MyReviewsTabProps> = ({ ratings, currentUsername, o
|
||||
onClick={() => setCategory(cat)}
|
||||
color={category === cat ? 'primary' : 'default'}
|
||||
variant={category === cat ? 'filled' : 'outlined'}
|
||||
sx={{ fontWeight: 'bold', flexShrink: 0 }}
|
||||
sx={{ fontWeight: 700, flexShrink: 0 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -93,7 +93,7 @@ const RecommendationCard: React.FC<RecommendationCardProps> = ({ rec }) => {
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
mb: 0.5,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 1,
|
||||
@@ -112,7 +112,7 @@ const RecommendationCard: React.FC<RecommendationCardProps> = ({ rec }) => {
|
||||
label={reasonLabel}
|
||||
color={rec.reason === 'cf' ? 'primary' : 'default'}
|
||||
variant="outlined"
|
||||
sx={{ fontWeight: 'bold', maxWidth: '100%' }}
|
||||
sx={{ fontWeight: 700, maxWidth: '100%' }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Link>
|
||||
|
||||
@@ -40,7 +40,7 @@ const RecommendationsSection: React.FC<RecommendationsSectionProps> = ({ enabled
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<AutoAwesomeIcon color="primary" />
|
||||
<Typography variant="h6" sx={{ fontWeight: '900' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900 }}>
|
||||
{t('dashboard.recHeading')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
@@ -202,18 +202,18 @@ const FlavorDetail: React.FC = () => {
|
||||
size="small"
|
||||
sx={{
|
||||
mb: 2,
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.1),
|
||||
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}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<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')}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -248,7 +248,7 @@ const FlavorDetail: React.FC = () => {
|
||||
borderRadius: 3,
|
||||
py: 1.5,
|
||||
fontSize: '1rem',
|
||||
fontWeight: '900',
|
||||
fontWeight: 900,
|
||||
boxShadow: (theme) => `0 8px 20px ${alpha(theme.palette.primary.main, 0.3)}`,
|
||||
}}
|
||||
>
|
||||
@@ -340,7 +340,7 @@ const FlavorDetail: React.FC = () => {
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 'bold', fontSize: '1rem' }}
|
||||
sx={{ fontWeight: 700, fontSize: '1rem' }}
|
||||
>
|
||||
{rating.user}
|
||||
</Typography>
|
||||
@@ -358,7 +358,7 @@ const FlavorDetail: React.FC = () => {
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => navigate(`/admin-panel/rating/${rating.id}`)}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 700 }}
|
||||
>
|
||||
{t('admin.manageRating')}
|
||||
</Button>
|
||||
@@ -402,7 +402,7 @@ const FlavorDetail: React.FC = () => {
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
color: 'text.secondary',
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{rating.replies.length > 0
|
||||
@@ -415,7 +415,7 @@ const FlavorDetail: React.FC = () => {
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => startEdit(rating)}
|
||||
sx={{ minWidth: 0, mr: 1, fontWeight: 'bold' }}
|
||||
sx={{ minWidth: 0, mr: 1, fontWeight: 700 }}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
@@ -423,7 +423,7 @@ const FlavorDetail: React.FC = () => {
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteRating(rating.id)}
|
||||
sx={{ minWidth: 0, fontWeight: 'bold' }}
|
||||
sx={{ minWidth: 0, fontWeight: 700 }}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
@@ -461,7 +461,7 @@ const FlavorDetail: React.FC = () => {
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 'bold', fontSize: '0.85rem' }}
|
||||
sx={{ fontWeight: 700, fontSize: '0.85rem' }}
|
||||
>
|
||||
{reply.user}
|
||||
{reply.user === rating.user && (
|
||||
@@ -492,7 +492,7 @@ const FlavorDetail: React.FC = () => {
|
||||
minWidth: 0,
|
||||
py: 0,
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
onClick={() => {
|
||||
setEditingReplyId(reply.id);
|
||||
@@ -508,7 +508,7 @@ const FlavorDetail: React.FC = () => {
|
||||
minWidth: 0,
|
||||
py: 0,
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
onClick={() => handleDeleteReply(reply.id)}
|
||||
>
|
||||
@@ -526,7 +526,7 @@ const FlavorDetail: React.FC = () => {
|
||||
minWidth: 0,
|
||||
py: 0,
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{t('admin.manageReply')}
|
||||
@@ -591,7 +591,7 @@ const FlavorDetail: React.FC = () => {
|
||||
onClick={() => handleReplySubmit(rating.id)}
|
||||
sx={{
|
||||
px: 2,
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
height: 40,
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
|
||||
@@ -44,7 +44,7 @@ const RatingForm: React.FC<RatingFormProps> = ({ flavorId }) => {
|
||||
return (
|
||||
<GlassCard intensity="default">
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 700 }}>
|
||||
{t('flavorDetail.rateThisFlavor')}
|
||||
</Typography>
|
||||
<form onSubmit={handleSubmit}>
|
||||
@@ -67,7 +67,7 @@ const RatingForm: React.FC<RatingFormProps> = ({ flavorId }) => {
|
||||
variant="contained"
|
||||
type="submit"
|
||||
disabled={!score || createRating.isPending}
|
||||
sx={{ mt: 2, borderRadius: 2, fontWeight: 'bold' }}
|
||||
sx={{ mt: 2, borderRadius: 2, fontWeight: 700 }}
|
||||
>
|
||||
{t('flavorDetail.submitReview')}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 GroupsIcon from '@mui/icons-material/Groups';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -8,7 +8,7 @@ import { useSimilarFlavors } from '../../../api/queries/useSimilarFlavors';
|
||||
import type { SimilarFlavor } from '../../../api/types';
|
||||
import RatingBadge from '../../../components/RatingBadge';
|
||||
import StatusBadge from '../../../components/StatusBadge';
|
||||
import { GlassCard } from '../../../components/ui';
|
||||
import { GlassCard, FlavorGrid } from '../../../components/ui';
|
||||
|
||||
export interface SimilarFlavorsSectionProps {
|
||||
flavorId: number | undefined;
|
||||
@@ -68,7 +68,7 @@ const SimilarCard: React.FC<{ item: SimilarFlavor }> = ({ item }) => {
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
mb: 0.5,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 1,
|
||||
@@ -88,7 +88,7 @@ const SimilarCard: React.FC<{ item: SimilarFlavor }> = ({ item }) => {
|
||||
label={t('flavorDetail.similarCoRaters', { count: item.co_raters })}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{ fontWeight: 'bold', maxWidth: '100%' }}
|
||||
sx={{ fontWeight: 700, maxWidth: '100%' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</CardContent>
|
||||
@@ -110,13 +110,11 @@ const SimilarFlavorsSection: React.FC<SimilarFlavorsSectionProps> = ({ flavorId
|
||||
return (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Skeleton variant="text" width={280} height={36} sx={{ mb: 2 }} />
|
||||
<Grid container spacing={2}>
|
||||
<FlavorGrid compact>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Grid key={i} size={{ xs: 6, sm: 4, md: 3 }}>
|
||||
<Skeleton variant="rounded" height={240} sx={{ borderRadius: 2 }} />
|
||||
</Grid>
|
||||
<Skeleton key={i} variant="rounded" height={240} sx={{ borderRadius: 2 }} />
|
||||
))}
|
||||
</Grid>
|
||||
</FlavorGrid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -127,17 +125,15 @@ const SimilarFlavorsSection: React.FC<SimilarFlavorsSectionProps> = ({ flavorId
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<RecommendIcon color="primary" />
|
||||
<Typography variant="h6" sx={{ fontWeight: '900' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900 }}>
|
||||
{t('flavorDetail.similarHeading')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Grid container spacing={2}>
|
||||
<FlavorGrid compact>
|
||||
{data.map((item) => (
|
||||
<Grid key={item.id} size={{ xs: 6, sm: 4, md: 3 }}>
|
||||
<SimilarCard item={item} />
|
||||
</Grid>
|
||||
<SimilarCard key={item.id} item={item} />
|
||||
))}
|
||||
</Grid>
|
||||
</FlavorGrid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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] `MyReviewCard.tsx` confirm dialogs, error toasts
|
||||
- [x] `PublicProfile.tsx` tabs, empty states, confirm dialogs, error toasts
|
||||
- [ ] **Deferred (admin-only, superuser surface):** `AdminUserDetail.tsx`, `admin/*` (Banners, Tickets, Overview, Config, Jobs, Users), `AdminRatingDetail.tsx`, `AdminReplyDetail.tsx`, `Settings.tsx` theme-list subheaders. Lower UX priority — only the operator sees these.
|
||||
- [x] **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`).
|
||||
- [ ] **#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.
|
||||
|
||||
## 🟡 Medium
|
||||
@@ -27,12 +27,12 @@ Tracking doc from the frontend/UX audit. Items grouped by priority. Check off as
|
||||
|
||||
## 🟢 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.
|
||||
- [ ] **#12 Font-weight scale** — 600/700/800/900/'bold' scattered; enforce via theme.
|
||||
- [ ] **#13 Avatar fallback casing** — inconsistent `.toUpperCase()`.
|
||||
- [ ] **#14 Image placeholder** — `CommunityFeed`/`MyReviewCard` raw `<img>` can show broken-image icon; reuse `FlavorCard` pattern.
|
||||
- [ ] **#15 Standardize empty states** — `EmptyState` component vs inline `<Typography>`.
|
||||
- [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).
|
||||
- [x] **#13 Avatar fallback casing** — all avatar/initial fallbacks now `.charAt(0).toUpperCase()`; fixed the 2 outliers missing `.toUpperCase()` (`MainPage` featured review, `PublicProfile` header).
|
||||
- [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`.
|
||||
- [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)
|
||||
ScoreInput a11y (radiogroup, arrow/number keys). Glass design system + skeletons. Optimistic mutations. Lazy routes. Status/Rating badges.
|
||||
Reference in new issue
Block a user