Added settings page
This commit is contained in:
parent
0ef35eb1d5
commit
19a52e28cd
6 changed files with 488 additions and 7 deletions
351
frontend/src/pages/Settings.tsx
Normal file
351
frontend/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useAppContext } from '../store/AppContext';
|
||||
import { Sun, Moon, Lock, Trash2, Film, ChevronRight, AlertTriangle } from 'lucide-react';
|
||||
|
||||
const TMDB_GENRES = [
|
||||
{ id: 28, name: 'Action' },
|
||||
{ id: 12, name: 'Adventure' },
|
||||
{ id: 16, name: 'Animation' },
|
||||
{ id: 35, name: 'Comedy' },
|
||||
{ id: 80, name: 'Crime' },
|
||||
{ id: 99, name: 'Documentary' },
|
||||
{ id: 18, name: 'Drama' },
|
||||
{ id: 10751, name: 'Family' },
|
||||
{ id: 14, name: 'Fantasy' },
|
||||
{ id: 36, name: 'History' },
|
||||
{ id: 27, name: 'Horror' },
|
||||
{ id: 10402, name: 'Music' },
|
||||
{ id: 9648, name: 'Mystery' },
|
||||
{ id: 10749, name: 'Romance' },
|
||||
{ id: 878, name: 'Science Fiction' },
|
||||
{ id: 53, name: 'Thriller' },
|
||||
{ id: 10752, name: 'War' },
|
||||
{ id: 37, name: 'Western' },
|
||||
];
|
||||
|
||||
type SettingsSection = 'main' | 'genres' | 'password' | 'delete';
|
||||
|
||||
export const Settings: React.FC = () => {
|
||||
const { user, token, theme, toggleTheme, updateGenres, logout } = useAppContext();
|
||||
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('main');
|
||||
const [selectedGenres, setSelectedGenres] = useState<number[]>(user?.genres ?? []);
|
||||
const [genresSaved, setGenresSaved] = useState(false);
|
||||
|
||||
// Password reset state
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [passwordSuccess, setPasswordSuccess] = useState('');
|
||||
|
||||
// Delete account state
|
||||
const [deletePassword, setDeletePassword] = useState('');
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
|
||||
const isGuest = !user?.username;
|
||||
|
||||
const toggleGenre = (id: number) => {
|
||||
setSelectedGenres((prev) =>
|
||||
prev.includes(id) ? prev.filter((g) => g !== id) : [...prev, id]
|
||||
);
|
||||
setGenresSaved(false);
|
||||
};
|
||||
|
||||
const handleSaveGenres = async () => {
|
||||
if (selectedGenres.length === 0) return;
|
||||
await updateGenres(selectedGenres);
|
||||
setGenresSaved(true);
|
||||
setTimeout(() => setGenresSaved(false), 2000);
|
||||
};
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setPasswordError('');
|
||||
setPasswordSuccess('');
|
||||
|
||||
if (newPassword.length < 4) {
|
||||
setPasswordError('New password must be at least 4 characters.');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError('New passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/password', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setPasswordSuccess('Password changed successfully!');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
} catch (err: unknown) {
|
||||
setPasswordError(err instanceof Error ? err.message : 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (!deletePassword) {
|
||||
setDeleteError('Please enter your password to confirm.');
|
||||
return;
|
||||
}
|
||||
setDeleteError('');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/account', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ password: deletePassword }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
logout();
|
||||
} catch (err: unknown) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
// ── Sub-sections ──────────────────────────────────────────────
|
||||
|
||||
if (activeSection === 'genres') {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground p-6 max-w-md mx-auto pb-24">
|
||||
<button
|
||||
onClick={() => setActiveSection('main')}
|
||||
className="text-sm text-slate-400 hover:text-foreground mb-4 flex items-center gap-1"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<h2 className="text-xl font-bold mb-2 flex items-center gap-2">
|
||||
<Film className="w-5 h-5 text-primary" />
|
||||
Movie Styles
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm mb-6">
|
||||
Select the genres you enjoy. This influences your movie feed.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{TMDB_GENRES.map((genre) => (
|
||||
<button
|
||||
key={genre.id}
|
||||
onClick={() => toggleGenre(genre.id)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors border ${
|
||||
selectedGenres.includes(genre.id)
|
||||
? 'bg-primary border-primary text-white'
|
||||
: 'bg-surface border-border text-muted hover:border-slate-500'
|
||||
}`}
|
||||
>
|
||||
{genre.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveGenres}
|
||||
disabled={selectedGenres.length === 0}
|
||||
className="w-full mt-8 py-3 px-4 bg-primary hover:bg-primary-hover text-white rounded-xl font-medium transition-colors disabled:opacity-40"
|
||||
>
|
||||
{genresSaved ? '✓ Saved!' : 'Save Preferences'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeSection === 'password') {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground p-6 max-w-md mx-auto pb-24">
|
||||
<button
|
||||
onClick={() => setActiveSection('main')}
|
||||
className="text-sm text-slate-400 hover:text-foreground mb-4 flex items-center gap-1"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<h2 className="text-xl font-bold mb-2 flex items-center gap-2">
|
||||
<Lock className="w-5 h-5 text-primary" />
|
||||
Change Password
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm mb-6">
|
||||
Update your account password.
|
||||
</p>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
{passwordError && <p className="text-red-500 text-sm">{passwordError}</p>}
|
||||
{passwordSuccess && <p className="text-green-500 text-sm">{passwordSuccess}</p>}
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
placeholder="Current password"
|
||||
className="w-full bg-surface border border-border rounded-xl p-3 text-foreground placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="New password"
|
||||
className="w-full bg-surface border border-border rounded-xl p-3 text-foreground placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm new password"
|
||||
className="w-full bg-surface border border-border rounded-xl p-3 text-foreground placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 px-4 bg-primary hover:bg-primary-hover text-white rounded-xl font-medium transition-colors"
|
||||
>
|
||||
Update Password
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeSection === 'delete') {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground p-6 max-w-md mx-auto pb-24">
|
||||
<button
|
||||
onClick={() => setActiveSection('main')}
|
||||
className="text-sm text-slate-400 hover:text-foreground mb-4 flex items-center gap-1"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<h2 className="text-xl font-bold mb-2 flex items-center gap-2 text-red-500">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Delete Account
|
||||
</h2>
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4 mb-6">
|
||||
<p className="text-red-400 text-sm">
|
||||
This action is <strong>permanent and irreversible</strong>. All your data — swipes,
|
||||
matches, and account info — will be deleted.
|
||||
</p>
|
||||
</div>
|
||||
{deleteError && <p className="text-red-500 text-sm mb-4">{deleteError}</p>}
|
||||
<label htmlFor="delete-password" className="block text-sm text-slate-400 mb-2">
|
||||
Enter your password to confirm:
|
||||
</label>
|
||||
<input
|
||||
id="delete-password"
|
||||
type="password"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
placeholder="Your password"
|
||||
className="w-full bg-surface border border-red-500/40 rounded-xl p-3 text-foreground placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent mb-4"
|
||||
/>
|
||||
<button
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={!deletePassword}
|
||||
className="w-full py-3 px-4 bg-red-600 hover:bg-red-700 text-white rounded-xl font-medium transition-colors disabled:opacity-40"
|
||||
>
|
||||
Permanently Delete My Account
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main settings menu ────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground p-6 max-w-md mx-auto pb-24">
|
||||
<h1 className="text-2xl font-bold mb-6">Settings</h1>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Theme toggle */}
|
||||
<div className="bg-surface rounded-2xl border border-border p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{theme === 'dark' ? (
|
||||
<Moon className="w-5 h-5 text-primary" />
|
||||
) : (
|
||||
<Sun className="w-5 h-5 text-primary" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">Theme</p>
|
||||
<p className="text-sm text-muted">{theme === 'dark' ? 'Dark mode' : 'Light mode'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${
|
||||
theme === 'dark' ? 'bg-primary' : 'bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${
|
||||
theme === 'dark' ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Movie Styles */}
|
||||
<button
|
||||
onClick={() => setActiveSection('genres')}
|
||||
className="w-full bg-surface rounded-2xl border border-border p-4 flex items-center justify-between hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Film className="w-5 h-5 text-primary" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Movie Styles</p>
|
||||
<p className="text-sm text-muted">
|
||||
{user.genres && user.genres.length > 0
|
||||
? `${user.genres.length} genre${user.genres.length > 1 ? 's' : ''} selected`
|
||||
: 'No genres selected'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-muted" />
|
||||
</button>
|
||||
|
||||
{/* Password Reset (only for registered users) */}
|
||||
{!isGuest && (
|
||||
<button
|
||||
onClick={() => setActiveSection('password')}
|
||||
className="w-full bg-surface rounded-2xl border border-border p-4 flex items-center justify-between hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Lock className="w-5 h-5 text-primary" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Change Password</p>
|
||||
<p className="text-sm text-muted">Update your account password</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-muted" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete Account (hidden for guests) */}
|
||||
{!isGuest && (
|
||||
<button
|
||||
onClick={() => setActiveSection('delete')}
|
||||
className="w-full bg-surface rounded-2xl border border-red-500/20 p-4 flex items-center justify-between hover:bg-red-500/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Trash2 className="w-5 h-5 text-red-500" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-red-500">Delete Account</p>
|
||||
<p className="text-sm text-muted">Permanently remove your data</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue