Rename Footer to ImportButton, add SSE progress bar on import
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -1,65 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { fetchDatabaseVersion, triggerFullImport } from '../../services/api';
|
|
||||||
|
|
||||||
function Footer({ onImportComplete }) {
|
|
||||||
const [dbVersion, setDbVersion] = useState(null);
|
|
||||||
const [importing, setImporting] = useState(false);
|
|
||||||
const [modalMessage, setModalMessage] = useState('');
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchDatabaseVersion()
|
|
||||||
.then(data => setDbVersion(data.database_version))
|
|
||||||
.catch(() => setDbVersion('N/A'));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleImport = async () => {
|
|
||||||
setImporting(true);
|
|
||||||
try {
|
|
||||||
const result = await triggerFullImport();
|
|
||||||
const data = await fetchDatabaseVersion();
|
|
||||||
setDbVersion(data.database_version);
|
|
||||||
setModalMessage(result.message || 'Import completed');
|
|
||||||
if (onImportComplete) await onImportComplete();
|
|
||||||
} catch (err) {
|
|
||||||
setModalMessage(`Import failed: ${err.message}`);
|
|
||||||
} finally {
|
|
||||||
setImporting(false);
|
|
||||||
setShowModal(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', fontSize: '12px', color: '#555' }}>
|
|
||||||
<span>DB: {dbVersion ?? '…'}</span>
|
|
||||||
<button
|
|
||||||
onClick={handleImport}
|
|
||||||
disabled={importing}
|
|
||||||
style={{
|
|
||||||
background: '#2a2a2a', color: '#ccc', border: '1px solid #3a3a3a',
|
|
||||||
borderRadius: '5px', padding: '3px 10px', fontSize: '12px',
|
|
||||||
cursor: importing ? 'not-allowed' : 'pointer',
|
|
||||||
opacity: importing ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{importing ? 'Importing…' : 'Full Import'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showModal && createPortal(
|
|
||||||
<>
|
|
||||||
<div className="modal-backdrop" onClick={() => setShowModal(false)} />
|
|
||||||
<div className="import-modal">
|
|
||||||
<div>{modalMessage}</div>
|
|
||||||
<button onClick={() => setShowModal(false)}>Close</button>
|
|
||||||
</div>
|
|
||||||
</>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Footer;
|
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { fetchDatabaseVersion } from '../../services/api';
|
||||||
|
|
||||||
|
function ImportButton({ onImportComplete }) {
|
||||||
|
const [dbVersion, setDbVersion] = useState(null);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [progressMessage, setProgressMessage] = useState('');
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [resultMessage, setResultMessage] = useState('');
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDatabaseVersion()
|
||||||
|
.then(data => setDbVersion(data.database_version))
|
||||||
|
.catch(() => setDbVersion('N/A'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
setImporting(true);
|
||||||
|
setDone(false);
|
||||||
|
setIsError(false);
|
||||||
|
setProgress(0);
|
||||||
|
setProgressMessage('Starting…');
|
||||||
|
setResultMessage('');
|
||||||
|
setShowModal(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/import/full-import', { method: 'POST' });
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done: streamDone, value } = await reader.read();
|
||||||
|
if (streamDone) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const parts = buffer.split('\n\n');
|
||||||
|
buffer = parts.pop();
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!part.startsWith('data: ')) continue;
|
||||||
|
const data = JSON.parse(part.slice(6));
|
||||||
|
if (data.progress !== undefined) setProgress(data.progress);
|
||||||
|
if (data.message) setProgressMessage(data.message);
|
||||||
|
if (data.done) {
|
||||||
|
setDone(true);
|
||||||
|
setImporting(false);
|
||||||
|
setIsError(!!data.error);
|
||||||
|
if (data.version) setDbVersion(data.version);
|
||||||
|
if (data.result) {
|
||||||
|
const r = data.result;
|
||||||
|
setResultMessage(
|
||||||
|
`${r.cards_added} added · ${r.cards_removed ?? 0} removed · ${r.total_cards.toLocaleString()} total · ${r.duration_seconds}s`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!data.error && onImportComplete) onImportComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setProgressMessage(`Error: ${err.message}`);
|
||||||
|
setDone(true);
|
||||||
|
setIsError(true);
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', fontSize: '12px', color: '#555' }}>
|
||||||
|
<span>DB: {dbVersion ?? '…'}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={importing}
|
||||||
|
style={{
|
||||||
|
background: '#2a2a2a', color: '#ccc', border: '1px solid #3a3a3a',
|
||||||
|
borderRadius: '5px', padding: '3px 10px', fontSize: '12px',
|
||||||
|
cursor: importing ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: importing ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Full Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showModal && createPortal(
|
||||||
|
<>
|
||||||
|
<div className="modal-backdrop" onClick={() => done && setShowModal(false)} />
|
||||||
|
<div className="import-modal">
|
||||||
|
<div style={{ fontSize: '13px', color: isError ? '#c04040' : '#ccc', marginBottom: '12px' }}>
|
||||||
|
{progressMessage}
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#2a2a2a', borderRadius: '4px', height: '6px', overflow: 'hidden', marginBottom: '12px' }}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${Math.round(progress * 100)}%`,
|
||||||
|
background: isError ? '#c04040' : '#4a90d9',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'width 0.3s ease',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
{!done && (
|
||||||
|
<div style={{ fontSize: '11px', color: '#444', textAlign: 'right' }}>
|
||||||
|
{Math.round(progress * 100)}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{done && (
|
||||||
|
<>
|
||||||
|
{resultMessage && (
|
||||||
|
<div style={{ fontSize: '12px', color: '#555', marginBottom: '12px' }}>{resultMessage}</div>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setShowModal(false)}>Close</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImportButton;
|
||||||
@@ -8,7 +8,7 @@ import { fuzzyMatch } from '../utils/search';
|
|||||||
import CardRow from '../components/CardRow/CardRow';
|
import CardRow from '../components/CardRow/CardRow';
|
||||||
import SearchBar from '../components/SearchBar/SearchBar';
|
import SearchBar from '../components/SearchBar/SearchBar';
|
||||||
import FilterBar from '../components/FilterBar/FilterBar';
|
import FilterBar from '../components/FilterBar/FilterBar';
|
||||||
import Footer from '../components/Footer/Footer';
|
import ImportButton from '../components/ImportButton/ImportButton';
|
||||||
import PrintingRow from '../components/PrintingRow/PrintingRow';
|
import PrintingRow from '../components/PrintingRow/PrintingRow';
|
||||||
|
|
||||||
const BADGE = {
|
const BADGE = {
|
||||||
@@ -234,7 +234,7 @@ function HomePage() {
|
|||||||
onClick={exportCollection}
|
onClick={exportCollection}
|
||||||
style={{ background: '#2a2a2a', color: '#ccc', border: '1px solid #3a3a3a', borderRadius: '5px', padding: '3px 10px', fontSize: '12px', cursor: 'pointer' }}
|
style={{ background: '#2a2a2a', color: '#ccc', border: '1px solid #3a3a3a', borderRadius: '5px', padding: '3px 10px', fontSize: '12px', cursor: 'pointer' }}
|
||||||
>Export</button>
|
>Export</button>
|
||||||
<Footer onImportComplete={loadCards} />
|
<ImportButton onImportComplete={loadCards} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -37,13 +37,6 @@ export async function fetchDatabaseVersion() {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function triggerFullImport() {
|
|
||||||
const response = await fetch(`${API_BASE}/import/full-import`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error('Failed to trigger full import');
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSets() {
|
export async function fetchSets() {
|
||||||
const response = await fetch(`${API_BASE}/sets`);
|
const response = await fetch(`${API_BASE}/sets`);
|
||||||
|
|||||||
Reference in New Issue
Block a user