feat: add UploadZone component for drag-and-drop file processing
All checks were successful
On Push to Main / test (push) Successful in 31s
On Push to Main / publish (push) Successful in 57s
On Push to Main / deploy-dev (push) Successful in 1m34s

This commit is contained in:
RaymondVerhoef
2026-05-18 22:03:10 +02:00
parent 00541f4a08
commit d2b067735c

View File

@@ -1,23 +1,10 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef } from 'react';
import { UploadCloud, AlertCircle, CheckCircle, RefreshCw, GitBranch, FileText, ArrowUpCircle, MinusCircle } from 'lucide-react'; import { UploadCloud, AlertCircle, CheckCircle } from 'lucide-react';
import { processSourceText } from '../../lib/extractionPipeline'; import { processSourceText } from '../../lib/extractionPipeline';
import { getRepoFolder, getFileContent, parseGitHubUrl } from '../../lib/giteaService';
import * as db from '../../lib/db'; import * as db from '../../lib/db';
import Card from '../ui/Card'; import Card from '../ui/Card';
import Button from '../ui/Button'; import Button from '../ui/Button';
// ── File status badge ─────────────────────────────────────────────────────────
const StatusBadge = ({ status }) => {
if (status === 'new') return <span className="text-xs font-medium text-teal bg-teal/10 px-2 py-0.5 rounded-full flex items-center gap-1"><ArrowUpCircle size={11}/> New</span>;
if (status === 'updated') return <span className="text-xs font-medium text-purple-700 bg-purple-100 px-2 py-0.5 rounded-full flex items-center gap-1"><RefreshCw size={11}/> Updated</span>;
if (status === 'current') return <span className="text-xs font-medium text-fg-muted bg-bg-warm px-2 py-0.5 rounded-full flex items-center gap-1"><MinusCircle size={11}/> Up to date</span>;
if (status === 'done') return <span className="text-xs font-medium text-teal bg-teal/10 px-2 py-0.5 rounded-full flex items-center gap-1"><CheckCircle size={11}/> Processed</span>;
if (status === 'error') return <span className="text-xs font-medium text-red-700 bg-red-100 px-2 py-0.5 rounded-full flex items-center gap-1"><AlertCircle size={11}/> Failed</span>;
if (status === 'skipped') return <span className="text-xs font-medium text-fg-muted bg-bg-warm px-2 py-0.5 rounded-full flex items-center gap-1"><MinusCircle size={11}/> Skipped</span>;
return null;
};
// ── Main Component ──────────────────────────────────────────────────────────── // ── Main Component ────────────────────────────────────────────────────────────
const UploadZone = ({ onUploadComplete }) => { const UploadZone = ({ onUploadComplete }) => {
@@ -25,21 +12,8 @@ const UploadZone = ({ onUploadComplete }) => {
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
// GitHub sync state
const [githubUrl, setGithubUrl] = useState('https://github.com/respellion/employee-handbook/tree/main/docs/knowledge-base');
const [isFetching, setIsFetching] = useState(false);
const [fileList, setFileList] = useState(null);
const [syncProgress, setSyncProgress] = useState(null);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
// Load GitHub URL from PocketBase settings on mount
useEffect(() => {
db.getSetting('github:url', '').then(url => {
if (url) setGithubUrl(url);
});
}, []);
// ── File upload (drag & drop / browse) ──────────────────────────────────── // ── File upload (drag & drop / browse) ────────────────────────────────────
const processFile = async (file) => { const processFile = async (file) => {
@@ -77,88 +51,6 @@ const UploadZone = ({ onUploadComplete }) => {
if (e.target.files?.length > 0) processFile(e.target.files[0]); if (e.target.files?.length > 0) processFile(e.target.files[0]);
}; };
// ── Gitea Sync ─────────────────────────────────────────────────────────────
const handleFetchFiles = async () => {
const parsed = parseGitHubUrl(githubUrl);
if (!parsed) {
setStatus({ type: 'error', msg: 'Enter a valid GitHub folder URL (e.g. https://github.com/owner/repo/tree/main/docs).' });
return;
}
setIsFetching(true);
setFileList(null);
setStatus(null);
try {
const files = await getRepoFolder(parsed.owner, parsed.repo, parsed.folder, parsed.branch);
const enriched = await Promise.all(files.map(async (f) => {
const storedSha = await db.getSetting(`github:sha:${f.name}`, null);
let syncStatus = 'new';
if (storedSha === f.sha) syncStatus = 'current';
else if (storedSha && storedSha !== f.sha) syncStatus = 'updated';
return { ...f, syncStatus, _parsed: parsed };
}));
setFileList(enriched);
// Persist URL for next visit
await db.setSetting('github:url', githubUrl.trim());
} catch (err) {
setStatus({ type: 'error', msg: `Could not fetch from GitHub: ${err.message}` });
} finally {
setIsFetching(false);
}
};
const handleSync = async () => {
const toProcess = fileList.filter(f => f.syncStatus === 'new' || f.syncStatus === 'updated');
if (toProcess.length === 0) {
setStatus({ type: 'success', msg: 'Everything is already up to date.' });
return;
}
setIsProcessing(true);
setSyncProgress({ current: 0, total: toProcess.length });
let processed = 0, failed = 0;
// Update a single file's status in the list
const updateFileStatus = (filename, newStatus) => {
setFileList(prev => prev.map(f => f.name === filename ? { ...f, syncStatus: newStatus } : f));
};
for (const file of toProcess) {
const { _parsed: p } = file;
try {
const content = await getFileContent(p.owner, p.repo, file.path, p.branch);
// If this is an update, delete the old source so deduplication allows re-processing
if (file.syncStatus === 'updated') {
const existing = await db.getSources();
const old = existing.find(s => s.name === file.name && s.status === 'completed');
if (old) await db.deleteSource(old.id);
}
await processSourceText(content, file.name);
await db.setSetting(`github:sha:${file.name}`, file.sha);
updateFileStatus(file.name, 'done');
processed++;
} catch (err) {
console.error(`[GitHub Sync] Failed: ${file.name}`, err);
updateFileStatus(file.name, 'error');
failed++;
}
setSyncProgress(p => ({ ...p, current: p.current + 1 }));
}
setSyncProgress(null);
setIsProcessing(false);
if (onUploadComplete) onUploadComplete();
const parts = [];
if (processed > 0) parts.push(`${processed} file${processed > 1 ? 's' : ''} processed`);
if (failed > 0) parts.push(`${failed} failed`);
setStatus({ type: failed > 0 ? 'error' : 'success', msg: `Sync complete: ${parts.join(', ')}.` });
};
const toProcess = fileList ? fileList.filter(f => f.syncStatus === 'new' || f.syncStatus === 'updated') : [];
// ── Render ───────────────────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────────────────
return ( return (
@@ -189,84 +81,6 @@ const UploadZone = ({ onUploadComplete }) => {
/> />
</Card> </Card>
{/* ─── Divider ─── */}
<div className="flex items-center gap-4">
<div className="flex-1 border-t border-bg-warm"></div>
<span className="text-sm text-fg-muted uppercase tracking-wider">OR</span>
<div className="flex-1 border-t border-bg-warm"></div>
</div>
{/* ─── GitHub Sync Panel ─── */}
<Card className="border border-bg-warm">
<div className="flex items-start justify-between mb-4 gap-4">
<div className="flex items-center gap-3 flex-shrink-0">
<div className="w-9 h-9 rounded-[var(--r-sm)] bg-teal/10 flex items-center justify-center">
<GitBranch size={18} className="text-teal" />
</div>
<h3 className="font-semibold">GitHub Sync</h3>
</div>
<input
type="url"
value={githubUrl}
onChange={e => setGithubUrl(e.target.value)}
placeholder="https://github.com/owner/repo/tree/main/folder"
className="flex-1 h-9 px-3 rounded-[var(--r-sm)] border border-bg-warm bg-bg text-sm focus:outline-none focus:ring-2 focus:ring-teal/30 focus:border-teal"
/>
</div>
<div className="flex justify-end gap-2 mb-4">
<div className="flex items-center gap-2">
{fileList && toProcess.length > 0 && (
<Button onClick={handleSync} disabled={isProcessing}>
{isProcessing && syncProgress
? `Processing ${syncProgress.current}/${syncProgress.total}...`
: `Sync ${toProcess.length} file${toProcess.length > 1 ? 's' : ''}`}
</Button>
)}
<Button
variant="outline"
onClick={handleFetchFiles}
disabled={isFetching || isProcessing}
>
<RefreshCw size={15} className={`mr-1.5 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Checking...' : fileList ? 'Refresh' : 'Check for updates'}
</Button>
</div>
</div>
{/* File list */}
{fileList && (
<div className="divide-y divide-bg-warm border border-bg-warm rounded-[var(--r-sm)] overflow-hidden">
{fileList.length === 0 ? (
<p className="text-sm text-fg-muted p-4 text-center">No .md or .txt files found in this folder.</p>
) : fileList.map(file => (
<div key={file.name} className="flex items-center justify-between px-4 py-3 hover:bg-bg-warm/30 transition-colors">
<div className="flex items-center gap-3">
<FileText size={15} className="text-fg-muted flex-shrink-0" />
<span className="text-sm font-medium">{file.name}</span>
</div>
<StatusBadge status={file.syncStatus} />
</div>
))}
</div>
)}
{/* Progress bar */}
{syncProgress && (
<div className="mt-4">
<div className="h-1.5 bg-bg-warm rounded-full overflow-hidden">
<div
className="h-full bg-teal transition-all duration-300"
style={{ width: `${(syncProgress.current / syncProgress.total) * 100}%` }}
/>
</div>
<p className="text-xs text-fg-muted mt-1.5">
Processing {syncProgress.current} of {syncProgress.total} files
</p>
</div>
)}
</Card>
{/* ─── Status messages ─── */} {/* ─── Status messages ─── */}
{status && ( {status && (
<div className={`p-4 rounded-[var(--r-sm)] flex items-start gap-3 ${ <div className={`p-4 rounded-[var(--r-sm)] flex items-start gap-3 ${