Files
citation_sentinel/client/src/components/SourcePanel.jsx

209 lines
5.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from 'react';
import * as sourcesApi from '../api/sources.js';
const ALLOWED_EXTENSIONS = ['.pdf', '.txt', '.md', '.docx', '.mp3', '.wav', '.m4a', '.ogg', '.flac', '.webm'];
const FILE_ACCEPT = ALLOWED_EXTENSIONS.join(',');
function isFileAllowed(file) {
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
return ALLOWED_EXTENSIONS.includes(ext);
}
function SourcePanel({ sourceCorpusId, hoveredSourceIndex, onSourceHover, onSourcesChange, children }) {
const [sources, setSources] = useState([]);
const [uploading, setUploading] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [urlInput, setUrlInput] = useState('');
const [addingUrl, setAddingUrl] = useState(false);
const [error, setError] = useState(null);
const fileRef = useRef(null);
const dragCounter = useRef(0);
const errorTimer = useRef(null);
const showError = (msg) => {
setError(msg);
clearTimeout(errorTimer.current);
errorTimer.current = setTimeout(() => setError(null), 8000);
};
useEffect(() => {
setSources([]);
onSourcesChange?.(0);
sourcesApi.listSources(sourceCorpusId).then((s) => {
setSources(s);
onSourcesChange?.(s.length);
}).catch((err) => showError(err.message || 'Failed to load sources'));
}, [sourceCorpusId, onSourcesChange]);
useEffect(() => () => clearTimeout(errorTimer.current), []);
const uploadFile = async (file) => {
if (!file) return;
if (!isFileAllowed(file)) {
showError(`Unsupported file type. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`);
return;
}
setError(null);
setUploading(true);
try {
const src = await sourcesApi.uploadSource(sourceCorpusId, file);
setSources((prev) => {
const next = [...prev, src];
onSourcesChange?.(next.length);
return next;
});
} catch (err) {
showError(err.message || 'Upload failed');
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = '';
}
};
const handleUpload = (e) => {
uploadFile(e.target.files?.[0]);
};
const handleAddUrl = async (e) => {
e.preventDefault();
const url = urlInput.trim();
if (!url || addingUrl) return;
setError(null);
setAddingUrl(true);
try {
const src = await sourcesApi.addUrlSource(sourceCorpusId, url);
if (src.error) {
showError(src.error);
} else {
setSources((prev) => {
const next = [...prev, src];
onSourcesChange?.(next.length);
return next;
});
setUrlInput('');
}
} catch (err) {
showError(err.message || 'Failed to add URL');
} finally {
setAddingUrl(false);
}
};
const handleDragEnter = (e) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current++;
setIsDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current--;
if (dragCounter.current === 0) setIsDragging(false);
};
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounter.current = 0;
if (uploading) return;
const file = e.dataTransfer.files?.[0];
uploadFile(file);
};
const busy = uploading || addingUrl;
return (
<div className="source-panel">
<h3>Sources</h3>
<ul role="list">
{sources.map((s, idx) => {
const docIndex = idx + 1;
const isHighlighted = hoveredSourceIndex === docIndex;
return (
<li
key={s.id}
className={`source-item${isHighlighted ? ' source-highlighted' : ''}`}
tabIndex={0}
role="button"
aria-label={`Source ${docIndex}: ${s.name}`}
onMouseEnter={() => onSourceHover(docIndex)}
onMouseLeave={() => onSourceHover(null)}
onFocus={() => onSourceHover(docIndex)}
onBlur={() => onSourceHover(null)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSourceHover(docIndex);
}
}}
>
<span className="source-number">{docIndex}</span>
{s.name}
</li>
);
})}
</ul>
<div
className={`source-upload-area${isDragging ? ' dragging' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<input
ref={fileRef}
type="file"
accept={FILE_ACCEPT}
onChange={handleUpload}
hidden
/>
<button
className="btn-upload"
onClick={() => fileRef.current?.click()}
disabled={busy}
>
{uploading ? 'Uploading...' : '+ Upload Source'}
</button>
<div className="drop-zone">
[ or drag and drop ]
</div>
</div>
<form className="source-url-form" onSubmit={handleAddUrl}>
<input
className="source-url-input"
type="text"
placeholder="Paste a URL or YouTube link..."
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
disabled={busy}
/>
<button
className="btn-add-url"
type="submit"
disabled={busy || !urlInput.trim()}
>
{addingUrl ? 'Adding...' : '+ Add'}
</button>
</form>
{error && (
<div className="source-error" role="alert">
<span>{error}</span>
<button className="source-error-dismiss" onClick={() => setError(null)} aria-label="Dismiss">×</button>
</div>
)}
{children}
</div>
);
}
export default SourcePanel;