| import React, { useState, useEffect } from 'react'; |
| import { useAuth } from '../context/AuthContext'; |
| import type { DocumentInfo } from '../types/api'; |
| import { Upload, FilePlus, Activity, Trash2, Eye, Globe, X, FileText, Link, Hash, BookOpen } from 'lucide-react'; |
| import ReactMarkdown from 'react-markdown'; |
| import remarkGfm from 'remark-gfm'; |
|
|
| const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api'; |
|
|
| interface PreviewData { |
| filename: string; |
| file_type: string; |
| word_count: number; |
| char_count: number; |
| content: string; |
| } |
|
|
| const Process: React.FC = () => { |
| const { token, logout } = useAuth(); |
| const [documents, setDocuments] = useState<DocumentInfo[]>([]); |
| const [loadingDocs, setLoadingDocs] = useState(true); |
| const [uploading, setUploading] = useState(false); |
| const [file, setFile] = useState<File | null>(null); |
| const [url, setUrl] = useState(''); |
| const [message, setMessage] = useState(''); |
|
|
| |
| const [previewOpen, setPreviewOpen] = useState(false); |
| const [previewData, setPreviewData] = useState<PreviewData | null>(null); |
| const [previewLoading, setPreviewLoading] = useState(false); |
|
|
| interface TaskState { |
| status: string; |
| progress?: { |
| current_chunk?: number; |
| total_chunks?: number; |
| file?: string; |
| }; |
| } |
|
|
| const [tasks, setTasks] = useState<Record<string, TaskState>>({}); |
|
|
| const fetchDocuments = async () => { |
| setLoadingDocs(true); |
| try { |
| const res = await fetch(`${API_BASE}/documents`, { |
| headers: { Authorization: `Bearer ${token}` } |
| }); |
| if (res.status === 401) { logout(); return; } |
| if (res.ok) { |
| const data = await res.json(); |
| setDocuments(data.documents); |
| } |
| } catch (err) { |
| console.error('Failed to fetch docs', err); |
| } finally { |
| setLoadingDocs(false); |
| } |
| }; |
|
|
| useEffect(() => { |
| fetchDocuments(); |
| const interval = setInterval(fetchDocuments, 10000); |
| return () => clearInterval(interval); |
| }, [token]); |
|
|
| useEffect(() => { |
| const activeTasks = Object.keys(tasks).filter( |
| tid => tasks[tid].status !== 'completed' && tasks[tid].status !== 'failure' |
| ); |
| if (activeTasks.length === 0) return; |
|
|
| const interval = setInterval(() => { |
| activeTasks.forEach(async (tid) => { |
| try { |
| const res = await fetch(`${API_BASE}/documents/status/${tid}`, { |
| headers: { Authorization: `Bearer ${token}` } |
| }); |
| if (res.status === 401) { logout(); return; } |
| if (res.ok) { |
| const data = await res.json(); |
| setTasks(prev => ({ ...prev, [tid]: { status: data.status, progress: data.progress } })); |
| if (data.status === 'completed' || data.status === 'failure') fetchDocuments(); |
| } |
| } catch (e) {} |
| }); |
| }, 2000); |
|
|
| return () => clearInterval(interval); |
| }, [tasks, token]); |
|
|
| const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
| if (e.target.files && e.target.files.length > 0) setFile(e.target.files[0]); |
| }; |
|
|
| const handleUpload = async (e: React.FormEvent) => { |
| e.preventDefault(); |
| if (!file) return; |
| setUploading(true); |
| setMessage(''); |
| const formData = new FormData(); |
| formData.append('file', file); |
| try { |
| const res = await fetch(`${API_BASE}/documents/upload`, { |
| method: 'POST', |
| headers: { Authorization: `Bearer ${token}` }, |
| body: formData |
| }); |
| if (res.status === 401) { logout(); return; } |
| const data = await res.json(); |
| if (!res.ok) { |
| setMessage(`UPLOAD FAILED: ${data.detail}`); |
| } else { |
| setMessage('FILE UPLOADED. INGESTION TASK QUEUED.'); |
| if (data.task_id) setTasks(prev => ({ ...prev, [data.task_id]: { status: 'pending' } })); |
| setFile(null); |
| fetchDocuments(); |
| } |
| } catch (err: any) { |
| setMessage(`ERROR: ${err.message}`); |
| } finally { |
| setUploading(false); |
| } |
| }; |
|
|
| const handleUrlScrape = async (e: React.FormEvent) => { |
| e.preventDefault(); |
| if (!url.trim()) return; |
| setUploading(true); |
| setMessage(''); |
| try { |
| const res = await fetch(`${API_BASE}/documents/scrape`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, |
| body: JSON.stringify({ url: url.trim() }) |
| }); |
| if (res.status === 401) { logout(); return; } |
| const data = await res.json(); |
| if (!res.ok) { |
| setMessage(`SCRAPE FAILED: ${data.detail}`); |
| } else { |
| setMessage('URL SCRAPED. INGESTION TASK QUEUED.'); |
| if (data.task_id) setTasks(prev => ({ ...prev, [data.task_id]: { status: 'pending' } })); |
| setUrl(''); |
| fetchDocuments(); |
| } |
| } catch (err: any) { |
| setMessage(`ERROR: ${err.message}`); |
| } finally { |
| setUploading(false); |
| } |
| }; |
|
|
| const handleDelete = async (docId: string) => { |
| if (!window.confirm('WARNING: Delete this document and all its graph data?')) return; |
| try { |
| const res = await fetch(`${API_BASE}/documents/${docId}`, { |
| method: 'DELETE', |
| headers: { Authorization: `Bearer ${token}` } |
| }); |
| if (res.status === 401) { logout(); return; } |
| fetchDocuments(); |
| } catch (err) { |
| console.error(err); |
| } |
| }; |
|
|
| const handleView = async (doc: any) => { |
| const fileType = (doc.file_type || '').toLowerCase(); |
|
|
| |
| if (fileType === '.txt' || fileType === '.md' || fileType === '') { |
| setPreviewOpen(true); |
| setPreviewLoading(true); |
| setPreviewData(null); |
| try { |
| const res = await fetch(`${API_BASE}/documents/${doc.id}/preview`, { |
| headers: { Authorization: `Bearer ${token}` } |
| }); |
| if (res.status === 401) { logout(); return; } |
| if (res.ok) { |
| const data = await res.json(); |
| setPreviewData(data); |
| } else { |
| const err = await res.json(); |
| setMessage(`PREVIEW ERROR: ${err.detail}`); |
| setPreviewOpen(false); |
| } |
| } catch (err) { |
| setMessage('ERROR: Failed to load preview.'); |
| setPreviewOpen(false); |
| } finally { |
| setPreviewLoading(false); |
| } |
| return; |
| } |
|
|
| |
| const newTab = window.open('', '_blank'); |
| if (!newTab) { setMessage('ERROR: Please allow popups to view PDFs.'); return; } |
| newTab.document.write('<div style="font-family:monospace;padding:20px;">Fetching secure document...</div>'); |
| try { |
| const res = await fetch(`${API_BASE}/documents/${doc.id}/download`, { |
| headers: { Authorization: `Bearer ${token}` } |
| }); |
| if (res.status === 401) { newTab.close(); logout(); return; } |
| if (res.ok) { |
| const contentType = res.headers.get('content-type') || 'application/pdf'; |
| const blob = await res.blob(); |
| const fileBlob = new Blob([blob], { type: contentType }); |
| const blobUrl = window.URL.createObjectURL(fileBlob); |
| newTab.location.href = blobUrl; |
| } else { |
| newTab.close(); |
| setMessage('ERROR: Document file not found on server.'); |
| } |
| } catch (err) { |
| newTab.close(); |
| setMessage('ERROR: Failed to open document.'); |
| } |
| }; |
|
|
| const isTxtDoc = (doc: any) => { |
| const ft = (doc.file_type || '').toLowerCase(); |
| return ft === '.txt' || ft === '.md' || ft === ''; |
| }; |
|
|
| return ( |
| <div className="container" style={{ animation: 'fadeIn 0.5s ease' }}> |
| <div className="page-header flex-between"> |
| <div> |
| <h1>INGESTION PIPELINE</h1> |
| <p className="mono-text">DATA PROCESSING & GRAPH CONSTRUCTION</p> |
| </div> |
| <Activity size={32} /> |
| </div> |
| |
| <div className="process-layout"> |
| {/* Upload / Scrape Section */} |
| <div className="card upload-card"> |
| <h2 className="mono-text flex-center"><FilePlus style={{ marginRight: '0.5rem' }}/> ADD DOCUMENT</h2> |
| |
| <form onSubmit={handleUpload} className="upload-form"> |
| <div className="file-input-wrapper"> |
| <input type="file" id="file-upload" onChange={handleFileChange} accept=".pdf,.txt,.md" /> |
| <label htmlFor="file-upload" className="file-label"> |
| {file ? file.name : 'SELECT FILE FOR INGESTION'} |
| </label> |
| </div> |
| <button |
| type="submit" |
| className="app-btn full-width" |
| disabled={!file || uploading} |
| style={{ marginTop: '1rem', display: 'flex', justifyContent: 'center', gap: '8px' }} |
| > |
| {uploading && file ? 'UPLOADING...' : <><Upload size={18} /> INITIALIZE FILE INGESTION</>} |
| </button> |
| </form> |
| |
| <hr style={{ margin: '2rem 0', borderColor: 'var(--border-color)', borderStyle: 'dotted' }} /> |
| |
| <h2 className="mono-text flex-center"><Globe style={{ marginRight: '0.5rem' }}/> SCRAPE URL</h2> |
| <p style={{ fontSize: '0.85rem', color: '#666', marginBottom: '1rem', lineHeight: 1.5 }}> |
| Paste any public URL — articles, docs, Wikipedia pages — to scrape and ingest the content into your knowledge graph. |
| </p> |
| <form onSubmit={handleUrlScrape} className="upload-form"> |
| <input |
| type="url" |
| className="query-input mono-text" |
| placeholder="https://example.com/article" |
| value={url} |
| onChange={(e) => setUrl(e.target.value)} |
| required |
| style={{ width: '100%', marginBottom: '1rem' }} |
| /> |
| <button |
| type="submit" |
| className="app-btn full-width" |
| disabled={!url || uploading} |
| style={{ display: 'flex', justifyContent: 'center', gap: '8px' }} |
| > |
| {uploading && url ? 'SCRAPING...' : <><Activity size={18} /> INGEST FROM URL</>} |
| </button> |
| </form> |
| |
| {Object.keys(tasks).length > 0 && ( |
| <div className="task-tracker" style={{ marginTop: '1rem' }}> |
| <h4 className="mono-text">ACTIVE TASKS</h4> |
| {Object.entries(tasks).map(([tid, taskObj]) => { |
| const pct = (taskObj.progress?.current_chunk && taskObj.progress?.total_chunks) |
| ? Math.round((taskObj.progress.current_chunk / taskObj.progress.total_chunks) * 100) |
| : 0; |
| return ( |
| <div key={tid} className="task-row" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '8px' }}> |
| <div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}> |
| <span title={tid}>{tid.substring(0,8)}...</span> |
| <span className={`status-badge ${taskObj.status}`}> |
| {taskObj.status.toUpperCase()}{taskObj.status === 'processing' && pct > 0 ? ` ${pct}%` : ''} |
| </span> |
| </div> |
| {taskObj.status === 'processing' && taskObj.progress?.total_chunks && ( |
| <div style={{ width: '100%', height: '4px', background: '#e0e0e0', borderRadius: '2px', overflow: 'hidden' }}> |
| <div style={{ width: `${pct}%`, height: '100%', background: '#000', transition: 'width 0.3s ease' }}></div> |
| </div> |
| )} |
| </div> |
| ); |
| })} |
| </div> |
| )} |
| </div> |
|
|
| {} |
| <div className="card documents-card"> |
| <h2 className="mono-text">CORPUS INDEX</h2> |
| <div className="table-wrapper"> |
| <table className="docs-table"> |
| <thead> |
| <tr> |
| <th>DOCUMENT NAME</th> |
| <th>TYPE</th> |
| <th>SIZE</th> |
| <th>INGESTED</th> |
| <th>ACTIONS</th> |
| </tr> |
| </thead> |
| <tbody> |
| {documents.length === 0 && !loadingDocs ? ( |
| <tr><td colSpan={5} style={{ textAlign: 'center', padding: '3rem' }} className="mono-text"> |
| <p style={{ marginBottom: '1rem' }}>NO DOCUMENTS INDEXED IN GRAPH DATABASE</p> |
| <p style={{ color: '#666', fontSize: '0.85rem' }}>Upload a file or scrape a URL above to begin.</p> |
| </td></tr> |
| ) : loadingDocs ? ( |
| <tr><td colSpan={5} style={{ textAlign: 'center', padding: '3rem' }} className="mono-text"> |
| FETCHING CORPUS DATA... |
| </td></tr> |
| ) : ( |
| documents.map((doc: any) => ( |
| <tr key={doc.id}> |
| <td className="mono-text" style={{ maxWidth: '220px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> |
| <span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> |
| {isTxtDoc(doc) ? <Link size={14} /> : <FileText size={14} />} |
| {doc.filename} |
| </span> |
| </td> |
| <td> |
| <span className={`type-badge ${isTxtDoc(doc) ? 'scraped' : 'pdf'}`}> |
| {isTxtDoc(doc) ? 'WEB / TXT' : (doc.file_type || 'PDF').toUpperCase()} |
| </span> |
| </td> |
| <td className="mono-text">{(doc.size_bytes / 1024).toFixed(1)} KB</td> |
| <td className="mono-text" style={{ fontSize: '0.8rem' }}>{doc.upload_date?.substring(0,19) || '—'}</td> |
| <td style={{ whiteSpace: 'nowrap' }}> |
| <button |
| className="icon-btn" |
| onClick={() => handleView(doc)} |
| title={isTxtDoc(doc) ? 'Preview Content' : 'Open PDF'} |
| style={{ marginRight: '10px' }} |
| > |
| <Eye size={16} /> |
| </button> |
| <button className="icon-btn" onClick={() => handleDelete(doc.id)} title="Delete Document"> |
| <Trash2 size={16} /> |
| </button> |
| </td> |
| </tr> |
| )) |
| )} |
| </tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
|
|
| {} |
| {previewOpen && ( |
| <div className="preview-overlay" onClick={() => setPreviewOpen(false)}> |
| <div className="preview-modal" onClick={(e) => e.stopPropagation()}> |
| {/* Modal Header */} |
| <div className="preview-header"> |
| <div className="preview-header-left"> |
| <BookOpen size={20} /> |
| <div> |
| <div className="preview-title">{previewData?.filename || 'Loading...'}</div> |
| {previewData && ( |
| <div className="preview-meta-row"> |
| <span className="preview-meta-chip"><Hash size={12} /> {previewData.word_count.toLocaleString()} words</span> |
| <span className="preview-meta-chip"><FileText size={12} /> {(previewData.char_count / 1000).toFixed(1)}K chars</span> |
| <span className="type-badge scraped">WEB SCRAPE</span> |
| </div> |
| )} |
| </div> |
| </div> |
| <button className="icon-btn" onClick={() => setPreviewOpen(false)} style={{ fontSize: '1.5rem' }}> |
| <X size={22} /> |
| </button> |
| </div> |
| |
| {/* Modal Body */} |
| <div className="preview-body"> |
| {previewLoading ? ( |
| <div className="loading-spinner"><Activity size={32} style={{ animation: 'pulse 1.5s infinite' }} /> Fetching Content...</div> |
| ) : previewData ? ( |
| <div className="markdown-content formatted"> |
| <ReactMarkdown remarkPlugins={[remarkGfm]}>{previewData.content}</ReactMarkdown> |
| </div> |
| ) : ( |
| <div className="error-text">Failed to load content.</div> |
| )} |
| </div> |
| </div> |
| </div> |
| )} |
|
|
| {message && ( |
| <div className={`status-toast ${message.includes('ERROR') || message.includes('FAILED') ? 'error' : ''}`}> |
| <Activity size={20} /> {message} |
| <button |
| onClick={() => setMessage('')} |
| className="toast-dismiss-btn" |
| > |
| × |
| </button> |
| </div> |
| )} |
|
|
| <style>{` |
| .process-layout { |
| display: grid; |
| grid-template-columns: 1fr 2fr; |
| gap: 2rem; |
| } |
| |
| @media (max-width: 900px) { |
| .process-layout { grid-template-columns: 1fr; } |
| } |
| |
| .file-input-wrapper { |
| position: relative; |
| overflow: hidden; |
| display: inline-block; |
| width: 100%; |
| border: 2px dashed var(--border-color); |
| text-align: center; |
| background: var(--hover-bg); |
| transition: var(--transition-speed); |
| } |
| |
| .file-input-wrapper:hover { background: #e0e0e0; } |
| |
| .file-input-wrapper input[type=file] { |
| font-size: 100px; |
| position: absolute; |
| left: 0; top: 0; |
| opacity: 0; |
| cursor: pointer; |
| } |
| |
| .file-label { |
| display: block; |
| padding: 2rem; |
| font-family: var(--font-mono); |
| font-weight: 600; |
| cursor: pointer; |
| } |
| |
| .task-tracker { |
| margin-top: 2rem; |
| border-top: 2px solid var(--border-color); |
| padding-top: 1rem; |
| } |
| |
| .task-row { |
| display: flex; |
| justify-content: space-between; |
| padding: 0.5rem 0; |
| font-family: var(--font-mono); |
| font-size: 0.85rem; |
| border-bottom: 1px dotted var(--border-color); |
| } |
| |
| .status-badge { |
| padding: 0.1rem 0.5rem; |
| background: var(--text-color); |
| color: var(--bg-color); |
| font-weight: 700; |
| font-size: 0.75rem; |
| font-family: var(--font-mono); |
| } |
| .status-badge.processing { background: #666; } |
| .status-badge.completed { background: #000; } |
| .status-badge.failure { background: #990000; color: white; } |
| |
| .type-badge { |
| display: inline-block; |
| padding: 0.15rem 0.5rem; |
| font-size: 0.7rem; |
| font-family: var(--font-mono); |
| font-weight: 700; |
| letter-spacing: 0.5px; |
| border: 1.5px solid var(--border-color); |
| } |
| .type-badge.scraped { background: #000; color: #fff; border-color: #000; } |
| .type-badge.pdf { background: transparent; color: #444; } |
| |
| .table-wrapper { |
| overflow-x: auto; |
| margin-top: 1rem; |
| width: 100%; |
| -webkit-overflow-scrolling: touch; |
| } |
| |
| .docs-table { |
| width: 100%; |
| border-collapse: collapse; |
| border: 2px solid var(--border-color); |
| } |
| |
| .docs-table th, .docs-table td { |
| border: 1px solid var(--border-color); |
| padding: 0.75rem; |
| text-align: left; |
| font-size: 0.9rem; |
| } |
| |
| .docs-table th { |
| background-color: var(--text-color); |
| color: var(--bg-color); |
| font-family: var(--font-display); |
| letter-spacing: 1px; |
| white-space: nowrap; |
| } |
| |
| .docs-table tr:hover { background-color: var(--hover-bg); } |
| |
| .icon-btn { |
| background: none; |
| border: none; |
| cursor: pointer; |
| color: var(--text-color); |
| padding: 4px; |
| } |
| .icon-btn:hover { color: #ff0000; } |
| |
| /* Preview Modal */ |
| .preview-overlay { |
| position: fixed; |
| inset: 0; |
| background: rgba(0, 0, 0, 0.65); |
| z-index: 5000; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| padding: 2rem; |
| animation: fadeIn 0.2s ease; |
| } |
| |
| .preview-modal { |
| background: var(--bg-color); |
| border: 3px solid var(--border-color); |
| box-shadow: 8px 8px 0 var(--border-color); |
| width: 100%; |
| max-width: 860px; |
| max-height: 85vh; |
| display: flex; |
| flex-direction: column; |
| animation: scaleIn 0.2s ease; |
| } |
| |
| @keyframes scaleIn { |
| from { transform: scale(0.95); opacity: 0; } |
| to { transform: scale(1); opacity: 1; } |
| } |
| |
| .preview-header { |
| display: flex; |
| align-items: flex-start; |
| justify-content: space-between; |
| padding: 1.25rem 1.5rem; |
| border-bottom: 2px solid var(--border-color); |
| background: var(--text-color); |
| color: var(--bg-color); |
| gap: 1rem; |
| } |
| |
| .preview-header-left { |
| display: flex; |
| align-items: flex-start; |
| gap: 1rem; |
| flex: 1; |
| min-width: 0; |
| } |
| |
| .preview-title { |
| font-family: var(--font-mono); |
| font-weight: 700; |
| font-size: 0.95rem; |
| word-break: break-all; |
| margin-bottom: 0.4rem; |
| } |
| |
| .preview-meta-row { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 0.5rem; |
| align-items: center; |
| } |
| |
| .preview-meta-chip { |
| display: inline-flex; |
| align-items: center; |
| gap: 4px; |
| background: rgba(255,255,255,0.15); |
| padding: 0.15rem 0.5rem; |
| font-size: 0.75rem; |
| font-family: var(--font-mono); |
| border-radius: 2px; |
| } |
| |
| .preview-header .icon-btn { color: var(--bg-color); } |
| .preview-header .icon-btn:hover { color: #ffaaaa; } |
| |
| .preview-body { |
| flex: 1; |
| overflow-y: auto; |
| padding: 0; |
| } |
| |
| .markdown-content.formatted { |
| padding: 2rem; |
| font-family: var(--font-sans); |
| line-height: 1.6; |
| color: #333; |
| font-size: 1rem; |
| } |
| |
| .markdown-content.formatted h1, |
| .markdown-content.formatted h2, |
| .markdown-content.formatted h3 { |
| font-family: var(--font-mono); |
| color: #000; |
| margin-top: 1.5em; |
| margin-bottom: 0.5em; |
| border-bottom: 2px solid #eaeaea; |
| padding-bottom: 0.3em; |
| } |
| |
| .markdown-content.formatted p { |
| margin-bottom: 1em; |
| } |
| |
| .markdown-content.formatted ul, |
| .markdown-content.formatted ol { |
| margin-bottom: 1em; |
| padding-left: 2em; |
| } |
| |
| .markdown-content.formatted a { |
| color: #0056b3; |
| text-decoration: underline; |
| } |
| |
| .markdown-content.formatted code { |
| font-family: var(--font-mono); |
| background: #f4f4f4; |
| padding: 0.1em 0.3em; |
| border-radius: 3px; |
| font-size: 0.9em; |
| } |
| |
| .markdown-content.formatted pre { |
| background: #111; |
| color: #fff; |
| padding: 1em; |
| overflow-x: auto; |
| border-radius: 4px; |
| } |
| |
| .markdown-content.formatted pre code { |
| background: transparent; |
| color: inherit; |
| padding: 0; |
| } |
| |
| .markdown-content.formatted blockquote { |
| border-left: 4px solid #ccc; |
| margin: 0; |
| padding-left: 1em; |
| color: #666; |
| font-style: italic; |
| } |
| |
| .preview-loading { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| gap: 1rem; |
| height: 300px; |
| font-family: var(--font-mono); |
| font-weight: bold; |
| letter-spacing: 2px; |
| color: #666; |
| } |
| |
| .preview-content { |
| font-family: var(--font-mono); |
| font-size: 0.82rem; |
| line-height: 1.7; |
| white-space: pre-wrap; |
| word-break: break-word; |
| padding: 2rem; |
| margin: 0; |
| color: #111; |
| border: none; |
| background: #fafafa; |
| } |
| |
| .spin-slow { |
| animation: spin 1.5s linear infinite; |
| } |
| |
| @keyframes spin { |
| 100% { transform: rotate(360deg); } |
| } |
| |
| .status-toast { |
| position: fixed; |
| bottom: 2rem; |
| right: 2rem; |
| background: var(--text-color); |
| color: var(--bg-color); |
| padding: 1rem 1.5rem; |
| border-left: 6px solid var(--text-color); |
| box-shadow: 4px 4px 0 var(--border-color); |
| display: flex; |
| align-items: center; |
| gap: 1rem; |
| z-index: 9999; |
| font-family: var(--font-mono); |
| font-weight: bold; |
| font-size: 0.9rem; |
| animation: slideUp 0.3s ease-out; |
| max-width: 420px; |
| } |
| |
| .status-toast.error { border-left-color: #ff0000; } |
| |
| @keyframes slideUp { |
| from { transform: translateY(100px); opacity: 0; } |
| to { transform: translateY(0); opacity: 1; } |
| } |
| |
| @keyframes fadeIn { |
| from { opacity: 0; } |
| to { opacity: 1; } |
| } |
| `}</style> |
| </div> |
| ); |
| }; |
|
|
| export default Process; |
|
|