1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

ui: comprehensive UI polish pass

- LoginPage/SetupPage: centered card layout with display font, styled fields and submit button
- Topbar: fix duplicate Settings nav item; add styled user-menu with username + logout button
- RunsView: format ISO timestamps to readable dates, add colored status badges (completed/failed/running)
- AdminView: view-tabs system, styled admin-table, admin-input, status-badge (active/disabled), btn-primary
- SettingsView: replace all inline styles with form-section/form-field/field-input/btn-primary/btn-danger
- CollectionsView: restructure create form with proper field labels and btn-primary
- TagsView: add Tags section heading with separator
- ContextRail: show visibility as human-readable label not raw number; fix assign-error class
- styles.css: add auth-loading, view-tabs, form utilities, btn variants, status badges,
  run-status pills, token-banner/row, checkbox-row, coll-create-form, tag-tree-header CSS
This commit is contained in:
TheGeneralist 2026-06-28 22:14:08 +02:00
parent 3ccfcce87b
commit 4311e85f95
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
21 changed files with 737 additions and 440 deletions

View file

@ -114,13 +114,13 @@ export default function AdminView({ archives }) {
<section id="admin-view" className="view admin-view is-active">
<h1>Admin</h1>
<div className="admin-tabs">
<button className={`admin-tab${tab === 'users' ? ' is-active' : ''}`} onClick={() => setTab('users')}>Users</button>
<button className={`admin-tab${tab === 'roles' ? ' is-active' : ''}`} onClick={() => setTab('roles')}>Roles</button>
<button className={`admin-tab${tab === 'archives' ? ' is-active' : ''}`} onClick={() => setTab('archives')}>Archives</button>
<div className="view-tabs">
<button className={`view-tab${tab === 'users' ? ' is-active' : ''}`} onClick={() => setTab('users')}>Users</button>
<button className={`view-tab${tab === 'roles' ? ' is-active' : ''}`} onClick={() => setTab('roles')}>Roles</button>
<button className={`view-tab${tab === 'archives' ? ' is-active' : ''}`} onClick={() => setTab('archives')}>Archives</button>
</div>
{error && <div className="capture-error">{error}</div>}
{error && <div className="form-msg form-msg--err">{error}</div>}
{tab === 'users' && (
<div className="admin-section">
@ -156,9 +156,9 @@ export default function AdminView({ archives }) {
value={newPassword} onChange={e => setNewPassword(e.target.value)} required />
<input className="admin-input" type="email" placeholder="Email (optional)"
value={newEmail} onChange={e => setNewEmail(e.target.value)} />
{createError && <div className="capture-error">{createError}</div>}
<button className="capture-submit" type="submit" disabled={creating}>
{creating ? 'Creating' : 'Create User'}
{createError && <div className="form-msg form-msg--err">{createError}</div>}
<button className="btn-primary" type="submit" disabled={creating}>
{creating ? 'Creating\u2026' : 'Create User'}
</button>
</form>
</div>
@ -192,9 +192,9 @@ export default function AdminView({ archives }) {
onChange={e => setNewRoleSlug(e.target.value)} required />
<input className="admin-input" placeholder="Display Name (e.g. Moderator)"
value={newRoleName} onChange={e => setNewRoleName(e.target.value)} required />
{roleCreateError && <div className="capture-error">{roleCreateError}</div>}
<button className="capture-submit" type="submit" disabled={creatingRole}>
{creatingRole ? 'Creating' : 'Create Role'}
{roleCreateError && <div className="form-msg form-msg--err">{roleCreateError}</div>}
<button className="btn-primary" type="submit" disabled={creatingRole}>
{creatingRole ? 'Creating\u2026' : 'Create Role'}
</button>
</form>
</div>

View file

@ -324,40 +324,43 @@ export default function CollectionsView({ archiveId }) {
</div>
{/* Create form */}
<details className="coll-create-details" style={{ marginTop: '1.5rem' }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Create collection</summary>
<form onSubmit={handleCreate} style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem', maxWidth: 400 }}>
<label>
Name
<details className="coll-create-details">
<summary>+ Create collection</summary>
<form className="coll-create-form" onSubmit={handleCreate}>
<div className="form-field">
<label className="form-label" htmlFor="coll-name">Name</label>
<input
className="capture-input"
id="coll-name"
type="text"
value={newName}
onChange={e => { setNewName(e.target.value); if (!newSlug) setNewSlug(e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')) }}
placeholder="My Collection"
required
/>
</label>
<label>
Slug
</div>
<div className="form-field">
<label className="form-label" htmlFor="coll-slug">Slug</label>
<input
className="capture-input"
id="coll-slug"
type="text"
value={newSlug}
onChange={e => setNewSlug(e.target.value)}
placeholder="my-collection"
required
/>
</label>
<label>
Default visibility
<select className="capture-input" style={{ height: 42 }} value={newVis} onChange={e => setNewVis(Number(e.target.value))}>
</div>
<div className="form-field">
<label className="form-label" htmlFor="coll-vis">Default visibility</label>
<select className="capture-input" id="coll-vis" style={{ height: 42 }}
value={newVis} onChange={e => setNewVis(Number(e.target.value))}>
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
</div>
{createError && <div className="collections-error">{createError}</div>}
<button className="capture-submit" type="submit" disabled={creating} style={{ alignSelf: 'flex-start', padding: '8px 20px' }}>
{creating ? 'Creating…' : 'Create'}
<button className="btn-primary" type="submit" disabled={creating}>
{creating ? 'Creating\u2026' : 'Create collection'}
</button>
</form>
</details>

View file

@ -70,7 +70,7 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
['Added', formatTimestamp(detail.summary.archived_at)],
['Source', detail.summary.source_kind],
['Type', detail.summary.entity_kind],
['Visibility', detail.summary.visibility],
['Visibility', VIS_LABEL[detail.summary.visibility] ?? detail.summary.visibility],
['Root', detail.structured_root_relpath],
] : []
@ -158,7 +158,7 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
</div>
)}
{assignError && (
<p style={{ color: 'var(--accent)', fontSize: 13, margin: '0 0 8px' }}>{assignError}</p>
<p className="form-msg form-msg--err" style={{ margin: '0 0 8px' }}>{assignError}</p>
)}
<div className="tag-input-wrap">
<span className="hash">/</span>

View file

@ -23,32 +23,41 @@ export default function LoginPage({ onLogin }) {
return (
<div className="login-page">
<h1>Archivr</h1>
<form onSubmit={handleSubmit}>
<label>
Username
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
autoFocus
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</label>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? 'Logging in\u2026' : 'Log in'}
</button>
</form>
<div className="login-card">
<h1 className="login-brand">Archivr</h1>
<p className="login-tagline">Sign in to your archive</p>
<form onSubmit={handleSubmit}>
<div className="login-field">
<label className="login-label" htmlFor="login-username">Username</label>
<input
className="login-input"
id="login-username"
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
autoFocus
required
autoComplete="username"
/>
</div>
<div className="login-field">
<label className="login-label" htmlFor="login-password">Password</label>
<input
className="login-input"
id="login-password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
{error && <p className="login-error">{error}</p>}
<button className="login-submit" type="submit" disabled={loading}>
{loading ? 'Signing in\u2026' : 'Sign in'}
</button>
</form>
</div>
</div>
);
}

View file

@ -1,24 +1,50 @@
function fmtDate(iso) {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
} catch {
return iso;
}
}
function StatusBadge({ status }) {
const cls = status === 'completed' ? 'run-status--completed'
: status === 'failed' ? 'run-status--failed'
: status === 'running' ? 'run-status--running'
: '';
return <span className={`run-status ${cls}`}>{status || '—'}</span>;
}
export default function RunsView({ runs }) {
return (
<section id="runs-view" className="view is-active">
<table className="entry-table">
<thead>
<tr>
<th>Started</th><th>Status</th><th>Requested</th><th>Completed</th><th>Failed</th>
<th>Started</th>
<th>Status</th>
<th>Requested</th>
<th>Completed</th>
<th>Failed</th>
</tr>
</thead>
<tbody>
{runs.map((run, i) => (
{runs.length === 0 ? (
<tr><td colSpan={5} style={{ color: 'var(--muted)', padding: '24px 16px', textAlign: 'center' }}>No runs yet.</td></tr>
) : runs.map((run, i) => (
<tr key={i}>
<td>{run.started_at ?? ''}</td>
<td>{run.status ?? ''}</td>
<td>{run.requested_count ?? ''}</td>
<td>{run.completed_count ?? ''}</td>
<td>{run.failed_count ?? ''}</td>
<td>{fmtDate(run.started_at)}</td>
<td><StatusBadge status={run.status} /></td>
<td>{run.requested_count ?? ''}</td>
<td>{run.completed_count ?? ''}</td>
<td>{run.failed_count ?? ''}</td>
</tr>
))}
</tbody>
</table>
</section>
)
);
}

View file

@ -7,23 +7,24 @@ import {
} from '../api.js'
const ROLE_ADMIN = 4
const ROLE_OWNER = 8
export default function SettingsView() {
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}
const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0)
const [tab, setTab] = useState('profile')
const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance'] : [])]
const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance' }
return (
<section className="admin-view">
<h1>Settings</h1>
<div className="admin-tabs" style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
{['profile', 'tokens', ...(isAdmin ? ['instance'] : [])].map(t => (
<div className="view-tabs">
{tabs.map(t => (
<button key={t}
className={`nav-link${tab === t ? ' is-active' : ''}`}
style={{ borderBottom: tab === t ? '2px solid var(--accent)' : undefined, color: 'var(--ink)' }}
className={`view-tab${tab === t ? ' is-active' : ''}`}
onClick={() => setTab(t)}>
{t === 'profile' ? 'Profile' : t === 'tokens' ? 'API Tokens' : 'Instance'}
{tabLabels[t]}
</button>
))}
</div>
@ -78,34 +79,43 @@ function ProfileTab({ currentUser, setCurrentUser }) {
}
return (
<div style={{ maxWidth: 480 }}>
<div className="admin-section">
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Display Name</h2>
<form className="admin-form" onSubmit={handleSaveProfile}>
<input className="admin-input" placeholder={currentUser?.username ?? ''}
value={displayName} onChange={e => setDisplayName(e.target.value)} />
{saveMsg && (
<div className={saveMsg.ok ? 'muted' : 'capture-error'}>{saveMsg.text}</div>
)}
<button className="capture-submit" type="submit" disabled={saving}>
<div style={{ maxWidth: 440 }}>
<div className="form-section">
<h2>Display Name</h2>
<form onSubmit={handleSaveProfile}>
<div className="form-field">
<label className="form-label" htmlFor="display-name">Name shown in the UI</label>
<input className="field-input" id="display-name"
placeholder={currentUser?.username ?? ''}
value={displayName} onChange={e => setDisplayName(e.target.value)} />
</div>
{saveMsg && <div className={`form-msg form-msg--${saveMsg.ok ? 'ok' : 'err'}`}>{saveMsg.text}</div>}
<button className="btn-primary" type="submit" disabled={saving}>
{saving ? 'Saving\u2026' : 'Save'}
</button>
</form>
</div>
<div className="admin-section" style={{ marginTop: 28 }}>
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Change Password</h2>
<form className="admin-form" onSubmit={handleChangePassword}>
<input className="admin-input" type="password" placeholder="Current password"
value={curPw} onChange={e => setCurPw(e.target.value)} required />
<input className="admin-input" type="password" placeholder="New password"
value={newPw} onChange={e => setNewPw(e.target.value)} required />
<input className="admin-input" type="password" placeholder="Confirm new password"
value={confirmPw} onChange={e => setConfirmPw(e.target.value)} required />
{pwMsg && (
<div className={pwMsg.ok ? 'muted' : 'capture-error'}>{pwMsg.text}</div>
)}
<button className="capture-submit" type="submit" disabled={pwSaving}>
<div className="form-section">
<h2>Change Password</h2>
<form onSubmit={handleChangePassword}>
<div className="form-field">
<label className="form-label" htmlFor="cur-pw">Current password</label>
<input className="field-input" id="cur-pw" type="password"
value={curPw} onChange={e => setCurPw(e.target.value)} required />
</div>
<div className="form-field">
<label className="form-label" htmlFor="new-pw">New password</label>
<input className="field-input" id="new-pw" type="password"
value={newPw} onChange={e => setNewPw(e.target.value)} required />
</div>
<div className="form-field">
<label className="form-label" htmlFor="confirm-pw">Confirm new password</label>
<input className="field-input" id="confirm-pw" type="password"
value={confirmPw} onChange={e => setConfirmPw(e.target.value)} required />
</div>
{pwMsg && <div className={`form-msg form-msg--${pwMsg.ok ? 'ok' : 'err'}`}>{pwMsg.text}</div>}
<button className="btn-primary" type="submit" disabled={pwSaving}>
{pwSaving ? 'Changing\u2026' : 'Change Password'}
</button>
</form>
@ -120,7 +130,7 @@ function TokensTab() {
const [error, setError] = useState(null)
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
const [newToken, setNewToken] = useState(null) // { raw_token, name }
const [newToken, setNewToken] = useState(null)
const refresh = useCallback(async () => {
setLoading(true); setError(null)
@ -157,48 +167,47 @@ function TokensTab() {
}
return (
<div style={{ maxWidth: 640 }}>
<h2 style={{ fontSize: 15, marginBottom: 12 }}>API Tokens</h2>
{newToken && (
<div style={{ background: '#e8f5e9', border: '1px solid #a5d6a7', padding: '12px 14px', marginBottom: 16, fontSize: 13 }}>
<strong>Token created.</strong> Copy it now \u2014 it will not be shown again.<br />
<code style={{ wordBreak: 'break-all', display: 'block', marginTop: 6, padding: '6px 8px', background: '#f5f5f5', border: '1px solid #ddd' }}>
{newToken.raw_token}
</code>
<button style={{ marginTop: 8, fontSize: 12, border: '1px solid #aaa', background: 'none', cursor: 'pointer' }}
onClick={() => setNewToken(null)}>Dismiss</button>
</div>
)}
<form className="admin-form" onSubmit={handleCreate} style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input className="admin-input" placeholder="Token name" value={newName}
onChange={e => setNewName(e.target.value)} style={{ flex: 1 }} required />
<button className="capture-submit" type="submit" disabled={creating}>
{creating ? 'Creating\u2026' : 'Create'}
</button>
</form>
{error && <div className="capture-error">{error}</div>}
{loading ? <div className="muted">Loading\u2026</div> : (
<div className="admin-list">
{tokens.length === 0 && <div className="muted">No tokens yet.</div>}
{tokens.map(tok => (
<div key={tok.token_uid} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
border: '1px solid var(--line)', background: 'var(--paper-3)', padding: '10px 12px' }}>
<div>
<strong style={{ fontSize: 14 }}>{tok.name}</strong>
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
Created {tok.created_at.slice(0, 10)}
{tok.last_used_at && ` \u00b7 Last used ${tok.last_used_at.slice(0, 10)}`}
<div style={{ maxWidth: 600 }}>
<div className="form-section">
<h2>API Tokens</h2>
{newToken && (
<div className="token-banner">
<strong>Token created.</strong> Copy it now it won't be shown again.
<code>{newToken.raw_token}</code>
<button className="token-dismiss" onClick={() => setNewToken(null)}>Dismiss</button>
</div>
)}
<form className="token-create-row" onSubmit={handleCreate}>
<input className="field-input field-input--flex" placeholder="Token name"
value={newName} onChange={e => setNewName(e.target.value)} required />
<button className="btn-primary" type="submit" disabled={creating}>
{creating ? 'Creating\u2026' : 'Create token'}
</button>
</form>
{error && <div className="form-msg form-msg--err">{error}</div>}
{loading ? (
<div className="muted">Loading\u2026</div>
) : (
<div>
{tokens.length === 0 && <div className="muted">No tokens yet.</div>}
{tokens.map(tok => (
<div key={tok.token_uid} className="token-row">
<div className="token-row-info">
<strong>{tok.name}</strong>
<div className="muted">
Created {tok.created_at.slice(0, 10)}
{tok.last_used_at && ` \u00b7 Last used ${tok.last_used_at.slice(0, 10)}`}
</div>
</div>
<button className="btn-danger" style={{ fontSize: 12, padding: '4px 10px' }}
onClick={() => handleDelete(tok.token_uid)}>
Revoke
</button>
</div>
<button onClick={() => handleDelete(tok.token_uid)}
style={{ border: '1px solid var(--line)', background: 'none', cursor: 'pointer',
color: 'var(--accent)', fontSize: 12, padding: '4px 10px' }}>
Revoke
</button>
</div>
))}
</div>
)}
))}
</div>
)}
</div>
</div>
)
}
@ -232,40 +241,40 @@ function InstanceTab() {
}
if (loading) return <div className="muted">Loading\u2026</div>
if (error) return <div className="capture-error">{error}</div>
if (error) return <div className="form-msg form-msg--err">{error}</div>
if (!settings) return null
return (
<div style={{ maxWidth: 480 }}>
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Instance Settings</h2>
<form onSubmit={handleSave}>
<div className="admin-section">
<div style={{ maxWidth: 440 }}>
<div className="form-section">
<h2>Instance Settings</h2>
<form onSubmit={handleSave}>
{[
['public_index_enabled', 'Public index (unauthenticated browsing)'],
['public_entry_content_enabled', 'Public entry content'],
['open_registration_enabled', 'Open registration'],
].map(([key, label]) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12, cursor: 'pointer' }}>
<label key={key} className="checkbox-row">
<input type="checkbox" checked={!!settings[key]}
onChange={e => setSettings(s => ({ ...s, [key]: e.target.checked }))} />
{label}
</label>
))}
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Default entry visibility</label>
<select className="admin-input" value={settings.default_entry_visibility}
<div className="form-field" style={{ marginTop: 4 }}>
<label className="form-label">Default entry visibility</label>
<select className="field-input" value={settings.default_entry_visibility}
onChange={e => setSettings(s => ({ ...s, default_entry_visibility: Number(e.target.value) }))}>
<option value={0}>Private (0)</option>
<option value={2}>Unlisted (2)</option>
<option value={3}>Public (3)</option>
<option value={0}>Private</option>
<option value={2}>Unlisted</option>
<option value={3}>Public</option>
</select>
</div>
</div>
{saveMsg && <div className={saveMsg.ok ? 'muted' : 'capture-error'}>{saveMsg.text}</div>}
<button className="capture-submit" type="submit" disabled={saving}>
{saving ? 'Saving\u2026' : 'Save Settings'}
</button>
</form>
{saveMsg && <div className={`form-msg form-msg--${saveMsg.ok ? 'ok' : 'err'}`}>{saveMsg.text}</div>}
<button className="btn-primary" type="submit" disabled={saving}>
{saving ? 'Saving\u2026' : 'Save Settings'}
</button>
</form>
</div>
</div>
)
}

View file

@ -32,42 +32,53 @@ export default function SetupPage({ onComplete }) {
return (
<div className="setup-page">
<h1>Welcome to Archivr</h1>
<p>Create your owner account to get started.</p>
<form onSubmit={handleSubmit}>
<label>
Username
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
autoFocus
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</label>
<label>
Confirm password
<input
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
required
/>
</label>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? 'Creating account\u2026' : 'Create account'}
</button>
</form>
<div className="setup-card">
<h1 className="setup-brand">Archivr</h1>
<p className="setup-tagline">Create your owner account to get started.</p>
<form onSubmit={handleSubmit}>
<div className="setup-field">
<label className="setup-label" htmlFor="setup-username">Username</label>
<input
className="setup-input"
id="setup-username"
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
autoFocus
required
autoComplete="username"
/>
</div>
<div className="setup-field">
<label className="setup-label" htmlFor="setup-password">Password</label>
<input
className="setup-input"
id="setup-password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
autoComplete="new-password"
/>
</div>
<div className="setup-field">
<label className="setup-label" htmlFor="setup-confirm">Confirm password</label>
<input
className="setup-input"
id="setup-confirm"
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
required
autoComplete="new-password"
/>
</div>
{error && <p className="setup-error">{error}</p>}
<button className="setup-submit" type="submit" disabled={loading}>
{loading ? 'Creating account\u2026' : 'Create account'}
</button>
</form>
</div>
</div>
);
}

View file

@ -31,8 +31,14 @@ export default function TagsView({ tagNodes, tagFilter, onTagFilterSet, onViewCh
return (
<section id="tags-view" className="view is-active">
<div className="tag-tree">
<div className="tag-tree-header">
<span className="tag-tree-title">Tags</span>
{tagFilter && (
<span className="tag-tree-active">Filtering: {tagFilter}</span>
)}
</div>
{tagNodes.length === 0 ? (
<div>No tags yet.</div>
<p className="muted" style={{ padding: '8px 0' }}>No tags yet.</p>
) : (
<ul className="tag-tree-list">
{tagNodes.map(node => (

View file

@ -33,9 +33,8 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
<button className="capture-button" onClick={onCaptureClick}>+ Capture</button>
{currentUser && (
<div className="user-menu">
<span className="username">{currentUser.display_name || currentUser.username}</span>
<button className="nav-link" onClick={() => onViewChange('settings')} style={{ color: '#d7cdbf', fontSize: 13 }}>Settings</button>
<button onClick={handleLogout} disabled={loggingOut} className="logout-btn">
<span className="user-name">{currentUser.display_name || currentUser.username}</span>
<button className="logout-btn" onClick={handleLogout} disabled={loggingOut}>
{loggingOut ? 'Logging out\u2026' : 'Log out'}
</button>
</div>

View file

@ -564,6 +564,7 @@ select {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
}
.form-field .capture-input { margin-bottom: 0; }
.capture-error {
color: var(--accent);
font-size: 13px;
@ -604,10 +605,6 @@ select {
.admin-view {
padding: 22px;
}
.admin-view h1 {
margin: 0 0 16px;
font-size: 18px;
}
.admin-list {
display: grid;
gap: 10px;
@ -622,7 +619,28 @@ select {
/* ── Tag tree ────────────────────────────────────────────────────────────── */
.tag-tree {
padding: 12px;
padding: 20px 22px;
max-width: 320px;
}
.tag-tree-header {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 14px;
border-bottom: 1px solid var(--line-soft);
padding-bottom: 10px;
}
.tag-tree-title {
font-size: 11px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.07em;
}
.tag-tree-active {
font-size: 12px;
color: var(--muted-2);
font-style: italic;
}
.tag-tree-list {
list-style: none;
@ -877,7 +895,424 @@ select {
}
.coll-add-btn:hover { opacity: 0.85; }
.coll-add-btn:disabled { opacity: 0.45; cursor: default; }
.coll-create-details summary { font-weight: 600; cursor: pointer; }
/* old override — superseded by coll-create-details section below */
/* ── Auth loading state ─────────────────────────────────────────────────── */
.auth-loading {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
font-size: 15px;
background: var(--paper);
}
/* ── User menu (topbar right) ───────────────────────────────────────────── */
.user-menu {
display: flex;
align-items: center;
gap: 14px;
padding-left: 6px;
flex-shrink: 0;
}
.user-name {
font-size: 12.5px;
color: var(--muted-2);
letter-spacing: 0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 140px;
}
.logout-btn {
border: 1px solid rgba(255,255,255,0.18);
background: transparent;
color: #c8bfb0;
font-size: 12px;
padding: 4px 11px;
border-radius: var(--r);
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.logout-btn:hover { background: rgba(255,255,255,0.1); color: var(--paper); }
.logout-btn:disabled { opacity: 0.5; cursor: default; }
/* ── Login / Setup pages ─────────────────────────────────────────────────── */
.login-page,
.setup-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--paper);
}
.login-card,
.setup-card {
width: 100%;
max-width: 360px;
padding: 40px 36px 44px;
background: var(--paper-3);
border: 1px solid var(--line);
border-radius: var(--r3);
box-shadow: 0 2px 20px rgba(20,29,24,0.07);
}
.login-brand,
.setup-brand {
font-family: var(--display);
font-size: 32px;
font-weight: 600;
color: var(--ink);
letter-spacing: -0.01em;
margin: 0 0 6px;
}
.login-tagline,
.setup-tagline {
font-size: 13px;
color: var(--muted);
margin: 0 0 28px;
}
.login-field,
.setup-field {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 14px;
}
.login-label,
.setup-label {
font-size: 11.5px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.login-input,
.setup-input {
width: 100%;
border: 1px solid var(--line);
background: var(--field);
color: var(--ink);
padding: 9px 11px;
font-size: 14px;
border-radius: var(--r2);
outline: none;
transition: border-color 0.15s;
}
.login-input:focus,
.setup-input:focus { border-color: var(--accent); }
.login-error,
.setup-error {
font-size: 13px;
color: var(--accent);
margin: 4px 0 8px;
}
.login-submit,
.setup-submit {
width: 100%;
margin-top: 8px;
border: none;
background: var(--top);
color: var(--paper);
font-size: 14px;
font-weight: 600;
padding: 11px 16px;
border-radius: var(--r2);
cursor: pointer;
letter-spacing: 0.02em;
transition: opacity 0.15s;
}
.login-submit:hover,
.setup-submit:hover { opacity: 0.85; }
.login-submit:disabled,
.setup-submit:disabled { opacity: 0.45; cursor: default; }
/* ── Shared sub-view tabs ────────────────────────────────────────────────── */
.view-tabs {
display: flex;
gap: 2px;
margin-bottom: 22px;
border-bottom: 1px solid var(--line-soft);
padding-bottom: 0;
}
.view-tab {
border: none;
background: none;
color: var(--muted);
font-size: 13px;
font-weight: 600;
padding: 7px 14px;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
letter-spacing: 0.02em;
transition: color 0.12s;
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 11.5px;
}
.view-tab:hover { color: var(--ink); }
.view-tab.is-active { color: var(--ink); border-bottom-color: var(--accent); }
/* ── Shared form elements ────────────────────────────────────────────────── */
.form-section { margin-bottom: 32px; }
.form-section h2 {
font-size: 14px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0 0 14px;
}
.form-row { display: flex; gap: 8px; margin-bottom: 10px; }
.form-field {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 12px;
}
.form-label {
font-size: 11.5px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.field-input {
border: 1px solid var(--line);
background: var(--field);
color: var(--ink);
padding: 8px 10px;
font-size: 13.5px;
border-radius: var(--r);
outline: none;
transition: border-color 0.15s;
min-width: 0;
}
.field-input:focus { border-color: var(--accent); }
.field-input--flex { flex: 1; }
.form-msg { font-size: 13px; margin: 6px 0; }
.form-msg--ok { color: var(--link); }
.form-msg--err { color: var(--accent); }
.btn-primary {
border: none;
background: var(--top);
color: var(--paper);
font-size: 13px;
font-weight: 600;
padding: 8px 16px;
border-radius: var(--r);
cursor: pointer;
white-space: nowrap;
}
.btn-primary:hover { opacity: 0.85; }
.btn-primary:disabled { opacity: 0.45; cursor: default; }
.btn-ghost {
border: 1px solid var(--line);
background: none;
color: var(--ink);
font-size: 13px;
padding: 7px 14px;
border-radius: var(--r);
cursor: pointer;
}
.btn-ghost:hover { background: var(--paper-2); }
.btn-danger {
border: 1px solid var(--accent);
background: none;
color: var(--accent);
font-size: 13px;
padding: 7px 14px;
border-radius: var(--r);
cursor: pointer;
}
.btn-danger:hover { background: var(--accent); color: var(--paper); }
/* ── Admin view overrides ────────────────────────────────────────────────── */
.admin-view h1 { font-family: var(--display); font-weight: 600; font-size: 26px; margin: 0 0 20px; }
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
margin-bottom: 28px;
}
.admin-table th {
text-align: left;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--muted);
padding: 8px 10px;
border-bottom: 1px solid var(--line);
background: var(--paper-2);
}
.admin-table th:first-child { padding-left: 16px; }
.admin-table td {
padding: 9px 10px;
border-bottom: 1px solid var(--line-soft);
color: var(--ink);
vertical-align: middle;
}
.admin-table td:first-child { padding-left: 16px; }
.admin-table tr:hover td { background: var(--paper-2); }
.admin-row-disabled td { opacity: 0.45; }
.admin-section { margin-bottom: 36px; max-width: 860px; }
.admin-section h2 {
font-size: 14px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0 0 14px;
}
.admin-section h3 {
font-size: 13px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 22px 0 10px;
}
.admin-form { display: flex; flex-direction: column; gap: 8px; max-width: 480px; }
.admin-input {
border: 1px solid var(--line);
background: var(--field);
color: var(--ink);
padding: 8px 10px;
font-size: 13.5px;
border-radius: var(--r);
outline: none;
transition: border-color 0.15s;
}
.admin-input:focus { border-color: var(--accent); }
.admin-action-btn {
border: 1px solid var(--line);
background: none;
color: var(--ink);
font-size: 12px;
padding: 4px 10px;
border-radius: var(--r);
cursor: pointer;
white-space: nowrap;
}
.admin-action-btn:hover { border-color: var(--accent); color: var(--accent); }
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--r);
font-size: 11.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status-active { background: #d8eddf; color: #235c35; border: 1px solid #b4d9be; }
.status-disabled { background: #ede8e0; color: var(--muted); border: 1px solid var(--line); }
/* ── Runs view improvements ──────────────────────────────────────────────── */
.run-status {
display: inline-block;
padding: 2px 8px;
border-radius: var(--r);
font-size: 11.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.run-status--completed { background: #d8eddf; color: #235c35; border: 1px solid #b4d9be; }
.run-status--failed { background: #f5ddd8; color: #8d3f30; border: 1px solid #e0b8b0; }
.run-status--running { background: #dde8f5; color: #245f72; border: 1px solid #b8cde0; }
/* ── Token created banner ────────────────────────────────────────────────── */
.token-banner {
background: #d8eddf;
border: 1px solid #b4d9be;
padding: 12px 14px;
margin-bottom: 16px;
font-size: 13px;
border-radius: var(--r2);
}
.token-banner code {
word-break: break-all;
display: block;
margin-top: 6px;
padding: 6px 8px;
background: var(--field);
border: 1px solid var(--line);
border-radius: var(--r);
font-size: 12px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
}
.token-dismiss {
margin-top: 8px;
font-size: 12px;
border: 1px solid var(--line);
background: none;
cursor: pointer;
padding: 3px 8px;
border-radius: var(--r);
color: var(--muted);
}
.token-dismiss:hover { background: var(--paper-2); }
.token-row {
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid var(--line);
background: var(--paper-3);
padding: 10px 12px;
border-radius: var(--r);
margin-bottom: 6px;
}
.token-row-info strong { font-size: 14px; display: block; }
.token-row-info .muted { font-size: 12px; margin-top: 2px; }
.token-create-row { display: flex; gap: 8px; margin-bottom: 16px; }
/* ── Instance settings checkboxes ───────────────────────────────────────── */
.checkbox-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
cursor: pointer;
font-size: 14px;
}
.checkbox-row input[type=checkbox] { width: 15px; height: 15px; accent-color: var(--accent); }
/* ── Collections create details ─────────────────────────────────────────── */
.coll-create-details {
margin-top: 16px;
}
.coll-create-details summary {
font-weight: 600;
cursor: pointer;
font-size: 13px;
color: var(--link);
list-style: none;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border: 1px solid var(--line-soft);
border-radius: var(--r2);
background: var(--paper-3);
width: fit-content;
}
.coll-create-details summary:hover { background: var(--paper-2); }
.coll-create-details[open] summary { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
.coll-create-form {
border: 1px solid var(--line-soft);
border-top: none;
padding: 14px;
border-radius: 0 0 var(--r2) var(--r2);
background: var(--paper-3);
display: flex;
flex-direction: column;
gap: 10px;
max-width: 440px;
}
/* ── Responsive ──────────────────────────────────────────────────────────── */
@media (max-width: 900px) {