feat(infra): add tools service with document scanner, image & PDF tools

Implement self-hosted document scanner and media processing tools as
an alternative to CamScanner/ilovepdf without third-party uploads.

Backend: Rust Axum gateway + worker pool with NATS JetStream queue
Frontend: Next.js 16 + shadcn/ui + Tailwind v4 + Framer Motion
Pipeline: Canny edge detection -> DLT homography warp -> Sauvola
binarization -> Hough deskew -> Tesseract OCR -> searchable PDF

Phase 1 (MVP) delivers:
- Document scanner with perspective correction and OCR
- Image compress/resize/convert tools
- PDF merge/split/compress tools
- Real-time WebSocket progress updates
- Rate limiting, auto-cleanup, Prometheus metrics
- Full CI/CD pipeline with Docker multi-stage build

Co-Authored-By: Kilo <kilo@kilo.ai>
This commit is contained in:
asepharyana
2026-07-24 13:08:09 +07:00
co-authored by Kilo
parent 9c12d135e4
commit 67288c8723
102 changed files with 11527 additions and 3 deletions
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"extends": ["../../biome.json"]
}
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+10
View File
@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactCompiler: true,
turbopack: {
root: process.cwd(),
},
};
export default nextConfig;
+35
View File
@@ -0,0 +1,35 @@
{
"name": "tools-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --port 3002",
"build": "next build",
"start": "next start",
"lint": "biome check",
"format": "biome format --write"
},
"dependencies": {
"@shadcn/react": "^0.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.42.2",
"lucide-react": "^1.26.0",
"next": "16.2.11",
"next-themes": "^0.4.6",
"react": "19.2.8",
"react-dom": "19.2.8",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@biomejs/biome": "2.5.5",
"@tailwindcss/postcss": "^4",
"@types/node": "^26",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"typescript": "^5.9.3"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+13
View File
@@ -0,0 +1,13 @@
{
"name": "Tools — Asep Haryana",
"short_name": "Tools",
"description": "Document Scanner, Image & PDF Tools",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a1a",
"theme_color": "#0a0a1a",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
const RUST_GATEWAY = process.env.RUST_GATEWAY_URL || "http://localhost:3001";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
const response = await fetch(`${RUST_GATEWAY}/api/download/${id}`);
if (!response.ok) {
const data = await response.json().catch(() => null);
return NextResponse.json(
data ?? { error: "Download failed" },
{ status: response.status },
);
}
// Stream the file back
const blob = await response.blob();
const contentType =
response.headers.get("content-type") || "application/octet-stream";
const contentDisposition =
response.headers.get("content-disposition") ||
"attachment; filename=\"result\"";
return new NextResponse(blob, {
headers: {
"Content-Type": contentType,
"Content-Disposition": contentDisposition,
},
});
} catch (error) {
console.error("Download proxy error:", error);
return NextResponse.json(
{ error: "Failed to download file" },
{ status: 500 },
);
}
}
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
const RUST_GATEWAY = process.env.RUST_GATEWAY_URL || "http://localhost:3001";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
const response = await fetch(`${RUST_GATEWAY}/api/job/${id}`);
const data = await response.json();
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error("Job status proxy error:", error);
return NextResponse.json(
{ error: "Failed to fetch job status" },
{ status: 500 },
);
}
}
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
// WebSocket is handled directly by the client connecting to the Rust gateway.
// Next.js App Router cannot proxy WebSocket connections in route handlers.
// The client-side useJobStatus hook connects directly to ws://localhost:3001/api/job/{id}/ws
// In production, configure the WebSocket to connect to wss://tools.asepharyana.my.id/api/job/{id}/ws
export function GET() {
return NextResponse.json(
{
note: "WebSocket connections go directly to the Rust gateway",
ws_url:
process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:3001/api/job/{id}/ws",
},
);
}
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
const RUST_GATEWAY = process.env.RUST_GATEWAY_URL || "http://localhost:3001";
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get("file");
const tool = formData.get("tool");
const options = formData.get("options");
if (!file || !tool) {
return NextResponse.json(
{ error: "Missing file or tool parameter" },
{ status: 400 },
);
}
// Forward to Rust gateway
const gatewayForm = new FormData();
gatewayForm.append("file", file);
gatewayForm.append("tool", tool as string);
if (options) {
gatewayForm.append("options", options as string);
}
const response = await fetch(`${RUST_GATEWAY}/api/upload`, {
method: "POST",
body: gatewayForm,
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data, { status: 202 });
} catch (error) {
console.error("Upload proxy error:", error);
return NextResponse.json(
{ error: "Failed to process upload" },
{ status: 500 },
);
}
}
@@ -0,0 +1,103 @@
"use client";
import { useState, useCallback } from "react";
import { Mic } from "lucide-react";
import { ToolLayout } from "@/components/tools/tool-layout";
import { UploadZone } from "@/components/tools/upload-zone";
import { useUpload } from "@/hooks/use-upload";
import { useJobStatus } from "@/hooks/use-job-status";
import { ProgressBar } from "@/components/tools/progress-bar";
import { ResultPreview } from "@/components/tools/result-preview";
type PageState = "upload" | "processing" | "result" | "error";
export default function AudioConvertPage() {
const [pageState, setPageState] = useState<PageState>("upload");
const [jobId, setJobId] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const { upload } = useUpload({
tool: "audio-convert",
options: { quality: 80 },
});
const handleComplete = useCallback(() => setPageState("result"), []);
const handleError = useCallback(
(err: string) => {
setErrorMsg(err);
setPageState("error");
},
[],
);
const { progress, stage, message, status, result } = useJobStatus(jobId, {
onComplete: handleComplete,
onError: handleError,
});
const handleUpload = useCallback(
async (file: File) => {
setErrorMsg(null);
const res = await upload(file);
if (res) {
setJobId(res.job_id);
setPageState("processing");
}
},
[upload],
);
const handleRetry = useCallback(() => {
setPageState("upload");
setJobId(null);
setErrorMsg(null);
}, []);
return (
<ToolLayout
title="Audio Convert"
description="Convert antar format audio"
icon={Mic}
phase={3}
>
{pageState === "upload" && (
<UploadZone
accept="audio/*"
tool="audio-convert"
onUpload={handleUpload}
/>
)}
{pageState === "processing" && jobId && (
<ProgressBar
progress={progress}
stage={stage}
message={message}
status={status}
onRetry={handleRetry}
/>
)}
{pageState === "result" && result && (
<ResultPreview
result={result}
onProcessAnother={handleRetry}
/>
)}
{pageState === "error" && (
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
<p className="text-destructive font-medium mb-4">
{errorMsg || "Terjadi kesalahan"}
</p>
<button
onClick={handleRetry}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
>
Coba Lagi
</button>
</div>
)}
</ToolLayout>
);
}
+121
View File
@@ -0,0 +1,121 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--color-ring: var(--ring);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-sans: "Geist", sans-serif;
--font-mono: "Geist Mono", monospace;
}
:root {
--radius: 0.625rem;
--background: oklch(0.97 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0.042 265.755);
--primary-foreground: oklch(0.985 0 0);
--muted: oklch(0.965 0.001 286.375);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.965 0.001 286.375);
--accent-foreground: oklch(0.205 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.004 286.375);
--ring: oklch(0.205 0.042 265.755);
}
.dark {
--background: oklch(0.07 0.015 265);
--foreground: oklch(0.985 0 0);
--card: oklch(0.12 0.02 265);
--card-foreground: oklch(0.985 0 0);
--primary: oklch(0.7 0.15 265);
--primary-foreground: oklch(0.07 0.015 265);
--muted: oklch(0.15 0.02 265);
--muted-foreground: oklch(0.6 0.02 265);
--accent: oklch(0.15 0.02 265);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.2 0.02 265);
--ring: oklch(0.7 0.15 265);
}
* {
border-color: var(--border);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
}
/* Glass effect */
.glass {
background: oklch(from var(--card) l c h / 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid oklch(from var(--border) l c h / 0.5);
}
/* Gradient text */
.gradient-text {
background: linear-gradient(135deg, var(--primary), oklch(0.6 0.2 265));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Terminal cursor blink */
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.cursor-blink::after {
content: "█";
animation: blink 1s step-end infinite;
color: var(--primary);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--muted);
}
::-webkit-scrollbar-thumb {
background: var(--muted-foreground);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--primary);
}
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
const RUST_GATEWAY = process.env.RUST_GATEWAY_URL || "http://localhost:3001";
export async function GET() {
try {
const response = await fetch(`${RUST_GATEWAY}/health`, {
signal: AbortSignal.timeout(5000),
});
const data = await response.json();
return NextResponse.json(data);
} catch {
return NextResponse.json(
{ status: "error", message: "Gateway unreachable" },
{ status: 503 },
);
}
}
@@ -0,0 +1,103 @@
"use client";
import { useState, useCallback } from "react";
import { ImageDown } from "lucide-react";
import { ToolLayout } from "@/components/tools/tool-layout";
import { UploadZone } from "@/components/tools/upload-zone";
import { useUpload } from "@/hooks/use-upload";
import { useJobStatus } from "@/hooks/use-job-status";
import { ProgressBar } from "@/components/tools/progress-bar";
import { ResultPreview } from "@/components/tools/result-preview";
type PageState = "upload" | "processing" | "result" | "error";
export default function ImageCompressPage() {
const [pageState, setPageState] = useState<PageState>("upload");
const [jobId, setJobId] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const { upload } = useUpload({
tool: "image-compress",
options: { quality: 80 },
});
const handleComplete = useCallback(() => setPageState("result"), []);
const handleError = useCallback(
(err: string) => {
setErrorMsg(err);
setPageState("error");
},
[],
);
const { progress, stage, message, status, result } = useJobStatus(jobId, {
onComplete: handleComplete,
onError: handleError,
});
const handleUpload = useCallback(
async (file: File) => {
setErrorMsg(null);
const res = await upload(file);
if (res) {
setJobId(res.job_id);
setPageState("processing");
}
},
[upload],
);
const handleRetry = useCallback(() => {
setPageState("upload");
setJobId(null);
setErrorMsg(null);
}, []);
return (
<ToolLayout
title="Compress Image"
description="Kecilin ukuran JPEG/PNG/WebP — atur kualitasnya"
icon={ImageDown}
phase={1}
>
{pageState === "upload" && (
<UploadZone
accept="image/*"
tool="image-compress"
onUpload={handleUpload}
/>
)}
{pageState === "processing" && jobId && (
<ProgressBar
progress={progress}
stage={stage}
message={message}
status={status}
onRetry={handleRetry}
/>
)}
{pageState === "result" && result && (
<ResultPreview
result={result}
onProcessAnother={handleRetry}
/>
)}
{pageState === "error" && (
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
<p className="text-destructive font-medium mb-4">
{errorMsg || "Terjadi kesalahan"}
</p>
<button
onClick={handleRetry}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
>
Coba Lagi
</button>
</div>
)}
</ToolLayout>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useState, useCallback } from "react";
import { Repeat } from "lucide-react";
import { ToolLayout } from "@/components/tools/tool-layout";
import { UploadZone } from "@/components/tools/upload-zone";
import { useUpload } from "@/hooks/use-upload";
import { useJobStatus } from "@/hooks/use-job-status";
import { ProgressBar } from "@/components/tools/progress-bar";
import { ResultPreview } from "@/components/tools/result-preview";
type PageState = "upload" | "processing" | "result" | "error";
export default function ImageConvertPage() {
const [pageState, setPageState] = useState<PageState>("upload");
const [jobId, setJobId] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const { upload } = useUpload({
tool: "image-convert",
options: { quality: 80 },
});
const handleComplete = useCallback(() => setPageState("result"), []);
const handleError = useCallback(
(err: string) => {
setErrorMsg(err);
setPageState("error");
},
[],
);
const { progress, stage, message, status, result } = useJobStatus(jobId, {
onComplete: handleComplete,
onError: handleError,
});
const handleUpload = useCallback(
async (file: File) => {
setErrorMsg(null);
const res = await upload(file);
if (res) {
setJobId(res.job_id);
setPageState("processing");
}
},
[upload],
);
const handleRetry = useCallback(() => {
setPageState("upload");
setJobId(null);
setErrorMsg(null);
}, []);
return (
<ToolLayout
title="Convert Image"
description="Convert HEIC->JPEG, PNG->WebP"
icon={Repeat}
phase={1}
>
{pageState === "upload" && (
<UploadZone
accept="image/*"
tool="image-convert"
onUpload={handleUpload}
/>
)}
{pageState === "processing" && jobId && (
<ProgressBar
progress={progress}
stage={stage}
message={message}
status={status}
onRetry={handleRetry}
/>
)}
{pageState === "result" && result && (
<ResultPreview
result={result}
onProcessAnother={handleRetry}
/>
)}
{pageState === "error" && (
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
<p className="text-destructive font-medium mb-4">
{errorMsg || "Terjadi kesalahan"}
</p>
<button
onClick={handleRetry}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
>
Coba Lagi
</button>
</div>
)}
</ToolLayout>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useState, useCallback } from "react";
import { Shrink } from "lucide-react";
import { ToolLayout } from "@/components/tools/tool-layout";
import { UploadZone } from "@/components/tools/upload-zone";
import { useUpload } from "@/hooks/use-upload";
import { useJobStatus } from "@/hooks/use-job-status";
import { ProgressBar } from "@/components/tools/progress-bar";
import { ResultPreview } from "@/components/tools/result-preview";
type PageState = "upload" | "processing" | "result" | "error";
export default function ImageRemoveBgPage() {
const [pageState, setPageState] = useState<PageState>("upload");
const [jobId, setJobId] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const { upload } = useUpload({
tool: "image-remove-bg",
options: { quality: 80 },
});
const handleComplete = useCallback(() => setPageState("result"), []);
const handleError = useCallback(
(err: string) => {
setErrorMsg(err);
setPageState("error");
},
[],
);
const { progress, stage, message, status, result } = useJobStatus(jobId, {
onComplete: handleComplete,
onError: handleError,
});
const handleUpload = useCallback(
async (file: File) => {
setErrorMsg(null);
const res = await upload(file);
if (res) {
setJobId(res.job_id);
setPageState("processing");
}
},
[upload],
);
const handleRetry = useCallback(() => {
setPageState("upload");
setJobId(null);
setErrorMsg(null);
}, []);
return (
<ToolLayout
title="Remove Background"
description="Hapus latar belakang"
icon={Shrink}
phase={2}
>
{pageState === "upload" && (
<UploadZone
accept="image/*"
tool="image-remove-bg"
onUpload={handleUpload}
/>
)}
{pageState === "processing" && jobId && (
<ProgressBar
progress={progress}
stage={stage}
message={message}
status={status}
onRetry={handleRetry}
/>
)}
{pageState === "result" && result && (
<ResultPreview
result={result}
onProcessAnother={handleRetry}
/>
)}
{pageState === "error" && (
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
<p className="text-destructive font-medium mb-4">
{errorMsg || "Terjadi kesalahan"}
</p>
<button
onClick={handleRetry}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
>
Coba Lagi
</button>
</div>
)}
</ToolLayout>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useState, useCallback } from "react";
import { ImageResize } from "lucide-react";
import { ToolLayout } from "@/components/tools/tool-layout";
import { UploadZone } from "@/components/tools/upload-zone";
import { useUpload } from "@/hooks/use-upload";
import { useJobStatus } from "@/hooks/use-job-status";
import { ProgressBar } from "@/components/tools/progress-bar";
import { ResultPreview } from "@/components/tools/result-preview";
type PageState = "upload" | "processing" | "result" | "error";
export default function ImageResizePage() {
const [pageState, setPageState] = useState<PageState>("upload");
const [jobId, setJobId] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const { upload } = useUpload({
tool: "image-resize",
options: { quality: 80 },
});
const handleComplete = useCallback(() => setPageState("result"), []);
const handleError = useCallback(
(err: string) => {
setErrorMsg(err);
setPageState("error");
},
[],
);
const { progress, stage, message, status, result } = useJobStatus(jobId, {
onComplete: handleComplete,
onError: handleError,
});
const handleUpload = useCallback(
async (file: File) => {
setErrorMsg(null);
const res = await upload(file);
if (res) {
setJobId(res.job_id);
setPageState("processing");
}
},
[upload],
);
const handleRetry = useCallback(() => {
setPageState("upload");
setJobId(null);
setErrorMsg(null);
}, []);
return (
<ToolLayout
title="Resize Image"
description="Ubah dimensi gambar"
icon={ImageResize}
phase={1}
>
{pageState === "upload" && (
<UploadZone
accept="image/*"
tool="image-resize"
onUpload={handleUpload}
/>
)}
{pageState === "processing" && jobId && (
<ProgressBar
progress={progress}
stage={stage}
message={message}
status={status}
onRetry={handleRetry}
/>
)}
{pageState === "result" && result && (
<ResultPreview
result={result}
onProcessAnother={handleRetry}
/>
)}
{pageState === "error" && (
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
<p className="text-destructive font-medium mb-4">
{errorMsg || "Terjadi kesalahan"}
</p>
<button
onClick={handleRetry}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
>
Coba Lagi
</button>
</div>
)}
</ToolLayout>
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { ThemeProvider } from "next-themes";
import "./globals.css";
import { Header } from "@/components/tools/header";
import { Footer } from "@/components/tools/footer";
export const metadata: Metadata = {
title: "Tools — Asep Haryana",
description:
"Self-hosted document scanner, image tools & PDF tools. No upload to third-party servers.",
manifest: "/manifest.json",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="id" suppressHydrationWarning>
<body className="min-h-screen flex flex-col antialiased">
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<Header />
<main className="flex-1">{children}</main>
<Footer />
</ThemeProvider>
</body>
</html>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { ToolGrid } from "@/components/tools/tool-grid";
export default function HomePage() {
return (
<div className="container mx-auto px-4 py-12">
{/* Hero */}
<section className="text-center mb-16">
<h1 className="text-4xl md:text-5xl font-bold mb-4">
<span className="gradient-text">Tools</span>
</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
Self-hosted document scanner, image tools & PDF tools.
<br />
Semua proses di backend cepat, hemat,{" "}
<span className="text-primary font-semibold">privacy first</span>.
</p>
<div className="flex items-center justify-center gap-4 mt-6 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-green-500" />
Rust + WASM
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-primary" />
No upload to 3rd party
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-amber-500" />
Auto-delete 1 jam
</span>
</div>
</section>
{/* Tools Grid */}
<section>
<div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-bold">All Tools</h2>
<span className="text-sm text-muted-foreground font-mono">
14 tools
</span>
</div>
<ToolGrid />
</section>
{/* Privacy Note */}
<section className="mt-16 p-6 rounded-xl border glass text-center">
<h2 className="text-lg font-semibold mb-2">🔒 Privacy First</h2>
<p className="text-sm text-muted-foreground max-w-xl mx-auto">
Semua file diproses di server kami dan{" "}
<span className="text-primary font-medium">
otomatis dihapus setelah 1 jam
</span>
. Tidak ada data yang dikirim ke pihak ketiga. Source code
open-source di GitHub.
</p>
</section>
</div>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useState, useCallback } from "react";
import { FileImage } from "lucide-react";
import { ToolLayout } from "@/components/tools/tool-layout";
import { UploadZone } from "@/components/tools/upload-zone";
import { useUpload } from "@/hooks/use-upload";
import { useJobStatus } from "@/hooks/use-job-status";
import { ProgressBar } from "@/components/tools/progress-bar";
import { ResultPreview } from "@/components/tools/result-preview";
type PageState = "upload" | "processing" | "result" | "error";
export default function PdfCompressPage() {
const [pageState, setPageState] = useState<PageState>("upload");
const [jobId, setJobId] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const { upload } = useUpload({
tool: "pdf-compress",
options: { quality: 80 },
});
const handleComplete = useCallback(() => setPageState("result"), []);
const handleError = useCallback(
(err: string) => {
setErrorMsg(err);
setPageState("error");
},
[],
);
const { progress, stage, message, status, result } = useJobStatus(jobId, {
onComplete: handleComplete,
onError: handleError,
});
const handleUpload = useCallback(
async (file: File) => {
setErrorMsg(null);
const res = await upload(file);
if (res) {
setJobId(res.job_id);
setPageState("processing");
}
},
[upload],
);
const handleRetry = useCallback(() => {
setPageState("upload");
setJobId(null);
setErrorMsg(null);
}, []);
return (
<ToolLayout
title="Compress PDF"
description="Kecilin ukuran PDF"
icon={FileImage}
phase={2}
>
{pageState === "upload" && (
<UploadZone
accept="application/pdf"
tool="pdf-compress"
onUpload={handleUpload}
/>
)}
{pageState === "processing" && jobId && (
<ProgressBar
progress={progress}
stage={stage}
message={message}
status={status}
onRetry={handleRetry}
/>
)}
{pageState === "result" && result && (
<ResultPreview
result={result}
onProcessAnother={handleRetry}
/>
)}
{pageState === "error" && (
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
<p className="text-destructive font-medium mb-4">
{errorMsg || "Terjadi kesalahan"}
</p>
<button
onClick={handleRetry}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
>
Coba Lagi
</button>
</div>
)}
</ToolLayout>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useState, useCallback } from "react";
import { Images } from "lucide-react";
import { ToolLayout } from "@/components/tools/tool-layout";
import { UploadZone } from "@/components/tools/upload-zone";
import { useUpload } from "@/hooks/use-upload";
import { useJobStatus } from "@/hooks/use-job-status";
import { ProgressBar } from "@/components/tools/progress-bar";
import { ResultPreview } from "@/components/tools/result-preview";
type PageState = "upload" | "processing" | "result" | "error";
export default function ImagesToPdfPage() {
const [pageState, setPageState] = useState<PageState>("upload");
const [jobId, setJobId] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const { upload } = useUpload({
tool: "pdf-images-to-pdf",
options: { quality: 80 },
});
const handleComplete = useCallback(() => setPageState("result"), []);
const handleError = useCallback(
(err: string) => {
setErrorMsg(err);
setPageState("error");
},
[],
);
const { progress, stage, message, status, result } = useJobStatus(jobId, {
onComplete: handleComplete,
onError: handleError,
});
const handleUpload = useCallback(
async (file: File) => {
setErrorMsg(null);
const res = await upload(file);
if (res) {
setJobId(res.job_id);
setPageState("processing");
}
},
[upload],
);
const handleRetry = useCallback(() => {
setPageState("upload");
setJobId(null);
setErrorMsg(null);
}, []);
return (
<ToolLayout
title="Images to PDF"
description="Kumpulan foto jadi 1 file"
icon={Images}
phase={2}
>
{pageState === "upload" && (
<UploadZone
accept="image/*"
tool="pdf-images-to-pdf"
onUpload={handleUpload}
/>
)}
{pageState === "processing" && jobId && (
<ProgressBar
progress={progress}
stage={stage}
message={message}
status={status}
onRetry={handleRetry}
/>
)}
{pageState === "result" && result && (
<ResultPreview
result={result}
onProcessAnother={handleRetry}
/>
)}
{pageState === "error" && (
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
<p className="text-destructive font-medium mb-4">
{errorMsg || "Terjadi kesalahan"}
</p>
<button
onClick={handleRetry}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
>
Coba Lagi
</button>
</div>
)}
</ToolLayout>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useState, useCallback } from "react";
import { Merge } from "lucide-react";
import { ToolLayout } from "@/components/tools/tool-layout";
import { UploadZone } from "@/components/tools/upload-zone";
import { useUpload } from "@/hooks/use-upload";
import { useJobStatus } from "@/hooks/use-job-status";
import { ProgressBar } from "@/components/tools/progress-bar";
import { ResultPreview } from "@/components/tools/result-preview";
type PageState = "upload" | "processing" | "result" | "error";
export default function PdfMergePage() {
const [pageState, setPageState] = useState<PageState>("upload");
const [jobId, setJobId] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const { upload } = useUpload({
tool: "pdf-merge",
options: { quality: 80 },
});
const handleComplete = useCallback(() => setPageState("result"), []);
const handleError = useCallback(
(err: string) => {
setErrorMsg(err);
setPageState("error");
},
[],
);
const { progress, stage, message, status, result } = useJobStatus(jobId, {
onComplete: handleComplete,
onError: handleError,
});
const handleUpload = useCallback(
async (file: File) => {
setErrorMsg(null);
const res = await upload(file);
if (res) {
setJobId(res.job_id);
setPageState("processing");
}
},
[upload],
);
const handleRetry = useCallback(() => {
setPageState("upload");
setJobId(null);
setErrorMsg(null);
}, []);
return (
<ToolLayout
title="Merge PDF"
description="Gabung beberapa file PDF"
icon={Merge}
phase={2}
>
{pageState === "upload" && (
<UploadZone
accept="application/pdf"
tool="pdf-merge"
onUpload={handleUpload}
/>
)}
{pageState === "processing" && jobId && (
<ProgressBar
progress={progress}
stage={stage}
message={message}
status={status}
onRetry={handleRetry}
/>
)}
{pageState === "result" && result && (
<ResultPreview
result={result}
onProcessAnother={handleRetry}
/>
)}
{pageState === "error" && (
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
<p className="text-destructive font-medium mb-4">
{errorMsg || "Terjadi kesalahan"}
</p>
<button
onClick={handleRetry}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
>
Coba Lagi
</button>
</div>
)}
</ToolLayout>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useState, useCallback } from "react";
import { ImageIcon } from "lucide-react";
import { ToolLayout } from "@/components/tools/tool-layout";
import { UploadZone } from "@/components/tools/upload-zone";
import { useUpload } from "@/hooks/use-upload";
import { useJobStatus } from "@/hooks/use-job-status";
import { ProgressBar } from "@/components/tools/progress-bar";
import { ResultPreview } from "@/components/tools/result-preview";
type PageState = "upload" | "processing" | "result" | "error";
export default function PdfToImagesPage() {
const [pageState, setPageState] = useState<PageState>("upload");
const [jobId, setJobId] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const { upload } = useUpload({
tool: "pdf-pdf-to-images",
options: { quality: 80 },
});
const handleComplete = useCallback(() => setPageState("result"), []);
const handleError = useCallback(
(err: string) => {
setErrorMsg(err);
setPageState("error");
},
[],
);
const { progress, stage, message, status, result } = useJobStatus(jobId, {
onComplete: handleComplete,
onError: handleError,
});
const handleUpload = useCallback(
async (file: File) => {
setErrorMsg(null);
const res = await upload(file);
if (res) {
setJobId(res.job_id);
setPageState("processing");
}
},
[upload],
);
const handleRetry = useCallback(() => {
setPageState("upload");
setJobId(null);
setErrorMsg(null);
}, []);
return (
<ToolLayout
title="PDF to Images"
description="Convert tiap halaman ke gambar"
icon={ImageIcon}
phase={2}
>
{pageState === "upload" && (
<UploadZone
accept="application/pdf"
tool="pdf-pdf-to-images"
onUpload={handleUpload}
/>
)}
{pageState === "processing" && jobId && (
<ProgressBar
progress={progress}
stage={stage}
message={message}
status={status}
onRetry={handleRetry}
/>
)}
{pageState === "result" && result && (
<ResultPreview
result={result}
onProcessAnother={handleRetry}
/>
)}
{pageState === "error" && (
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
<p className="text-destructive font-medium mb-4">
{errorMsg || "Terjadi kesalahan"}
</p>
<button
onClick={handleRetry}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
>
Coba Lagi
</button>
</div>
)}
</ToolLayout>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useState, useCallback } from "react";
import { Split } from "lucide-react";
import { ToolLayout } from "@/components/tools/tool-layout";
import { UploadZone } from "@/components/tools/upload-zone";
import { useUpload } from "@/hooks/use-upload";
import { useJobStatus } from "@/hooks/use-job-status";
import { ProgressBar } from "@/components/tools/progress-bar";
import { ResultPreview } from "@/components/tools/result-preview";
type PageState = "upload" | "processing" | "result" | "error";
export default function PdfSplitPage() {
const [pageState, setPageState] = useState<PageState>("upload");
const [jobId, setJobId] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const { upload } = useUpload({
tool: "pdf-split",
options: { quality: 80 },
});
const handleComplete = useCallback(() => setPageState("result"), []);
const handleError = useCallback(
(err: string) => {
setErrorMsg(err);
setPageState("error");
},
[],
);
const { progress, stage, message, status, result } = useJobStatus(jobId, {
onComplete: handleComplete,
onError: handleError,
});
const handleUpload = useCallback(
async (file: File) => {
setErrorMsg(null);
const res = await upload(file);
if (res) {
setJobId(res.job_id);
setPageState("processing");
}
},
[upload],
);
const handleRetry = useCallback(() => {
setPageState("upload");
setJobId(null);
setErrorMsg(null);
}, []);
return (
<ToolLayout
title="Split PDF"
description="Ekstrak halaman tertentu"
icon={Split}
phase={2}
>
{pageState === "upload" && (
<UploadZone
accept="application/pdf"
tool="pdf-split"
onUpload={handleUpload}
/>
)}
{pageState === "processing" && jobId && (
<ProgressBar
progress={progress}
stage={stage}
message={message}
status={status}
onRetry={handleRetry}
/>
)}
{pageState === "result" && result && (
<ResultPreview
result={result}
onProcessAnother={handleRetry}
/>
)}
{pageState === "error" && (
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
<p className="text-destructive font-medium mb-4">
{errorMsg || "Terjadi kesalahan"}
</p>
<button
onClick={handleRetry}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
>
Coba Lagi
</button>
</div>
)}
</ToolLayout>
);
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { useState, useCallback } from "react";
import { Scan } from "lucide-react";
import { UploadZone } from "@/components/tools/upload-zone";
import { ProgressBar } from "@/components/tools/progress-bar";
import { ResultPreview } from "@/components/tools/result-preview";
import { useUpload } from "@/hooks/use-upload";
import { useJobStatus } from "@/hooks/use-job-status";
type PageState = "upload" | "processing" | "result" | "error";
export default function ScanPage() {
const [pageState, setPageState] = useState<PageState>("upload");
const [jobId, setJobId] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const { upload, isUploading } = useUpload({
tool: "scan",
options: { ocr: true, enhance: true, output_format: "pdf", dpi: 300 },
});
const handleComplete = useCallback(() => {
setPageState("result");
}, []);
const handleError = useCallback((err: string) => {
setErrorMsg(err);
setPageState("error");
}, []);
const { progress, stage, message, status, result } = useJobStatus(jobId, {
onComplete: handleComplete,
onError: handleError,
});
const handleUpload = useCallback(
async (file: File) => {
setErrorMsg(null);
const res = await upload(file);
if (res) {
setJobId(res.job_id);
setPageState("processing");
}
},
[upload],
);
const handleRetry = useCallback(() => {
setPageState("upload");
setJobId(null);
setErrorMsg(null);
}, []);
return (
<div className="container mx-auto px-4 py-8 max-w-3xl">
<div className="flex items-center gap-3 mb-8">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
<Scan className="h-6 w-6" />
</div>
<div>
<h1 className="text-2xl font-bold">Document Scanner</h1>
<p className="text-sm text-muted-foreground">
Foto dokumen pake HP auto-detect, lurusin, enhance, OCR
</p>
</div>
</div>
{pageState === "upload" && (
<div className="space-y-6">
<UploadZone
accept="image/*"
tool="scan"
onUpload={handleUpload}
maxSizeMB={50}
/>
{/* Options info */}
<div className="p-4 rounded-lg border glass text-sm text-muted-foreground">
<p className="font-medium text-foreground mb-2">Scan Options</p>
<ul className="space-y-1">
<li> OCR: Enabled (English + Indonesian)</li>
<li> Output: Searchable PDF</li>
<li> DPI: 300</li>
<li> Auto-enhance: On</li>
</ul>
</div>
</div>
)}
{pageState === "processing" && jobId && (
<ProgressBar
progress={progress}
stage={stage}
message={message}
status={status}
onRetry={handleRetry}
/>
)}
{pageState === "result" && result && (
<ResultPreview
result={{
download_url: result.download_url,
file_size: result.file_size,
file_name: result.file_name,
preview_url: result.preview_url,
}}
ocrText={result.ocr_text}
onProcessAnother={handleRetry}
/>
)}
{pageState === "error" && (
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center space-y-4">
<p className="text-destructive font-medium">
{errorMsg || "Terjadi kesalahan"}
</p>
<button
onClick={handleRetry}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
>
Coba Lagi
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,32 @@
"use client";
import { useParams } from "next/navigation";
import { useJobStatus } from "@/hooks/use-job-status";
import { ProgressBar } from "@/components/tools/progress-bar";
import { ResultPreview } from "@/components/tools/result-preview";
export default function ScanResultPage() {
const params = useParams();
const id = params.id as string;
const { progress, stage, message, status, result, error } = useJobStatus(id, {
onComplete: () => {},
onError: () => {},
});
return (
<div className="container mx-auto px-4 py-8 max-w-3xl">
{status === "processing" && (
<ProgressBar progress={progress} stage={stage} message={message} status={status} />
)}
{status === "completed" && result && (
<ResultPreview result={result} onProcessAnother={() => window.location.href = "/scan"} />
)}
{status === "failed" && (
<div className="p-6 rounded-xl border glass text-center">
<p className="text-destructive font-medium">{error || "Processing failed"}</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useState, useCallback } from "react";
import { Music } from "lucide-react";
import { ToolLayout } from "@/components/tools/tool-layout";
import { UploadZone } from "@/components/tools/upload-zone";
import { useUpload } from "@/hooks/use-upload";
import { useJobStatus } from "@/hooks/use-job-status";
import { ProgressBar } from "@/components/tools/progress-bar";
import { ResultPreview } from "@/components/tools/result-preview";
type PageState = "upload" | "processing" | "result" | "error";
export default function AudioExtractPage() {
const [pageState, setPageState] = useState<PageState>("upload");
const [jobId, setJobId] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const { upload } = useUpload({
tool: "video-audio-extract",
options: { quality: 80 },
});
const handleComplete = useCallback(() => setPageState("result"), []);
const handleError = useCallback(
(err: string) => {
setErrorMsg(err);
setPageState("error");
},
[],
);
const { progress, stage, message, status, result } = useJobStatus(jobId, {
onComplete: handleComplete,
onError: handleError,
});
const handleUpload = useCallback(
async (file: File) => {
setErrorMsg(null);
const res = await upload(file);
if (res) {
setJobId(res.job_id);
setPageState("processing");
}
},
[upload],
);
const handleRetry = useCallback(() => {
setPageState("upload");
setJobId(null);
setErrorMsg(null);
}, []);
return (
<ToolLayout
title="Extract Audio"
description="Ambil audio dari video"
icon={Music}
phase={3}
>
{pageState === "upload" && (
<UploadZone
accept="video/*"
tool="video-audio-extract"
onUpload={handleUpload}
/>
)}
{pageState === "processing" && jobId && (
<ProgressBar
progress={progress}
stage={stage}
message={message}
status={status}
onRetry={handleRetry}
/>
)}
{pageState === "result" && result && (
<ResultPreview
result={result}
onProcessAnother={handleRetry}
/>
)}
{pageState === "error" && (
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
<p className="text-destructive font-medium mb-4">
{errorMsg || "Terjadi kesalahan"}
</p>
<button
onClick={handleRetry}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
>
Coba Lagi
</button>
</div>
)}
</ToolLayout>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useState, useCallback } from "react";
import { VideoIcon } from "lucide-react";
import { ToolLayout } from "@/components/tools/tool-layout";
import { UploadZone } from "@/components/tools/upload-zone";
import { useUpload } from "@/hooks/use-upload";
import { useJobStatus } from "@/hooks/use-job-status";
import { ProgressBar } from "@/components/tools/progress-bar";
import { ResultPreview } from "@/components/tools/result-preview";
type PageState = "upload" | "processing" | "result" | "error";
export default function VideoCompressPage() {
const [pageState, setPageState] = useState<PageState>("upload");
const [jobId, setJobId] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const { upload } = useUpload({
tool: "video-compress",
options: { quality: 80 },
});
const handleComplete = useCallback(() => setPageState("result"), []);
const handleError = useCallback(
(err: string) => {
setErrorMsg(err);
setPageState("error");
},
[],
);
const { progress, stage, message, status, result } = useJobStatus(jobId, {
onComplete: handleComplete,
onError: handleError,
});
const handleUpload = useCallback(
async (file: File) => {
setErrorMsg(null);
const res = await upload(file);
if (res) {
setJobId(res.job_id);
setPageState("processing");
}
},
[upload],
);
const handleRetry = useCallback(() => {
setPageState("upload");
setJobId(null);
setErrorMsg(null);
}, []);
return (
<ToolLayout
title="Compress Video"
description="Turunin bitrate & resolusi"
icon={VideoIcon}
phase={3}
>
{pageState === "upload" && (
<UploadZone
accept="video/*"
tool="video-compress"
onUpload={handleUpload}
/>
)}
{pageState === "processing" && jobId && (
<ProgressBar
progress={progress}
stage={stage}
message={message}
status={status}
onRetry={handleRetry}
/>
)}
{pageState === "result" && result && (
<ResultPreview
result={result}
onProcessAnother={handleRetry}
/>
)}
{pageState === "error" && (
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
<p className="text-destructive font-medium mb-4">
{errorMsg || "Terjadi kesalahan"}
</p>
<button
onClick={handleRetry}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
>
Coba Lagi
</button>
</div>
)}
</ToolLayout>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useState, useCallback } from "react";
import { Film } from "lucide-react";
import { ToolLayout } from "@/components/tools/tool-layout";
import { UploadZone } from "@/components/tools/upload-zone";
import { useUpload } from "@/hooks/use-upload";
import { useJobStatus } from "@/hooks/use-job-status";
import { ProgressBar } from "@/components/tools/progress-bar";
import { ResultPreview } from "@/components/tools/result-preview";
type PageState = "upload" | "processing" | "result" | "error";
export default function GifMakerPage() {
const [pageState, setPageState] = useState<PageState>("upload");
const [jobId, setJobId] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const { upload } = useUpload({
tool: "video-gif-maker",
options: { quality: 80 },
});
const handleComplete = useCallback(() => setPageState("result"), []);
const handleError = useCallback(
(err: string) => {
setErrorMsg(err);
setPageState("error");
},
[],
);
const { progress, stage, message, status, result } = useJobStatus(jobId, {
onComplete: handleComplete,
onError: handleError,
});
const handleUpload = useCallback(
async (file: File) => {
setErrorMsg(null);
const res = await upload(file);
if (res) {
setJobId(res.job_id);
setPageState("processing");
}
},
[upload],
);
const handleRetry = useCallback(() => {
setPageState("upload");
setJobId(null);
setErrorMsg(null);
}, []);
return (
<ToolLayout
title="GIF Maker"
description="Convert video ke GIF"
icon={Film}
phase={3}
>
{pageState === "upload" && (
<UploadZone
accept="video/*"
tool="video-gif-maker"
onUpload={handleUpload}
/>
)}
{pageState === "processing" && jobId && (
<ProgressBar
progress={progress}
stage={stage}
message={message}
status={status}
onRetry={handleRetry}
/>
)}
{pageState === "result" && result && (
<ResultPreview
result={result}
onProcessAnother={handleRetry}
/>
)}
{pageState === "error" && (
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
<p className="text-destructive font-medium mb-4">
{errorMsg || "Terjadi kesalahan"}
</p>
<button
onClick={handleRetry}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
>
Coba Lagi
</button>
</div>
)}
</ToolLayout>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useState, useCallback } from "react";
import { Scissors } from "lucide-react";
import { ToolLayout } from "@/components/tools/tool-layout";
import { UploadZone } from "@/components/tools/upload-zone";
import { useUpload } from "@/hooks/use-upload";
import { useJobStatus } from "@/hooks/use-job-status";
import { ProgressBar } from "@/components/tools/progress-bar";
import { ResultPreview } from "@/components/tools/result-preview";
type PageState = "upload" | "processing" | "result" | "error";
export default function VideoTrimPage() {
const [pageState, setPageState] = useState<PageState>("upload");
const [jobId, setJobId] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const { upload } = useUpload({
tool: "video-trim",
options: { quality: 80 },
});
const handleComplete = useCallback(() => setPageState("result"), []);
const handleError = useCallback(
(err: string) => {
setErrorMsg(err);
setPageState("error");
},
[],
);
const { progress, stage, message, status, result } = useJobStatus(jobId, {
onComplete: handleComplete,
onError: handleError,
});
const handleUpload = useCallback(
async (file: File) => {
setErrorMsg(null);
const res = await upload(file);
if (res) {
setJobId(res.job_id);
setPageState("processing");
}
},
[upload],
);
const handleRetry = useCallback(() => {
setPageState("upload");
setJobId(null);
setErrorMsg(null);
}, []);
return (
<ToolLayout
title="Trim Video"
description="Potong segmen video"
icon={Scissors}
phase={3}
>
{pageState === "upload" && (
<UploadZone
accept="video/*"
tool="video-trim"
onUpload={handleUpload}
/>
)}
{pageState === "processing" && jobId && (
<ProgressBar
progress={progress}
stage={stage}
message={message}
status={status}
onRetry={handleRetry}
/>
)}
{pageState === "result" && result && (
<ResultPreview
result={result}
onProcessAnother={handleRetry}
/>
)}
{pageState === "error" && (
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
<p className="text-destructive font-medium mb-4">
{errorMsg || "Terjadi kesalahan"}
</p>
<button
onClick={handleRetry}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
>
Coba Lagi
</button>
</div>
)}
</ToolLayout>
);
}
@@ -0,0 +1,16 @@
export function Footer() {
return (
<footer className="border-t py-6 mt-auto">
<div className="container mx-auto px-4 flex flex-col md:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
<p>
&copy; {new Date().getFullYear()} Asep Haryana Saputra. All rights
reserved.
</p>
<p className="flex items-center gap-1">
Powered by{" "}
<span className="font-mono text-primary">Rust + Next.js</span>
</p>
</div>
</footer>
);
}
@@ -0,0 +1,70 @@
"use client";
import Link from "next/link";
import { useTheme } from "next-themes";
import { useState, useEffect } from "react";
import { Sun, Moon, Github, Sparkles } from "lucide-react";
export function Header() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return (
<header className="sticky top-0 z-50 w-full border-b glass">
<div className="container mx-auto flex h-16 items-center justify-between px-4">
<Link href="/" className="flex items-center gap-2 group">
<Sparkles className="h-5 w-5 text-primary group-hover:rotate-12 transition-transform" />
<span className="font-mono text-lg font-bold gradient-text">
Tools
</span>
</Link>
<nav className="hidden md:flex items-center gap-6 text-sm">
<Link
href="/scan"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Scanner
</Link>
<Link
href="/image/compress"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Image
</Link>
<Link
href="/pdf/merge"
className="text-muted-foreground hover:text-foreground transition-colors"
>
PDF
</Link>
</nav>
<div className="flex items-center gap-2">
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="p-2 rounded-md hover:bg-muted transition-colors"
aria-label="Toggle theme"
>
{mounted && theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</button>
<a
href="https://github.com/asepharyana/asepharyana-hub"
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-md hover:bg-muted transition-colors"
aria-label="GitHub"
>
<Github className="h-4 w-4" />
</a>
</div>
</div>
</header>
);
}
@@ -0,0 +1,132 @@
"use client";
import { useState, useRef, useCallback } from "react";
interface PreviewBeforeAfterProps {
originalUrl: string;
processedUrl: string;
originalSize?: number;
processedSize?: number;
}
export function PreviewBeforeAfter({
originalUrl,
processedUrl,
originalSize,
processedSize,
}: PreviewBeforeAfterProps) {
const [sliderPos, setSliderPos] = useState(50);
const containerRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const handleMove = useCallback(
(clientX: number) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
setSliderPos((x / rect.width) * 100);
},
[],
);
const handleMouseDown = () => {
isDragging.current = true;
};
const handleMouseUp = () => {
isDragging.current = false;
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging.current) return;
handleMove(e.clientX);
};
const handleTouchMove = (e: React.TouchEvent) => {
handleMove(e.touches[0].clientX);
};
const formatSize = (bytes?: number) => {
if (!bytes) return "";
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
return (
<div className="space-y-3">
<div
ref={containerRef}
className="relative rounded-lg overflow-hidden select-none cursor-ew-resize aspect-[4/3] max-h-96 bg-muted"
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseUp}
onTouchMove={handleTouchMove}
>
{/* Processed (full) */}
<img
src={processedUrl}
alt="Processed"
className="absolute inset-0 w-full h-full object-contain"
draggable={false}
/>
{/* Original (clipped) */}
<div
className="absolute inset-0 overflow-hidden"
style={{ width: `${sliderPos}%` }}
>
<img
src={originalUrl}
alt="Original"
className="absolute top-0 left-0 w-full h-full object-contain"
style={{
width: `${100 / (sliderPos / 100)}%`,
maxWidth: "none",
}}
draggable={false}
/>
</div>
{/* Slider */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-white shadow-lg z-10"
style={{ left: `${sliderPos}%` }}
>
<div className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-8 h-8 rounded-full bg-white shadow-lg flex items-center justify-center text-xs text-gray-800 font-bold">
</div>
</div>
{/* Labels */}
<div className="absolute top-2 left-2 px-2 py-1 bg-black/60 text-white text-xs rounded backdrop-blur-sm">
Original
</div>
<div className="absolute top-2 right-2 px-2 py-1 bg-black/60 text-white text-xs rounded backdrop-blur-sm">
Processed
</div>
</div>
{(originalSize || processedSize) && (
<div className="flex items-center justify-center gap-4 text-sm text-muted-foreground">
{originalSize && (
<span>
Original:{" "}
<span className="text-foreground font-medium">
{formatSize(originalSize)}
</span>
</span>
)}
{processedSize && (
<span>
Processed:{" "}
<span className="text-green-500 font-medium">
{formatSize(processedSize)}
</span>
</span>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,101 @@
"use client";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
export type JobStatus = "queued" | "processing" | "completed" | "failed";
interface ProgressBarProps {
progress: number;
stage: string;
message: string;
status: JobStatus;
onRetry?: () => void;
}
const stageLabels: Record<string, string> = {
preprocess: "Memuat gambar...",
edge_detection: "Mendeteksi tepi dokumen...",
corner_detection: "Mencari sudut dokumen...",
warp: "Meluruskan perspektif...",
shadow_removal: "Menghilangkan bayangan...",
binarization: "Mengubah ke hitam-putih...",
deskew: "Meluruskan teks...",
enhance: "Mengoptimalkan kontras...",
ocr: "Membaca teks...",
pdf_generation: "Membuat PDF...",
complete: "Selesai!",
};
function getStageLabel(stage: string): string {
return stageLabels[stage] || stage;
}
export function ProgressBar({
progress,
stage,
message,
status,
onRetry,
}: ProgressBarProps) {
const barColor =
status === "completed"
? "bg-green-500"
: status === "failed"
? "bg-destructive"
: "bg-primary";
const statusBadge =
status === "processing" ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
Processing
</span>
) : status === "completed" ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-500/10 text-green-600 dark:text-green-400">
Completed
</span>
) : status === "failed" ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive">
Failed
</span>
) : null;
return (
<div className="space-y-3 p-6 rounded-xl border glass">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{statusBadge}</span>
<span className="text-sm font-mono text-muted-foreground">
{progress}%
</span>
</div>
<div className="relative h-2 bg-muted rounded-full overflow-hidden">
<motion.div
className={cn("absolute inset-y-0 left-0 rounded-full", barColor)}
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.5, ease: "easeOut" }}
/>
</div>
<div>
<p className="text-sm font-medium">
{getStageLabel(stage)}
</p>
{message && (
<p className="text-xs text-muted-foreground mt-0.5">{message}</p>
)}
</div>
{status === "failed" && onRetry && (
<button
onClick={onRetry}
className="text-sm text-primary hover:underline"
>
Coba lagi
</button>
)}
</div>
);
}
@@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import { Download, RefreshCw, FileText, Copy } from "lucide-react";
interface ResultInfo {
download_url: string;
file_size: number;
file_name: string;
preview_url?: string;
}
interface ResultPreviewProps {
result: ResultInfo;
ocrText?: string;
onProcessAnother: () => void;
}
export function ResultPreview({
result,
ocrText,
onProcessAnother,
}: ResultPreviewProps) {
const [copied, setCopied] = useState(false);
const [autoDownload, setAutoDownload] = useState(false);
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(
`${window.location.origin}${result.download_url}`,
);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback
}
};
return (
<div className="space-y-4 p-6 rounded-xl border glass">
<div className="flex items-center gap-3">
<div className="p-3 rounded-lg bg-primary/10 text-primary">
<FileText className="h-6 w-6" />
</div>
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{result.file_name}</p>
<p className="text-sm text-muted-foreground">
{formatSize(result.file_size)}
</p>
</div>
</div>
{result.preview_url && (
<div className="relative rounded-lg overflow-hidden bg-muted aspect-[4/3] max-h-80">
<img
src={result.preview_url}
alt="Preview"
className="w-full h-full object-contain"
/>
</div>
)}
{ocrText && (
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
OCR Text
</summary>
<pre className="mt-2 p-3 bg-muted rounded-lg text-xs overflow-auto max-h-32">
{ocrText}
</pre>
</details>
)}
<div className="flex flex-wrap items-center gap-3">
<a
href={result.download_url}
download
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity font-medium"
>
<Download className="h-4 w-4" />
Download
</a>
{typeof navigator !== "undefined" && navigator.clipboard && (
<button
onClick={handleCopy}
className="inline-flex items-center gap-2 px-4 py-2 border rounded-lg hover:bg-muted transition-colors text-sm"
>
<Copy className="h-4 w-4" />
{copied ? "Copied!" : "Copy Link"}
</button>
)}
<button
onClick={onProcessAnother}
className="inline-flex items-center gap-2 px-4 py-2 border rounded-lg hover:bg-muted transition-colors text-sm ml-auto"
>
<RefreshCw className="h-4 w-4" />
Process Another
</button>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={autoDownload}
onChange={(e) => setAutoDownload(e.target.checked)}
className="rounded"
/>
Auto-download on complete
</label>
</div>
);
}
@@ -0,0 +1,68 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import type { LucideIcon } from "lucide-react";
interface ToolCardProps {
title: string;
description: string;
icon: LucideIcon;
href: string;
phase: number;
index: number;
}
export function ToolCard({
title,
description,
icon: Icon,
href,
phase,
index,
}: ToolCardProps) {
const isAvailable = phase === 1;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
>
<Link
href={isAvailable ? href : "#"}
className={`group block p-6 rounded-xl border transition-all duration-200 ${
isAvailable
? "hover:border-primary hover:shadow-lg hover:shadow-primary/5 cursor-pointer"
: "opacity-50 cursor-not-allowed"
} glass`}
onClick={(e) => {
if (!isAvailable) e.preventDefault();
}}
>
<div className="flex items-start gap-4">
<div className="p-3 rounded-lg bg-primary/10 text-primary shrink-0">
<Icon className="h-6 w-6" />
</div>
<div className="min-w-0">
<h3 className="font-semibold mb-1 group-hover:text-primary transition-colors">
{title}
</h3>
<p className="text-sm text-muted-foreground line-clamp-2">
{description}
</p>
<span
className={`inline-block mt-3 text-xs px-2 py-0.5 rounded-full ${
isAvailable
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground"
}`}
>
{isAvailable ? "Available" : "Coming Soon"}
</span>
</div>
</div>
</Link>
</motion.div>
);
}
@@ -0,0 +1,163 @@
"use client";
import {
Scan,
ImageDown,
Crop,
Repeat,
Shrink,
Merge,
Split,
Images,
FileImage,
Video,
Music,
Scissors,
Film,
Mic,
} from "lucide-react";
import { ToolCard } from "./tool-card";
interface ToolDefinition {
id: string;
title: string;
description: string;
icon: typeof Scan;
href: string;
phase: number;
}
const tools: ToolDefinition[] = [
{
id: "scan",
title: "Document Scanner",
description:
"Foto dokumen pake HP — auto-detect tepi, lurusin, enhance, OCR. Output searchable PDF.",
icon: Scan,
href: "/scan",
phase: 1,
},
{
id: "image-compress",
title: "Compress Image",
description: "Kecilin ukuran JPEG/PNG/WebP tanpa ilangin kualitas. Atur quality %.",
icon: ImageDown,
href: "/image/compress",
phase: 1,
},
{
id: "image-resize",
title: "Resize Image",
description:
"Ubah dimensi gambar. Preset ukuran social media, aspect ratio lock.",
icon: Crop,
href: "/image/resize",
phase: 1,
},
{
id: "image-convert",
title: "Convert Image",
description: "Convert HEIC→JPEG, PNG→WebP, SVG→PNG, dan banyak lagi.",
icon: Repeat,
href: "/image/convert",
phase: 1,
},
{
id: "remove-bg",
title: "Remove Background",
description: "Hapus latar belakang foto otomatis pake AI. Download PNG transparan.",
icon: Shrink,
href: "/image/remove-bg",
phase: 2,
},
{
id: "pdf-merge",
title: "Merge PDF",
description: "Gabung beberapa file PDF jadi satu. Drag to reorder halaman.",
icon: Merge,
href: "/pdf/merge",
phase: 2,
},
{
id: "pdf-split",
title: "Split PDF",
description: "Ekstrak halaman tertentu dari PDF. Pilih via thumbnail atau range.",
icon: Split,
href: "/pdf/split",
phase: 2,
},
{
id: "images-to-pdf",
title: "Images to PDF",
description: "Kumpulan foto jadi 1 file PDF. Atur ukuran halaman dan margin.",
icon: Images,
href: "/pdf/images-to-pdf",
phase: 2,
},
{
id: "pdf-compress",
title: "Compress PDF",
description: "Kecilin ukuran PDF dengan kompresi embedded images.",
icon: FileImage,
href: "/pdf/compress",
phase: 2,
},
{
id: "video-compress",
title: "Compress Video",
description: "Turunin bitrate & resolusi video. H.264/H.265/VP9.",
icon: Video,
href: "/video/compress",
phase: 3,
},
{
id: "audio-extract",
title: "Extract Audio",
description: "Ambil audio dari file video. MP3, AAC, WAV, FLAC.",
icon: Music,
href: "/video/audio-extract",
phase: 3,
},
{
id: "video-trim",
title: "Trim Video",
description: "Potong segmen video. Set start/end via timeline.",
icon: Scissors,
href: "/video/trim",
phase: 3,
},
{
id: "gif-maker",
title: "GIF Maker",
description: "Convert video segment ke animated GIF. Atur FPS, resolusi, dither.",
icon: Film,
href: "/video/gif-maker",
phase: 3,
},
{
id: "audio-convert",
title: "Audio Convert",
description: "Convert audio antar format. MP3, WAV, FLAC, AAC, OGG.",
icon: Mic,
href: "/audio/convert",
phase: 3,
},
];
export function ToolGrid() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{tools.map((tool, index) => (
<ToolCard
key={tool.id}
title={tool.title}
description={tool.description}
icon={tool.icon}
href={tool.href}
phase={tool.phase}
index={index}
/>
))}
</div>
);
}
@@ -0,0 +1,47 @@
"use client";
import type { LucideIcon } from "lucide-react";
interface ToolLayoutProps {
title: string;
description: string;
icon: LucideIcon;
phase: number;
children: React.ReactNode;
}
export function ToolLayout({
title,
description,
icon: Icon,
phase,
children,
}: ToolLayoutProps) {
const isAvailable = phase === 1;
return (
<div className="container mx-auto px-4 py-8 max-w-3xl">
<div className="flex items-center gap-3 mb-8">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
<Icon className="h-6 w-6" />
</div>
<div>
<h1 className="text-2xl font-bold">{title}</h1>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</div>
{!isAvailable ? (
<div className="p-8 rounded-xl border glass text-center space-y-3">
<p className="text-lg font-medium">Coming Soon</p>
<p className="text-sm text-muted-foreground">
Tool ini sedang dalam pengembangan dan akan tersedia di fase
berikutnya.
</p>
</div>
) : (
children
)}
</div>
);
}
@@ -0,0 +1,177 @@
"use client";
import { useState, useRef, useCallback, type DragEvent } from "react";
import { Upload, File, X, Image as ImageIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface UploadZoneProps {
accept?: string;
maxSizeMB?: number;
multiple?: boolean;
tool: string;
onUpload: (file: File) => void;
onCancel?: () => void;
}
export function UploadZone({
accept = "image/*,.pdf",
maxSizeMB = 50,
multiple = false,
tool,
onUpload,
onCancel,
}: UploadZoneProps) {
const [isDragging, setIsDragging] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const validateFile = useCallback(
(file: File): string | null => {
const maxBytes = maxSizeMB * 1024 * 1024;
if (file.size > maxBytes) {
return `File terlalu besar: ${(file.size / 1024 / 1024).toFixed(1)}MB (max ${maxSizeMB}MB)`;
}
return null;
},
[maxSizeMB],
);
const handleDrop = useCallback(
(e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (!file) return;
const err = validateFile(file);
if (err) {
setError(err);
return;
}
setError(null);
setSelectedFile(file);
},
[validateFile],
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const err = validateFile(file);
if (err) {
setError(err);
return;
}
setError(null);
setSelectedFile(file);
},
[validateFile],
);
const handleUpload = useCallback(async () => {
if (!selectedFile) return;
setIsUploading(true);
setError(null);
try {
await onUpload(selectedFile);
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
} finally {
setIsUploading(false);
}
}, [selectedFile, onUpload]);
const formatSize = (bytes: number) => {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
return (
<div className="space-y-4">
<div
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => !selectedFile && inputRef.current?.click()}
className={cn(
"relative border-2 border-dashed rounded-xl p-8 md:p-12 text-center transition-all duration-200 cursor-pointer",
isDragging
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50",
selectedFile && "border-solid border-primary/30",
)}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
className="hidden"
onChange={handleFileSelect}
/>
{!selectedFile ? (
<div className="space-y-4">
<div className="flex justify-center">
<Upload className="h-12 w-12 text-muted-foreground" />
</div>
<div>
<p className="font-medium">
Drag & drop file here, or click to browse
</p>
<p className="text-sm text-muted-foreground mt-1">
Max {maxSizeMB}MB per file
</p>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-center gap-3">
<ImageIcon className="h-8 w-8 text-primary" />
<div className="text-left">
<p className="font-medium truncate max-w-[300px]">
{selectedFile.name}
</p>
<p className="text-sm text-muted-foreground">
{formatSize(selectedFile.size)}
</p>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setSelectedFile(null);
setError(null);
}}
className="p-1 hover:bg-muted rounded"
>
<X className="h-4 w-4" />
</button>
</div>
<button
onClick={(e) => {
e.stopPropagation();
handleUpload();
}}
disabled={isUploading}
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 font-medium"
>
{isUploading ? "Uploading..." : "Process"}
</button>
</div>
)}
</div>
{error && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-sm text-destructive">
{error}
</div>
)}
</div>
);
}
@@ -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 };
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}