Added settings page
This commit is contained in:
parent
0ef35eb1d5
commit
19a52e28cd
6 changed files with 488 additions and 7 deletions
|
|
@ -3,17 +3,19 @@ import { AppProvider } from './store/AppContext';
|
|||
import { Home } from './pages/Home';
|
||||
import { Swipe } from './pages/Swipe';
|
||||
import { Matches } from './pages/Matches';
|
||||
import { Settings } from './pages/Settings';
|
||||
import { Navbar } from './components/Navbar';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AppProvider>
|
||||
<Router>
|
||||
<div className="min-h-screen bg-background text-white font-sans">
|
||||
<div className="min-h-screen bg-background text-foreground font-sans">
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/swipe" element={<Swipe />} />
|
||||
<Route path="/matches" element={<Matches />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
<Navbar />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,38 @@
|
|||
import React from 'react';
|
||||
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';
|
||||
|
||||
export const Navbar: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const { matches } = useAppContext();
|
||||
const { user, matches } = useAppContext();
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', icon: Users, label: 'Partner' },
|
||||
{ path: '/swipe', icon: Film, label: 'Discover' },
|
||||
{ path: '/matches', icon: Heart, label: 'Matches', badge: matches.length },
|
||||
{ path: '/settings', icon: Settings, label: 'Settings', requiresAuth: true },
|
||||
];
|
||||
|
||||
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">
|
||||
{navItems.map(({ path, icon: Icon, label, badge }) => {
|
||||
{navItems.map(({ path, icon: Icon, label, badge, requiresAuth }) => {
|
||||
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 (
|
||||
<Link
|
||||
key={path}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,36 @@
|
|||
--color-primary-hover: #be123c;
|
||||
--color-background: #0f172a;
|
||||
--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 {
|
||||
background-color: var(--color-background);
|
||||
color: white;
|
||||
color: var(--color-foreground);
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
overflow-x: hidden;
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -22,6 +22,8 @@ export interface Movie {
|
|||
isPartnerLike?: boolean;
|
||||
}
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
interface AppState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
|
|
@ -41,6 +43,8 @@ interface AppState {
|
|||
isLoading: boolean;
|
||||
newMatch: Movie | null;
|
||||
clearNewMatch: () => void;
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppState | undefined>(undefined);
|
||||
|
|
@ -55,9 +59,25 @@ export const AppProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
const [watchedMovies, setWatchedMovies] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
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 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 headers = {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -299,7 +319,9 @@ export const AppProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
watchedMovies,
|
||||
isLoading,
|
||||
newMatch,
|
||||
clearNewMatch
|
||||
clearNewMatch,
|
||||
theme,
|
||||
toggleTheme
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue