Redesign Settings page — responsive grid, abridged docs, less scroll
- Page max-width 1100px (from 600px) for desktop side-by-side layout - CSS grid auto-fits cards at minmax(320px, 1fr) — 1 col mobile, 2-3 cols desktop - Cards align to top (alignItems: start) so short ones don't stretch - Password change hidden behind a <details> summary to reduce scroll - Notifications + Appearance + Admin compacted (smaller padding, tighter layout) - Documents section: shows 5 most recent, 'Show all N' button expands inline, 'Show less' collapses — no separate page needed - Long filenames truncate with ellipsis - Removed redundant descriptive text, shortened labels Mobile: single column stack, preserves all functionality. Desktop: 2-3 columns depending on viewport width. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2a0dd56f95
commit
0489bb49a1
1 changed files with 120 additions and 87 deletions
|
|
@ -4,10 +4,10 @@ import { useAuth } from '../context/AuthContext'
|
|||
import { useTheme } from '../context/ThemeContext'
|
||||
import api from '../api/client'
|
||||
|
||||
function Section({ title, children }) {
|
||||
function Section({ title, children, compact = false }) {
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<h2 style={{ marginBottom: 20 }}>{title}</h2>
|
||||
<div className="card" style={{ padding: compact ? 16 : 20, margin: 0 }}>
|
||||
<h2 style={{ marginBottom: 16, fontSize: '1rem' }}>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -44,7 +44,7 @@ function ProfileSection({ user }) {
|
|||
|
||||
return (
|
||||
<Section title="Profile">
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 20 }}>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 16 }}>
|
||||
{user?.email}
|
||||
<span style={{
|
||||
fontSize: '0.72rem', fontWeight: 600, padding: '1px 8px', borderRadius: 10,
|
||||
|
|
@ -59,23 +59,26 @@ function ProfileSection({ user }) {
|
|||
<label>Display Name</label>
|
||||
<input type="text" value={name} onChange={e => setName(e.target.value)} required />
|
||||
</div>
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||||
<p style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: 12 }}>
|
||||
Change Password <span style={{ fontWeight: 400 }}>(leave blank to keep current)</span>
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<label>Current Password</label>
|
||||
<input type="password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} placeholder="Required to change password" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>New Password</label>
|
||||
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} placeholder="At least 8 characters" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Confirm New Password</label>
|
||||
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} />
|
||||
</div>
|
||||
<button className="btn btn-primary" type="submit" disabled={loading}>{loading ? 'Saving...' : 'Save Changes'}</button>
|
||||
<details style={{ marginTop: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: 12 }}>
|
||||
Change password
|
||||
</summary>
|
||||
<div className="form-group">
|
||||
<label>Current Password</label>
|
||||
<input type="password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} placeholder="Required to change password" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>New Password</label>
|
||||
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} placeholder="At least 8 characters" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Confirm New Password</label>
|
||||
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} />
|
||||
</div>
|
||||
</details>
|
||||
<button className="btn btn-primary" type="submit" disabled={loading} style={{ marginTop: 12 }}>
|
||||
{loading ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</form>
|
||||
</Section>
|
||||
)
|
||||
|
|
@ -102,15 +105,15 @@ function NotificationsSection() {
|
|||
if (!loaded) return null
|
||||
|
||||
return (
|
||||
<Section title="Notifications">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Section title="Notifications" compact>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<strong style={{ display: 'block', marginBottom: 4, fontSize: '0.9rem' }}>Quiz Reminders</strong>
|
||||
<span style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>
|
||||
Receive email reminders to review quizzes based on your performance.
|
||||
Emails to review quizzes based on performance.
|
||||
</span>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', flexShrink: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!remindersDisabled}
|
||||
|
|
@ -131,19 +134,19 @@ function AppearanceSection() {
|
|||
{ value: 'markdown', label: 'Warm Brown', desc: 'Parchment serif — easy on the eyes', icon: '📖' },
|
||||
]
|
||||
return (
|
||||
<Section title="Appearance">
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Section title="Appearance" compact>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 10 }}>
|
||||
{themes.map(t => (
|
||||
<div key={t.value} onClick={() => setTheme(t.value)}
|
||||
style={{
|
||||
flex: 1, minWidth: 140, padding: '14px 16px', borderRadius: 10, cursor: 'pointer',
|
||||
padding: '12px', borderRadius: 10, cursor: 'pointer',
|
||||
border: `2px solid ${theme === t.value ? 'var(--primary)' : 'var(--border)'}`,
|
||||
background: theme === t.value ? 'var(--option-sel-bg)' : 'var(--card-bg)',
|
||||
transition: 'border-color 0.15s',
|
||||
}}>
|
||||
<div style={{ fontSize: '1.4rem', marginBottom: 6 }}>{t.icon}</div>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.9rem', color: theme === t.value ? 'var(--primary)' : 'var(--text)' }}>{t.label}</div>
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 2 }}>{t.desc}</div>
|
||||
<div style={{ fontSize: '1.2rem', marginBottom: 4 }}>{t.icon}</div>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.88rem', color: theme === t.value ? 'var(--primary)' : 'var(--text)' }}>{t.label}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 2 }}>{t.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -157,24 +160,20 @@ function NextcloudSection() {
|
|||
const [password, setPassword] = useState('')
|
||||
const [status, setStatus] = useState(null)
|
||||
const [statusMsg, setStatusMsg] = useState('')
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
// Load from server on mount
|
||||
useEffect(() => {
|
||||
api.get('/auth/me/settings').then(res => {
|
||||
const s = res.data
|
||||
if (s.nc_server) setServer(s.nc_server)
|
||||
if (s.nc_username) setUsername(s.nc_username)
|
||||
if (s.nc_password) setPassword(s.nc_password)
|
||||
// Also sync to localStorage for UploadPage
|
||||
if (s.nc_server) localStorage.setItem('nc_server', s.nc_server)
|
||||
if (s.nc_username) localStorage.setItem('nc_username', s.nc_username)
|
||||
if (s.nc_password) localStorage.setItem('nc_password', s.nc_password)
|
||||
}).catch(() => {}).finally(() => setLoaded(true))
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const save = async () => {
|
||||
// Save to both server (cross-browser) and localStorage (for UploadPage)
|
||||
localStorage.setItem('nc_server', server)
|
||||
localStorage.setItem('nc_username', username)
|
||||
localStorage.setItem('nc_password', password)
|
||||
|
|
@ -186,15 +185,12 @@ function NextcloudSection() {
|
|||
}
|
||||
|
||||
const test = async () => {
|
||||
setStatus('testing')
|
||||
setStatusMsg('')
|
||||
setStatus('testing'); setStatusMsg('')
|
||||
try {
|
||||
const res = await api.post('/nextcloud/test', { server, username, password })
|
||||
setStatus('ok')
|
||||
setStatusMsg(res.data.message)
|
||||
setStatus('ok'); setStatusMsg(res.data.message)
|
||||
} catch (err) {
|
||||
setStatus('error')
|
||||
setStatusMsg(err.response?.data?.detail || 'Connection failed')
|
||||
setStatus('error'); setStatusMsg(err.response?.data?.detail || 'Connection failed')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -208,60 +204,58 @@ function NextcloudSection() {
|
|||
}
|
||||
|
||||
return (
|
||||
<Section title="Nextcloud Integration">
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 16 }}>
|
||||
Connect your Nextcloud to import PDFs directly from Upload page.
|
||||
<Section title="Nextcloud">
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.82rem', marginBottom: 12 }}>
|
||||
Connect to import PDFs directly from the Upload page.
|
||||
</p>
|
||||
{status === 'ok' && <div className="alert alert-success">✓ {statusMsg}</div>}
|
||||
{status === 'error' && <div className="alert alert-error">✗ {statusMsg}</div>}
|
||||
{status === 'saved' && <div className="alert alert-success">Settings saved</div>}
|
||||
{status === 'saved' && <div className="alert alert-success">Saved</div>}
|
||||
<div className="form-group">
|
||||
<label>Server URL</label>
|
||||
<input type="url" value={server} onChange={e => setServer(e.target.value)} placeholder="https://cloud.example.com" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder="your-username" autoComplete="off" />
|
||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} autoComplete="off" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>App Password</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Generate in Nextcloud → Security → App Passwords" autoComplete="new-password" />
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="new-password" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-primary" onClick={save}>Save</button>
|
||||
<button className="btn btn-secondary" onClick={test} disabled={!username || !password || status === 'testing'}>
|
||||
{status === 'testing' ? 'Testing...' : 'Test Connection'}
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={save}>Save</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={test} disabled={!username || !password || status === 'testing'}>
|
||||
{status === 'testing' ? 'Testing...' : 'Test'}
|
||||
</button>
|
||||
{username && <button className="btn btn-secondary" onClick={clear}>Disconnect</button>}
|
||||
{username && <button className="btn btn-secondary btn-sm" onClick={clear}>Disconnect</button>}
|
||||
</div>
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--text-subtle)', marginTop: 10 }}>
|
||||
Use an App Password (Nextcloud → Settings → Security), not your account password.
|
||||
</p>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminSection() {
|
||||
const items = [
|
||||
{ to: '/admin', icon: '⚙️', label: 'Admin Dashboard' },
|
||||
{ to: '/upload', icon: '📄', label: 'Upload PDF' },
|
||||
{ to: '/trash', icon: '🗑️', label: 'Trash' },
|
||||
{ to: '/jobs', icon: '📋', label: 'Extraction Jobs' },
|
||||
]
|
||||
return (
|
||||
<Section title="Administration">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 12 }}>
|
||||
{[
|
||||
{ to: '/admin', icon: '⚙️', label: 'Admin Dashboard', desc: 'Models, users, and settings' },
|
||||
{ to: '/upload', icon: '📄', label: 'Upload PDF', desc: 'Add new documents' },
|
||||
{ to: '/trash', icon: '🗑️', label: 'Trash', desc: 'Restore deleted quizzes' },
|
||||
{ to: '/jobs', icon: '📋', label: 'Extraction Jobs', desc: 'View extraction history' },
|
||||
].map(item => (
|
||||
<Section title="Administration" compact>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 8 }}>
|
||||
{items.map(item => (
|
||||
<Link key={item.to} to={item.to} style={{ textDecoration: 'none' }}>
|
||||
<div style={{
|
||||
padding: '16px', borderRadius: 10, border: '1px solid var(--border)',
|
||||
background: 'var(--bg)', transition: 'border-color 0.15s',
|
||||
padding: '10px 12px', borderRadius: 8, border: '1px solid var(--border)',
|
||||
background: 'var(--bg)', display: 'flex', alignItems: 'center', gap: 8,
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}
|
||||
>
|
||||
<div style={{ fontSize: '1.4rem', marginBottom: 6 }}>{item.icon}</div>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--text)' }}>{item.label}</div>
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 2 }}>{item.desc}</div>
|
||||
<span style={{ fontSize: '1.1rem' }}>{item.icon}</span>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text)' }}>{item.label}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
|
@ -270,39 +264,67 @@ function AdminSection() {
|
|||
)
|
||||
}
|
||||
|
||||
const DOCS_PREVIEW = 5
|
||||
|
||||
function DocumentsSection() {
|
||||
const [documents, setDocuments] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/documents/').then(res => setDocuments(res.data)).catch(() => {}).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const shown = showAll ? documents : documents.slice(0, DOCS_PREVIEW)
|
||||
const hiddenCount = documents.length - DOCS_PREVIEW
|
||||
|
||||
return (
|
||||
<Section title="Documents">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', margin: 0 }}>
|
||||
Your uploaded PDFs for quiz and flashcard generation.
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, gap: 8 }}>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.82rem', margin: 0 }}>
|
||||
{documents.length} uploaded PDF{documents.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<Link to="/upload" className="btn btn-primary btn-sm">Upload PDF</Link>
|
||||
<Link to="/upload" className="btn btn-primary btn-sm">Upload</Link>
|
||||
</div>
|
||||
{loading && <div className="loading"><div className="spinner" /></div>}
|
||||
{!loading && documents.length === 0 && (
|
||||
<div className="empty-state" style={{ padding: '16px 0' }}><p>No documents yet.</p></div>
|
||||
)}
|
||||
{documents.map(doc => (
|
||||
{shown.map(doc => (
|
||||
<Link to={`/documents/${doc.id}`} key={doc.id} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<div className="section-item">
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<strong style={{ fontSize: '0.9rem' }}>{doc.original_filename}</strong>
|
||||
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>
|
||||
{doc.total_pages ? `${doc.total_pages} pages` : 'Processing...'} · {new Date(doc.uploaded_at).toLocaleDateString()}
|
||||
<strong style={{ fontSize: '0.88rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{doc.original_filename}</strong>
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>
|
||||
{doc.total_pages ? `${doc.total_pages} pages` : 'Processing...'} · {new Date(doc.uploaded_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`badge badge-${doc.status}`}>{doc.status}</span>
|
||||
<span className={`badge badge-${doc.status}`} style={{ flexShrink: 0 }}>{doc.status}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{!showAll && hiddenCount > 0 && (
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
style={{
|
||||
width: '100%', marginTop: 8, padding: '8px', border: '1px dashed var(--border)',
|
||||
borderRadius: 6, background: 'transparent', color: 'var(--primary)',
|
||||
cursor: 'pointer', fontSize: '0.82rem', fontWeight: 600,
|
||||
}}>
|
||||
Show all {documents.length} documents
|
||||
</button>
|
||||
)}
|
||||
{showAll && documents.length > DOCS_PREVIEW && (
|
||||
<button
|
||||
onClick={() => setShowAll(false)}
|
||||
style={{
|
||||
width: '100%', marginTop: 8, padding: '8px', border: 'none',
|
||||
background: 'transparent', color: 'var(--text-muted)',
|
||||
cursor: 'pointer', fontSize: '0.82rem',
|
||||
}}>
|
||||
Show less
|
||||
</button>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
|
@ -313,16 +335,27 @@ export default function SettingsPage() {
|
|||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600, margin: '0 auto' }}>
|
||||
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '0 4px' }}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<h1 style={{ fontSize: '1.4rem', fontWeight: 700 }}>Settings</h1>
|
||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, margin: 0 }}>Settings</h1>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginTop: 4 }}>
|
||||
Manage your account, preferences, and integrations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
||||
gap: 16,
|
||||
alignItems: 'start',
|
||||
}}>
|
||||
<ProfileSection user={user} />
|
||||
<NotificationsSection />
|
||||
<AppearanceSection />
|
||||
{isModerator && <NextcloudSection />}
|
||||
{isModerator && <DocumentsSection />}
|
||||
{(isAdmin || isModerator) && <AdminSection />}
|
||||
</div>
|
||||
<ProfileSection user={user} />
|
||||
<NotificationsSection />
|
||||
<AppearanceSection />
|
||||
{isModerator && <NextcloudSection />}
|
||||
{isModerator && <DocumentsSection />}
|
||||
{(isAdmin || isModerator) && <AdminSection />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue