351 lines
14 KiB
TypeScript
351 lines
14 KiB
TypeScript
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>
|
|
);
|
|
};
|