feat: initial tools service with document scanner, image & PDF tools
Self-hosted document scanner and media processing tools. - Rust Axum gateway + worker pool with NATS JetStream - Next.js 16 frontend with shadcn/ui - Scanner pipeline: edge detection, warp, binarization, OCR - Image tools: compress, resize, convert - PDF tools: merge, split, compress - CI/CD with Docker multi-stage build Co-Authored-By: Kilo <kilo@kilo.ai>
This commit is contained in:
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user