diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index af971b2..fe713ac 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ import { useAuth } from './hooks/useAuth'; import { useTheme } from './hooks/useTheme'; import { useDrawerAnchor } from './hooks/useDrawerAnchor'; import { NavSidebar, NAV_SIDEBAR_WIDTH } from './components/layout/NavSidebar'; +import { BrandMark } from './components/layout/BrandMark'; import { useCategories } from './api/queries/useCategories'; import { NotificationMenu } from './components/layout/NotificationMenu'; import { RequireSuperuser } from './components/auth/RequireSuperuser'; @@ -114,6 +115,10 @@ const App: React.FC = () => { {mobileAnchor === 'left' && hamburger} + + + + diff --git a/frontend/src/app/GlobalSearch.tsx b/frontend/src/app/GlobalSearch.tsx index cb98780..df6b573 100644 --- a/frontend/src/app/GlobalSearch.tsx +++ b/frontend/src/app/GlobalSearch.tsx @@ -111,7 +111,7 @@ export const GlobalSearch = ({ compact = false }: GlobalSearchProps) => { }} /> - + {option.name} diff --git a/frontend/src/components/BannerSettingsDialog.tsx b/frontend/src/components/BannerSettingsDialog.tsx index b3e3fb9..43040a0 100644 --- a/frontend/src/components/BannerSettingsDialog.tsx +++ b/frontend/src/components/BannerSettingsDialog.tsx @@ -91,7 +91,7 @@ const BannerSettingsDialog: React.FC = ({ return ( - + {schema.label} {schema.description && ( @@ -143,7 +143,7 @@ const BannerSettingsDialog: React.FC = ({ return ( - Banner Settings: {banner?.name} + Banner Settings: {banner?.name} {error && ( diff --git a/frontend/src/components/CookieBanner.tsx b/frontend/src/components/CookieBanner.tsx index 2ecbd4e..99d4988 100644 --- a/frontend/src/components/CookieBanner.tsx +++ b/frontend/src/components/CookieBanner.tsx @@ -56,7 +56,7 @@ const CookieBanner: React.FC = ({ onThemeChange, currentTheme }, }} > - + {t('cookieBanner.title')} 🍪 @@ -76,7 +76,7 @@ const CookieBanner: React.FC = ({ onThemeChange, currentTheme Technical Details: @@ -94,7 +94,7 @@ const CookieBanner: React.FC = ({ onThemeChange, currentTheme variant="outlined" color="primary" onClick={handleToggleTheme} - sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', m: '0 !important' }} + sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 700, m: '0 !important' }} > {currentTheme === 'holy_light' ? t('cookieBanner.switchToDark') @@ -108,7 +108,7 @@ const CookieBanner: React.FC = ({ onThemeChange, currentTheme sx={{ borderRadius: 2, textTransform: 'none', - fontWeight: 'bold', + fontWeight: 700, px: 4, m: '0 !important', }} diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index c7cd2bf..598f309 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -30,7 +30,7 @@ const Footer: React.FC = () => { mb: 2, }} > - + © {year} Holy Flavors Archive diff --git a/frontend/src/components/MentionTextField.tsx b/frontend/src/components/MentionTextField.tsx index 6e7ddb4..392e33e 100644 --- a/frontend/src/components/MentionTextField.tsx +++ b/frontend/src/components/MentionTextField.tsx @@ -194,7 +194,7 @@ const MentionTextField: React.FC = ({ ))} diff --git a/frontend/src/components/RichText.tsx b/frontend/src/components/RichText.tsx index bfbc7e7..29c6903 100644 --- a/frontend/src/components/RichText.tsx +++ b/frontend/src/components/RichText.tsx @@ -24,7 +24,7 @@ const RichText: React.FC = ({ text }) => { style={{ color: theme.palette.primary.main, textDecoration: 'none', - fontWeight: 'bold', + fontWeight: 700, backgroundColor: alpha(theme.palette.primary.main, 0.1), padding: '0 4px', borderRadius: '4px', diff --git a/frontend/src/components/layout/BrandMark.tsx b/frontend/src/components/layout/BrandMark.tsx new file mode 100644 index 0000000..455ba02 --- /dev/null +++ b/frontend/src/components/layout/BrandMark.tsx @@ -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 ( + + theme.tokens.accent.gradient, + boxShadow: (theme) => theme.tokens.elevation.sm, + }} + > + + H + + + {!compact && ( + + + {t('brand.name')} + + + {t('brand.tagline')} + + + )} + + ); +}; diff --git a/frontend/src/components/layout/NavSidebar.tsx b/frontend/src/components/layout/NavSidebar.tsx index 0b433c4..633afc3 100644 --- a/frontend/src/components/layout/NavSidebar.tsx +++ b/frontend/src/components/layout/NavSidebar.tsx @@ -32,6 +32,7 @@ import { useTranslation } from 'react-i18next'; import { useAuth } from '../../hooks/useAuth'; import { GlobalSearch } from '../../app/GlobalSearch'; import { NotificationMenu } from './NotificationMenu'; +import { BrandMark } from './BrandMark'; export const NAV_SIDEBAR_WIDTH = 280; @@ -73,6 +74,16 @@ export const NavSidebar = ({ }} role="presentation" > + + + + {user ? ( - + {t('community.notifications')} {unreadCount > 0 && ( diff --git a/frontend/src/components/ui/BackButton.tsx b/frontend/src/components/ui/BackButton.tsx index c5fb3e3..118f181 100644 --- a/frontend/src/components/ui/BackButton.tsx +++ b/frontend/src/components/ui/BackButton.tsx @@ -18,7 +18,7 @@ const baseSx: SxProps = { alignSelf: 'flex-start', borderRadius: 2, textTransform: 'none', - fontWeight: 'bold', + fontWeight: 700, color: 'text.secondary', }; diff --git a/frontend/src/components/ui/FlavorGrid.tsx b/frontend/src/components/ui/FlavorGrid.tsx new file mode 100644 index 0000000..f5f6225 --- /dev/null +++ b/frontend/src/components/ui/FlavorGrid.tsx @@ -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; +} + +/** + * 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) => ( + + {children} + +); diff --git a/frontend/src/components/ui/FlavorThumb.tsx b/frontend/src/components/ui/FlavorThumb.tsx new file mode 100644 index 0000000..16f2794 --- /dev/null +++ b/frontend/src/components/ui/FlavorThumb.tsx @@ -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; + /** sx border-radius (theme spacing units). */ + radius?: number; + sx?: SxProps; +} + +/** + * 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 ( + + {showImg ? ( + setFailed(true)} + sx={{ width: '100%', height: '100%', objectFit: 'cover' }} + /> + ) : ( + + + {name.charAt(0).toUpperCase()} + + + )} + + ); +}; diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 1bd5746..b6dcfb3 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -5,6 +5,8 @@ export { SectionHeader, type SectionHeaderProps } from './SectionHeader'; export { HeroBackdrop, type HeroBackdropProps } from './HeroBackdrop'; export { ReviewRow, type ReviewRowProps, type ReviewRowData } from './ReviewRow'; export { FlavorCard, type FlavorCardProps, type FlavorCardData } from './FlavorCard'; +export { FlavorThumb, type FlavorThumbProps } from './FlavorThumb'; +export { FlavorGrid, type FlavorGridProps } from './FlavorGrid'; export { FormCard, type FormCardProps } from './FormCard'; export { EmptyState, type EmptyStateProps } from './EmptyState'; export { BackButton, type BackButtonProps } from './BackButton'; diff --git a/frontend/src/contexts/ConfirmContext.tsx b/frontend/src/contexts/ConfirmContext.tsx index 38a55c9..b2b29ad 100644 --- a/frontend/src/contexts/ConfirmContext.tsx +++ b/frontend/src/contexts/ConfirmContext.tsx @@ -50,7 +50,7 @@ export const ConfirmProvider = ({ children }: { children: ReactNode }) => { fullWidth PaperProps={{ sx: { borderRadius: 3 } }} > - {opts?.title && {opts.title}} + {opts?.title && {opts.title}} {opts?.message} diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index d52d9d6..056131a 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -17,6 +17,10 @@ const resources = { following: 'Following', categories: 'Categories', }, + brand: { + name: 'Holy Archive', + tagline: 'Flavor Rater', + }, categories: { energy: 'Energy', hydration: 'Hydration', @@ -60,6 +64,9 @@ const resources = { subtitle: 'Manage your account and preferences', appearanceTitle: 'Appearance & Language', themeLabel: 'Theme', + themeGroupHoly: 'Holy Archive', + themeGroupLight: 'Light Themes', + themeGroupDark: 'Dark Themes', langLabel: 'Language', bannerLabel: 'Interactive Banner', bannerDefault: 'Site Default', @@ -157,6 +164,10 @@ const resources = { confirmDeleteReply: 'Delete this reply?', confirmDeleteComment: 'Delete this comment?', notFoundTitle: '404 — Page Not Found', + home: 'Home', + processing: 'Processing...', + active: 'Active', + inactive: 'Inactive', }, status: { limited: 'Limited', @@ -316,6 +327,135 @@ const resources = { activate: 'Activate Account', dangerZone: 'Danger Zone', deleteUser: 'Delete User Permanently', + sections: { + overview: 'Overview', + users: 'Users', + flavors: 'Flavors', + banners: 'Banners', + jobs: 'Jobs', + tickets: 'Tickets', + config: 'Config', + }, + updateFailed: 'Update failed', + deleteFailed: 'Delete failed', + actionFailed: 'Action failed', + retryLoading: 'Retry Loading', + userNum: 'User #{{n}}', + ratingNum: 'Rating #{{n}}', + replyNum: 'Reply #{{n}}', + noBanners: 'No banner models found', + noBannersSub: 'No banners registered in the database.', + bannerSetDefault: 'Banner set as default', + bannerActivateFailed: 'Failed to activate banner', + bannerToggleFailed: 'Failed to toggle visibility', + bannerHidden: 'Hidden from Users', + bannerGlobalDefault: 'Global Default', + bannerSlug: 'Slug', + editSettings: 'Edit Settings', + disableForUsers: 'Disable for Users', + enableForUsers: 'Enable for Users', + setAsDefault: 'Set as Default', + configSaved: 'Configuration saved', + configSaveFailed: 'Failed to save configuration', + configLiveHint: 'Live values applied without restart. Form posts the full config on save.', + siteName: 'Site Name', + siteNameHint: 'Displayed in the browser tab and various headers.', + maintenanceMode: 'Maintenance Mode', + maintenanceModeHint: 'Block non-admin users from interacting with the site.', + allowSignups: 'Allow New Signups', + allowSignupsHint: 'When off, the registration endpoint rejects new accounts.', + requireVerification: 'Require Email Verification', + requireVerificationHint: 'New accounts must confirm their email before logging in.', + saving: 'Saving…', + saveChanges: 'Save changes', + discard: 'Discard', + jobsTitle: 'Background Worker Jobs', + jobsFilter: 'Filter jobs…', + jobQueued: 'Job queued', + jobTriggerFailed: 'Failed to trigger job', + jobScheduleFailed: 'Failed to update schedule', + jobStatusQueued: 'QUEUED', + jobLastRun: 'Last Run', + jobNextRun: 'Next Run', + jobNever: 'Never', + jobQueuedShort: 'Queued…', + jobDisabled: 'Disabled', + jobSchedule: 'Schedule', + jobLastOutput: 'Last Run Output', + jobLastError: 'Last Error', + jobTriggerNow: 'Trigger Now', + schedHourly: 'Hourly', + sched6h: 'Every 6 Hours', + sched12h: 'Every 12 Hours', + schedDaily: 'Daily', + schedWeekly: 'Weekly', + testEmailFailed: 'Failed to send', + envInfo: 'Environment Info (.env)', + apiStatus: 'API Status', + frontendUrl: 'Frontend URL', + allowedHosts: 'Allowed Hosts', + envHint: + 'These values are read from your server environment or .env file. To change them, you must update the file and restart the backend container.', + colId: 'ID', + colUser: 'User', + colEmail: 'Email', + colStatus: 'Status', + colJoined: 'Joined', + colIp: 'IP History', + colActions: 'Actions', + roleAdmin: 'Admin', + roleSuperuser: 'Superuser', + ticketMessageFailed: 'Failed to send message', + ticketStatusSet: 'Status set to {{status}}', + ticketStatusFailed: 'Failed to update status', + ticketDeleteConfirm: 'Delete this ticket?', + ticketDeleted: 'Ticket deleted', + ticketDeleteFailed: 'Failed to delete ticket', + viewProfile: 'View Profile', + supportTag: 'Support', + userNotFound: 'User not found', + userMgmtTitle: 'User Management', + joinedLabel: 'Joined', + lastLoginLabel: 'Last Login', + tabContent: 'Content', + tabSecurity: 'Security & IP', + tabDanger: 'Danger Zone', + ratingsDiscussion: 'User Ratings & Discussion', + scoreLabel: 'Score', + noCommentShort: 'No comment', + noRatingsYet: 'No ratings yet.', + noIpHistory: 'No IP history available.', + deleteUserTitle: 'Delete user', + deleteUserConfirm: 'PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.', + deleteForever: 'Delete forever', + deleteUserWarning: + 'Deleting this user will permanently remove their profile, all their ratings, replies, and IP history. This action is irreversible.', + deleteUserNote: + 'Proceed only if the user has been reviewed for policy violations or requested deletion.', + deleteRatingConfirm: 'Delete this rating?', + deleteReplyConfirm: 'Delete this reply?', + ratingMgmtTitle: 'Rating Management', + replyMgmtTitle: 'Reply Management', + ratingUpdated: 'Rating updated!', + replyUpdated: 'Reply updated!', + deleteRatingRepliesConfirm: 'Delete this rating and all replies?', + userLabel: 'User: {{user}}', + replyByLabel: 'Reply by {{user}}', + manageFlavors: 'Manage Flavors', + mergeFlavors: 'Merge Flavors', + mergeFlavorsDesc: + 'Combine two flavors into one. Ratings from the source flavor will be moved to the target flavor. If a user rated both, the more detailed review is kept.', + flavorToRemove: 'Flavor to Remove (Source)', + flavorToRemoveHint: 'This flavor will be DELETED', + flavorToKeep: 'Flavor to Keep (Target)', + flavorToKeepHint: 'This flavor will be PRESERVED', + startMerge: 'Start Merge', + confirmMergeTitle: 'Confirm Flavor Merge', + confirmMergeText: + 'Are you sure you want to merge "{{remove}}" into "{{keep}}"? This action is irreversible. "{{remove}}" will be deleted, and all its ratings will be transferred.', + confirmMerge: 'Merge Flavors', + mergeSuccess: 'Flavors merged successfully', + errorPrefix: 'Error: {{message}}', }, about: { title: 'About This Project', @@ -408,6 +548,10 @@ const resources = { following: 'Folge ich', categories: 'Kategorien', }, + brand: { + name: 'Holy-Archiv', + tagline: 'Geschmacks-Rating', + }, categories: { energy: 'Energy', hydration: 'Hydration', @@ -451,6 +595,9 @@ const resources = { subtitle: 'Verwalte dein Konto und deine Einstellungen', appearanceTitle: 'Erscheinungsbild & Sprache', themeLabel: 'Design', + themeGroupHoly: 'Holy Archiv', + themeGroupLight: 'Helle Designs', + themeGroupDark: 'Dunkle Designs', langLabel: 'Sprache', bannerLabel: 'Interaktiver Banner', bannerDefault: 'Seiten-Standard', @@ -548,6 +695,10 @@ const resources = { confirmDeleteReply: 'Diese Antwort löschen?', confirmDeleteComment: 'Diesen Kommentar löschen?', notFoundTitle: '404 — Seite nicht gefunden', + home: 'Startseite', + processing: 'Wird verarbeitet...', + active: 'Aktiv', + inactive: 'Inaktiv', }, status: { limited: 'Limitiert', @@ -704,13 +855,145 @@ const resources = { sendTestEmail: 'Test-E-Mail senden', testEmailSuccess: 'Test-E-Mail erfolgreich gesendet!', ipHistory: 'Login-IP-Verlauf', - manageUser: 'Manage User', - manageRating: 'Manage Rating', - manageReply: 'Manage Reply', - deactivate: 'Deactivate Account', + manageUser: 'Benutzer verwalten', + manageRating: 'Bewertung verwalten', + manageReply: 'Antwort verwalten', + deactivate: 'Konto deaktivieren', activate: 'Konto aktivieren', dangerZone: 'Gefahrenzone', deleteUser: 'Benutzer dauerhaft löschen', + sections: { + overview: 'Übersicht', + users: 'Benutzer', + flavors: 'Sorten', + banners: 'Banner', + jobs: 'Jobs', + tickets: 'Tickets', + config: 'Konfiguration', + }, + updateFailed: 'Aktualisierung fehlgeschlagen', + deleteFailed: 'Löschen fehlgeschlagen', + actionFailed: 'Aktion fehlgeschlagen', + retryLoading: 'Erneut laden', + userNum: 'Benutzer #{{n}}', + ratingNum: 'Bewertung #{{n}}', + replyNum: 'Antwort #{{n}}', + noBanners: 'Keine Banner-Modelle gefunden', + noBannersSub: 'Keine Banner in der Datenbank registriert.', + bannerSetDefault: 'Banner als Standard festgelegt', + bannerActivateFailed: 'Banner konnte nicht aktiviert werden', + bannerToggleFailed: 'Sichtbarkeit konnte nicht umgeschaltet werden', + bannerHidden: 'Für Nutzer ausgeblendet', + bannerGlobalDefault: 'Globaler Standard', + bannerSlug: 'Slug', + editSettings: 'Einstellungen bearbeiten', + disableForUsers: 'Für Nutzer deaktivieren', + enableForUsers: 'Für Nutzer aktivieren', + setAsDefault: 'Als Standard festlegen', + configSaved: 'Konfiguration gespeichert', + configSaveFailed: 'Konfiguration konnte nicht gespeichert werden', + configLiveHint: + 'Live-Werte werden ohne Neustart angewendet. Beim Speichern wird die vollständige Konfiguration gesendet.', + siteName: 'Seitenname', + siteNameHint: 'Wird im Browser-Tab und in verschiedenen Kopfzeilen angezeigt.', + maintenanceMode: 'Wartungsmodus', + maintenanceModeHint: 'Nicht-Admin-Nutzer von der Interaktion mit der Seite ausschließen.', + allowSignups: 'Neue Registrierungen erlauben', + allowSignupsHint: 'Wenn aus, lehnt der Registrierungs-Endpunkt neue Konten ab.', + requireVerification: 'E-Mail-Verifizierung erforderlich', + requireVerificationHint: + 'Neue Konten müssen ihre E-Mail bestätigen, bevor sie sich anmelden können.', + saving: 'Wird gespeichert…', + saveChanges: 'Änderungen speichern', + discard: 'Verwerfen', + jobsTitle: 'Hintergrund-Worker-Jobs', + jobsFilter: 'Jobs filtern…', + jobQueued: 'Job eingereiht', + jobTriggerFailed: 'Job konnte nicht ausgelöst werden', + jobScheduleFailed: 'Zeitplan konnte nicht aktualisiert werden', + jobStatusQueued: 'EINGEREIHT', + jobLastRun: 'Letzter Lauf', + jobNextRun: 'Nächster Lauf', + jobNever: 'Nie', + jobQueuedShort: 'Eingereiht…', + jobDisabled: 'Deaktiviert', + jobSchedule: 'Zeitplan', + jobLastOutput: 'Letzte Ausgabe', + jobLastError: 'Letzter Fehler', + jobTriggerNow: 'Jetzt auslösen', + schedHourly: 'Stündlich', + sched6h: 'Alle 6 Stunden', + sched12h: 'Alle 12 Stunden', + schedDaily: 'Täglich', + schedWeekly: 'Wöchentlich', + testEmailFailed: 'Senden fehlgeschlagen', + envInfo: 'Umgebungsinfo (.env)', + apiStatus: 'API-Status', + frontendUrl: 'Frontend-URL', + allowedHosts: 'Erlaubte Hosts', + envHint: + 'Diese Werte werden aus der Server-Umgebung oder der .env-Datei gelesen. Um sie zu ändern, musst du die Datei aktualisieren und den Backend-Container neu starten.', + colId: 'ID', + colUser: 'Benutzer', + colEmail: 'E-Mail', + colStatus: 'Status', + colJoined: 'Beigetreten', + colIp: 'IP-Verlauf', + colActions: 'Aktionen', + roleAdmin: 'Admin', + roleSuperuser: 'Superuser', + ticketMessageFailed: 'Nachricht konnte nicht gesendet werden', + ticketStatusSet: 'Status auf {{status}} gesetzt', + ticketStatusFailed: 'Status konnte nicht aktualisiert werden', + ticketDeleteConfirm: 'Dieses Ticket löschen?', + ticketDeleted: 'Ticket gelöscht', + ticketDeleteFailed: 'Ticket konnte nicht gelöscht werden', + viewProfile: 'Profil ansehen', + supportTag: 'Support', + userNotFound: 'Benutzer nicht gefunden', + userMgmtTitle: 'Benutzerverwaltung', + joinedLabel: 'Beigetreten', + lastLoginLabel: 'Letzte Anmeldung', + tabContent: 'Inhalt', + tabSecurity: 'Sicherheit & IP', + tabDanger: 'Gefahrenzone', + ratingsDiscussion: 'Bewertungen & Diskussion des Benutzers', + scoreLabel: 'Bewertung', + noCommentShort: 'Kein Kommentar', + noRatingsYet: 'Noch keine Bewertungen.', + noIpHistory: 'Kein IP-Verlauf verfügbar.', + deleteUserTitle: 'Benutzer löschen', + deleteUserConfirm: + 'DIESEN BENUTZER UND ALLE DATEN DAUERHAFT LÖSCHEN? Dies kann nicht rückgängig gemacht werden.', + deleteForever: 'Endgültig löschen', + deleteUserWarning: + 'Das Löschen dieses Benutzers entfernt dauerhaft sein Profil, alle Bewertungen, Antworten und den IP-Verlauf. Diese Aktion ist unwiderruflich.', + deleteUserNote: + 'Fahre nur fort, wenn der Benutzer auf Richtlinienverstöße geprüft wurde oder die Löschung beantragt hat.', + deleteRatingConfirm: 'Diese Bewertung löschen?', + deleteReplyConfirm: 'Diese Antwort löschen?', + ratingMgmtTitle: 'Bewertungsverwaltung', + replyMgmtTitle: 'Antwortverwaltung', + ratingUpdated: 'Bewertung aktualisiert!', + replyUpdated: 'Antwort aktualisiert!', + deleteRatingRepliesConfirm: 'Diese Bewertung und alle Antworten löschen?', + userLabel: 'Benutzer: {{user}}', + replyByLabel: 'Antwort von {{user}}', + manageFlavors: 'Sorten verwalten', + mergeFlavors: 'Sorten zusammenführen', + mergeFlavorsDesc: + 'Kombiniere zwei Sorten zu einer. Bewertungen der Quellsorte werden zur Zielsorte verschoben. Hat ein Nutzer beide bewertet, wird die ausführlichere Bewertung behalten.', + flavorToRemove: 'Zu entfernende Sorte (Quelle)', + flavorToRemoveHint: 'Diese Sorte wird GELÖSCHT', + flavorToKeep: 'Zu behaltende Sorte (Ziel)', + flavorToKeepHint: 'Diese Sorte wird BEHALTEN', + startMerge: 'Zusammenführen starten', + confirmMergeTitle: 'Sorten-Zusammenführung bestätigen', + confirmMergeText: + 'Möchtest du „{{remove}}" wirklich in „{{keep}}" zusammenführen? Diese Aktion ist unwiderruflich. „{{remove}}" wird gelöscht und alle Bewertungen werden übertragen.', + confirmMerge: 'Sorten zusammenführen', + mergeSuccess: 'Sorten erfolgreich zusammengeführt', + errorPrefix: 'Fehler: {{message}}', }, about: { title: 'Über dieses Projekt', diff --git a/frontend/src/pages/About.tsx b/frontend/src/pages/About.tsx index 33a28d9..375cd8f 100644 --- a/frontend/src/pages/About.tsx +++ b/frontend/src/pages/About.tsx @@ -68,7 +68,7 @@ const About: React.FC = () => { - + {t('about.contact')}: @@ -85,7 +85,7 @@ const About: React.FC = () => { - + {t('about.responsible')} diff --git a/frontend/src/pages/AdminRatingDetail.tsx b/frontend/src/pages/AdminRatingDetail.tsx index 9ecedf9..642e975 100644 --- a/frontend/src/pages/AdminRatingDetail.tsx +++ b/frontend/src/pages/AdminRatingDetail.tsx @@ -36,27 +36,29 @@ const AdminRatingDetail: React.FC = () => { if (error) navigate('/admin-panel'); }, [error, navigate]); - useTitle(rating ? `${t('admin.manageRating')}: ${rating.flavor_name}` : 'Rating Management'); + useTitle( + rating ? `${t('admin.manageRating')}: ${rating.flavor_name}` : t('admin.ratingMgmtTitle'), + ); const handleUpdate = async (e: React.FormEvent) => { e.preventDefault(); if (!id) return; try { await updateRating.mutateAsync({ id: Number(id), score, comment }); - notify({ message: 'Rating updated!', severity: 'success' }); + notify({ message: t('admin.ratingUpdated'), severity: 'success' }); } catch { - notify({ message: 'Update failed', severity: 'error' }); + notify({ message: t('admin.updateFailed'), severity: 'error' }); } }; const handleDelete = async () => { if (!id) return; - if (!(await confirm({ message: 'Delete this rating and all replies?', danger: true }))) return; + if (!(await confirm({ message: t('admin.deleteRatingRepliesConfirm'), danger: true }))) return; try { await deleteRating.mutateAsync(Number(id)); navigate('/admin-panel'); } catch { - notify({ message: 'Delete failed', severity: 'error' }); + notify({ message: t('admin.deleteFailed'), severity: 'error' }); } }; @@ -87,7 +89,7 @@ const AdminRatingDetail: React.FC = () => { @@ -107,7 +109,7 @@ const AdminRatingDetail: React.FC = () => { } > { if (error) navigate('/admin-panel'); }, [error, navigate]); - useTitle(reply ? `${t('admin.manageReply')}` : 'Reply Management'); + useTitle(reply ? `${t('admin.manageReply')}` : t('admin.replyMgmtTitle')); const handleUpdate = async (e: React.FormEvent) => { e.preventDefault(); if (!id) return; try { await updateReply.mutateAsync({ replyId: Number(id), text }); - notify({ message: 'Reply updated!', severity: 'success' }); + notify({ message: t('admin.replyUpdated'), severity: 'success' }); } catch { - notify({ message: 'Update failed', severity: 'error' }); + notify({ message: t('admin.updateFailed'), severity: 'error' }); } }; const handleDelete = async () => { if (!id) return; - if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return; + if (!(await confirm({ message: t('admin.deleteReplyConfirm'), danger: true }))) return; try { await deleteReply.mutateAsync(Number(id)); navigate('/admin-panel'); } catch { - notify({ message: 'Delete failed', severity: 'error' }); + notify({ message: t('admin.deleteFailed'), severity: 'error' }); } }; @@ -83,7 +83,7 @@ const AdminReplyDetail: React.FC = () => { diff --git a/frontend/src/pages/AdminUserDetail.tsx b/frontend/src/pages/AdminUserDetail.tsx index ce20e8b..0727f18 100644 --- a/frontend/src/pages/AdminUserDetail.tsx +++ b/frontend/src/pages/AdminUserDetail.tsx @@ -110,14 +110,14 @@ const AdminUserDetail: React.FC = () => { if (error) navigate('/admin-panel'); }, [error, navigate]); - useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : 'User Management'); + useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : t('admin.userMgmtTitle')); const handleToggleActive = async () => { if (!user || !id) return; try { await toggleActive.mutateAsync({ id, isActive: user.is_active }); } catch { - notify({ message: 'Action failed', severity: 'error' }); + notify({ message: t('admin.actionFailed'), severity: 'error' }); } }; @@ -130,16 +130,16 @@ const AdminUserDetail: React.FC = () => { }); setEditingRatingId(null); } catch { - notify({ message: 'Update failed', severity: 'error' }); + notify({ message: t('admin.updateFailed'), severity: 'error' }); } }; const handleDeleteRating = async (ratingId: number) => { - if (!(await confirm({ message: 'Delete this rating?', danger: true }))) return; + if (!(await confirm({ message: t('admin.deleteRatingConfirm'), danger: true }))) return; try { await deleteRatingMutation.mutateAsync(ratingId); } catch { - notify({ message: 'Delete failed', severity: 'error' }); + notify({ message: t('admin.deleteFailed'), severity: 'error' }); } }; @@ -148,25 +148,25 @@ const AdminUserDetail: React.FC = () => { await updateReplyMutation.mutateAsync({ replyId, text: editReplyText }); setEditingReplyId(null); } catch { - notify({ message: 'Update failed', severity: 'error' }); + notify({ message: t('admin.updateFailed'), severity: 'error' }); } }; const handleDeleteReply = async (replyId: number) => { - if (!(await confirm({ message: 'Delete this reply?', danger: true }))) return; + if (!(await confirm({ message: t('admin.deleteReplyConfirm'), danger: true }))) return; try { await deleteReplyMutation.mutateAsync(replyId); } catch { - notify({ message: 'Delete failed', severity: 'error' }); + notify({ message: t('admin.deleteFailed'), severity: 'error' }); } }; const handleDelete = async () => { if (!id) return; const ok = await confirm({ - title: 'Delete user', - message: 'PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.', - confirmLabel: 'Delete forever', + title: t('admin.deleteUserTitle'), + message: t('admin.deleteUserConfirm'), + confirmLabel: t('admin.deleteForever'), danger: true, }); if (!ok) return; @@ -174,7 +174,7 @@ const AdminUserDetail: React.FC = () => { await deleteUserMutation.mutateAsync(id); navigate('/admin-panel'); } catch { - notify({ message: 'Delete failed', severity: 'error' }); + notify({ message: t('admin.deleteFailed'), severity: 'error' }); } }; @@ -184,7 +184,7 @@ const AdminUserDetail: React.FC = () => { ); - if (!user) return User not found; + if (!user) return {t('admin.userNotFound')}; return ( @@ -215,7 +215,7 @@ const AdminUserDetail: React.FC = () => { - + {user.username} @@ -227,7 +227,8 @@ const AdminUserDetail: React.FC = () => { display="block" sx={{ mt: 0.5 }} > - Joined: {formatDate(user.date_joined)} • Last Login: {formatDate(user.last_login)} + {t('admin.joinedLabel')}: {formatDate(user.date_joined)} •{' '} + {t('admin.lastLoginLabel')}: {formatDate(user.last_login)} { sx={{ mt: 1.5, justifyContent: { xs: 'center', sm: 'flex-start' } }} > - {user.is_superuser && } + {user.is_superuser && ( + + )} @@ -249,7 +252,7 @@ const AdminUserDetail: React.FC = () => { variant="contained" color={user.is_active ? 'warning' : 'success'} onClick={handleToggleActive} - sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }} + sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 700 }} > {user.is_active ? t('admin.deactivate') : t('admin.activate')} @@ -262,14 +265,14 @@ const AdminUserDetail: React.FC = () => { setActiveTab(v)} variant="fullWidth"> } - label={`Content (${user.ratings.length})`} - sx={{ fontWeight: 'bold' }} + label={`${t('admin.tabContent')} (${user.ratings.length})`} + sx={{ fontWeight: 700 }} /> - } label="Security & IP" sx={{ fontWeight: 'bold' }} /> + } label={t('admin.tabSecurity')} sx={{ fontWeight: 700 }} /> } - label="Danger Zone" - sx={{ fontWeight: 'bold', color: 'error.main' }} + label={t('admin.tabDanger')} + sx={{ fontWeight: 700, color: 'error.main' }} /> @@ -277,7 +280,7 @@ const AdminUserDetail: React.FC = () => { {activeTab === 0 && ( - + {user.ratings.map((rating) => ( @@ -285,7 +288,7 @@ const AdminUserDetail: React.FC = () => { {editingRatingId === rating.id ? ( - Score: + {t('admin.scoreLabel')}: { alignItems="flex-start" sx={{ mb: 1 }} > - + {rating.flavor_name} @@ -334,7 +337,7 @@ const AdminUserDetail: React.FC = () => { label={`${rating.score}/10`} size="small" color="primary" - sx={{ fontWeight: '900' }} + sx={{ fontWeight: 900 }} /> @@ -366,7 +369,7 @@ const AdminUserDetail: React.FC = () => { color="text.secondary" sx={{ fontStyle: 'italic' }} > - No comment + {t('admin.noCommentShort')} )} @@ -408,7 +411,7 @@ const AdminUserDetail: React.FC = () => { { setEditReplyText(reply.text); }} > - Edit + {t('common.edit')} @@ -452,7 +455,7 @@ const AdminUserDetail: React.FC = () => { ))} - {user.ratings.length === 0 && } + {user.ratings.length === 0 && } )} @@ -462,7 +465,7 @@ const AdminUserDetail: React.FC = () => { {t('admin.ipHistory')} @@ -471,12 +474,12 @@ const AdminUserDetail: React.FC = () => { ))} {user.ips.length === 0 && ( - No IP history available. + {t('admin.noIpHistory')} )} @@ -485,7 +488,7 @@ const AdminUserDetail: React.FC = () => { {activeTab === 2 && ( { size="large" startIcon={} onClick={handleDelete} - sx={{ borderRadius: 2, fontWeight: 'bold', px: 4 }} + sx={{ borderRadius: 2, fontWeight: 700, px: 4 }} > {t('admin.deleteUser')} } > - Proceed only if the user has been reviewed for policy violations or requested deletion. + {t('admin.deleteUserNote')} )} diff --git a/frontend/src/pages/CommunityFeed.tsx b/frontend/src/pages/CommunityFeed.tsx index b9016d5..15a3d72 100644 --- a/frontend/src/pages/CommunityFeed.tsx +++ b/frontend/src/pages/CommunityFeed.tsx @@ -38,6 +38,7 @@ import { HeroBackdrop, SectionHeader, GlassCard, + FlavorThumb, EmptyState, BackButton, } from '../components/ui'; @@ -60,7 +61,7 @@ const SidebarCard: React.FC<{ }} > {icon} - + {title} @@ -170,7 +171,7 @@ const CommunityFeed: React.FC = () => { > {rating.user} @@ -193,7 +194,7 @@ const CommunityFeed: React.FC = () => { variant="outlined" color="secondary" onClick={() => navigate(`/admin-panel/rating/${rating.id}`)} - sx={{ mb: 2, borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }} + sx={{ mb: 2, borderRadius: 2, textTransform: 'none', fontWeight: 700 }} > {t('admin.manageRating')} @@ -208,7 +209,7 @@ const CommunityFeed: React.FC = () => { to={`/flavor/${rating.flavor}`} sx={{ color: 'primary.main', - fontWeight: 'bold', + fontWeight: 700, textDecoration: 'none', }} > @@ -233,24 +234,12 @@ const CommunityFeed: React.FC = () => { )} - - - + @@ -274,7 +263,7 @@ const CommunityFeed: React.FC = () => { {reply.user} @@ -355,34 +344,21 @@ const CommunityFeed: React.FC = () => { {topFollowed.map((flavor, idx) => ( - + {idx + 1} - - - + diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 3c49865..72ff751 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -63,7 +63,7 @@ const Dashboard: React.FC = () => { - + {t('dashboard.myReviews')} { } /> {searchLoading ? ( - + {Array.from({ length: 8 }).map((_, i) => ( ))} - + ) : searchResults.length === 0 ? ( ) : ( - + {searchResults.map((flavor) => ( ))} - + )} ); @@ -423,7 +404,7 @@ const MainPage: React.FC = () => { label={`${t('common.rank')} #${safeIndex + 1}`} color="primary" size="small" - sx={{ width: 'fit-content', mb: 1, fontWeight: 'bold' }} + sx={{ width: 'fit-content', mb: 1, fontWeight: 700 }} /> { }} > - + {currentTop.ratings.length} {t('common.reviews')} @@ -486,7 +467,7 @@ const MainPage: React.FC = () => { src={featuredReview.user_avatar || undefined} sx={{ width: 22, height: 22, fontSize: '0.6rem' }} > - {!featuredReview.user_avatar && featuredReview.user.charAt(0)} + {!featuredReview.user_avatar && featuredReview.user.charAt(0).toUpperCase()} — {featuredReview.user} @@ -553,46 +534,19 @@ const MainPage: React.FC = () => { {newestLoading ? ( - + {Array.from({ length: 6 }).map((_, i) => ( ))} - + ) : newestFlavors.length === 0 ? ( ) : ( - + {newestFlavors.map((flavor) => ( ))} - + )} diff --git a/frontend/src/pages/Privacy.tsx b/frontend/src/pages/Privacy.tsx index 9f42eae..0597f10 100644 --- a/frontend/src/pages/Privacy.tsx +++ b/frontend/src/pages/Privacy.tsx @@ -81,7 +81,7 @@ const Privacy: React.FC = () => { ] as const ).map(([titleKey, descKey]) => ( - + {t(`privacy.${titleKey}`)} diff --git a/frontend/src/pages/PublicProfile.tsx b/frontend/src/pages/PublicProfile.tsx index 9e94bff..5505d45 100644 --- a/frontend/src/pages/PublicProfile.tsx +++ b/frontend/src/pages/PublicProfile.tsx @@ -230,7 +230,7 @@ const PublicProfile: React.FC = () => { borderColor: 'divider', }} > - {user.username.charAt(0)} + {user.username.charAt(0).toUpperCase()} { to={`/profile/${user.username}`} variant="subtitle1" sx={{ - fontWeight: '900', + fontWeight: 900, color: 'text.primary', textDecoration: 'none', '&:hover': { color: 'primary.main' }, @@ -249,7 +249,7 @@ const PublicProfile: React.FC = () => { > {user.username} - + Community Member @@ -288,7 +288,7 @@ const PublicProfile: React.FC = () => { alignSelf: 'flex-start', borderRadius: 2, textTransform: 'none', - fontWeight: 'bold', + fontWeight: 700, color: 'text.secondary', }} > @@ -367,7 +367,7 @@ const PublicProfile: React.FC = () => { { ].map((stat, i) => ( - + {stat.val} { sx={{ borderRadius: 1, px: 4, - fontWeight: '900', + fontWeight: 900, py: 1.2, textTransform: 'none', fontSize: '1rem', @@ -447,7 +447,7 @@ const PublicProfile: React.FC = () => { variant="outlined" color="secondary" onClick={() => navigate(`/admin-panel/user/${data.id}`)} - sx={{ borderRadius: 1, px: 2.5, fontWeight: 'bold', textTransform: 'none' }} + sx={{ borderRadius: 1, px: 2.5, fontWeight: 700, textTransform: 'none' }} > {t('admin.manageUser')} @@ -474,7 +474,7 @@ const PublicProfile: React.FC = () => { variant="fullWidth" sx={{ '& .MuiTab-root': { - fontWeight: '900', + fontWeight: 900, textTransform: 'none', fontSize: '1rem', borderRadius: 1, @@ -509,7 +509,7 @@ const PublicProfile: React.FC = () => { onClick={() => setCategoryTab(idx)} color={categoryTab === idx ? 'primary' : 'default'} variant={categoryTab === idx ? 'filled' : 'outlined'} - sx={{ fontWeight: '900', px: 1.5, borderRadius: 1 }} + sx={{ fontWeight: 900, px: 1.5, borderRadius: 1 }} /> ))} @@ -542,7 +542,7 @@ const PublicProfile: React.FC = () => { justifyContent: 'center', bgcolor: tier.color, color: 'black', - fontWeight: '900', + fontWeight: 900, fontSize: '2rem', borderRadius: 0.5, boxShadow: `0 0 20px ${alpha(tier.color, 0.6)}`, @@ -553,14 +553,14 @@ const PublicProfile: React.FC = () => { {tier.title} {items.length} items @@ -622,7 +622,7 @@ const PublicProfile: React.FC = () => { { textTransform: 'none', fontSize: '0.6rem', py: 0, - fontWeight: 'bold', + fontWeight: 700, }} > Manage @@ -683,7 +683,7 @@ const PublicProfile: React.FC = () => { variant="contained" type="submit" endIcon={} - sx={{ borderRadius: 2, px: 5, fontWeight: '900', textTransform: 'none' }} + sx={{ borderRadius: 2, px: 5, fontWeight: 900, textTransform: 'none' }} > {t('support.reply')} @@ -742,7 +742,7 @@ const PublicProfile: React.FC = () => { > {comment.author_username} @@ -750,7 +750,7 @@ const PublicProfile: React.FC = () => { {formatDate(comment.created_at)} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 90720ad..6634fe6 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -202,7 +202,7 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { variant="outlined" onClick={handleGoBack} startIcon={} - 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')} @@ -215,7 +215,7 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { {!avatar && username.charAt(0).toUpperCase()} - + {username} @@ -235,7 +235,7 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { - + {t('settings.appearanceTitle')} @@ -245,11 +245,11 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { label={t('settings.themeLabel')} onChange={(e) => onThemeChange(e.target.value as CatppuccinTheme)} > - Holy Archive + {t('settings.themeGroupHoly')} Holy Light Holy Dark - Light Themes + {t('settings.themeGroupLight')} Catppuccin Latte Pink Pastel Mint Pastel @@ -258,7 +258,7 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { - Dark Themes + {t('settings.themeGroupDark')} Catppuccin Mocha Catppuccin Frappé Catppuccin Macchiato @@ -336,7 +336,7 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { {/* Avatar: NOT a FormCard — file input lives outside any form */} - + {t('settings.avatarTitle')} diff --git a/frontend/src/pages/Support.tsx b/frontend/src/pages/Support.tsx index 88bc597..348d687 100644 --- a/frontend/src/pages/Support.tsx +++ b/frontend/src/pages/Support.tsx @@ -214,7 +214,7 @@ const Support: React.FC = () => { /> )} - + {ticket.subject} @@ -228,14 +228,14 @@ const Support: React.FC = () => { label={t('support.unread')} color="primary" size="small" - sx={{ fontWeight: 'bold', height: 20, fontSize: '0.6rem' }} + sx={{ fontWeight: 700, height: 20, fontSize: '0.6rem' }} /> )} @@ -276,10 +276,7 @@ const Support: React.FC = () => { boxShadow: msg.is_admin ? 2 : 0, }} > - + {msg.username} {msg.is_admin && '(Support)'} diff --git a/frontend/src/pages/VerifyEmail.tsx b/frontend/src/pages/VerifyEmail.tsx index 22261a1..1adee0d 100644 --- a/frontend/src/pages/VerifyEmail.tsx +++ b/frontend/src/pages/VerifyEmail.tsx @@ -58,7 +58,7 @@ const VerifyEmail: React.FC = () => { {status === 'loading' && ( - + Verifying your account... @@ -70,7 +70,7 @@ const VerifyEmail: React.FC = () => { {status === 'success' && ( - + {t('auth.verifySuccess')} @@ -91,7 +91,7 @@ const VerifyEmail: React.FC = () => { {status === 'error' && ( - + Verification Failed diff --git a/frontend/src/pages/admin/AdminBanners.tsx b/frontend/src/pages/admin/AdminBanners.tsx index d1d7dac..1acbfcd 100644 --- a/frontend/src/pages/admin/AdminBanners.tsx +++ b/frontend/src/pages/admin/AdminBanners.tsx @@ -46,10 +46,10 @@ const AdminBanners = () => { const handleActivateBanner = async (id: number) => { try { await activateBannerMutation.mutateAsync(id); - notify({ message: 'Banner set as default', severity: 'success' }); + notify({ message: t('admin.bannerSetDefault'), severity: 'success' }); } catch (err) { console.error(err); - notify({ message: 'Failed to activate banner', severity: 'error' }); + notify({ message: t('admin.bannerActivateFailed'), severity: 'error' }); } }; @@ -59,7 +59,7 @@ const AdminBanners = () => { } catch (err) { const error = err as { response?: { data?: { error?: string } } }; notify({ - message: error.response?.data?.error || 'Failed to toggle visibility', + message: error.response?.data?.error || t('admin.bannerToggleFailed'), severity: 'error', }); } @@ -75,20 +75,20 @@ const AdminBanners = () => { return ( - + {t('admin.banners')} ({banners.length}) {banners.length === 0 ? ( qc.invalidateQueries({ queryKey: ['banners'] })} > - Retry Loading + {t('admin.retryLoading')} } /> @@ -108,20 +108,20 @@ const AdminBanners = () => { mb: 2, }} > - - {banner.name} (Slug: {banner.slug}) + + {banner.name} ({t('admin.bannerSlug')}: {banner.slug}) {!banner.is_enabled && ( )} {banner.is_active && ( - + )} @@ -145,7 +145,7 @@ const AdminBanners = () => { setIsSettingsDialogOpen(true); }} > - Edit Settings + {t('admin.editSettings')} {!banner.is_active && ( )} diff --git a/frontend/src/pages/admin/AdminConfig.tsx b/frontend/src/pages/admin/AdminConfig.tsx index b9771a7..d4c035d 100644 --- a/frontend/src/pages/admin/AdminConfig.tsx +++ b/frontend/src/pages/admin/AdminConfig.tsx @@ -59,13 +59,13 @@ const AdminConfig = () => { await api.patch('admin-custom/config/', draft); qc.invalidateQueries({ queryKey: ['adminConfig'] }); notify({ - message: t('admin.configSaved', { defaultValue: 'Configuration saved' }), + message: t('admin.configSaved'), severity: 'success', }); } catch (err) { const error = err as { response?: { data?: { error?: string } } }; notify({ - message: error.response?.data?.error || 'Failed to save configuration', + message: error.response?.data?.error || t('admin.configSaveFailed'), severity: 'error', }); } finally { @@ -87,21 +87,21 @@ const AdminConfig = () => { return ( - - {t('admin.sections.config', { defaultValue: 'System Configuration' })} + + {t('admin.config')} - Live values applied without restart. Form posts the full config on save. + {t('admin.configLiveHint')} e.preventDefault()}> setDraft({ ...draft, site_name: e.target.value })} fullWidth - helperText="Displayed in the browser tab and various headers." + helperText={t('admin.siteNameHint')} /> @@ -114,9 +114,9 @@ const AdminConfig = () => { } label={ - Maintenance Mode + {t('admin.maintenanceMode')} - Block non-admin users from interacting with the site. + {t('admin.maintenanceModeHint')} } @@ -130,9 +130,9 @@ const AdminConfig = () => { } label={ - Allow New Signups + {t('admin.allowSignups')} - When off, the registration endpoint rejects new accounts. + {t('admin.allowSignupsHint')} } @@ -148,9 +148,9 @@ const AdminConfig = () => { } label={ - Require Email Verification + {t('admin.requireVerification')} - New accounts must confirm their email before logging in. + {t('admin.requireVerificationHint')} } @@ -165,7 +165,7 @@ const AdminConfig = () => { disabled={!dirty || saving} onClick={handleSave} > - {saving ? 'Saving…' : 'Save changes'} + {saving ? t('admin.saving') : t('admin.saveChanges')} diff --git a/frontend/src/pages/admin/AdminFlavors.tsx b/frontend/src/pages/admin/AdminFlavors.tsx index 7f258e8..f310ffb 100644 --- a/frontend/src/pages/admin/AdminFlavors.tsx +++ b/frontend/src/pages/admin/AdminFlavors.tsx @@ -57,7 +57,7 @@ const AdminFlavors: React.FC = () => { setConfirmOpen(false); } catch (err: any) { const errorMsg = err.response?.data?.error || err.message; - notify({ message: `Error: ${errorMsg}`, severity: 'error' }); + notify({ message: t('admin.errorPrefix', { message: errorMsg }), severity: 'error' }); } finally { setIsMerging(false); } @@ -67,7 +67,7 @@ const AdminFlavors: React.FC = () => { return ( - + {t('admin.manageFlavors', { defaultValue: 'Manage Flavors' })} diff --git a/frontend/src/pages/admin/AdminJobs.tsx b/frontend/src/pages/admin/AdminJobs.tsx index bb35e80..aa5c4e2 100644 --- a/frontend/src/pages/admin/AdminJobs.tsx +++ b/frontend/src/pages/admin/AdminJobs.tsx @@ -16,6 +16,7 @@ import { Typography, } from '@mui/material'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import { useTranslation } from 'react-i18next'; import { GlassCard } from '../../components/ui'; import { useToast } from '../../hooks/useToast'; import { formatDate } from '../../utils/date'; @@ -35,6 +36,7 @@ interface Job { } const AdminJobs = () => { + const { t } = useTranslation(); const qc = useQueryClient(); const { notify } = useToast(); const [search, setSearch] = useState(''); @@ -58,9 +60,9 @@ const AdminJobs = () => { const handleTriggerJob = async (id: number) => { try { await triggerJobMutation.mutateAsync(id); - notify({ message: 'Job queued', severity: 'success' }); + notify({ message: t('admin.jobQueued'), severity: 'success' }); } catch { - notify({ message: 'Failed to trigger job', severity: 'error' }); + notify({ message: t('admin.jobTriggerFailed'), severity: 'error' }); } }; @@ -68,7 +70,7 @@ const AdminJobs = () => { try { await updateJobScheduleMutation.mutateAsync({ id, data: { interval_hours: interval } }); } catch { - notify({ message: 'Failed to update schedule', severity: 'error' }); + notify({ message: t('admin.jobScheduleFailed'), severity: 'error' }); } }; @@ -95,12 +97,12 @@ const AdminJobs = () => { sx={{ mb: 3 }} spacing={2} > - - Background Worker Jobs ({filtered.length}) + + {t('admin.jobsTitle')} ({filtered.length}) setSearch(e.target.value)} sx={{ width: { xs: '100%', md: 300 } }} @@ -122,11 +124,13 @@ const AdminJobs = () => { mb: 2, }} > - + {job.name_display} { - Last Run + {t('admin.jobLastRun')} - {job.last_run ? formatDate(job.last_run) : 'Never'} + {job.last_run ? formatDate(job.last_run) : t('admin.jobNever')} - Next Run + {t('admin.jobNextRun')} {job.next_run ? formatDate(job.next_run) : job.interval_hours > 0 - ? 'Queued…' - : 'Disabled'} + ? t('admin.jobQueuedShort') + : t('admin.jobDisabled')} @@ -169,7 +173,7 @@ const AdminJobs = () => { display="block" sx={{ mb: 0.5 }} > - Schedule + {t('admin.jobSchedule')} @@ -191,7 +195,7 @@ const AdminJobs = () => { {job.last_output && ( - Last Run Output + {t('admin.jobLastOutput')} { {job.error_message && ( - Last Error + {t('admin.jobLastError')} {job.error_message} @@ -235,7 +239,7 @@ const AdminJobs = () => { disabled={job.status === 'running'} onClick={() => handleTriggerJob(job.id)} > - Trigger Now + {t('admin.jobTriggerNow')} diff --git a/frontend/src/pages/admin/AdminLayout.tsx b/frontend/src/pages/admin/AdminLayout.tsx index 1133bb7..7f27522 100644 --- a/frontend/src/pages/admin/AdminLayout.tsx +++ b/frontend/src/pages/admin/AdminLayout.tsx @@ -53,11 +53,11 @@ const AdminLayout = () => { const detailLabel = () => { if (!detailSegment) return null; - if (sectionSlug === 'users' && params.id) return `User #${params.id}`; + if (sectionSlug === 'users' && params.id) return t('admin.userNum', { n: params.id }); if (sectionSlug === 'ratings' || segments[1] === 'rating') - return `Rating #${params.id ?? detailSegment}`; + return t('admin.ratingNum', { n: params.id ?? detailSegment }); if (sectionSlug === 'replies' || segments[1] === 'reply') - return `Reply #${params.id ?? detailSegment}`; + return t('admin.replyNum', { n: params.id ?? detailSegment }); return decodeURIComponent(detailSegment); }; @@ -67,7 +67,7 @@ const AdminLayout = () => { - {t('common.home', { defaultValue: 'Home' })} + {t('common.home')} @@ -86,7 +86,7 @@ const AdminLayout = () => { {detailLabel() && {detailLabel()}} - + {t('admin.title')} diff --git a/frontend/src/pages/admin/AdminOverview.tsx b/frontend/src/pages/admin/AdminOverview.tsx index d31956f..4e772b4 100644 --- a/frontend/src/pages/admin/AdminOverview.tsx +++ b/frontend/src/pages/admin/AdminOverview.tsx @@ -58,7 +58,9 @@ const AdminOverview = () => { } catch (err) { const error = err as { response?: { data?: { error?: string } } }; notify({ - message: `Error: ${error.response?.data?.error || 'Failed to send'}`, + message: t('admin.errorPrefix', { + message: error.response?.data?.error || t('admin.testEmailFailed'), + }), severity: 'error', }); } @@ -84,7 +86,7 @@ const AdminOverview = () => { {t('admin.totalUsers')} - + {stats.total_users} @@ -98,7 +100,7 @@ const AdminOverview = () => { {stats.open_tickets} @@ -115,7 +117,7 @@ const AdminOverview = () => { spacing={2} > - + {t('admin.config')}: SMTP @@ -139,8 +141,8 @@ const AdminOverview = () => { - - Environment Info (.env) + + {t('admin.envInfo')} @@ -148,9 +150,9 @@ const AdminOverview = () => { - API Status + {t('admin.apiStatus')} DEBUG={String(stats.server_info.debug)} @@ -160,9 +162,9 @@ const AdminOverview = () => { - Frontend URL + {t('admin.frontendUrl')} {stats.server_info.frontend_url} @@ -172,9 +174,9 @@ const AdminOverview = () => { - Allowed Hosts + {t('admin.allowedHosts')} {stats.server_info.allowed_hosts.join(', ')} @@ -184,8 +186,7 @@ const AdminOverview = () => { - These values are read from your server environment or .env file. To change them, you - must update the file and restart the backend container. + {t('admin.envHint')} diff --git a/frontend/src/pages/admin/AdminTickets.tsx b/frontend/src/pages/admin/AdminTickets.tsx index 56ef189..7ca241f 100644 --- a/frontend/src/pages/admin/AdminTickets.tsx +++ b/frontend/src/pages/admin/AdminTickets.tsx @@ -111,26 +111,29 @@ const AdminTickets: React.FC = () => { await addMessage.mutateAsync({ ticketId, text }); setReplyText({ ...replyText, [ticketId]: '' }); } catch { - notify({ message: 'Failed to send message', severity: 'error' }); + notify({ message: t('admin.ticketMessageFailed'), severity: 'error' }); } }; const handleUpdateStatus = async (ticketId: number, status: string) => { try { await updateStatusMutation.mutateAsync({ ticketId, status }); - notify({ message: `Status set to ${t(`support.${status}`)}`, severity: 'success' }); + notify({ + message: t('admin.ticketStatusSet', { status: t(`support.${status}`) }), + severity: 'success', + }); } catch { - notify({ message: 'Failed to update status', severity: 'error' }); + notify({ message: t('admin.ticketStatusFailed'), severity: 'error' }); } }; const handleDeleteTicket = async (ticketId: number) => { - if (!(await confirm({ message: 'Delete this ticket?', danger: true }))) return; + if (!(await confirm({ message: t('admin.ticketDeleteConfirm'), danger: true }))) return; try { await deleteTicketMutation.mutateAsync(ticketId); - notify({ message: 'Ticket deleted', severity: 'success' }); + notify({ message: t('admin.ticketDeleted'), severity: 'success' }); } catch { - notify({ message: 'Failed to delete ticket', severity: 'error' }); + notify({ message: t('admin.ticketDeleteFailed'), severity: 'error' }); } }; @@ -151,7 +154,7 @@ const AdminTickets: React.FC = () => { spacing={2} sx={{ mb: 3 }} > - + {t('support.title')} ({filtered.length}) @@ -204,7 +207,7 @@ const AdminTickets: React.FC = () => { /> )} - + {ticket.subject} { label={t('support.unread')} color="primary" size="small" - sx={{ fontWeight: 'bold', height: 20, fontSize: '0.6rem' }} + sx={{ fontWeight: 700, height: 20, fontSize: '0.6rem' }} /> )} { { }} sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }} > - View Profile + {t('admin.viewProfile')}