feat: initial tools service with document scanner, image & PDF tools

Self-hosted document scanner and media processing tools.
- Rust Axum gateway + worker pool with NATS JetStream
- Next.js 16 frontend with shadcn/ui
- Scanner pipeline: edge detection, warp, binarization, OCR
- Image tools: compress, resize, convert
- PDF tools: merge, split, compress
- CI/CD with Docker multi-stage build

Co-Authored-By: Kilo <kilo@kilo.ai>
This commit is contained in:
asepharyana
2026-07-24 13:10:59 +07:00
co-authored by Kilo
commit a00ad62f6c
98 changed files with 11399 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
export function Footer() {
return (
<footer className="border-t py-6 mt-auto">
<div className="container mx-auto px-4 flex flex-col md:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
<p>
&copy; {new Date().getFullYear()} Asep Haryana Saputra. All rights
reserved.
</p>
<p className="flex items-center gap-1">
Powered by{" "}
<span className="font-mono text-primary">Rust + Next.js</span>
</p>
</div>
</footer>
);
}
+70
View File
@@ -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>
);
}
+163
View File
@@ -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>
);
}