added delete option to user settings and updated privacy

This commit is contained in:
nvrl committed 2026-02-17 03:32:42 +01:00
1 parent 8d5d20577c
commit 3046103d48
12 files changed
+588 -61

No files matched your search

@@ -0,0 +1,18 @@
# Generated by Django 6.0.2 on 2026-02-17 02:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0012_user_following'),
]
operations = [
migrations.AddField(
model_name='user',
name='language',
field=models.CharField(choices=[('en', 'English'), ('de', 'Deutsch')], default='en', max_length=2),
),
]
+5
View File
@@ -19,7 +19,12 @@ class User(AbstractUser):
('cyberpunk', 'Cyberpunk'),
('forest', 'Forest Dark'),
]
LANGUAGE_CHOICES = [
('en', 'English'),
('de', 'Deutsch'),
]
theme = models.CharField(max_length=20, choices=THEME_CHOICES, default='mocha')
language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default='en')
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
pending_email = models.EmailField(max_length=254, blank=True, null=True)
email_confirmation_code = models.CharField(max_length=6, blank=True, null=True)
+1 -1
View File
@@ -9,7 +9,7 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email', 'theme', 'avatar', 'following_count', 'followers_count', 'is_following']
fields = ['id', 'username', 'email', 'theme', 'language', 'avatar', 'following_count', 'followers_count', 'is_following']
def get_avatar(self, obj):
if obj.avatar:
+42 -3
View File
@@ -242,14 +242,23 @@ class UserViewSet(viewsets.ModelViewSet):
return Response(serializer.data)
@action(detail=False, methods=['patch'])
def update_theme(self, request):
def update_preferences(self, request):
user = request.user
theme = request.data.get('theme')
language = request.data.get('language')
updated = False
if theme in dict(User.THEME_CHOICES):
user.theme = theme
updated = True
if language in dict(User.LANGUAGE_CHOICES):
user.language = language
updated = True
if updated:
user.save()
return Response({'status': 'theme updated', 'theme': theme})
return Response({'error': 'Invalid theme'}, status=status.HTTP_400_BAD_REQUEST)
return Response({'status': 'preferences updated', 'theme': user.theme, 'language': user.language})
return Response({'error': 'No valid updates provided'}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=['post'])
def update_avatar(self, request):
@@ -380,6 +389,36 @@ class UserViewSet(viewsets.ModelViewSet):
user.save()
return Response({'status': 'Password reset successful!'})
@action(detail=False, methods=['post'])
@method_decorator(ratelimit(key='user', rate='3/h', method='POST', block=True))
def request_account_deletion(self, request):
import secrets
import string
code = ''.join(secrets.choice(string.digits) for _ in range(6))
user = request.user
user.email_confirmation_code = code
user.save()
send_mail(
'Confirm Account Deletion - Holy Flavors Archive',
f'Hi {user.username},\n\nYou requested to delete your account. This action is permanent.\n\nYour deletion code is: {code}',
'noreply@holyflavors.com',
[user.email],
fail_silently=False,
)
return Response({'status': 'Code sent to your email'})
@action(detail=False, methods=['post'])
def confirm_account_deletion(self, request):
user = request.user
code = request.data.get('code')
if not user.email_confirmation_code or code != user.email_confirmation_code:
return Response({'error': 'Invalid or expired code'}, status=status.HTTP_400_BAD_REQUEST)
user.delete()
return Response({'status': 'Account deleted'})
@action(detail=False, methods=['get'])
def dashboard(self, request):
user = request.user
+88 -1
View File
@@ -14,8 +14,11 @@
"@mui/icons-material": "^7.3.8",
"@mui/material": "^7.3.8",
"axios": "^1.13.5",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^15.4.0",
"react-router-dom": "^7.13.0"
},
"devDependencies": {
@@ -3197,6 +3200,55 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "24.2.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz",
"integrity": "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.10"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3748,6 +3800,32 @@
"react": "^19.2.4"
}
},
"node_modules/react-i18next": {
"version": "15.7.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
"integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.4.0",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
@@ -4047,7 +4125,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -4204,6 +4282,15 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+3
View File
@@ -16,8 +16,11 @@
"@mui/icons-material": "^7.3.8",
"@mui/material": "^7.3.8",
"axios": "^1.13.5",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^15.4.0",
"react-router-dom": "^7.13.0"
},
"devDependencies": {
+18 -9
View File
@@ -33,6 +33,9 @@ import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore';
import { getTheme, type CatppuccinTheme } from './theme';
import api from './api';
import './i18n';
import { useTranslation } from 'react-i18next';
import Footer from './components/Footer';
// Code splitting
const MainPage = lazy(() => import('./pages/MainPage'));
@@ -42,6 +45,7 @@ const Dashboard = lazy(() => import('./pages/Dashboard'));
const PublicProfile = lazy(() => import('./pages/PublicProfile'));
const CommunityFeed = lazy(() => import('./pages/CommunityFeed'));
const About = lazy(() => import('./pages/About'));
const Privacy = lazy(() => import('./pages/Privacy'));
const Login = lazy(() => import('./pages/Login'));
const Settings = lazy(() => import('./pages/Settings'));
@@ -159,6 +163,7 @@ const GlobalSearch = () => {
};
const App: React.FC = () => {
const { i18n, t } = useTranslation();
const [themeName, setThemeName] = useState<CatppuccinTheme>((localStorage.getItem('theme') as CatppuccinTheme) || 'mocha');
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [catAnchorEl, setCatAnchorEl] = useState<null | HTMLElement>(null);
@@ -181,6 +186,7 @@ const App: React.FC = () => {
if (userRes.data && userRes.data.username) {
setUser(userRes.data);
if (userRes.data.theme) setThemeName(userRes.data.theme);
if (userRes.data.language) i18n.changeLanguage(userRes.data.language);
// Also fetch following list for the mobile sidebar
try {
@@ -223,6 +229,7 @@ const App: React.FC = () => {
localStorage.removeItem('token');
setUser(null);
setFollowing([]);
i18n.changeLanguage(navigator.language.split('-')[0] || 'en');
window.location.href = '/';
};
@@ -243,19 +250,19 @@ const App: React.FC = () => {
<List>
<ListItem disablePadding>
<ListItemButton component={Link} to="/" onClick={() => setDrawerOpen(false)}>
<ListItemText primary="Home" />
<ListItemText primary={t('nav.home')} />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton component={Link} to="/community" onClick={() => setDrawerOpen(false)}>
<ListItemText primary="Community" />
<ListItemText primary={t('nav.community')} />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton component={Link} to="/about" onClick={() => setDrawerOpen(false)}>
<ListItemText primary="About" />
<ListItemText primary={t('nav.about')} />
</ListItemButton>
</ListItem>
@@ -285,12 +292,12 @@ const App: React.FC = () => {
<>
<ListItem disablePadding>
<ListItemButton component={Link} to="/dashboard" onClick={() => setDrawerOpen(false)}>
<ListItemText primary="Dashboard" />
<ListItemText primary={t('nav.dashboard')} />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton component={Link} to="/settings" onClick={() => setDrawerOpen(false)}>
<ListItemText primary="Account Settings" />
<ListItemText primary={t('nav.settings')} />
</ListItemButton>
</ListItem>
@@ -315,14 +322,14 @@ const App: React.FC = () => {
<ListItem disablePadding>
<ListItemButton onClick={handleLogout}>
<ListItemText primary="Logout" sx={{ color: 'error.main' }} />
<ListItemText primary={t('nav.logout')} sx={{ color: 'error.main' }} />
</ListItemButton>
</ListItem>
</>
) : (
<ListItem disablePadding>
<ListItemButton component={Link} to="/login" onClick={() => setDrawerOpen(false)}>
<ListItemText primary="Login / Signup" />
<ListItemText primary={t('nav.login')} />
</ListItemButton>
</ListItem>
)}
@@ -356,7 +363,7 @@ const App: React.FC = () => {
to="/community"
sx={{ fontWeight: 'bold', textTransform: 'none', fontSize: '1rem', mr: 2 }}
>
Community
{t('nav.community')}
</Button>
<Button
color="inherit"
@@ -364,7 +371,7 @@ const App: React.FC = () => {
to="/about"
sx={{ fontWeight: 'bold', textTransform: 'none', fontSize: '1rem', mr: 2 }}
>
About
{t('nav.about')}
</Button>
<Button
color="inherit"
@@ -457,6 +464,7 @@ const App: React.FC = () => {
<Route path="/profile/:username" element={<PublicProfile />} />
<Route path="/community" element={<CommunityFeed />} />
<Route path="/about" element={<About />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings themeName={themeName} onThemeChange={handleThemeChange} />} />
<Route path="/login" element={<Login />} />
@@ -464,6 +472,7 @@ const App: React.FC = () => {
</Routes>
</Suspense>
</Box>
<Footer />
</Box>
</Router>
</ThemeProvider>
+36
View File
@@ -0,0 +1,36 @@
import React from 'react';
import { Box, Container, Typography, Link as MuiLink, Divider } from '@mui/material';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const Footer: React.FC = () => {
const { t } = useTranslation();
const year = new Date().getFullYear();
return (
<Box component="footer" sx={{ bgcolor: 'background.paper', pt: 6, pb: 4, mt: 'auto', borderTop: '1px solid', borderColor: 'divider' }}>
<Container maxWidth="lg">
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, justifyContent: 'space-between', alignItems: 'center', gap: 2, mb: 2 }}>
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
© {year} Holy Flavors Archive
</Typography>
<Box sx={{ display: 'flex', gap: 3 }}>
<MuiLink component={Link} to="/about" color="inherit" sx={{ fontSize: '0.8rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
Impressum
</MuiLink>
<MuiLink component={Link} to="/privacy" color="inherit" sx={{ fontSize: '0.8rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
Privacy Policy
</MuiLink>
</Box>
</Box>
<Divider sx={{ my: 2, opacity: 0.5 }} />
<Typography variant="caption" color="text.secondary" align="center" display="block">
{t('nav.about')} • Fan Project
</Typography>
</Container>
</Box>
);
};
export default Footer;
+109
View File
@@ -0,0 +1,109 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
const resources = {
en: {
translation: {
"nav": {
"home": "Home",
"community": "Community",
"about": "About",
"dashboard": "Dashboard",
"settings": "Settings",
"logout": "Logout",
"login": "Login / Signup"
},
"hero": {
"title": "The Ultimate Holy Archive",
"subtitle": "Browse every flavor ever released, discover new favorites, and share your ratings with the community.",
"join": "Join Community",
"whatIsThis": "What is this for?"
},
"home": {
"activity": "Community Activity",
"activitySubtitle": "Latest reviews from people you follow",
"viewFullFeed": "View Full Feed",
"quietFeed": "Your feed is a bit quiet",
"followMore": "Follow more users to see their activity here!",
"exploreFlavors": "Explore Flavors",
"hallOfFame": "Hall of Fame",
"communityVoice": "Community Voice",
"newArrivals": "New Arrivals",
"viewAllReviews": "View All Reviews",
"viewMoreReviews": "View more reviews",
"rated": "rated"
},
"common": {
"search": "Search...",
"backToArchive": "Back to Archive",
"backToHome": "Back to Home",
"backTo": "Back to",
"buyNow": "Buy Now",
"reviews": "reviews",
"rank": "Rank",
"loading": "Loading..."
}
}
},
de: {
translation: {
"nav": {
"home": "Startseite",
"community": "Community",
"about": "Über uns",
"dashboard": "Dashboard",
"settings": "Einstellungen",
"logout": "Abmelden",
"login": "Anmelden / Registrieren"
},
"hero": {
"title": "Das ultimative Holy-Archiv",
"subtitle": "Durchsuche jede jemals veröffentlichte Sorte, entdecke neue Favoriten und teile deine Bewertungen mit der Community.",
"join": "Community beitreten",
"whatIsThis": "Was ist das?"
},
"home": {
"activity": "Community-Aktivität",
"activitySubtitle": "Neueste Bewertungen von Personen, denen du folgst",
"viewFullFeed": "Vollständiger Feed",
"quietFeed": "Dein Feed ist noch etwas ruhig",
"followMore": "Folge mehr Benutzern, um deren Aktivitäten hier zu sehen!",
"exploreFlavors": "Sorten entdecken",
"hallOfFame": "Hall of Fame",
"communityVoice": "Community-Stimmen",
"newArrivals": "Neuheiten",
"viewAllReviews": "Alle Bewertungen ansehen",
"viewMoreReviews": "Mehr Bewertungen",
"rated": "bewertete"
},
"common": {
"search": "Suchen...",
"backToArchive": "Zurück zum Archiv",
"backToHome": "Zurück zur Startseite",
"backTo": "Zurück zu",
"buyNow": "Jetzt kaufen",
"reviews": "Bewertungen",
"rank": "Rang",
"loading": "Wird geladen..."
}
}
}
};
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
interpolation: {
escapeValue: false
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage']
}
});
export default i18n;
+20 -17
View File
@@ -15,6 +15,7 @@ import {
useMediaQuery
} from '@mui/material';
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import StarIcon from '@mui/icons-material/Star';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
@@ -46,6 +47,7 @@ interface Review {
}
const MainPage: React.FC = () => {
const { t } = useTranslation();
const isMobileSize = useMediaQuery('(max-width:600px)');
const [topFlavors, setTopFlavors] = useState<Flavor[]>([]);
const [newestFlavors, setNewestFlavors] = useState<Flavor[]>([]);
@@ -218,19 +220,19 @@ const MainPage: React.FC = () => {
<Container maxWidth="lg">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', mb: 4, flexWrap: 'wrap', gap: 2 }}>
<Box>
<Typography variant="h3" sx={{ fontWeight: 'bold', mb: 1, fontSize: { xs: '2rem', md: '3rem' } }}>Community Activity</Typography>
<Typography variant="body1" color="text.secondary">Latest reviews from people you follow</Typography>
<Typography variant="h3" sx={{ fontWeight: 'bold', mb: 1, fontSize: { xs: '2rem', md: '3rem' } }}>{t('home.activity')}</Typography>
<Typography variant="body1" color="text.secondary">{t('home.activitySubtitle')}</Typography>
</Box>
<Button component={Link} to="/community" variant="outlined" sx={{ borderRadius: 2, textTransform: 'none' }}>
View Full Feed
{t('home.viewFullFeed')}
</Button>
</Box>
{feedRatings.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center', borderRadius: 4, bgcolor: 'action.hover', border: '2px dashed', borderColor: 'divider', elevation: 0 }}>
<Typography variant="h6">Your feed is a bit quiet</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>Follow more users to see their activity here!</Typography>
<Button variant="contained" component={Link} to="/" size="small" sx={{ borderRadius: 2 }}>Explore Flavors</Button>
<Typography variant="h6">{t('home.quietFeed')}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t('home.followMore')}</Typography>
<Button variant="contained" component={Link} to="/" size="small" sx={{ borderRadius: 2 }}>{t('home.exploreFlavors')}</Button>
</Paper>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }, gap: 3 }}>
@@ -300,17 +302,17 @@ const MainPage: React.FC = () => {
}}
>
<Typography variant="h2" component="h1" gutterBottom sx={{ fontWeight: 'bold', fontSize: { xs: '2.2rem', sm: '3rem', md: '4.5rem' }, overflowWrap: 'break-word', px: 2 }}>
The Ultimate Holy Archive
{t('hero.title')}
</Typography>
<Typography variant="h5" color="text.secondary" sx={{ mb: 4, maxWidth: 800, mx: 'auto', px: 2 }}>
Browse every flavor ever released, discover new favorites, and share your ratings with the community.
{t('hero.subtitle')}
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 2, flexWrap: 'wrap', px: 2 }}>
<Button variant="contained" size="large" component={Link} to="/login" sx={{ px: 4, py: 1.5, borderRadius: 2 }}>
Join Community
{t('hero.join')}
</Button>
<Button variant="outlined" size="large" component={Link} to="/about" sx={{ px: 4, py: 1.5, borderRadius: 2 }}>
What is this for?
{t('hero.whatIsThis')}
</Button>
</Box>
</Paper>
@@ -325,7 +327,7 @@ const MainPage: React.FC = () => {
<Box sx={{ display: 'flex', alignItems: 'center', mb: 4, justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<StarIcon color="primary" sx={{ mr: 1, fontSize: '2rem' }} />
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>Hall of Fame</Typography>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>{t('home.hallOfFame')}</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<IconButton onClick={handlePrev} sx={{ border: '1px solid', borderColor: 'divider' }}><ChevronLeftIcon /></IconButton>
@@ -362,8 +364,9 @@ const MainPage: React.FC = () => {
loading="lazy"
/>
</Box>
<CardContent sx={{ flex: 1, p: { xs: 3, sm: 4, md: 6 }, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<Chip label={`Rank #${activeIndex + 1}`} color="primary" size="small" sx={{ width: 'fit-content', mb: 1, fontWeight: 'bold' }} />
<CardContent sx={{ flex: 1, p: { xs: 3, sm: 4, md: 6 }, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<Chip label={`${t('common.rank')} #${activeIndex + 1}`} color="primary" size="small" sx={{ width: 'fit-content', mb: 1, fontWeight: 'bold' }} />
<Typography variant="h3" gutterBottom sx={{ fontWeight: 'bold', fontSize: { xs: '1.75rem', sm: '2.5rem', md: '3rem' } }}>
{currentTop.name}
</Typography>
@@ -395,7 +398,7 @@ const MainPage: React.FC = () => {
to={`/flavor/${currentTop.id}`}
sx={{ mt: { xs: 3, md: 4 }, width: { xs: '100%', sm: 'fit-content' }, px: 4, py: 1.5, borderRadius: 2 }}
>
View All Reviews
{t('home.viewAllReviews')}
</Button>
</CardContent>
</Card>
@@ -423,7 +426,7 @@ const MainPage: React.FC = () => {
{/* Recent Reviews - SIDE BY SIDE */}
{recentReviews.length > 0 && (
<Box sx={{ flex: { xs: '1 1 100%', xl: '1 1 35%' }, minWidth: 0 }}>
<Typography variant="h4" sx={{ mb: 4, fontWeight: 'bold' }}>Community Voice</Typography>
<Typography variant="h4" sx={{ mb: 4, fontWeight: 'bold' }}>{t('home.communityVoice')}</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{recentReviews.slice(0, 5).map(review => (
<Paper
@@ -457,7 +460,7 @@ const MainPage: React.FC = () => {
</Paper>
))}
<Button component={Link} to="/community" variant="text" size="small" sx={{ alignSelf: 'center', mt: 1 }}>
View more reviews
{t('home.viewMoreReviews')}
</Button>
</Box>
</Box>
@@ -467,7 +470,7 @@ const MainPage: React.FC = () => {
{/* Newest Arrivals */}
{newestFlavors.length > 0 && (
<Box sx={{ mb: 4 }}>
<Typography variant="h4" sx={{ mb: 4, fontWeight: 'bold' }}>New Arrivals</Typography>
<Typography variant="h4" sx={{ mb: 4, fontWeight: 'bold' }}>{t('home.newArrivals')}</Typography>
<Box sx={{
display: 'grid',
gridTemplateColumns: {
+130
View File
@@ -0,0 +1,130 @@
import React from 'react';
import { Typography, Box, Container, Paper, Button, ToggleButton, ToggleButtonGroup } from '@mui/material';
import { Link } from 'react-router-dom';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useTitle } from '../hooks/useTitle';
const Privacy: React.FC = () => {
useTitle('Privacy Policy / Datenschutzerklärung');
const [lang, setLang] = React.useState<'de' | 'en'>('de');
return (
<Container maxWidth="md" sx={{ py: 8 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4, flexWrap: 'wrap', gap: 2 }}>
<Button
variant="outlined"
component={Link}
to="/"
startIcon={<ArrowBackIcon />}
sx={{ textTransform: 'none', borderRadius: 2 }}
>
{lang === 'de' ? 'Zurück' : 'Back'}
</Button>
<ToggleButtonGroup
value={lang}
exclusive
onChange={(_, v) => v && setLang(v)}
size="small"
>
<ToggleButton value="de">Deutsch</ToggleButton>
<ToggleButton value="en">English</ToggleButton>
</ToggleButtonGroup>
</Box>
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4 }}>
{lang === 'de' ? (
<Box>
<Typography variant="h3" gutterBottom sx={{ fontWeight: 'bold' }}>Datenschutzerklärung</Typography>
<Typography variant="body1" paragraph>
Der Schutz Ihrer persönlichen Daten ist uns ein wichtiges Anliegen. Nachfolgend informieren wir Sie
über die Verarbeitung Ihrer Daten auf unserer Webseite gemäß den Anforderungen der DSGVO.
</Typography>
<Typography variant="h5" gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>1. Verantwortlicher</Typography>
<Typography variant="body2" paragraph>
Verantwortlich für die Datenverarbeitung auf dieser Webseite ist:<br />
{import.meta.env.VITE_IMPRESSUM_NAME || '[Ihr Name]'}<br />
{import.meta.env.VITE_IMPRESSUM_STREET || '[Ihre Straße]'}<br />
{import.meta.env.VITE_IMPRESSUM_CITY || '[Ihre Stadt]'}
</Typography>
<Typography variant="h5" gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>2. Erfassung und Speicherung personenbezogener Daten</Typography>
<Typography variant="body2" paragraph>
<strong>Registrierung:</strong> Wenn Sie ein Konto erstellen, speichern wir Ihren Benutzernamen,
Ihre E-Mail-Adresse und Ihr Passwort (verschlüsselt). Diese Daten werden zur Bereitstellung der
Community-Funktionen (Bewertungen, Folgen von Nutzern) verwendet. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.
</Typography>
<Typography variant="body2" paragraph>
<strong>Öffentliches Profil:</strong> Ihr Benutzername, Ihr Avatar und Ihre Bewertungen sind für
andere Nutzer öffentlich sichtbar. Sie können diese Daten jederzeit in den Einstellungen ändern oder löschen.
</Typography>
<Typography variant="body2" paragraph>
<strong>Kontolöschung:</strong> Sie können Ihr Konto und alle damit verbundenen Daten jederzeit
in den Kontoeinstellungen dauerhaft löschen. Zur Sicherheit ist hierfür eine Verifizierung per E-Mail-Code erforderlich.
</Typography>
<Typography variant="h5" gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>3. Datenübermittlung an Dritte</Typography>
<Typography variant="body2" paragraph>
Wir geben Ihre Daten nicht ohne Ihre ausdrückliche Einwilligung an Dritte weiter, es sei denn,
dies ist zur Vertragserfüllung oder zur Erfüllung gesetzlicher Verpflichtungen erforderlich.
</Typography>
<Typography variant="h5" gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>4. Ihre Rechte</Typography>
<Typography variant="body2" paragraph>
Sie haben das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16 DSGVO), Löschung (Art. 17 DSGVO)
und Einschränkung der Verarbeitung Ihrer Daten. Bitte kontaktieren Sie uns hierzu unter:
{import.meta.env.VITE_IMPRESSUM_EMAIL}
</Typography>
</Box>
) : (
<Box>
<Typography variant="h3" gutterBottom sx={{ fontWeight: 'bold' }}>Privacy Policy</Typography>
<Typography variant="body1" paragraph>
Protecting your personal data is a top priority for us. Below we inform you about the processing
of your data on our website in accordance with the requirements of the GDPR.
</Typography>
<Typography variant="h5" gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>1. Controller</Typography>
<Typography variant="body2" paragraph>
The party responsible for data processing on this website is:<br />
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}<br />
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street]'}<br />
{import.meta.env.VITE_IMPRESSUM_CITY || '[Your City]'}
</Typography>
<Typography variant="h5" gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>2. Collection and Storage of Personal Data</Typography>
<Typography variant="body2" paragraph>
<strong>Registration:</strong> When you create an account, we store your username, email address,
and password (encrypted). This data is used to provide community features (ratings, following users).
The legal basis is Art. 6 Para. 1 lit. b GDPR.
</Typography>
<Typography variant="body2" paragraph>
<strong>Public Profile:</strong> Your username, avatar, and ratings are publicly visible to other users.
You can change or delete this data at any time in your settings.
</Typography>
<Typography variant="body2" paragraph>
<strong>Account Deletion:</strong> You can permanently delete your account and all associated data
at any time in your account settings. For security reasons, this requires verification via an email code.
</Typography>
<Typography variant="h5" gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>3. Data Transfer to Third Parties</Typography>
<Typography variant="body2" paragraph>
We do not pass your data on to third parties without your express consent, unless this is necessary
to fulfill a contract or a legal obligation.
</Typography>
<Typography variant="h5" gutterBottom sx={{ mt: 4, fontWeight: 'bold' }}>4. Your Rights</Typography>
<Typography variant="body2" paragraph>
You have the right to information (Art. 15 GDPR), correction (Art. 16 GDPR), deletion (Art. 17 GDPR),
and restriction of the processing of your data. Please contact us at:
{import.meta.env.VITE_IMPRESSUM_EMAIL}
</Typography>
</Box>
)}
</Paper>
</Container>
);
};
export default Privacy;
+118 -30
View File
@@ -18,6 +18,7 @@ import {
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { Link } from 'react-router-dom';
import api from '../api';
import { useTranslation } from 'react-i18next';
import { useTitle } from '../hooks/useTitle';
import type { CatppuccinTheme } from '../theme';
@@ -27,15 +28,19 @@ interface SettingsProps {
}
const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
useTitle('Account Settings');
const { t, i18n } = useTranslation();
useTitle(t('nav.settings'));
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [currentEmail, setCurrentEmail] = useState('');
const [avatar, setAvatar] = useState<string | null>(null);
const [language, setLanguage] = useState(i18n.language.split('-')[0]);
const [confirmationCode, setConfirmationCode] = useState('');
const [pendingConfirmation, setPendingConfirmation] = useState(false);
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [deletionCode, setDeletionCode] = useState('');
const [isDeleting, setIsDeleting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
useEffect(() => {
@@ -46,6 +51,9 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
setEmail(res.data.email);
setCurrentEmail(res.data.email);
setAvatar(res.data.avatar);
if (res.data.language) {
setLanguage(res.data.language);
}
} catch (err) {
console.error(err);
}
@@ -76,6 +84,16 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
}
};
const handleLanguageChange = async (newLang: string) => {
setLanguage(newLang);
i18n.changeLanguage(newLang);
try {
await api.patch('users/update_preferences/', { language: newLang });
} catch (err) {
console.error('Failed to update language on server');
}
};
const handleUpdateProfile = async (e: React.FormEvent) => {
e.preventDefault();
try {
@@ -117,6 +135,28 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
}
};
const handleRequestDeletion = async () => {
try {
await api.post('users/request_account_deletion/');
setIsDeleting(true);
setMessage({ type: 'success', text: 'Verification code sent to your email.' });
} catch (err: any) {
setMessage({ type: 'error', text: 'Failed to request deletion.' });
}
};
const handleConfirmDeletion = async (e: React.FormEvent) => {
e.preventDefault();
if (!window.confirm('Are you absolutely sure? This cannot be undone.')) return;
try {
await api.post('users/confirm_account_deletion/', { code: deletionCode });
localStorage.removeItem('token');
window.location.href = '/';
} catch (err: any) {
setMessage({ type: 'error', text: 'Invalid code. Deletion failed.' });
}
};
return (
<Container maxWidth="sm" sx={{ py: 4 }}>
<Button
@@ -149,35 +189,49 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
p: 3, mb: 4,
'& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 }
}}>
<Typography variant="h6" gutterBottom>Appearance</Typography>
<FormControl fullWidth margin="normal">
<InputLabel>Theme</InputLabel>
<Select
value={themeName}
label="Theme"
onChange={(e) => onThemeChange(e.target.value as CatppuccinTheme)}
>
<ListSubheader>Light Themes</ListSubheader>
<MenuItem value="latte">Latte</MenuItem>
<MenuItem value="pink">Pastel Pink</MenuItem>
<MenuItem value="solarized_light">Solarized Light</MenuItem>
<MenuItem value="one_light">One Light</MenuItem>
<MenuItem value="paper">Paper White</MenuItem>
<Divider />
<ListSubheader>Dark Themes</ListSubheader>
<MenuItem value="mocha">Mocha (Default)</MenuItem>
<MenuItem value="frappe">Frappé</MenuItem>
<MenuItem value="macchiato">Macchiato</MenuItem>
<MenuItem value="atom">Atom One Dark</MenuItem>
<MenuItem value="dracula">Dracula</MenuItem>
<MenuItem value="gruvbox">Gruvbox Dark</MenuItem>
<MenuItem value="nord">Nord</MenuItem>
<MenuItem value="cyberpunk">Cyberpunk</MenuItem>
<MenuItem value="forest">Forest Dark</MenuItem>
</Select>
</FormControl>
<Typography variant="h6" gutterBottom>Appearance & Language</Typography>
<Box sx={{ display: 'flex', gap: 2, flexDirection: { xs: 'column', sm: 'row' } }}>
<FormControl fullWidth margin="normal">
<InputLabel>Theme</InputLabel>
<Select
value={themeName}
label="Theme"
onChange={(e) => onThemeChange(e.target.value as CatppuccinTheme)}
>
<ListSubheader>Light Themes</ListSubheader>
<MenuItem value="latte">Latte</MenuItem>
<MenuItem value="pink">Pastel Pink</MenuItem>
<MenuItem value="solarized_light">Solarized Light</MenuItem>
<MenuItem value="one_light">One Light</MenuItem>
<MenuItem value="paper">Paper White</MenuItem>
<Divider />
<ListSubheader>Dark Themes</ListSubheader>
<MenuItem value="mocha">Mocha (Default)</MenuItem>
<MenuItem value="frappe">Frappé</MenuItem>
<MenuItem value="macchiato">Macchiato</MenuItem>
<MenuItem value="atom">Atom One Dark</MenuItem>
<MenuItem value="dracula">Dracula</MenuItem>
<MenuItem value="gruvbox">Gruvbox Dark</MenuItem>
<MenuItem value="nord">Nord</MenuItem>
<MenuItem value="cyberpunk">Cyberpunk</MenuItem>
<MenuItem value="forest">Forest Dark</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth margin="normal">
<InputLabel>Language</InputLabel>
<Select
value={language}
label="Language"
onChange={(e) => handleLanguageChange(e.target.value)}
>
<MenuItem value="en">English</MenuItem>
<MenuItem value="de">Deutsch</MenuItem>
</Select>
</FormControl>
</Box>
</Paper>
<Paper sx={{
@@ -274,6 +328,40 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
</Button>
</form>
</Paper>
<Paper sx={{
p: 3, mt: 4,
'& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 }
}}>
<Typography variant="h6" gutterBottom color="error" sx={{ fontWeight: 'bold' }}>Danger Zone</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Permanently delete your account and all associated data. This action is irreversible.
</Typography>
{!isDeleting ? (
<Button variant="outlined" color="error" onClick={handleRequestDeletion}>
Delete My Account
</Button>
) : (
<form onSubmit={handleConfirmDeletion}>
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>Enter verification code:</Typography>
<TextField
fullWidth
size="small"
label="Deletion Code"
value={deletionCode}
onChange={(e) => setDeletionCode(e.target.value)}
sx={{ mb: 2 }}
/>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="contained" color="error" type="submit" disabled={!deletionCode}>
Confirm Permanent Deletion
</Button>
<Button onClick={() => setIsDeleting(false)}>Cancel</Button>
</Box>
</form>
)}
</Paper>
</Container>
);
};