sync: PDF file upload from private repo

- Add drag-and-drop PDF upload to Quick Capture
- New upload endpoint with size limits (warn 10MB, reject 50MB)
- Add extractFromBuffer method to PaperExtractor
- Visual feedback: drag-over highlight, file preview card

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-01-14 20:13:13 +11:00
co-authored by Claude Opus 4.5
parent 2cbc950fd4
commit 419d29dbe8
3 changed files with 427 additions and 53 deletions
+281 -53
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from 'react';
import { useState, useCallback } from 'react';
import type { AgentDelegation } from '@/services/agents/delegation';
interface QuickAddSubmitPayload {
@@ -18,6 +18,9 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
const [input, setInput] = useState('');
const [isPosting, setIsPosting] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const maxConcurrent = 5;
const activeCount = activeDelegations.filter(
@@ -25,7 +28,51 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
).length;
const isSoftLimited = activeCount >= maxConcurrent;
const handleFileUpload = useCallback(async (file: File) => {
setIsPosting(true);
setUploadError(null);
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/extract/pdf/upload', {
method: 'POST',
body: formData,
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Upload failed');
}
// Success - clear state
setUploadedFile(null);
setInput('');
setIsExpanded(false);
// Show warning if present (large file)
if (result.warning) {
console.log('[QuickAddInput] Upload warning:', result.warning);
}
} catch (error) {
console.error('[QuickAddInput] Upload error:', error);
setUploadError(error instanceof Error ? error.message : 'Upload failed');
} finally {
setIsPosting(false);
}
}, []);
const handleSubmit = async () => {
// If there's a file, upload it
if (uploadedFile) {
await handleFileUpload(uploadedFile);
return;
}
// Otherwise, submit text as before
if (!input.trim() || isPosting || isSoftLimited) return;
setIsPosting(true);
@@ -52,9 +99,56 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
if (e.key === 'Escape') {
setIsExpanded(false);
setInput('');
setUploadedFile(null);
setUploadError(null);
}
};
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
setUploadError(null);
const file = e.dataTransfer?.files[0];
if (!file) return;
// Check if it's a PDF
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
setUploadError('Only PDF files are supported');
return;
}
// Check size (50MB limit)
const MAX_SIZE = 50 * 1024 * 1024;
if (file.size > MAX_SIZE) {
setUploadError(`File too large (${Math.round(file.size / 1024 / 1024)}MB). Maximum is 50MB.`);
return;
}
setUploadedFile(file);
setInput(''); // Clear text input when file is dropped
}, []);
const clearFile = useCallback(() => {
setUploadedFile(null);
setUploadError(null);
}, []);
const hasContent = input.trim() || uploadedFile;
// Collapsed state - prominent "ADD STUFF" button
if (!isExpanded) {
return (
@@ -109,21 +203,27 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
// Expanded state - full width overlay
return (
<div style={{
position: 'absolute',
top: '60px',
left: '20px',
right: '20px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
background: '#0a0a0a',
padding: '16px',
borderRadius: '12px',
border: '1px solid #2a2a2a',
zIndex: 100,
animation: 'fadeIn 150ms ease-out'
}}>
<div
style={{
position: 'absolute',
top: '60px',
left: '20px',
right: '20px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
background: '#0a0a0a',
padding: '16px',
borderRadius: '12px',
border: dragOver ? '1px solid #22c55e' : '1px solid #2a2a2a',
zIndex: 100,
animation: 'fadeIn 150ms ease-out',
transition: 'border-color 0.15s ease'
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Header */}
<div style={{
display: 'flex',
@@ -160,6 +260,8 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
onClick={() => {
setIsExpanded(false);
setInput('');
setUploadedFile(null);
setUploadError(null);
}}
style={{
padding: '6px',
@@ -182,48 +284,174 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
</button>
</div>
{/* Input area - expanded */}
<div style={{ position: 'relative' }}>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Paste a URL, note, or transcript — RA-H will figure out what it is."
disabled={isPosting || isSoftLimited}
autoFocus
{/* File preview (when a file is dropped) */}
{uploadedFile && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 14px',
background: '#0f1a0f',
border: '1px solid #1a3a1a',
borderRadius: '10px',
}}>
{/* PDF icon */}
<div style={{
width: '40px',
height: '40px',
borderRadius: '8px',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round"/>
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="16" y1="13" x2="8" y2="13" strokeLinecap="round"/>
<line x1="16" y1="17" x2="8" y2="17" strokeLinecap="round"/>
</svg>
</div>
{/* File info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
color: '#e5e5e5',
fontSize: '13px',
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{uploadedFile.name}
</div>
<div style={{
color: '#666',
fontSize: '11px',
marginTop: '2px'
}}>
{uploadedFile.size < 1024 * 1024
? `${Math.round(uploadedFile.size / 1024)} KB`
: `${(uploadedFile.size / 1024 / 1024).toFixed(1)} MB`}
</div>
</div>
{/* Remove button */}
<button
onClick={clearFile}
style={{
padding: '6px',
background: 'transparent',
border: 'none',
color: '#666',
cursor: 'pointer',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'color 0.15s ease'
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
)}
{/* Error message */}
{uploadError && (
<div style={{
padding: '10px 12px',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: '8px',
color: '#ef4444',
fontSize: '12px',
}}>
{uploadError}
</div>
)}
{/* Input area - show if no file uploaded */}
{!uploadedFile && (
<div
style={{
width: '100%',
minHeight: '140px',
maxHeight: '300px',
padding: '14px 16px',
background: '#0a0a0a',
border: '1px solid #1f1f1f',
position: 'relative',
borderRadius: '10px',
color: '#e5e5e5',
fontSize: '14px',
fontFamily: 'inherit',
outline: 'none',
resize: 'none',
lineHeight: '1.6',
transition: 'border-color 0.15s ease'
border: dragOver ? '2px dashed #22c55e' : '1px solid #1f1f1f',
background: dragOver ? 'rgba(34, 197, 94, 0.05)' : '#0a0a0a',
transition: 'all 0.15s ease'
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = '#333';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#1f1f1f';
}}
/>
</div>
>
{/* Drag overlay */}
{dragOver && (
<div style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(34, 197, 94, 0.05)',
borderRadius: '10px',
zIndex: 10,
pointerEvents: 'none'
}}>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
color: '#22c55e'
}}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round"/>
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span style={{ fontSize: '13px', fontWeight: 500 }}>Drop PDF here</span>
</div>
</div>
)}
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Paste a URL, note, or transcript — or drop a PDF file"
disabled={isPosting || isSoftLimited}
autoFocus
style={{
width: '100%',
minHeight: '140px',
maxHeight: '300px',
padding: '14px 16px',
background: 'transparent',
border: 'none',
color: '#e5e5e5',
fontSize: '14px',
fontFamily: 'inherit',
outline: 'none',
resize: 'none',
lineHeight: '1.6',
opacity: dragOver ? 0.3 : 1,
transition: 'opacity 0.15s ease'
}}
/>
</div>
)}
{/* Footer */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '11px', color: '#525252' }}>
to submit · esc to close
{uploadedFile ? 'Ready to upload' : '\u2318\u21B5 to submit \u00B7 esc to close'}
</span>
<button
onClick={handleSubmit}
disabled={!input.trim() || isPosting || isSoftLimited}
disabled={!hasContent || isPosting || isSoftLimited}
style={{
width: '38px',
height: '38px',
@@ -231,15 +459,15 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
alignItems: 'center',
justifyContent: 'center',
padding: '0',
background: input.trim() && !isPosting ? '#22c55e' : '#262626',
background: hasContent && !isPosting ? '#22c55e' : '#262626',
border: 'none',
borderRadius: '50%',
cursor: input.trim() && !isPosting ? 'pointer' : 'not-allowed',
cursor: hasContent && !isPosting ? 'pointer' : 'not-allowed',
transition: 'all 0.2s ease',
boxShadow: input.trim() && !isPosting ? '0 0 0 0 rgba(34, 197, 94, 0)' : 'none'
boxShadow: hasContent && !isPosting ? '0 0 0 0 rgba(34, 197, 94, 0)' : 'none'
}}
onMouseEnter={(e) => {
if (input.trim() && !isPosting) {
if (hasContent && !isPosting) {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(34, 197, 94, 0.3)';
}
@@ -259,7 +487,7 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
animation: 'spin 0.8s linear infinite'
}} />
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={input.trim() ? '#0a0a0a' : '#525252'} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={hasContent ? '#0a0a0a' : '#525252'} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 19V5"/>
<path d="M5 12l7-7 7 7"/>
</svg>
@@ -28,6 +28,13 @@ interface ExtractionResult {
url: string;
}
interface BufferExtractionResult {
content: string;
chunk: string;
metadata: PaperMetadata;
filename: string;
}
type PdfJsTextItem = {
str?: string;
};
@@ -341,6 +348,41 @@ export class PaperExtractor {
}
}
/**
* Extract text from a PDF buffer (for file uploads)
* @param buffer - The PDF file contents as a Buffer
* @param filename - Optional original filename for metadata
*/
async extractFromBuffer(buffer: Buffer, filename?: string): Promise<BufferExtractionResult> {
// Validate PDF header
if (!this.isLikelyPdf(buffer)) {
const preview = buffer.slice(0, 64).toString('utf8');
throw new Error(`File does not appear to be a valid PDF (header: ${preview.substring(0, 20)}...)`);
}
// Extract text and metadata using existing private method
const { text, metadata } = await this.extractFromPDF(buffer);
// Clean the text
const cleanedText = this.cleanAcademicContent(text);
// Add filename to metadata
metadata.filename = filename || 'uploaded.pdf';
if (!metadata.extraction_method) {
metadata.extraction_method = 'typescript_pdf-parse';
}
// Format content for display
const content = this.formatContent(metadata, cleanedText);
return {
content,
chunk: cleanedText,
metadata,
filename: metadata.filename,
};
}
private formatErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;