feat: add UploadZone component for drag-and-drop file processing
This commit is contained in:
@@ -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 ${
|
||||||
|
|||||||
Reference in New Issue
Block a user