precis / frontend /src /App.jsx
compendious's picture
Such slow progress...
2f317f9
import { useEffect, useState, useRef } from 'react'
import InlineResult from './components/InlineResult'
import { useStreaming } from './hooks/useStreaming'
import logoSvg from './assets/logo.svg'
import { API_BASE } from './config'
import './App.css'
function App() {
const [activeTab, setActiveTab] = useState('youtube')
const [youtubeUrl, setYoutubeUrl] = useState('')
const [transcript, setTranscript] = useState('')
const [selectedFile, setSelectedFile] = useState(null)
const [models, setModels] = useState([])
const [selectedModel, setSelectedModel] = useState('')
const fileInputRef = useRef(null)
const { loading, response, error, streamingText, submit } = useStreaming()
useEffect(() => {
let cancelled = false
;(async () => {
try {
const res = await fetch(`${API_BASE}/models`)
if (!res.ok) return
const data = await res.json()
if (cancelled) return
const available = Array.isArray(data.available) ? data.available : []
setModels(available)
const serverDefault = typeof data.default === 'string' ? data.default : ''
setSelectedModel((prev) => prev || serverDefault || available[0] || '')
} catch {
// Non-fatal: model list stays empty; backend will still pick default if model omitted.
}
})()
return () => { cancelled = true }
}, [])
const handleSubmit = () =>
submit(activeTab, {
youtubeUrl,
transcript,
selectedFile,
selectedModel: selectedModel || undefined,
})
const handleFileDrop = (e) => {
e.preventDefault()
e.stopPropagation()
const file = e.dataTransfer?.files[0] || e.target.files?.[0]
if (file && file.name.endsWith('.txt')) setSelectedFile(file)
else if (file) alert('Only .txt files are supported')
}
const formatFileSize = (bytes) => {
if (bytes < 1024) return bytes + ' bytes'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const ctrlEnter = (e) => {
if (e.key === 'Enter' && e.ctrlKey && !loading) handleSubmit()
}
const resultProps = { error, loading, response, streamingText, selectedModel }
return (
<>
<header className="header">
<a href="/" className="logo">
<img src={logoSvg} alt="Précis" className="logo-icon" />
<span className="logo-text">Précis</span>
</a>
<div className="header-actions">
<select
className="model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={loading || models.length === 0}
>
{models.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
<a href={`${API_BASE}/docs`} target="_blank" rel="noopener noreferrer" className="btn" style={{ textDecoration: 'none' }}>
API Docs
</a>
</div>
</header>
<main className="main">
<div className="container">
<div className="upload-section fade-in">
<h1 className="page-title">Summarize Content</h1>
<p className="page-subtitle">
Upload a YouTube video, paste a transcript, or drop a text file to generate a summary.
</p>
<div className="upload-card">
<div className="upload-header">
<div className="upload-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Upload Content
</div>
</div>
<div className="upload-body">
<div className="tabs">
{[['youtube', 'YouTube Video'], ['transcript', 'Article / Transcript'], ['file', 'Text File']].map(([key, label]) => (
<button key={key} className={`tab ${activeTab === key ? 'active' : ''}`} onClick={() => setActiveTab(key)}>
{label}
</button>
))}
</div>
{/* YouTube */}
<div className={`tab-panel ${activeTab === 'youtube' ? 'active' : ''}`}>
<div className="form-group">
<label className="form-label">YouTube URL</label>
<input
type="url" className="input"
placeholder="https://www.youtube.com/watch?v=..."
value={youtubeUrl}
onChange={(e) => setYoutubeUrl(e.target.value)}
onKeyDown={ctrlEnter}
/>
<p className="form-hint">Paste a YouTube URL. Ctrl+Enter to generate.</p>
</div>
{activeTab === 'youtube' && (
<InlineResult
{...resultProps}
loadingLabel="Fetching transcript…"
placeholderText="Fetching transcript…"
/>
)}
</div>
{/* Transcript */}
<div className={`tab-panel ${activeTab === 'transcript' ? 'active' : ''}`}>
<div className="form-group">
<label className="form-label">Article or Transcript Text</label>
<textarea
className="textarea"
placeholder="Paste your article or transcript here..."
value={transcript}
onChange={(e) => setTranscript(e.target.value)}
onKeyDown={ctrlEnter}
/>
<p className="form-hint">
Paste any text you want to summarize.{' '}
<kbd style={{ fontFamily: 'inherit', background: 'var(--color-canvas-inset)', border: '1px solid var(--color-border-muted)', borderRadius: 3, padding: '0 4px', fontSize: 11 }}>Ctrl</kbd>
{' + '}
<kbd style={{ fontFamily: 'inherit', background: 'var(--color-canvas-inset)', border: '1px solid var(--color-border-muted)', borderRadius: 3, padding: '0 4px', fontSize: 11 }}>Enter</kbd>
{' '}to generate.
</p>
</div>
{activeTab === 'transcript' && (
<InlineResult
{...resultProps}
loadingLabel="Generating…"
placeholderText="Waiting for model…"
/>
)}
</div>
{/* File upload */}
<div className={`tab-panel ${activeTab === 'file' ? 'active' : ''}`}>
<div className="form-group">
<label className="form-label">Text File (.txt)</label>
<div className="dropzone" onClick={() => fileInputRef.current?.click()} onDrop={handleFileDrop} onDragOver={(e) => e.preventDefault()}>
<svg className="dropzone-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="dropzone-text">Drag and drop a <strong>.txt</strong> file here, or click to browse</p>
<p className="dropzone-hint">Maximum file size: 10 MB</p>
</div>
<input ref={fileInputRef} type="file" className="file-input" accept=".txt" onChange={handleFileDrop} />
{selectedFile && (
<div className="file-selected">
<div className="file-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</div>
<div className="file-info">
<div className="file-name">{selectedFile.name}</div>
<div className="file-size">{formatFileSize(selectedFile.size)}</div>
</div>
<button className="file-remove" onClick={(e) => { e.stopPropagation(); setSelectedFile(null) }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
)}
</div>
{activeTab === 'file' && (
<InlineResult
{...resultProps}
loadingLabel="Reading file…"
placeholderText="Reading file…"
/>
)}
</div>
<div className="submit-section">
<button className="btn btn-primary btn-lg" onClick={handleSubmit} disabled={loading}>
{loading ? (
<><span className="loading-spinner" style={{ width: 16, height: 16 }} /> Processing...</>
) : (
<>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 2L11 13" /><path d="M22 2L15 22l-4-9-9-4L22 2z" />
</svg>
Generate Summary
</>
)}
</button>
</div>
</div>
</div>
</div>
</div>
</main>
<footer className="footer">
<p>Précis © 2026 · <a href={`${API_BASE}/docs`} target="_blank" rel="noopener noreferrer">API Documentation</a></p>
</footer>
</>
)
}
export default App