Add Nuggets host upload portal with Seafile integration; v2.0.4
- New /host-upload page: per-host login (username + password) with session cookie, drag-and-drop file upload to Seafile via server proxy - Two upload targets: Episode Upload (audio) and Episode Info (docs) - Server route: /api/nuggets-upload/* for host auth + file proxy, /api/admin/nuggets-upload/hosts for admin CRUD (create/disable/reset pw/delete) - Admin panel: Nuggets → Upload Portal view to manage host accounts - Seafile config via SEAFILE_URL / SEAFILE_TOKEN / SEAFILE_NUGGETS_REPO_ID env vars - Directories default to /Episode Upload and /Episode Info (configurable) - Uses multer for multipart uploads; 500 MB per-file limit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
29d2ace1b2
commit
afdc00114a
9 files changed
+1074
-3
No files matched your search
+182
-1
@@ -666,7 +666,7 @@ type AdminView =
|
||||
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
|
||||
| 'contact:profile' | 'contact:copy'
|
||||
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss'
|
||||
| 'nuggets:settings' | 'nuggets:hosts'
|
||||
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal'
|
||||
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||||
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
|
||||
@@ -721,6 +721,7 @@ const ADMIN_NAV: NavGroup[] = [
|
||||
{ kind: 'parent', id: 'nuggets', label: 'Nuggets', children: [
|
||||
{ kind: 'leaf', view: 'nuggets:settings', label: 'Show Settings' },
|
||||
{ kind: 'leaf', view: 'nuggets:hosts', label: 'Hosts' },
|
||||
{ kind: 'leaf', view: 'nuggets:upload-portal', label: 'Upload Portal' },
|
||||
]},
|
||||
{ kind: 'leaf', view: 'colossians-study', label: 'Studies' },
|
||||
{ kind: 'leaf', view: 'downloads', label: 'Resources' },
|
||||
@@ -1095,6 +1096,183 @@ function QrCodeImage({ url, label }: { url: string; label: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Nuggets Upload Portal admin panel ─────────────────────────────────────
|
||||
interface UploadHost {
|
||||
id: string
|
||||
name: string
|
||||
username: string
|
||||
active: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function NuggetsUploadPortalAdmin() {
|
||||
const [hosts, setHosts] = useState<UploadHost[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [addName, setAddName] = useState('')
|
||||
const [addUsername, setAddUsername] = useState('')
|
||||
const [addPassword, setAddPassword] = useState('')
|
||||
const [addError, setAddError] = useState('')
|
||||
const [addSaving, setAddSaving] = useState(false)
|
||||
const [resetId, setResetId] = useState<string | null>(null)
|
||||
const [resetPw, setResetPw] = useState('')
|
||||
const [resetError, setResetError] = useState('')
|
||||
const [resetSaving, setResetSaving] = useState(false)
|
||||
|
||||
function reload() {
|
||||
setLoading(true)
|
||||
fetch('/api/admin/nuggets-upload/hosts')
|
||||
.then(r => r.json())
|
||||
.then(d => setHosts(d.hosts ?? []))
|
||||
.catch(() => setError('Failed to load hosts.'))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { reload() }, [])
|
||||
|
||||
async function addHost(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setAddError('')
|
||||
setAddSaving(true)
|
||||
try {
|
||||
const r = await fetch('/api/admin/nuggets-upload/hosts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: addName, username: addUsername, password: addPassword }),
|
||||
})
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setAddError(d.message ?? 'Failed.'); return }
|
||||
setAddName(''); setAddUsername(''); setAddPassword('')
|
||||
reload()
|
||||
} finally {
|
||||
setAddSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(host: UploadHost) {
|
||||
await fetch(`/api/admin/nuggets-upload/hosts/${host.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ active: !host.active }),
|
||||
})
|
||||
reload()
|
||||
}
|
||||
|
||||
async function deleteHost(id: string) {
|
||||
if (!window.confirm('Remove this host account? This cannot be undone.')) return
|
||||
await fetch(`/api/admin/nuggets-upload/hosts/${id}`, { method: 'DELETE' })
|
||||
reload()
|
||||
}
|
||||
|
||||
async function resetPassword(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!resetId) return
|
||||
setResetError('')
|
||||
setResetSaving(true)
|
||||
try {
|
||||
const r = await fetch(`/api/admin/nuggets-upload/hosts/${resetId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: resetPw }),
|
||||
})
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setResetError(d.message ?? 'Failed.'); return }
|
||||
setResetId(null); setResetPw('')
|
||||
reload()
|
||||
} finally {
|
||||
setResetSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-panel-section" aria-label="Nuggets upload portal">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Nuggets — Upload Portal</h2>
|
||||
<p>
|
||||
Manage host login accounts for the upload portal at{' '}
|
||||
<a href="/host-upload" target="_blank" rel="noopener noreferrer">/host-upload</a>.
|
||||
Each host gets a username and password to upload episode files to Seafile.
|
||||
</p>
|
||||
<p style={{ fontSize: '0.82rem', color: '#888', marginTop: 4 }}>
|
||||
Make sure <code>SEAFILE_URL</code>, <code>SEAFILE_TOKEN</code>, and <code>SEAFILE_NUGGETS_REPO_ID</code> are set in your environment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: '#c0392b' }}>{error}</p>}
|
||||
{loading && <p className="admin-stats-note">Loading…</p>}
|
||||
|
||||
{!loading && hosts.length > 0 && (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: '1.5rem', fontSize: '0.88rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid var(--color-border, #e5e0d5)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Name</th>
|
||||
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Username</th>
|
||||
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Status</th>
|
||||
<th style={{ padding: '6px 8px' }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hosts.map(h => (
|
||||
<tr key={h.id} style={{ borderBottom: '1px solid var(--color-border, #e5e0d5)' }}>
|
||||
<td style={{ padding: '7px 8px' }}>{h.name}</td>
|
||||
<td style={{ padding: '7px 8px', fontFamily: 'monospace' }}>{h.username}</td>
|
||||
<td style={{ padding: '7px 8px' }}>
|
||||
<span style={{ color: h.active ? '#27ae60' : '#888', fontWeight: 600 }}>{h.active ? 'Active' : 'Disabled'}</span>
|
||||
</td>
|
||||
<td style={{ padding: '7px 8px', whiteSpace: 'nowrap', textAlign: 'right' }}>
|
||||
<button className="btn-admin-ghost" style={{ fontSize: '0.78rem', marginRight: 6 }} onClick={() => toggleActive(h)}>
|
||||
{h.active ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button className="btn-admin-ghost" style={{ fontSize: '0.78rem', marginRight: 6 }} onClick={() => { setResetId(h.id); setResetPw(''); setResetError('') }}>
|
||||
Reset PW
|
||||
</button>
|
||||
<button className="btn-admin-remove" style={{ fontSize: '0.78rem' }} onClick={() => deleteHost(h.id)}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{!loading && hosts.length === 0 && <p className="admin-stats-note">No upload hosts yet. Add one below.</p>}
|
||||
|
||||
{resetId && (
|
||||
<form onSubmit={resetPassword} style={{ background: 'var(--color-surface-alt, #f7f5f0)', border: '1px solid var(--color-border, #e5e0d5)', borderRadius: 8, padding: '1rem', marginBottom: '1.5rem' }}>
|
||||
<p style={{ fontWeight: 600, marginBottom: 8 }}>Reset password for {hosts.find(h => h.id === resetId)?.username}</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div className="admin-field" style={{ flex: '1 1 200px' }}>
|
||||
<label>New Password (min 8 chars)</label>
|
||||
<input type="password" value={resetPw} onChange={e => setResetPw(e.target.value)} required minLength={8} />
|
||||
</div>
|
||||
<button className="btn-admin-save" type="submit" disabled={resetSaving}>{resetSaving ? 'Saving…' : 'Save'}</button>
|
||||
<button className="btn-admin-ghost" type="button" onClick={() => setResetId(null)}>Cancel</button>
|
||||
</div>
|
||||
{resetError && <p style={{ color: '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{resetError}</p>}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<form onSubmit={addHost}>
|
||||
<h3 style={{ marginBottom: 10 }}>Add Host Account</h3>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<div className="admin-field" style={{ flex: '1 1 160px' }}>
|
||||
<label>Display Name</label>
|
||||
<input type="text" placeholder="Jane Smith" value={addName} onChange={e => setAddName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="admin-field" style={{ flex: '1 1 140px' }}>
|
||||
<label>Username</label>
|
||||
<input type="text" placeholder="janesmith" value={addUsername} onChange={e => setAddUsername(e.target.value.toLowerCase())} required />
|
||||
</div>
|
||||
<div className="admin-field" style={{ flex: '1 1 160px' }}>
|
||||
<label>Password (min 8 chars)</label>
|
||||
<input type="password" value={addPassword} onChange={e => setAddPassword(e.target.value)} required minLength={8} />
|
||||
</div>
|
||||
<button className="btn-admin-save" type="submit" disabled={addSaving}>{addSaving ? 'Adding…' : '+ Add Host'}</button>
|
||||
</div>
|
||||
{addError && <p style={{ color: '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{addError}</p>}
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const normalizedContent = normalizeSiteContentForAdmin(content)
|
||||
const [form, setForm] = useState<SiteContent>(normalizedContent)
|
||||
@@ -4717,6 +4895,9 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* NUGGETS UPLOAD PORTAL */}
|
||||
{adminView === 'nuggets:upload-portal' && <NuggetsUploadPortalAdmin />}
|
||||
|
||||
{/* STUDY COMMENTS */}
|
||||
{adminView === 'study-comments' && (() => {
|
||||
const filtered = studyComments.filter(c => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { usePageMeta } from './hooks/usePageMeta'
|
||||
import { useGlobalSearch } from './hooks/useGlobalSearch'
|
||||
import { GlobalSearch } from './components/GlobalSearch'
|
||||
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
|
||||
import { NuggetsHostPortal } from './components/NuggetsHostPortal'
|
||||
import { BIBLE_BOOKS } from '../shared/bible-books.js'
|
||||
import './App.css'
|
||||
import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking } from './analytics'
|
||||
@@ -3139,6 +3140,7 @@ export default function App() {
|
||||
<Route path="/subscribe" element={<SubscribePage />} />
|
||||
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
|
||||
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
|
||||
<Route path="/host-upload" element={<NuggetsHostPortal />} />
|
||||
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
|
||||
<Route path="/preview" element={<PreviewPage />} />
|
||||
<Route
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
type UploadTarget = 'upload' | 'info'
|
||||
|
||||
interface UploadRecord {
|
||||
filename: string
|
||||
dir: string
|
||||
at: string
|
||||
}
|
||||
|
||||
export function NuggetsHostPortal() {
|
||||
const [checking, setChecking] = useState(true)
|
||||
const [authenticated, setAuthenticated] = useState(false)
|
||||
const [hostName, setHostName] = useState('')
|
||||
const [seafileConfigured, setSeafileConfigured] = useState(true)
|
||||
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loginError, setLoginError] = useState('')
|
||||
const [loggingIn, setLoggingIn] = useState(false)
|
||||
|
||||
const [target, setTarget] = useState<UploadTarget>('upload')
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [uploadError, setUploadError] = useState('')
|
||||
const [uploads, setUploads] = useState<UploadRecord[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/nuggets-upload/me')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
setAuthenticated(d.authenticated)
|
||||
setHostName(d.hostName ?? '')
|
||||
setSeafileConfigured(d.configured !== false)
|
||||
})
|
||||
.finally(() => setChecking(false))
|
||||
}, [])
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoginError('')
|
||||
setLoggingIn(true)
|
||||
try {
|
||||
const r = await fetch('/api/nuggets-upload/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
const d = await r.json()
|
||||
if (!r.ok) {
|
||||
setLoginError(d.message ?? 'Login failed.')
|
||||
return
|
||||
}
|
||||
setHostName(d.hostName)
|
||||
setAuthenticated(true)
|
||||
} finally {
|
||||
setLoggingIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/nuggets-upload/logout', { method: 'POST' })
|
||||
setAuthenticated(false)
|
||||
setHostName('')
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
setUploads([])
|
||||
}
|
||||
|
||||
async function uploadFile(file: File) {
|
||||
if (uploading) return
|
||||
setUploadError('')
|
||||
setUploading(true)
|
||||
setProgress(0)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('targetDir', target)
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', '/api/nuggets-upload/upload')
|
||||
|
||||
xhr.upload.addEventListener('progress', e => {
|
||||
if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100))
|
||||
})
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
setUploading(false)
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const d = JSON.parse(xhr.responseText)
|
||||
setUploads(prev => [{ filename: d.filename, dir: d.dir, at: new Date().toLocaleTimeString() }, ...prev])
|
||||
setProgress(100)
|
||||
} catch {
|
||||
setUploadError('Upload succeeded but response was unexpected.')
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const d = JSON.parse(xhr.responseText)
|
||||
setUploadError(d.message ?? 'Upload failed.')
|
||||
} catch {
|
||||
setUploadError('Upload failed.')
|
||||
}
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
setUploading(false)
|
||||
setUploadError('Network error during upload.')
|
||||
resolve()
|
||||
})
|
||||
|
||||
xhr.send(formData)
|
||||
})
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) uploadFile(file)
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) uploadFile(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
<div style={styles.card}>
|
||||
<p style={{ color: 'var(--color-muted, #888)', textAlign: 'center' }}>Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
<div style={styles.card}>
|
||||
<div style={styles.logo}>
|
||||
<span style={styles.logoEmoji}>🎙️</span>
|
||||
<span style={styles.logoTitle}>Nuggets</span>
|
||||
</div>
|
||||
<h1 style={styles.heading}>Host Upload Portal</h1>
|
||||
<p style={styles.subheading}>Sign in to upload your episode files to Seafile.</p>
|
||||
|
||||
<form onSubmit={handleLogin} style={styles.form}>
|
||||
<div style={styles.field}>
|
||||
<label style={styles.label} htmlFor="hp-username">Username</label>
|
||||
<input
|
||||
id="hp-username"
|
||||
style={styles.input}
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
disabled={loggingIn}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div style={styles.field}>
|
||||
<label style={styles.label} htmlFor="hp-password">Password</label>
|
||||
<input
|
||||
id="hp-password"
|
||||
style={styles.input}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
disabled={loggingIn}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{loginError && <p style={styles.errorMsg}>{loginError}</p>}
|
||||
<button style={styles.btnPrimary} type="submit" disabled={loggingIn}>
|
||||
{loggingIn ? 'Signing in…' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const targetLabel = target === 'upload' ? 'Episode Upload' : 'Episode Info'
|
||||
const acceptTypes = target === 'upload'
|
||||
? '.mp3,.wav,.m4a,.aac,.flac,.ogg,.mp4,.mov'
|
||||
: '*'
|
||||
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
<div style={{ ...styles.card, maxWidth: 640 }}>
|
||||
<div style={styles.headerRow}>
|
||||
<div style={styles.logo}>
|
||||
<span style={styles.logoEmoji}>🎙️</span>
|
||||
<span style={styles.logoTitle}>Nuggets Upload Portal</span>
|
||||
</div>
|
||||
<div style={styles.headerRight}>
|
||||
<span style={styles.welcomeText}>Hi, {hostName}</span>
|
||||
<button style={styles.btnGhost} onClick={handleLogout}>Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!seafileConfigured && (
|
||||
<div style={styles.warningBanner}>
|
||||
Seafile is not configured yet. Contact the admin to set up the connection.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={styles.targetRow}>
|
||||
<button
|
||||
style={target === 'upload' ? styles.tabActive : styles.tab}
|
||||
onClick={() => { setTarget('upload'); setUploadError('') }}
|
||||
>
|
||||
🎵 Episode Upload
|
||||
</button>
|
||||
<button
|
||||
style={target === 'info' ? styles.tabActive : styles.tab}
|
||||
onClick={() => { setTarget('info'); setUploadError('') }}
|
||||
>
|
||||
📄 Episode Info
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={styles.targetHint}>
|
||||
{target === 'upload'
|
||||
? 'Upload your finished audio file (MP3, WAV, M4A, etc.).'
|
||||
: 'Upload show notes, outlines, scripts, or any supporting document.'}
|
||||
</p>
|
||||
|
||||
<div
|
||||
ref={dropRef}
|
||||
style={{
|
||||
...styles.dropZone,
|
||||
...(dragging ? styles.dropZoneActive : {}),
|
||||
...(uploading ? styles.dropZoneDisabled : {}),
|
||||
}}
|
||||
onDragOver={e => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !uploading && fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={acceptTypes}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{uploading ? (
|
||||
<div style={styles.progressWrap}>
|
||||
<p style={styles.dropLabel}>Uploading to {targetLabel}…</p>
|
||||
<div style={styles.progressBar}>
|
||||
<div style={{ ...styles.progressFill, width: `${progress}%` }} />
|
||||
</div>
|
||||
<p style={styles.progressPct}>{progress}%</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span style={styles.dropIcon}>{dragging ? '📂' : '⬆️'}</span>
|
||||
<p style={styles.dropLabel}>{dragging ? 'Drop to upload' : 'Drop a file here, or click to browse'}</p>
|
||||
<p style={styles.dropSub}>→ {targetLabel}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadError && <p style={styles.errorMsg}>{uploadError}</p>}
|
||||
|
||||
{uploads.length > 0 && (
|
||||
<div style={styles.uploadList}>
|
||||
<p style={styles.uploadListTitle}>Uploaded this session</p>
|
||||
{uploads.map((u, i) => (
|
||||
<div key={i} style={styles.uploadRow}>
|
||||
<span style={styles.uploadCheck}>✓</span>
|
||||
<span style={styles.uploadName}>{u.filename}</span>
|
||||
<span style={styles.uploadMeta}>{u.dir} · {u.at}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Styles ────────────────────────────────────────────────────────────────
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
page: {
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem 1rem',
|
||||
background: 'var(--color-bg, #f7f5f0)',
|
||||
},
|
||||
card: {
|
||||
background: 'var(--color-surface, #fff)',
|
||||
border: '1px solid var(--color-border, #e5e0d5)',
|
||||
borderRadius: 16,
|
||||
padding: '2.5rem 2rem',
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.06)',
|
||||
},
|
||||
logo: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
logoEmoji: { fontSize: '1.6rem' },
|
||||
logoTitle: { fontWeight: 700, fontSize: '1.05rem', letterSpacing: '-0.01em' },
|
||||
heading: { fontSize: '1.45rem', fontWeight: 700, margin: '0 0 6px' },
|
||||
subheading: { color: 'var(--color-muted, #888)', fontSize: '0.9rem', margin: '0 0 1.5rem' },
|
||||
form: { display: 'flex', flexDirection: 'column', gap: 16 },
|
||||
field: { display: 'flex', flexDirection: 'column', gap: 5 },
|
||||
label: { fontSize: '0.85rem', fontWeight: 600 },
|
||||
input: {
|
||||
padding: '0.55rem 0.75rem',
|
||||
border: '1px solid var(--color-border, #e5e0d5)',
|
||||
borderRadius: 8,
|
||||
fontSize: '0.95rem',
|
||||
background: 'var(--color-input-bg, #faf9f7)',
|
||||
outline: 'none',
|
||||
},
|
||||
errorMsg: { color: '#c0392b', fontSize: '0.85rem', margin: '2px 0' },
|
||||
btnPrimary: {
|
||||
background: '#c8a96e',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
padding: '0.65rem 1rem',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.95rem',
|
||||
cursor: 'pointer',
|
||||
marginTop: 4,
|
||||
},
|
||||
headerRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 20,
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
headerRight: { display: 'flex', alignItems: 'center', gap: 10 },
|
||||
welcomeText: { fontSize: '0.85rem', color: 'var(--color-muted, #888)' },
|
||||
btnGhost: {
|
||||
background: 'none',
|
||||
border: '1px solid var(--color-border, #e5e0d5)',
|
||||
borderRadius: 6,
|
||||
padding: '0.3rem 0.6rem',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
warningBanner: {
|
||||
background: '#fff8e1',
|
||||
border: '1px solid #ffe082',
|
||||
borderRadius: 8,
|
||||
padding: '0.65rem 0.9rem',
|
||||
fontSize: '0.85rem',
|
||||
marginBottom: 16,
|
||||
color: '#7a5f00',
|
||||
},
|
||||
targetRow: { display: 'flex', gap: 8, marginBottom: 10 },
|
||||
tab: {
|
||||
flex: 1,
|
||||
background: 'var(--color-surface-alt, #f7f5f0)',
|
||||
border: '1px solid var(--color-border, #e5e0d5)',
|
||||
borderRadius: 8,
|
||||
padding: '0.5rem',
|
||||
fontSize: '0.85rem',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 500,
|
||||
},
|
||||
tabActive: {
|
||||
flex: 1,
|
||||
background: '#c8a96e',
|
||||
border: '1px solid #c8a96e',
|
||||
borderRadius: 8,
|
||||
padding: '0.5rem',
|
||||
fontSize: '0.85rem',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 700,
|
||||
color: '#fff',
|
||||
},
|
||||
targetHint: { fontSize: '0.82rem', color: 'var(--color-muted, #888)', margin: '0 0 14px' },
|
||||
dropZone: {
|
||||
border: '2px dashed var(--color-border, #e5e0d5)',
|
||||
borderRadius: 12,
|
||||
padding: '2.5rem 1.5rem',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
userSelect: 'none',
|
||||
},
|
||||
dropZoneActive: {
|
||||
borderColor: '#c8a96e',
|
||||
background: 'rgba(200,169,110,0.06)',
|
||||
},
|
||||
dropZoneDisabled: { cursor: 'default', opacity: 0.7 },
|
||||
dropIcon: { fontSize: '2rem', display: 'block', marginBottom: 8 },
|
||||
dropLabel: { fontWeight: 600, margin: '0 0 4px', fontSize: '0.95rem' },
|
||||
dropSub: { color: 'var(--color-muted, #888)', fontSize: '0.82rem', margin: 0 },
|
||||
progressWrap: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 },
|
||||
progressBar: {
|
||||
width: '100%',
|
||||
height: 8,
|
||||
background: '#e5e0d5',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
background: '#c8a96e',
|
||||
borderRadius: 4,
|
||||
transition: 'width 0.2s',
|
||||
},
|
||||
progressPct: { fontSize: '0.85rem', color: 'var(--color-muted, #888)', margin: 0 },
|
||||
uploadList: {
|
||||
marginTop: 20,
|
||||
border: '1px solid var(--color-border, #e5e0d5)',
|
||||
borderRadius: 10,
|
||||
padding: '0.75rem 1rem',
|
||||
},
|
||||
uploadListTitle: { fontWeight: 600, fontSize: '0.82rem', color: 'var(--color-muted, #888)', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.04em' },
|
||||
uploadRow: { display: 'flex', alignItems: 'baseline', gap: 8, padding: '4px 0', flexWrap: 'wrap' },
|
||||
uploadCheck: { color: '#27ae60', fontWeight: 700 },
|
||||
uploadName: { fontSize: '0.9rem', fontWeight: 500, wordBreak: 'break-all' },
|
||||
uploadMeta: { fontSize: '0.78rem', color: 'var(--color-muted, #888)', whiteSpace: 'nowrap' },
|
||||
}
|
||||
Reference in new issue
Block a user