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:
asepharyana
2026-07-24 13:10:59 +07:00
co-authored by Kilo
commit a00ad62f6c
98 changed files with 11399 additions and 0 deletions
@@ -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 },
);
}
}
+26
View File
@@ -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 },
);
}
}
+15
View File
@@ -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",
},
);
}
+46
View File
@@ -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 },
);
}
}
+103
View File
@@ -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);
}
+18
View File
@@ -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 },
);
}
}
+103
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}