feat: initial tools service with document scanner, image & PDF tools
Self-hosted document scanner and media processing tools. - Rust Axum gateway + worker pool with NATS JetStream - Next.js 16 frontend with shadcn/ui - Scanner pipeline: edge detection, warp, binarization, OCR - Image tools: compress, resize, convert - PDF tools: merge, split, compress - CI/CD with Docker multi-stage build Co-Authored-By: Kilo <kilo@kilo.ai>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
export type JobStatusType = "queued" | "processing" | "completed" | "failed";
|
||||
|
||||
interface JobProgress {
|
||||
type: "progress" | "complete" | "error" | "status" | "ping";
|
||||
job_id: string;
|
||||
status: JobStatusType;
|
||||
progress: number;
|
||||
stage: string;
|
||||
message: string;
|
||||
result?: {
|
||||
download_url: string;
|
||||
file_name: string;
|
||||
file_size: number;
|
||||
preview_url?: string;
|
||||
ocr_text?: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface UseJobStatusOptions {
|
||||
onComplete?: (result: JobProgress["result"]) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export function useJobStatus(jobId: string | null, options?: UseJobStatusOptions) {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [stage, setStage] = useState("queued");
|
||||
const [message, setMessage] = useState("");
|
||||
const [status, setStatus] = useState<JobStatusType>("queued");
|
||||
const [result, setResult] = useState<JobProgress["result"] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const retryCount = useRef(0);
|
||||
const maxRetries = 3;
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!jobId) return;
|
||||
|
||||
// Connect directly to Rust gateway WebSocket (not via Next.js)
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const host = "localhost:3001"; // Rust gateway - wss for production
|
||||
const url = `${protocol}//${host}/api/job/${jobId}/ws`;
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
retryCount.current = 0;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data: JobProgress = JSON.parse(event.data);
|
||||
|
||||
if (data.type === "ping") return;
|
||||
|
||||
setStatus(data.status);
|
||||
setProgress(data.progress);
|
||||
setStage(data.stage);
|
||||
setMessage(data.message);
|
||||
|
||||
if (data.type === "complete") {
|
||||
setResult(data.result ?? null);
|
||||
options?.onComplete?.(data.result);
|
||||
}
|
||||
|
||||
if (data.type === "error") {
|
||||
setError(data.error ?? "Unknown error");
|
||||
options?.onError?.(data.error ?? "Unknown error");
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (retryCount.current < maxRetries) {
|
||||
retryCount.current++;
|
||||
setTimeout(connect, 1000 * retryCount.current);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
}, [jobId, options]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return { progress, stage, message, status, result, error };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
interface UploadResult {
|
||||
job_id: string;
|
||||
ws_url: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface UseUploadOptions {
|
||||
tool: string;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function useUpload({ tool, options }: UseUploadOptions) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<UploadResult | null>(null);
|
||||
const abortRef = useState<AbortController | null>(null);
|
||||
|
||||
const upload = useCallback(
|
||||
async (file: File): Promise<UploadResult | null> => {
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("tool", tool);
|
||||
if (options) {
|
||||
formData.append("options", JSON.stringify(options));
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef[1](controller);
|
||||
|
||||
const response = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
errData?.error ?? `Upload failed: ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data: UploadResult = await response.json();
|
||||
setResult(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
return null;
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : "Upload failed";
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
},
|
||||
[tool, options],
|
||||
);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
abortRef[1]?.abort();
|
||||
setIsUploading(false);
|
||||
}, [abortRef[1]]);
|
||||
|
||||
return { upload, cancel, isUploading, error, result };
|
||||
}
|
||||
Reference in New Issue
Block a user