feat(infra): add tools service with document scanner, image & PDF tools
Implement self-hosted document scanner and media processing tools as an alternative to CamScanner/ilovepdf without third-party uploads. Backend: Rust Axum gateway + worker pool with NATS JetStream queue Frontend: Next.js 16 + shadcn/ui + Tailwind v4 + Framer Motion Pipeline: Canny edge detection -> DLT homography warp -> Sauvola binarization -> Hough deskew -> Tesseract OCR -> searchable PDF Phase 1 (MVP) delivers: - Document scanner with perspective correction and OCR - Image compress/resize/convert tools - PDF merge/split/compress tools - Real-time WebSocket progress updates - Rate limiting, auto-cleanup, Prometheus metrics - Full CI/CD pipeline with Docker multi-stage build Co-Authored-By: Kilo <kilo@kilo.ai>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"extends": ["../../biome.json"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactCompiler: true,
|
||||
turbopack: {
|
||||
root: process.cwd(),
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "tools-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3002",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@shadcn/react": "^0.2.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.42.2",
|
||||
"lucide-react": "^1.26.0",
|
||||
"next": "16.2.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.5",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^26",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Tools — Asep Haryana",
|
||||
"short_name": "Tools",
|
||||
"description": "Document Scanner, Image & PDF Tools",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0a1a",
|
||||
"theme_color": "#0a0a1a",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const RUST_GATEWAY = process.env.RUST_GATEWAY_URL || "http://localhost:3001";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const response = await fetch(`${RUST_GATEWAY}/api/download/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
return NextResponse.json(
|
||||
data ?? { error: "Download failed" },
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
// Stream the file back
|
||||
const blob = await response.blob();
|
||||
const contentType =
|
||||
response.headers.get("content-type") || "application/octet-stream";
|
||||
const contentDisposition =
|
||||
response.headers.get("content-disposition") ||
|
||||
"attachment; filename=\"result\"";
|
||||
|
||||
return new NextResponse(blob, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": contentDisposition,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Download proxy error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to download file" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const RUST_GATEWAY = process.env.RUST_GATEWAY_URL || "http://localhost:3001";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const response = await fetch(`${RUST_GATEWAY}/api/job/${id}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Job status proxy error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch job status" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// WebSocket is handled directly by the client connecting to the Rust gateway.
|
||||
// Next.js App Router cannot proxy WebSocket connections in route handlers.
|
||||
// The client-side useJobStatus hook connects directly to ws://localhost:3001/api/job/{id}/ws
|
||||
// In production, configure the WebSocket to connect to wss://tools.asepharyana.my.id/api/job/{id}/ws
|
||||
export function GET() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
note: "WebSocket connections go directly to the Rust gateway",
|
||||
ws_url:
|
||||
process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:3001/api/job/{id}/ws",
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const RUST_GATEWAY = process.env.RUST_GATEWAY_URL || "http://localhost:3001";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
const tool = formData.get("tool");
|
||||
const options = formData.get("options");
|
||||
|
||||
if (!file || !tool) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing file or tool parameter" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Forward to Rust gateway
|
||||
const gatewayForm = new FormData();
|
||||
gatewayForm.append("file", file);
|
||||
gatewayForm.append("tool", tool as string);
|
||||
if (options) {
|
||||
gatewayForm.append("options", options as string);
|
||||
}
|
||||
|
||||
const response = await fetch(`${RUST_GATEWAY}/api/upload`, {
|
||||
method: "POST",
|
||||
body: gatewayForm,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(data, { status: 202 });
|
||||
} catch (error) {
|
||||
console.error("Upload proxy error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to process upload" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Mic } from "lucide-react";
|
||||
import { ToolLayout } from "@/components/tools/tool-layout";
|
||||
import { UploadZone } from "@/components/tools/upload-zone";
|
||||
import { useUpload } from "@/hooks/use-upload";
|
||||
import { useJobStatus } from "@/hooks/use-job-status";
|
||||
import { ProgressBar } from "@/components/tools/progress-bar";
|
||||
import { ResultPreview } from "@/components/tools/result-preview";
|
||||
|
||||
type PageState = "upload" | "processing" | "result" | "error";
|
||||
|
||||
export default function AudioConvertPage() {
|
||||
const [pageState, setPageState] = useState<PageState>("upload");
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const { upload } = useUpload({
|
||||
tool: "audio-convert",
|
||||
options: { quality: 80 },
|
||||
});
|
||||
|
||||
const handleComplete = useCallback(() => setPageState("result"), []);
|
||||
const handleError = useCallback(
|
||||
(err: string) => {
|
||||
setErrorMsg(err);
|
||||
setPageState("error");
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { progress, stage, message, status, result } = useJobStatus(jobId, {
|
||||
onComplete: handleComplete,
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File) => {
|
||||
setErrorMsg(null);
|
||||
const res = await upload(file);
|
||||
if (res) {
|
||||
setJobId(res.job_id);
|
||||
setPageState("processing");
|
||||
}
|
||||
},
|
||||
[upload],
|
||||
);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
setPageState("upload");
|
||||
setJobId(null);
|
||||
setErrorMsg(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToolLayout
|
||||
title="Audio Convert"
|
||||
description="Convert antar format audio"
|
||||
icon={Mic}
|
||||
phase={3}
|
||||
>
|
||||
{pageState === "upload" && (
|
||||
<UploadZone
|
||||
accept="audio/*"
|
||||
tool="audio-convert"
|
||||
onUpload={handleUpload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pageState === "processing" && jobId && (
|
||||
<ProgressBar
|
||||
progress={progress}
|
||||
stage={stage}
|
||||
message={message}
|
||||
status={status}
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pageState === "result" && result && (
|
||||
<ResultPreview
|
||||
result={result}
|
||||
onProcessAnother={handleRetry}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pageState === "error" && (
|
||||
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
|
||||
<p className="text-destructive font-medium mb-4">
|
||||
{errorMsg || "Terjadi kesalahan"}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
|
||||
>
|
||||
Coba Lagi
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</ToolLayout>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t py-6 mt-auto">
|
||||
<div className="container mx-auto px-4 flex flex-col md:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
© {new Date().getFullYear()} Asep Haryana Saputra. All rights
|
||||
reserved.
|
||||
</p>
|
||||
<p className="flex items-center gap-1">
|
||||
Powered by{" "}
|
||||
<span className="font-mono text-primary">Rust + Next.js</span>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Sun, Moon, Github, Sparkles } from "lucide-react";
|
||||
|
||||
export function Header() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b glass">
|
||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
<Link href="/" className="flex items-center gap-2 group">
|
||||
<Sparkles className="h-5 w-5 text-primary group-hover:rotate-12 transition-transform" />
|
||||
<span className="font-mono text-lg font-bold gradient-text">
|
||||
Tools
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-6 text-sm">
|
||||
<Link
|
||||
href="/scan"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Scanner
|
||||
</Link>
|
||||
<Link
|
||||
href="/image/compress"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Image
|
||||
</Link>
|
||||
<Link
|
||||
href="/pdf/merge"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
PDF
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="p-2 rounded-md hover:bg-muted transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{mounted && theme === "dark" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/asepharyana/asepharyana-hub"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-md hover:bg-muted transition-colors"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
|
||||
interface PreviewBeforeAfterProps {
|
||||
originalUrl: string;
|
||||
processedUrl: string;
|
||||
originalSize?: number;
|
||||
processedSize?: number;
|
||||
}
|
||||
|
||||
export function PreviewBeforeAfter({
|
||||
originalUrl,
|
||||
processedUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
}: PreviewBeforeAfterProps) {
|
||||
const [sliderPos, setSliderPos] = useState(50);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isDragging = useRef(false);
|
||||
|
||||
const handleMove = useCallback(
|
||||
(clientX: number) => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
|
||||
setSliderPos((x / rect.width) * 100);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseDown = () => {
|
||||
isDragging.current = true;
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDragging.current = false;
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
handleMove(e.clientX);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
handleMove(e.touches[0].clientX);
|
||||
};
|
||||
|
||||
const formatSize = (bytes?: number) => {
|
||||
if (!bytes) return "";
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative rounded-lg overflow-hidden select-none cursor-ew-resize aspect-[4/3] max-h-96 bg-muted"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onTouchMove={handleTouchMove}
|
||||
>
|
||||
{/* Processed (full) */}
|
||||
<img
|
||||
src={processedUrl}
|
||||
alt="Processed"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* Original (clipped) */}
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{ width: `${sliderPos}%` }}
|
||||
>
|
||||
<img
|
||||
src={originalUrl}
|
||||
alt="Original"
|
||||
className="absolute top-0 left-0 w-full h-full object-contain"
|
||||
style={{
|
||||
width: `${100 / (sliderPos / 100)}%`,
|
||||
maxWidth: "none",
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slider */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-white shadow-lg z-10"
|
||||
style={{ left: `${sliderPos}%` }}
|
||||
>
|
||||
<div className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-8 h-8 rounded-full bg-white shadow-lg flex items-center justify-center text-xs text-gray-800 font-bold">
|
||||
⟷
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
<div className="absolute top-2 left-2 px-2 py-1 bg-black/60 text-white text-xs rounded backdrop-blur-sm">
|
||||
Original
|
||||
</div>
|
||||
<div className="absolute top-2 right-2 px-2 py-1 bg-black/60 text-white text-xs rounded backdrop-blur-sm">
|
||||
Processed
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(originalSize || processedSize) && (
|
||||
<div className="flex items-center justify-center gap-4 text-sm text-muted-foreground">
|
||||
{originalSize && (
|
||||
<span>
|
||||
Original:{" "}
|
||||
<span className="text-foreground font-medium">
|
||||
{formatSize(originalSize)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{processedSize && (
|
||||
<span>
|
||||
Processed:{" "}
|
||||
<span className="text-green-500 font-medium">
|
||||
{formatSize(processedSize)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type JobStatus = "queued" | "processing" | "completed" | "failed";
|
||||
|
||||
interface ProgressBarProps {
|
||||
progress: number;
|
||||
stage: string;
|
||||
message: string;
|
||||
status: JobStatus;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
const stageLabels: Record<string, string> = {
|
||||
preprocess: "Memuat gambar...",
|
||||
edge_detection: "Mendeteksi tepi dokumen...",
|
||||
corner_detection: "Mencari sudut dokumen...",
|
||||
warp: "Meluruskan perspektif...",
|
||||
shadow_removal: "Menghilangkan bayangan...",
|
||||
binarization: "Mengubah ke hitam-putih...",
|
||||
deskew: "Meluruskan teks...",
|
||||
enhance: "Mengoptimalkan kontras...",
|
||||
ocr: "Membaca teks...",
|
||||
pdf_generation: "Membuat PDF...",
|
||||
complete: "Selesai!",
|
||||
};
|
||||
|
||||
function getStageLabel(stage: string): string {
|
||||
return stageLabels[stage] || stage;
|
||||
}
|
||||
|
||||
export function ProgressBar({
|
||||
progress,
|
||||
stage,
|
||||
message,
|
||||
status,
|
||||
onRetry,
|
||||
}: ProgressBarProps) {
|
||||
const barColor =
|
||||
status === "completed"
|
||||
? "bg-green-500"
|
||||
: status === "failed"
|
||||
? "bg-destructive"
|
||||
: "bg-primary";
|
||||
|
||||
const statusBadge =
|
||||
status === "processing" ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
|
||||
Processing
|
||||
</span>
|
||||
) : status === "completed" ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-500/10 text-green-600 dark:text-green-400">
|
||||
Completed
|
||||
</span>
|
||||
) : status === "failed" ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive">
|
||||
Failed
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 p-6 rounded-xl border glass">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{statusBadge}</span>
|
||||
<span className="text-sm font-mono text-muted-foreground">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative h-2 bg-muted rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className={cn("absolute inset-y-0 left-0 rounded-full", barColor)}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{getStageLabel(stage)}
|
||||
</p>
|
||||
{message && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status === "failed" && onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Coba lagi
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Download, RefreshCw, FileText, Copy } from "lucide-react";
|
||||
|
||||
interface ResultInfo {
|
||||
download_url: string;
|
||||
file_size: number;
|
||||
file_name: string;
|
||||
preview_url?: string;
|
||||
}
|
||||
|
||||
interface ResultPreviewProps {
|
||||
result: ResultInfo;
|
||||
ocrText?: string;
|
||||
onProcessAnother: () => void;
|
||||
}
|
||||
|
||||
export function ResultPreview({
|
||||
result,
|
||||
ocrText,
|
||||
onProcessAnother,
|
||||
}: ResultPreviewProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [autoDownload, setAutoDownload] = useState(false);
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(
|
||||
`${window.location.origin}${result.download_url}`,
|
||||
);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-6 rounded-xl border glass">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-lg bg-primary/10 text-primary">
|
||||
<FileText className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">{result.file_name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatSize(result.file_size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.preview_url && (
|
||||
<div className="relative rounded-lg overflow-hidden bg-muted aspect-[4/3] max-h-80">
|
||||
<img
|
||||
src={result.preview_url}
|
||||
alt="Preview"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ocrText && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
OCR Text
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-muted rounded-lg text-xs overflow-auto max-h-32">
|
||||
{ocrText}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href={result.download_url}
|
||||
download
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity font-medium"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
|
||||
{typeof navigator !== "undefined" && navigator.clipboard && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 border rounded-lg hover:bg-muted transition-colors text-sm"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
{copied ? "Copied!" : "Copy Link"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onProcessAnother}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 border rounded-lg hover:bg-muted transition-colors text-sm ml-auto"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Process Another
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoDownload}
|
||||
onChange={(e) => setAutoDownload(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Auto-download on complete
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface ToolCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
href: string;
|
||||
phase: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function ToolCard({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
href,
|
||||
phase,
|
||||
index,
|
||||
}: ToolCardProps) {
|
||||
const isAvailable = phase === 1;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
>
|
||||
<Link
|
||||
href={isAvailable ? href : "#"}
|
||||
className={`group block p-6 rounded-xl border transition-all duration-200 ${
|
||||
isAvailable
|
||||
? "hover:border-primary hover:shadow-lg hover:shadow-primary/5 cursor-pointer"
|
||||
: "opacity-50 cursor-not-allowed"
|
||||
} glass`}
|
||||
onClick={(e) => {
|
||||
if (!isAvailable) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 rounded-lg bg-primary/10 text-primary shrink-0">
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold mb-1 group-hover:text-primary transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
<span
|
||||
className={`inline-block mt-3 text-xs px-2 py-0.5 rounded-full ${
|
||||
isAvailable
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{isAvailable ? "Available" : "Coming Soon"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Scan,
|
||||
ImageDown,
|
||||
Crop,
|
||||
Repeat,
|
||||
Shrink,
|
||||
Merge,
|
||||
Split,
|
||||
Images,
|
||||
FileImage,
|
||||
Video,
|
||||
Music,
|
||||
Scissors,
|
||||
Film,
|
||||
Mic,
|
||||
} from "lucide-react";
|
||||
import { ToolCard } from "./tool-card";
|
||||
|
||||
interface ToolDefinition {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: typeof Scan;
|
||||
href: string;
|
||||
phase: number;
|
||||
}
|
||||
|
||||
const tools: ToolDefinition[] = [
|
||||
{
|
||||
id: "scan",
|
||||
title: "Document Scanner",
|
||||
description:
|
||||
"Foto dokumen pake HP — auto-detect tepi, lurusin, enhance, OCR. Output searchable PDF.",
|
||||
icon: Scan,
|
||||
href: "/scan",
|
||||
phase: 1,
|
||||
},
|
||||
{
|
||||
id: "image-compress",
|
||||
title: "Compress Image",
|
||||
description: "Kecilin ukuran JPEG/PNG/WebP tanpa ilangin kualitas. Atur quality %.",
|
||||
icon: ImageDown,
|
||||
href: "/image/compress",
|
||||
phase: 1,
|
||||
},
|
||||
{
|
||||
id: "image-resize",
|
||||
title: "Resize Image",
|
||||
description:
|
||||
"Ubah dimensi gambar. Preset ukuran social media, aspect ratio lock.",
|
||||
icon: Crop,
|
||||
href: "/image/resize",
|
||||
phase: 1,
|
||||
},
|
||||
{
|
||||
id: "image-convert",
|
||||
title: "Convert Image",
|
||||
description: "Convert HEIC→JPEG, PNG→WebP, SVG→PNG, dan banyak lagi.",
|
||||
icon: Repeat,
|
||||
href: "/image/convert",
|
||||
phase: 1,
|
||||
},
|
||||
{
|
||||
id: "remove-bg",
|
||||
title: "Remove Background",
|
||||
description: "Hapus latar belakang foto otomatis pake AI. Download PNG transparan.",
|
||||
icon: Shrink,
|
||||
href: "/image/remove-bg",
|
||||
phase: 2,
|
||||
},
|
||||
{
|
||||
id: "pdf-merge",
|
||||
title: "Merge PDF",
|
||||
description: "Gabung beberapa file PDF jadi satu. Drag to reorder halaman.",
|
||||
icon: Merge,
|
||||
href: "/pdf/merge",
|
||||
phase: 2,
|
||||
},
|
||||
{
|
||||
id: "pdf-split",
|
||||
title: "Split PDF",
|
||||
description: "Ekstrak halaman tertentu dari PDF. Pilih via thumbnail atau range.",
|
||||
icon: Split,
|
||||
href: "/pdf/split",
|
||||
phase: 2,
|
||||
},
|
||||
{
|
||||
id: "images-to-pdf",
|
||||
title: "Images to PDF",
|
||||
description: "Kumpulan foto jadi 1 file PDF. Atur ukuran halaman dan margin.",
|
||||
icon: Images,
|
||||
href: "/pdf/images-to-pdf",
|
||||
phase: 2,
|
||||
},
|
||||
{
|
||||
id: "pdf-compress",
|
||||
title: "Compress PDF",
|
||||
description: "Kecilin ukuran PDF dengan kompresi embedded images.",
|
||||
icon: FileImage,
|
||||
href: "/pdf/compress",
|
||||
phase: 2,
|
||||
},
|
||||
{
|
||||
id: "video-compress",
|
||||
title: "Compress Video",
|
||||
description: "Turunin bitrate & resolusi video. H.264/H.265/VP9.",
|
||||
icon: Video,
|
||||
href: "/video/compress",
|
||||
phase: 3,
|
||||
},
|
||||
{
|
||||
id: "audio-extract",
|
||||
title: "Extract Audio",
|
||||
description: "Ambil audio dari file video. MP3, AAC, WAV, FLAC.",
|
||||
icon: Music,
|
||||
href: "/video/audio-extract",
|
||||
phase: 3,
|
||||
},
|
||||
{
|
||||
id: "video-trim",
|
||||
title: "Trim Video",
|
||||
description: "Potong segmen video. Set start/end via timeline.",
|
||||
icon: Scissors,
|
||||
href: "/video/trim",
|
||||
phase: 3,
|
||||
},
|
||||
{
|
||||
id: "gif-maker",
|
||||
title: "GIF Maker",
|
||||
description: "Convert video segment ke animated GIF. Atur FPS, resolusi, dither.",
|
||||
icon: Film,
|
||||
href: "/video/gif-maker",
|
||||
phase: 3,
|
||||
},
|
||||
{
|
||||
id: "audio-convert",
|
||||
title: "Audio Convert",
|
||||
description: "Convert audio antar format. MP3, WAV, FLAC, AAC, OGG.",
|
||||
icon: Mic,
|
||||
href: "/audio/convert",
|
||||
phase: 3,
|
||||
},
|
||||
];
|
||||
|
||||
export function ToolGrid() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{tools.map((tool, index) => (
|
||||
<ToolCard
|
||||
key={tool.id}
|
||||
title={tool.title}
|
||||
description={tool.description}
|
||||
icon={tool.icon}
|
||||
href={tool.href}
|
||||
phase={tool.phase}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface ToolLayoutProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
phase: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ToolLayout({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
phase,
|
||||
children,
|
||||
}: ToolLayoutProps) {
|
||||
const isAvailable = phase === 1;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-3xl">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isAvailable ? (
|
||||
<div className="p-8 rounded-xl border glass text-center space-y-3">
|
||||
<p className="text-lg font-medium">Coming Soon</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tool ini sedang dalam pengembangan dan akan tersedia di fase
|
||||
berikutnya.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, type DragEvent } from "react";
|
||||
import { Upload, File, X, Image as ImageIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface UploadZoneProps {
|
||||
accept?: string;
|
||||
maxSizeMB?: number;
|
||||
multiple?: boolean;
|
||||
tool: string;
|
||||
onUpload: (file: File) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function UploadZone({
|
||||
accept = "image/*,.pdf",
|
||||
maxSizeMB = 50,
|
||||
multiple = false,
|
||||
tool,
|
||||
onUpload,
|
||||
onCancel,
|
||||
}: UploadZoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const validateFile = useCallback(
|
||||
(file: File): string | null => {
|
||||
const maxBytes = maxSizeMB * 1024 * 1024;
|
||||
if (file.size > maxBytes) {
|
||||
return `File terlalu besar: ${(file.size / 1024 / 1024).toFixed(1)}MB (max ${maxSizeMB}MB)`;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[maxSizeMB],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (!file) return;
|
||||
const err = validateFile(file);
|
||||
if (err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setSelectedFile(file);
|
||||
},
|
||||
[validateFile],
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const err = validateFile(file);
|
||||
if (err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setSelectedFile(file);
|
||||
},
|
||||
[validateFile],
|
||||
);
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
if (!selectedFile) return;
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onUpload(selectedFile);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Upload failed");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [selectedFile, onUpload]);
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !selectedFile && inputRef.current?.click()}
|
||||
className={cn(
|
||||
"relative border-2 border-dashed rounded-xl p-8 md:p-12 text-center transition-all duration-200 cursor-pointer",
|
||||
isDragging
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50",
|
||||
selectedFile && "border-solid border-primary/30",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
{!selectedFile ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<Upload className="h-12 w-12 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
Drag & drop file here, or click to browse
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Max {maxSizeMB}MB per file
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<ImageIcon className="h-8 w-8 text-primary" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium truncate max-w-[300px]">
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatSize(selectedFile.size)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFile(null);
|
||||
setError(null);
|
||||
}}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpload();
|
||||
}}
|
||||
disabled={isUploading}
|
||||
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 font-medium"
|
||||
>
|
||||
{isUploading ? "Uploading..." : "Process"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
export type JobStatusType = "queued" | "processing" | "completed" | "failed";
|
||||
|
||||
interface JobProgress {
|
||||
type: "progress" | "complete" | "error" | "status" | "ping";
|
||||
job_id: string;
|
||||
status: JobStatusType;
|
||||
progress: number;
|
||||
stage: string;
|
||||
message: string;
|
||||
result?: {
|
||||
download_url: string;
|
||||
file_name: string;
|
||||
file_size: number;
|
||||
preview_url?: string;
|
||||
ocr_text?: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface UseJobStatusOptions {
|
||||
onComplete?: (result: JobProgress["result"]) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export function useJobStatus(jobId: string | null, options?: UseJobStatusOptions) {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [stage, setStage] = useState("queued");
|
||||
const [message, setMessage] = useState("");
|
||||
const [status, setStatus] = useState<JobStatusType>("queued");
|
||||
const [result, setResult] = useState<JobProgress["result"] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const retryCount = useRef(0);
|
||||
const maxRetries = 3;
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!jobId) return;
|
||||
|
||||
// Connect directly to Rust gateway WebSocket (not via Next.js)
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const host = "localhost:3001"; // Rust gateway - wss for production
|
||||
const url = `${protocol}//${host}/api/job/${jobId}/ws`;
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
retryCount.current = 0;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data: JobProgress = JSON.parse(event.data);
|
||||
|
||||
if (data.type === "ping") return;
|
||||
|
||||
setStatus(data.status);
|
||||
setProgress(data.progress);
|
||||
setStage(data.stage);
|
||||
setMessage(data.message);
|
||||
|
||||
if (data.type === "complete") {
|
||||
setResult(data.result ?? null);
|
||||
options?.onComplete?.(data.result);
|
||||
}
|
||||
|
||||
if (data.type === "error") {
|
||||
setError(data.error ?? "Unknown error");
|
||||
options?.onError?.(data.error ?? "Unknown error");
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (retryCount.current < maxRetries) {
|
||||
retryCount.current++;
|
||||
setTimeout(connect, 1000 * retryCount.current);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
}, [jobId, options]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return { progress, stage, message, status, result, error };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
interface UploadResult {
|
||||
job_id: string;
|
||||
ws_url: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface UseUploadOptions {
|
||||
tool: string;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function useUpload({ tool, options }: UseUploadOptions) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<UploadResult | null>(null);
|
||||
const abortRef = useState<AbortController | null>(null);
|
||||
|
||||
const upload = useCallback(
|
||||
async (file: File): Promise<UploadResult | null> => {
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("tool", tool);
|
||||
if (options) {
|
||||
formData.append("options", JSON.stringify(options));
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef[1](controller);
|
||||
|
||||
const response = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
errData?.error ?? `Upload failed: ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data: UploadResult = await response.json();
|
||||
setResult(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
return null;
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : "Upload failed";
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
},
|
||||
[tool, options],
|
||||
);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
abortRef[1]?.abort();
|
||||
setIsUploading(false);
|
||||
}, [abortRef[1]]);
|
||||
|
||||
return { upload, cancel, isUploading, error, result };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user