deckhearth/pages/settings.js
Randall Stillwell 1769d4576a refactor(design): site-wide sweep — broken Tailwind tokens, rounded corners, SearchBar primitive (#117)
Comprehensive design sweep across the rest of the app following the
shipped Liquid Glass + corner-border-light system (#116).

## Three classes of finding

### 1. Broken Tailwind token classes (HIGH — pages were unstyled)

The decks / deck-builder / deck-detail cluster relied on Tailwind
classes that don't exist in `tailwind.config.js` (no `bg-bg-*`,
`text-text-*`, `border-border`, `bg-accent-ember`,
`focus:ring-accent-ember`, `hover:bg-accent-ember-dark`). Those classes
produced ZERO CSS — backgrounds were transparent, borders invisible,
hover states absent.

Rewrote with inline `style={{ ... CSS vars ... }}` + the `<Button>` /
`<SearchBar>` primitives + `glass-panel` surfaces:

- `pages/decks.js` (full page)
- `pages/deck/[id].js` (header, stats sidebar, group-by controls,
  card list)
- `pages/deck-builder.js` (loading spinner)
- `components/DeckBuilderView.js` (toolbar + main panel)
- `components/DeckBuilderCardBrowser.js` (full rewrite; integrated
  `<SearchBar>` for the card-picker input)
- `components/DeckBuilderDeckList.js` (full rewrite)
- `components/DeckBuilderStatsBar.js`
- `components/ManaSymbolSettings.js`
- `components/ManaSymbols.js` (single `text-text-secondary`)
- `pages/admin/card-editor.js` cluster was already clean

### 2. Duplicative / stale page searches

Replaced raw `<input>` search controls with the `<SearchBar>` primitive
(adds clear button, ember focus ring, system-consistent rounded
corners). Kept page-specific filter searches (they filter the visible
list — distinct from the global TopSearchBar command palette):

- `pages/my-cards.js`
- `pages/community/collections.js`
- `components/CardsPageView.js`
- `components/CollectionPageView.js`
- `components/DeckBuilderCardBrowser.js`

`pages/my-cards.js` filter wrapper also lifted into a `glass-panel`
chip instead of a solid `var(--bg-primary)` band.

### 3. Square corners + stale palette in shared views

- `components/CollectionPageView.js`: 10 action buttons (`rounded-lg`
  + `hover:bg-gray-50`) → `rounded-xl` + `nav-item-hover`; 4 filter
  selects (`focus:ring-purple-500 rounded-lg`) → `.input-field`;
  view-mode toggle (`bg-white text-gray-900` — invisible in dark mode)
  → tokenised; SYSTEM badge gradient (`from-blue-500 to-purple-600`)
  → ember↔flame; tooltip (`bg-gray-900`) → `glass-panel-strong`;
  search-results dropdown (`bg-white border-gray-200` — invisible in
  dark mode) → `glass-panel-strong`; Activity / game-count /
  TCG-game badges palette-aligned.
- `components/CardsPageView.js`: "Load More Cards" button
  (`bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg`) →
  `<Button variant="primary" size="lg">`.
- `components/CollectionsPageView.js`: matching SYSTEM badge +
  tooltip cleanup.
- `components/ShareModal.js`: user-search dropdown
  (`border-gray-200 hover:bg-gray-50`) and email-invite card moved
  onto `glass-panel` + `nav-item-hover`; social-share buttons
  `rounded-lg hover:bg-gray-50` → `rounded-xl nav-item-hover`.
- `components/Layout.js`: profile-menu dropdown row
  (`hover:bg-gray-50 dark:hover:bg-gray-700`) → `nav-item-hover`.
- `components/CardItem.js`: bulk-select checkbox
  `focus:ring-purple-500` → ember.

### 4. `dark:` modifier classes (broken with `[data-theme]` theming)

This app uses `[data-theme="dark"]` CSS selector theming, not
Tailwind's `class` strategy, so `dark:bg-green-900/20` etc. produced
no CSS in dark mode. Affected alerts on `pages/settings.js` and
`pages/profile.js` — replaced with `glass-panel` + semantic border
colour (flame for success, #dc2626 for error).

`pages/settings.js` sidebar nav also moved off its hardcoded full-ember
fill onto the system `nav-item` / `nav-item-active` / `nav-item-hover`
pattern for consistency with the global sidebar.

## Verification

- `npm run build` — green (Next 16 + Turbopack)
- `npm run lint` — 0 errors, 1 unrelated pre-existing warning
- `npm run test:run` — 113/113 pass (no test changes needed)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 12:55:29 -05:00

670 lines
No EOL
27 KiB
JavaScript

import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Layout from '../components/Layout';
export default function Settings() {
const router = useRouter();
// User state
const [user, setUser] = useState(null);
// Settings state
const [settings, setSettings] = useState({
// Account Settings
notifications_email: true,
notifications_marketing: false,
collection_visibility: 'private',
preferred_currency: 'USD',
cards_per_page: 50,
default_view: 'grid',
// Security Settings
two_factor_enabled: false,
// Display Settings
theme: 'system', // light, dark, system
language: 'en'
});
// Password change state
const [passwordForm, setPasswordForm] = useState({
current_password: '',
new_password: '',
confirm_password: ''
});
// UI state
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [changingPassword, setChangingPassword] = useState(false);
const [message, setMessage] = useState({ type: '', text: '' });
const [activeSection, setActiveSection] = useState('account');
const loadSettings = async () => {
try {
const token = localStorage.getItem('auth_token');
if (!token) {
router.push('/login');
return;
}
const response = await fetch('/api/user/settings', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
setUser(data.user);
setSettings(data.settings);
} else if (response.status === 401) {
router.push('/login');
}
} catch (error) {
console.error('Error loading settings:', error);
setMessage({ type: 'error', text: 'Failed to load settings' });
} finally {
setLoading(false);
}
}
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- load settings on mount
loadSettings();
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only settings load
}, []);
;
const handleSettingChange = (key, value) => {
setSettings(prev => ({
...prev,
[key]: value
}));
};
const saveSettings = async () => {
setSaving(true);
setMessage({ type: '', text: '' });
try {
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/user/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(settings)
});
if (response.ok) {
setMessage({ type: 'success', text: 'Settings saved successfully!' });
} else {
const error = await response.json();
setMessage({ type: 'error', text: error.message || 'Failed to save settings' });
}
} catch (error) {
console.error('Error saving settings:', error);
setMessage({ type: 'error', text: 'Failed to save settings' });
} finally {
setSaving(false);
}
};
const handlePasswordChange = async (e) => {
e.preventDefault();
if (passwordForm.new_password !== passwordForm.confirm_password) {
setMessage({ type: 'error', text: 'New passwords do not match' });
return;
}
if (passwordForm.new_password.length < 8) {
setMessage({ type: 'error', text: 'Password must be at least 8 characters long' });
return;
}
setChangingPassword(true);
setMessage({ type: '', text: '' });
try {
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/user/password', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
current_password: passwordForm.current_password,
new_password: passwordForm.new_password
})
});
if (response.ok) {
setPasswordForm({ current_password: '', new_password: '', confirm_password: '' });
setMessage({ type: 'success', text: 'Password changed successfully!' });
} else {
const error = await response.json();
setMessage({ type: 'error', text: error.message || 'Failed to change password' });
}
} catch (error) {
console.error('Error changing password:', error);
setMessage({ type: 'error', text: 'Failed to change password' });
} finally {
setChangingPassword(false);
}
};
const handleDeleteAccount = async () => {
const confirmed = window.confirm(
'Are you sure you want to delete your account? This action cannot be undone and will permanently delete all your cards, lists, and decks.'
);
if (!confirmed) return;
const doubleConfirmed = window.confirm(
'This is your final warning. Deleting your account will permanently remove all your data. Type "DELETE" to confirm.'
);
if (!doubleConfirmed) return;
try {
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/user/delete', {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
localStorage.removeItem('auth_token');
router.push('/login?message=Account deleted successfully');
} else {
const error = await response.json();
setMessage({ type: 'error', text: error.message || 'Failed to delete account' });
}
} catch (error) {
console.error('Error deleting account:', error);
setMessage({ type: 'error', text: 'Failed to delete account' });
}
};
const sections = [
{ id: 'account', name: 'Account', icon: '👤' },
{ id: 'security', name: 'Security', icon: '🔒' },
{ id: 'preferences', name: 'Preferences', icon: '⚙️' },
{ id: 'notifications', name: 'Notifications', icon: '🔔' },
{ id: 'display', name: 'Display', icon: '🎨' }
];
const currencyOptions = [
{ value: 'USD', label: 'US Dollar ($)' },
{ value: 'EUR', label: 'Euro (€)' },
{ value: 'GBP', label: 'British Pound (£)' },
{ value: 'CAD', label: 'Canadian Dollar (C$)' },
{ value: 'JPY', label: 'Japanese Yen (¥)' }
];
const cardsPerPageOptions = [
{ value: 25, label: '25 cards' },
{ value: 50, label: '50 cards' },
{ value: 100, label: '100 cards' }
];
const viewOptions = [
{ value: 'grid', label: 'Grid View' },
{ value: 'list', label: 'List View' }
];
const themeOptions = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'System' }
];
if (loading) {
return (
<Layout user={user}>
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--accent-ember)' }}></div>
</div>
</Layout>
);
}
return (
<Layout user={user}>
<div className="max-w-6xl mx-auto p-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
Settings
</h1>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
Manage your account preferences and security settings
</p>
</div>
{/* Message */}
{message.text && (
<div
className="mb-6 p-4 rounded-xl glass-panel"
style={{
color:
message.type === 'success' ? 'var(--accent-flame)' : '#dc2626',
borderColor:
message.type === 'success' ? 'var(--accent-flame)' : '#dc2626',
}}
>
{message.text}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Sidebar Navigation */}
<div className="lg:col-span-1">
<div className="card p-4">
<nav className="space-y-2">
{sections.map(section => (
<button
key={section.id}
onClick={() => setActiveSection(section.id)}
className={`nav-item w-full flex items-center gap-3 px-4 py-3 font-medium text-left ${
activeSection === section.id ? 'nav-item-active' : 'nav-item-hover'
}`}
>
<span>{section.icon}</span>
{section.name}
</button>
))}
</nav>
</div>
</div>
{/* Settings Content */}
<div className="lg:col-span-3">
<div className="card p-6">
{/* Account Settings */}
{activeSection === 'account' && (
<div>
<h2 className="text-2xl font-bold mb-6" style={{ color: 'var(--text-primary)' }}>
Account Settings
</h2>
<div className="space-y-6">
{/* Email */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Email Address
</label>
<div className="flex items-center gap-3">
<input
type="email"
value={user?.email || ''}
disabled
className="input-field flex-1 opacity-50 cursor-not-allowed"
/>
<span className="px-3 py-1 text-xs rounded-full bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-200">
Verified
</span>
</div>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
Contact support to change your email address
</p>
</div>
{/* Collection Visibility */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Default List Visibility
</label>
<select
value={settings.collection_visibility}
onChange={(e) => handleSettingChange('collection_visibility', e.target.value)}
className="input-field w-full"
>
<option value="private">Private</option>
<option value="public">Public</option>
<option value="unlisted">Unlisted</option>
</select>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
Controls who can see your new lists by default
</p>
</div>
{/* Preferred Currency */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Preferred Currency
</label>
<select
value={settings.preferred_currency}
onChange={(e) => handleSettingChange('preferred_currency', e.target.value)}
className="input-field w-full"
>
{currencyOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
Currency used for displaying card values
</p>
</div>
</div>
</div>
)}
{/* Security Settings */}
{activeSection === 'security' && (
<div>
<h2 className="text-2xl font-bold mb-6" style={{ color: 'var(--text-primary)' }}>
Security Settings
</h2>
<div className="space-y-8">
{/* Change Password */}
<div>
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Change Password
</h3>
<form onSubmit={handlePasswordChange} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Current Password
</label>
<input
type="password"
value={passwordForm.current_password}
onChange={(e) => setPasswordForm(prev => ({ ...prev, current_password: e.target.value }))}
className="input-field w-full"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
New Password
</label>
<input
type="password"
value={passwordForm.new_password}
onChange={(e) => setPasswordForm(prev => ({ ...prev, new_password: e.target.value }))}
className="input-field w-full"
minLength={8}
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Confirm New Password
</label>
<input
type="password"
value={passwordForm.confirm_password}
onChange={(e) => setPasswordForm(prev => ({ ...prev, confirm_password: e.target.value }))}
className="input-field w-full"
minLength={8}
required
/>
</div>
<button
type="submit"
disabled={changingPassword}
className="px-6 py-2 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
{changingPassword ? 'Changing Password...' : 'Change Password'}
</button>
</form>
</div>
{/* Two-Factor Authentication */}
<div>
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Two-Factor Authentication
</h3>
<div className="glass-panel flex items-center justify-between p-4 rounded-xl">
<div>
<p className="font-medium" style={{ color: 'var(--text-primary)' }}>
Enable 2FA
</p>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Add an extra layer of security to your account
</p>
</div>
<button
onClick={() => handleSettingChange('two_factor_enabled', !settings.two_factor_enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.two_factor_enabled ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.two_factor_enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{settings.two_factor_enabled && (
<p className="text-sm mt-2 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200">
Two-factor authentication is enabled. Use your authenticator app to log in.
</p>
)}
</div>
{/* Danger Zone */}
<div className="border-t pt-8" style={{ borderColor: 'var(--border)' }}>
<h3 className="text-lg font-semibold mb-4 text-red-600 dark:text-red-400">
Danger Zone
</h3>
<div className="p-4 rounded-xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-red-800 dark:text-red-200">
Delete Account
</p>
<p className="text-sm text-red-600 dark:text-red-300">
Permanently delete your account and all associated data
</p>
</div>
<button
onClick={handleDeleteAccount}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl font-medium transition-colors"
>
Delete Account
</button>
</div>
</div>
</div>
</div>
</div>
)}
{/* Preferences */}
{activeSection === 'preferences' && (
<div>
<h2 className="text-2xl font-bold mb-6" style={{ color: 'var(--text-primary)' }}>
Preferences
</h2>
<div className="space-y-6">
{/* Cards Per Page */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Cards Per Page
</label>
<select
value={settings.cards_per_page}
onChange={(e) => handleSettingChange('cards_per_page', parseInt(e.target.value))}
className="input-field w-full"
>
{cardsPerPageOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Default View */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Default View Mode
</label>
<select
value={settings.default_view}
onChange={(e) => handleSettingChange('default_view', e.target.value)}
className="input-field w-full"
>
{viewOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
</div>
)}
{/* Notifications */}
{activeSection === 'notifications' && (
<div>
<h2 className="text-2xl font-bold mb-6" style={{ color: 'var(--text-primary)' }}>
Notification Settings
</h2>
<div className="space-y-6">
{/* Email Notifications */}
<div className="glass-panel flex items-center justify-between p-4 rounded-xl">
<div>
<p className="font-medium" style={{ color: 'var(--text-primary)' }}>
Email Notifications
</p>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Receive notifications about your lists and cards
</p>
</div>
<button
onClick={() => handleSettingChange('notifications_email', !settings.notifications_email)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.notifications_email ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.notifications_email ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Marketing Emails */}
<div className="glass-panel flex items-center justify-between p-4 rounded-xl">
<div>
<p className="font-medium" style={{ color: 'var(--text-primary)' }}>
Marketing Emails
</p>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Receive updates about new features and promotions
</p>
</div>
<button
onClick={() => handleSettingChange('notifications_marketing', !settings.notifications_marketing)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.notifications_marketing ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.notifications_marketing ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</div>
)}
{/* Display Settings */}
{activeSection === 'display' && (
<div>
<h2 className="text-2xl font-bold mb-6" style={{ color: 'var(--text-primary)' }}>
Display Settings
</h2>
<div className="space-y-6">
{/* Theme */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Theme
</label>
<select
value={settings.theme}
onChange={(e) => handleSettingChange('theme', e.target.value)}
className="input-field w-full"
>
{themeOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
System will follow your device&apos;s theme preference
</p>
</div>
{/* Language */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Language
</label>
<select
value={settings.language}
onChange={(e) => handleSettingChange('language', e.target.value)}
className="input-field w-full"
>
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="ja">日本語</option>
</select>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
Interface language (coming soon)
</p>
</div>
</div>
</div>
)}
{/* Save Button */}
<div className="flex justify-end pt-8 border-t" style={{ borderColor: 'var(--border)' }}>
<button
onClick={saveSettings}
disabled={saving}
className="px-6 py-3 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
{saving ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
</div>
</div>
</div>
</Layout>
);
}