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