Added settings page

This commit is contained in:
Plexi09 2026-02-23 13:04:28 +01:00
parent 0ef35eb1d5
commit 19a52e28cd
Signed by: Plexi09
GPG key ID: 20D439A69163544A
6 changed files with 488 additions and 7 deletions

View file

@ -98,6 +98,72 @@ app.get('/api/auth/me', authenticate, (req, res) => {
}); });
}); });
// Change Password
app.put('/api/auth/password', authenticate, (req, res) => {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Current and new password required' });
if (newPassword.length < 4) return res.status(400).json({ error: 'New password must be at least 4 characters' });
db.get('SELECT * FROM users WHERE id = ?', [req.userId], async (err, user) => {
if (err || !user) return res.status(404).json({ error: 'User not found' });
if (!user.password) return res.status(400).json({ error: 'Guest accounts cannot change password' });
const match = await bcrypt.compare(currentPassword, user.password);
if (!match) return res.status(400).json({ error: 'Current password is incorrect' });
const hashedPassword = await bcrypt.hash(newPassword, 10);
db.run('UPDATE users SET password = ? WHERE id = ?', [hashedPassword, req.userId], (err) => {
if (err) return res.status(500).json({ error: 'Failed to update password' });
res.json({ success: true });
});
});
});
// Delete Account
app.delete('/api/auth/account', authenticate, async (req, res) => {
const { password } = req.body;
if (!password) return res.status(400).json({ error: 'Password is required' });
// Verify password first
const user = await new Promise((resolve, reject) => {
db.get('SELECT * FROM users WHERE id = ?', [req.userId], (err, row) => {
if (err) reject(err);
else resolve(row);
});
}).catch(() => null);
if (!user) return res.status(404).json({ error: 'User not found' });
if (!user.password) return res.status(400).json({ error: 'Guest accounts cannot be deleted this way' });
const match = await bcrypt.compare(password, user.password);
if (!match) return res.status(400).json({ error: 'Incorrect password' });
// Proceed with deletion — first unlink partner if any
db.get('SELECT partner_code FROM users WHERE id = ?', [req.userId], (err, userData) => {
if (err) return res.status(500).json({ error: 'Failed to delete account' });
const cleanup = () => {
// Delete all user data
db.run('DELETE FROM swipes WHERE user_id = ?', [req.userId], () => {
db.run('DELETE FROM watched WHERE user_id = ?', [req.userId], () => {
db.run('DELETE FROM users WHERE id = ?', [req.userId], (err) => {
if (err) return res.status(500).json({ error: 'Failed to delete account' });
res.json({ success: true });
});
});
});
};
if (userData && userData.partner_code) {
db.run('UPDATE users SET partner_code = NULL WHERE code = ?', [userData.partner_code], () => {
cleanup();
});
} else {
cleanup();
}
});
});
// Update Genres // Update Genres
app.post('/api/user/genres', authenticate, (req, res) => { app.post('/api/user/genres', authenticate, (req, res) => {
const { genres } = req.body; const { genres } = req.body;

View file

@ -3,17 +3,19 @@ import { AppProvider } from './store/AppContext';
import { Home } from './pages/Home'; import { Home } from './pages/Home';
import { Swipe } from './pages/Swipe'; import { Swipe } from './pages/Swipe';
import { Matches } from './pages/Matches'; import { Matches } from './pages/Matches';
import { Settings } from './pages/Settings';
import { Navbar } from './components/Navbar'; import { Navbar } from './components/Navbar';
function App() { function App() {
return ( return (
<AppProvider> <AppProvider>
<Router> <Router>
<div className="min-h-screen bg-background text-white font-sans"> <div className="min-h-screen bg-background text-foreground font-sans">
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/swipe" element={<Swipe />} /> <Route path="/swipe" element={<Swipe />} />
<Route path="/matches" element={<Matches />} /> <Route path="/matches" element={<Matches />} />
<Route path="/settings" element={<Settings />} />
</Routes> </Routes>
<Navbar /> <Navbar />
</div> </div>

View file

@ -1,23 +1,38 @@
import React from 'react'; import React from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { Film, Heart, Users } from 'lucide-react'; import { Film, Heart, Users, Settings } from 'lucide-react';
import { useAppContext } from '../store/AppContext'; import { useAppContext } from '../store/AppContext';
export const Navbar: React.FC = () => { export const Navbar: React.FC = () => {
const location = useLocation(); const location = useLocation();
const { matches } = useAppContext(); const { user, matches } = useAppContext();
const navItems = [ const navItems = [
{ path: '/', icon: Users, label: 'Partner' }, { path: '/', icon: Users, label: 'Partner' },
{ path: '/swipe', icon: Film, label: 'Discover' }, { path: '/swipe', icon: Film, label: 'Discover' },
{ path: '/matches', icon: Heart, label: 'Matches', badge: matches.length }, { path: '/matches', icon: Heart, label: 'Matches', badge: matches.length },
{ path: '/settings', icon: Settings, label: 'Settings', requiresAuth: true },
]; ];
return ( return (
<nav className="fixed bottom-0 w-full bg-surface border-t border-slate-700 pb-safe"> <nav className="fixed bottom-0 w-full bg-surface border-t border-border pb-safe">
<div className="flex justify-around items-center h-16 max-w-md mx-auto"> <div className="flex justify-around items-center h-16 max-w-md mx-auto">
{navItems.map(({ path, icon: Icon, label, badge }) => { {navItems.map(({ path, icon: Icon, label, badge, requiresAuth }) => {
const isActive = location.pathname === path; const isActive = location.pathname === path;
const isDisabled = requiresAuth && !user;
if (isDisabled) {
return (
<div
key={path}
className="flex flex-col items-center justify-center w-full h-full space-y-1 text-slate-600 cursor-not-allowed"
>
<Icon size={24} />
<span className="text-xs font-medium">{label}</span>
</div>
);
}
return ( return (
<Link <Link
key={path} key={path}

View file

@ -5,11 +5,36 @@
--color-primary-hover: #be123c; --color-primary-hover: #be123c;
--color-background: #0f172a; --color-background: #0f172a;
--color-surface: #1e293b; --color-surface: #1e293b;
--color-surface-hover: #334155;
--color-foreground: #f8fafc;
--color-muted: #94a3b8;
--color-border: #334155;
}
/* Dark theme (default) */
:root,
[data-theme='dark'] {
--color-background: #0f172a;
--color-surface: #1e293b;
--color-surface-hover: #334155;
--color-foreground: #f8fafc;
--color-muted: #94a3b8;
--color-border: #334155;
}
/* Light theme */
[data-theme='light'] {
--color-background: #f1f5f9;
--color-surface: #ffffff;
--color-surface-hover: #e2e8f0;
--color-foreground: #0f172a;
--color-muted: #64748b;
--color-border: #cbd5e1;
} }
body { body {
background-color: var(--color-background); background-color: var(--color-background);
color: white; color: var(--color-foreground);
margin: 0; margin: 0;
font-family: system-ui, -apple-system, sans-serif; font-family: system-ui, -apple-system, sans-serif;
overflow-x: hidden; overflow-x: hidden;

View 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>
);
};

View file

@ -22,6 +22,8 @@ export interface Movie {
isPartnerLike?: boolean; isPartnerLike?: boolean;
} }
type Theme = 'dark' | 'light';
interface AppState { interface AppState {
user: User | null; user: User | null;
token: string | null; token: string | null;
@ -41,6 +43,8 @@ interface AppState {
isLoading: boolean; isLoading: boolean;
newMatch: Movie | null; newMatch: Movie | null;
clearNewMatch: () => void; clearNewMatch: () => void;
theme: Theme;
toggleTheme: () => void;
} }
const AppContext = createContext<AppState | undefined>(undefined); const AppContext = createContext<AppState | undefined>(undefined);
@ -55,9 +59,25 @@ export const AppProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const [watchedMovies, setWatchedMovies] = useState<number[]>([]); const [watchedMovies, setWatchedMovies] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [newMatch, setNewMatch] = useState<Movie | null>(null); const [newMatch, setNewMatch] = useState<Movie | null>(null);
const [theme, setTheme] = useState<Theme>(() => {
return (localStorage.getItem('cinematch_theme') as Theme) || 'dark';
});
const clearNewMatch = () => setNewMatch(null); const clearNewMatch = () => setNewMatch(null);
const toggleTheme = () => {
setTheme((prev) => {
const next = prev === 'dark' ? 'light' : 'dark';
localStorage.setItem('cinematch_theme', next);
return next;
});
};
// Apply theme class to document
useEffect(() => {
document.documentElement.dataset.theme = theme;
}, [theme]);
const fetchWithAuth = async (url: string, options: RequestInit = {}) => { const fetchWithAuth = async (url: string, options: RequestInit = {}) => {
const headers = { const headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -299,7 +319,9 @@ export const AppProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
watchedMovies, watchedMovies,
isLoading, isLoading,
newMatch, newMatch,
clearNewMatch clearNewMatch,
theme,
toggleTheme
}} }}
> >
{children} {children}