pdflinux 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +165 -0
  2. package/bin/pdflinux +31 -0
  3. package/index.html +13 -0
  4. package/install.sh +254 -0
  5. package/package.json +57 -0
  6. package/public/favicon.svg +1 -0
  7. package/public/icons.svg +24 -0
  8. package/src/App.tsx +68 -0
  9. package/src/assets/hero.png +0 -0
  10. package/src/assets/react.svg +1 -0
  11. package/src/assets/vite.svg +1 -0
  12. package/src/components/Dropzone.tsx +90 -0
  13. package/src/components/FileItem.tsx +27 -0
  14. package/src/components/ProgressBar.tsx +18 -0
  15. package/src/components/Sidebar.tsx +92 -0
  16. package/src/hooks/useTheme.ts +23 -0
  17. package/src/index.css +675 -0
  18. package/src/main.tsx +10 -0
  19. package/src/pages/Dashboard.tsx +71 -0
  20. package/src/pages/ImageToPdf.tsx +100 -0
  21. package/src/pages/PdfCompress.tsx +175 -0
  22. package/src/pages/PdfCrop.tsx +112 -0
  23. package/src/pages/PdfDeletePages.tsx +151 -0
  24. package/src/pages/PdfGrayscale.tsx +100 -0
  25. package/src/pages/PdfInfo.tsx +71 -0
  26. package/src/pages/PdfMerge.tsx +135 -0
  27. package/src/pages/PdfOcr.tsx +136 -0
  28. package/src/pages/PdfPageNumbers.tsx +155 -0
  29. package/src/pages/PdfProtect.tsx +171 -0
  30. package/src/pages/PdfReorder.tsx +145 -0
  31. package/src/pages/PdfRotate.tsx +89 -0
  32. package/src/pages/PdfSplit.tsx +147 -0
  33. package/src/pages/PdfToImage.tsx +134 -0
  34. package/src/pages/PdfToText.tsx +107 -0
  35. package/src/pages/PdfUnlock.tsx +79 -0
  36. package/src/pages/PdfWatermark.tsx +77 -0
  37. package/src-tauri/Cargo.lock +5347 -0
  38. package/src-tauri/Cargo.toml +32 -0
  39. package/src-tauri/build.rs +3 -0
  40. package/src-tauri/capabilities/default.json +14 -0
  41. package/src-tauri/icons/128x128.png +0 -0
  42. package/src-tauri/icons/128x128@2x.png +0 -0
  43. package/src-tauri/icons/32x32.png +0 -0
  44. package/src-tauri/icons/Square107x107Logo.png +0 -0
  45. package/src-tauri/icons/Square142x142Logo.png +0 -0
  46. package/src-tauri/icons/Square150x150Logo.png +0 -0
  47. package/src-tauri/icons/Square284x284Logo.png +0 -0
  48. package/src-tauri/icons/Square30x30Logo.png +0 -0
  49. package/src-tauri/icons/Square310x310Logo.png +0 -0
  50. package/src-tauri/icons/Square44x44Logo.png +0 -0
  51. package/src-tauri/icons/Square71x71Logo.png +0 -0
  52. package/src-tauri/icons/Square89x89Logo.png +0 -0
  53. package/src-tauri/icons/StoreLogo.png +0 -0
  54. package/src-tauri/icons/icon.icns +0 -0
  55. package/src-tauri/icons/icon.ico +0 -0
  56. package/src-tauri/icons/icon.png +0 -0
  57. package/src-tauri/src/lib.rs +48 -0
  58. package/src-tauri/src/main.rs +6 -0
  59. package/src-tauri/src/pdf_engine.rs +1022 -0
  60. package/src-tauri/tauri.conf.json +48 -0
  61. package/tsconfig.app.json +28 -0
  62. package/tsconfig.json +7 -0
  63. package/tsconfig.node.json +26 -0
  64. package/uninstall.sh +65 -0
  65. package/vite.config.ts +7 -0
@@ -0,0 +1,71 @@
1
+ import { useNavigate } from 'react-router-dom';
2
+
3
+ const groups = [
4
+ {
5
+ title: 'Atur Halaman',
6
+ tools: [
7
+ { title: 'Gabung PDF', desc: 'Gabungkan beberapa PDF jadi satu.', icon: '⊕', path: '/merge' },
8
+ { title: 'Pisah PDF', desc: 'Pisahkan halaman tertentu.', icon: '✂', path: '/split' },
9
+ { title: 'Rotasi PDF', desc: 'Putar halaman 90°, 180°, 270°.', icon: '↻', path: '/rotate' },
10
+ { title: 'Crop PDF', desc: 'Potong margin halaman PDF.', icon: '⬡', path: '/crop' },
11
+ ],
12
+ },
13
+ {
14
+ title: 'Ukuran & Kualitas',
15
+ tools: [
16
+ { title: 'Kompres PDF', desc: 'Kurangi ukuran file PDF.', icon: '↓', path: '/compress' },
17
+ { title: 'Watermark', desc: 'Tambahkan teks watermark.', icon: '◈', path: '/watermark' },
18
+ ],
19
+ },
20
+ {
21
+ title: 'Konversi',
22
+ tools: [
23
+ { title: 'PDF → Gambar', desc: 'Konversi halaman ke PNG/JPG.', icon: '▣', path: '/to-image' },
24
+ { title: 'Gambar → PDF', desc: 'Konversi gambar ke satu PDF.', icon: '⊞', path: '/image-to-pdf' },
25
+ ],
26
+ },
27
+ {
28
+ title: 'Keamanan',
29
+ tools: [
30
+ { title: 'Proteksi PDF', desc: 'Tambahkan password ke PDF.', icon: '⊘', path: '/protect' },
31
+ { title: 'Buka Kunci PDF', desc: 'Hapus password dari PDF.', icon: '⊙', path: '/unlock' },
32
+ ],
33
+ },
34
+ {
35
+ title: 'Informasi',
36
+ tools: [
37
+ { title: 'Info PDF', desc: 'Lihat metadata dan detail file.', icon: 'ⓘ', path: '/info' },
38
+ ],
39
+ },
40
+ ];
41
+
42
+ export default function Dashboard() {
43
+ const navigate = useNavigate();
44
+
45
+ return (
46
+ <>
47
+ <div className="page-header">
48
+ <h2>Dashboard</h2>
49
+ <p>Pilih salah satu alat untuk mulai memproses file PDF Anda.</p>
50
+ </div>
51
+ <div className="page-body">
52
+ {groups.map((group) => (
53
+ <div key={group.title} style={{ marginBottom: '28px' }}>
54
+ <h3 style={{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '1px', color: 'var(--text-muted)', marginBottom: '10px' }}>
55
+ {group.title}
56
+ </h3>
57
+ <div className="tools-grid">
58
+ {group.tools.map((t) => (
59
+ <div key={t.title} className="tool-card" onClick={() => navigate(t.path)}>
60
+ <div className="card-icon">{t.icon}</div>
61
+ <h3>{t.title}</h3>
62
+ <p>{t.desc}</p>
63
+ </div>
64
+ ))}
65
+ </div>
66
+ </div>
67
+ ))}
68
+ </div>
69
+ </>
70
+ );
71
+ }
@@ -0,0 +1,100 @@
1
+ import { useState } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { invoke } from '@tauri-apps/api/core';
4
+ import { save, open } from '@tauri-apps/plugin-dialog';
5
+ import ProgressBar from '../components/ProgressBar';
6
+
7
+ interface ImageToPdfResult { output_path: string; output_size: number; image_count: number; }
8
+
9
+ interface ImageFile { name: string; path: string; }
10
+
11
+ export default function ImageToPdf() {
12
+ const navigate = useNavigate();
13
+ const [images, setImages] = useState<ImageFile[]>([]);
14
+ const [processing, setProcessing] = useState(false);
15
+ const [progress, setProgress] = useState(0);
16
+ const [result, setResult] = useState<ImageToPdfResult | null>(null);
17
+ const [error, setError] = useState('');
18
+
19
+ const handleAdd = async () => {
20
+ try {
21
+ const selected = await open({
22
+ multiple: true,
23
+ filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'bmp', 'webp', 'tiff'] }],
24
+ });
25
+ if (!selected) return;
26
+ const paths = Array.isArray(selected) ? selected : [selected];
27
+ const newImages = paths.map((p) => ({ name: p.split('/').pop() || 'image', path: p }));
28
+ setImages((prev) => [...prev, ...newImages]);
29
+ setResult(null); setError('');
30
+ } catch (err) { console.error(err); }
31
+ };
32
+
33
+ const removeImage = (index: number) => { setImages((prev) => prev.filter((_, i) => i !== index)); setResult(null); };
34
+
35
+ const handleConvert = async () => {
36
+ if (images.length === 0) return;
37
+ setProcessing(true); setProgress(20); setError(''); setResult(null);
38
+ try {
39
+ const tempDir = await invoke<string>('get_temp_dir');
40
+ setProgress(40);
41
+ const res = await invoke<ImageToPdfResult>('image_to_pdf', {
42
+ inputPaths: images.map((i) => i.path), outputPath: `${tempDir}/images_to_pdf.pdf`,
43
+ });
44
+ setProgress(100); setResult(res);
45
+ } catch (err: any) { setError(typeof err === 'string' ? err : err.message || 'Error'); }
46
+ finally { setProcessing(false); }
47
+ };
48
+
49
+ const handleSave = async () => {
50
+ if (!result) return;
51
+ const savePath = await save({ defaultPath: 'output.pdf', filters: [{ name: 'PDF', extensions: ['pdf'] }] });
52
+ if (savePath) await invoke('save_file_to', { source: result.output_path, destination: savePath });
53
+ };
54
+
55
+ const formatSize = (b: number) => b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(2) + ' MB';
56
+
57
+ return (
58
+ <>
59
+ <div className="page-header">
60
+ <button className="btn-back" onClick={() => navigate('/')}>← Kembali</button>
61
+ <h2>⊞ Gambar ke PDF</h2>
62
+ <p>Konversi satu atau beberapa gambar menjadi satu file PDF.</p>
63
+ </div>
64
+ <div className="page-body">
65
+ <div className="tool-page">
66
+ <div className="dropzone" onClick={handleAdd}>
67
+ <span className="drop-icon" style={{ opacity: 0.6 }}>🖼</span>
68
+ <h3>Klik untuk memilih gambar</h3>
69
+ <p>PNG, JPG, BMP, WebP, TIFF</p>
70
+ </div>
71
+
72
+ {images.length > 0 && (
73
+ <div className="file-list">
74
+ {images.map((img, i) => (
75
+ <div className="file-item animate-in" key={`${img.name}-${i}`}>
76
+ <div className="file-icon">🖼</div>
77
+ <div className="file-info">
78
+ <div className="file-name">{img.name}</div>
79
+ </div>
80
+ <button className="file-remove" onClick={() => removeImage(i)}>✕</button>
81
+ </div>
82
+ ))}
83
+ </div>
84
+ )}
85
+
86
+ {processing && <ProgressBar progress={progress} status="Mengonversi gambar..." />}
87
+ {error && <div className="result-card animate-in" style={{ borderColor: 'rgba(239,68,68,0.3)' }}><h4>✕ Error</h4><p style={{ color: 'var(--danger)', fontSize: '14px' }}>{error}</p></div>}
88
+ {result && (
89
+ <div className="result-card success animate-in">
90
+ <h4>✓ Konversi Berhasil!</h4>
91
+ <p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginBottom: '14px' }}>{result.image_count} gambar → 1 PDF ({formatSize(result.output_size)})</p>
92
+ <button className="btn-primary" onClick={handleSave}>Simpan File PDF</button>
93
+ </div>
94
+ )}
95
+ {images.length > 0 && !processing && !result && !error && <button className="btn-primary" onClick={handleConvert}>Konversi ke PDF ({images.length} gambar)</button>}
96
+ </div>
97
+ </div>
98
+ </>
99
+ );
100
+ }
@@ -0,0 +1,175 @@
1
+ import { useState } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { invoke } from '@tauri-apps/api/core';
4
+ import { save } from '@tauri-apps/plugin-dialog';
5
+ import Dropzone, { type PdfFile } from '../components/Dropzone';
6
+ import FileItem from '../components/FileItem';
7
+ import ProgressBar from '../components/ProgressBar';
8
+
9
+ type Quality = 'high' | 'medium' | 'low';
10
+
11
+ interface QualityOption {
12
+ key: Quality;
13
+ label: string;
14
+ desc: string;
15
+ }
16
+
17
+ const qualityOptions: QualityOption[] = [
18
+ { key: 'high', label: 'Tinggi', desc: 'Kualitas terbaik, pengurangan ukuran minimal' },
19
+ { key: 'medium', label: 'Sedang', desc: 'Keseimbangan kualitas & ukuran' },
20
+ { key: 'low', label: 'Rendah', desc: 'Ukuran terkecil, kualitas berkurang' },
21
+ ];
22
+
23
+ interface CompressResult {
24
+ original_size: number;
25
+ compressed_size: number;
26
+ output_path: string;
27
+ }
28
+
29
+ export default function PdfCompress() {
30
+ const navigate = useNavigate();
31
+ const [file, setFile] = useState<PdfFile | null>(null);
32
+ const [quality, setQuality] = useState<Quality>('medium');
33
+ const [processing, setProcessing] = useState(false);
34
+ const [progress, setProgress] = useState(0);
35
+ const [result, setResult] = useState<CompressResult | null>(null);
36
+ const [error, setError] = useState('');
37
+
38
+ const handleFiles = (files: PdfFile[]) => {
39
+ setFile(files[0]);
40
+ setResult(null);
41
+ setError('');
42
+ };
43
+
44
+ const removeFile = () => {
45
+ setFile(null);
46
+ setResult(null);
47
+ setError('');
48
+ };
49
+
50
+ const handleCompress = async () => {
51
+ if (!file) return;
52
+
53
+ setProcessing(true);
54
+ setProgress(10);
55
+ setError('');
56
+ setResult(null);
57
+
58
+ try {
59
+ const tempDir = await invoke<string>('get_temp_dir');
60
+ const outputPath = `${tempDir}/compressed_${file.name}`;
61
+
62
+ setProgress(30);
63
+
64
+ const res = await invoke<CompressResult>('compress_pdf', {
65
+ inputPath: file.path,
66
+ outputPath: outputPath,
67
+ quality: quality,
68
+ });
69
+
70
+ setProgress(100);
71
+ setResult(res);
72
+ } catch (err: any) {
73
+ setError(typeof err === 'string' ? err : err.message || 'Terjadi kesalahan.');
74
+ } finally {
75
+ setProcessing(false);
76
+ }
77
+ };
78
+
79
+ const handleSave = async () => {
80
+ if (!result) return;
81
+ try {
82
+ const savePath = await save({
83
+ defaultPath: `compressed_${file?.name || 'output.pdf'}`,
84
+ filters: [{ name: 'PDF Files', extensions: ['pdf'] }],
85
+ });
86
+ if (savePath) {
87
+ await invoke('save_file_to', { source: result.output_path, destination: savePath });
88
+ }
89
+ } catch (err: any) {
90
+ setError(typeof err === 'string' ? err : err.message || 'Gagal menyimpan file.');
91
+ }
92
+ };
93
+
94
+ const formatSize = (bytes: number) => {
95
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
96
+ return (bytes / 1048576).toFixed(2) + ' MB';
97
+ };
98
+
99
+ return (
100
+ <>
101
+ <div className="page-header">
102
+ <button className="btn-back" onClick={() => navigate('/')}>← Kembali ke Dashboard</button>
103
+ <h2>📦 Kompres PDF</h2>
104
+ <p>Kurangi ukuran file PDF Anda dengan memilih level kualitas kompresi.</p>
105
+ </div>
106
+ <div className="page-body">
107
+ <div className="tool-page">
108
+ {!file && <Dropzone onFilesSelected={handleFiles} />}
109
+
110
+ {file && (
111
+ <div className="file-list">
112
+ <FileItem file={file} onRemove={removeFile} />
113
+ </div>
114
+ )}
115
+
116
+ {file && !result && !error && (
117
+ <div className="options-panel animate-in">
118
+ <h4>⚙️ Kualitas Kompresi</h4>
119
+ <div className="quality-options">
120
+ {qualityOptions.map((opt) => (
121
+ <button
122
+ key={opt.key}
123
+ className={`quality-btn ${quality === opt.key ? 'selected' : ''}`}
124
+ onClick={() => setQuality(opt.key)}
125
+ >
126
+ <span className="quality-label">{opt.label}</span>
127
+ <span className="quality-desc">{opt.desc}</span>
128
+ </button>
129
+ ))}
130
+ </div>
131
+ </div>
132
+ )}
133
+
134
+ {processing && <ProgressBar progress={progress} status="Mengompres dengan Ghostscript..." />}
135
+
136
+ {error && (
137
+ <div className="result-card animate-in" style={{ borderColor: 'rgba(225,112,85,0.3)' }}>
138
+ <h4>❌ Error</h4>
139
+ <p style={{ color: 'var(--danger)', fontSize: '14px' }}>{error}</p>
140
+ </div>
141
+ )}
142
+
143
+ {result && (
144
+ <div className="result-card success animate-in">
145
+ <h4>✅ Kompresi Berhasil!</h4>
146
+ <div className="result-stats">
147
+ <div className="stat-item">
148
+ <div className="stat-value">{formatSize(result.original_size)}</div>
149
+ <div className="stat-label">Ukuran Asli</div>
150
+ </div>
151
+ <div className="stat-item">
152
+ <div className="stat-value">{formatSize(result.compressed_size)}</div>
153
+ <div className="stat-label">Ukuran Baru</div>
154
+ </div>
155
+ <div className="stat-item">
156
+ <div className="stat-value">
157
+ {Math.round((1 - result.compressed_size / result.original_size) * 100)}%
158
+ </div>
159
+ <div className="stat-label">Pengurangan</div>
160
+ </div>
161
+ </div>
162
+ <button className="btn-primary" onClick={handleSave}>💾 Simpan File</button>
163
+ </div>
164
+ )}
165
+
166
+ {file && !processing && !result && !error && (
167
+ <button className="btn-primary" onClick={handleCompress}>
168
+ 🚀 Mulai Kompres
169
+ </button>
170
+ )}
171
+ </div>
172
+ </div>
173
+ </>
174
+ );
175
+ }
@@ -0,0 +1,112 @@
1
+ import { useState } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { invoke } from '@tauri-apps/api/core';
4
+ import { save } from '@tauri-apps/plugin-dialog';
5
+ import Dropzone, { type PdfFile } from '../components/Dropzone';
6
+ import FileItem from '../components/FileItem';
7
+ import ProgressBar from '../components/ProgressBar';
8
+
9
+ interface CropResult { output_path: string; output_size: number; }
10
+
11
+ export default function PdfCrop() {
12
+ const navigate = useNavigate();
13
+ const [file, setFile] = useState<PdfFile | null>(null);
14
+ const [top, setTop] = useState('10');
15
+ const [bottom, setBottom] = useState('10');
16
+ const [left, setLeft] = useState('10');
17
+ const [right, setRight] = useState('10');
18
+ const [processing, setProcessing] = useState(false);
19
+ const [progress, setProgress] = useState(0);
20
+ const [result, setResult] = useState<CropResult | null>(null);
21
+ const [error, setError] = useState('');
22
+
23
+ const handleFiles = (files: PdfFile[]) => { setFile(files[0]); setResult(null); setError(''); };
24
+ const removeFile = () => { setFile(null); setResult(null); setError(''); };
25
+
26
+ const handleCrop = async () => {
27
+ if (!file) return;
28
+ setProcessing(true); setProgress(20); setError(''); setResult(null);
29
+ try {
30
+ const tempDir = await invoke<string>('get_temp_dir');
31
+ setProgress(50);
32
+ const res = await invoke<CropResult>('crop_pdf', {
33
+ inputPath: file.path,
34
+ outputPath: `${tempDir}/cropped_${file.name}`,
35
+ top: parseFloat(top) || 0,
36
+ bottom: parseFloat(bottom) || 0,
37
+ left: parseFloat(left) || 0,
38
+ right: parseFloat(right) || 0,
39
+ });
40
+ setProgress(100); setResult(res);
41
+ } catch (err: any) { setError(typeof err === 'string' ? err : err.message || 'Error'); }
42
+ finally { setProcessing(false); }
43
+ };
44
+
45
+ const handleSave = async () => {
46
+ if (!result) return;
47
+ const savePath = await save({ defaultPath: `cropped_${file?.name}`, filters: [{ name: 'PDF', extensions: ['pdf'] }] });
48
+ if (savePath) await invoke('save_file_to', { source: result.output_path, destination: savePath });
49
+ };
50
+
51
+ const inputStyle: React.CSSProperties = {
52
+ width: '100%', padding: '10px 14px', background: 'var(--bg-primary)', border: '1.5px solid var(--border-color)',
53
+ borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: '13px', fontFamily: 'inherit',
54
+ outline: 'none', textAlign: 'center',
55
+ };
56
+
57
+ return (
58
+ <>
59
+ <div className="page-header">
60
+ <button className="btn-back" onClick={() => navigate('/')}>← Kembali</button>
61
+ <h2>⬡ Crop PDF</h2>
62
+ <p>Potong margin halaman PDF dari setiap sisi.</p>
63
+ </div>
64
+ <div className="page-body">
65
+ <div className="tool-page">
66
+ {!file && <Dropzone onFilesSelected={handleFiles} />}
67
+ {file && <div className="file-list"><FileItem file={file} onRemove={removeFile} /></div>}
68
+
69
+ {file && !result && !error && (
70
+ <div className="options-panel animate-in">
71
+ <h4>⬡ Margin Crop (mm)</h4>
72
+ <p style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '14px' }}>
73
+ Masukkan jumlah milimeter yang ingin dipotong dari setiap sisi.
74
+ </p>
75
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
76
+ <div>
77
+ <label style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-secondary)', display: 'block', marginBottom: '4px' }}>Atas</label>
78
+ <input type="number" min="0" value={top} onChange={(e) => setTop(e.target.value)} style={inputStyle} />
79
+ </div>
80
+ <div>
81
+ <label style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-secondary)', display: 'block', marginBottom: '4px' }}>Bawah</label>
82
+ <input type="number" min="0" value={bottom} onChange={(e) => setBottom(e.target.value)} style={inputStyle} />
83
+ </div>
84
+ <div>
85
+ <label style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-secondary)', display: 'block', marginBottom: '4px' }}>Kiri</label>
86
+ <input type="number" min="0" value={left} onChange={(e) => setLeft(e.target.value)} style={inputStyle} />
87
+ </div>
88
+ <div>
89
+ <label style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-secondary)', display: 'block', marginBottom: '4px' }}>Kanan</label>
90
+ <input type="number" min="0" value={right} onChange={(e) => setRight(e.target.value)} style={inputStyle} />
91
+ </div>
92
+ </div>
93
+ </div>
94
+ )}
95
+
96
+ {processing && <ProgressBar progress={progress} status="Memotong halaman..." />}
97
+ {error && <div className="result-card animate-in" style={{ borderColor: 'rgba(239,68,68,0.3)' }}><h4>✕ Error</h4><p style={{ color: 'var(--danger)', fontSize: '14px' }}>{error}</p></div>}
98
+ {result && (
99
+ <div className="result-card success animate-in">
100
+ <h4>✓ Crop Berhasil!</h4>
101
+ <p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginBottom: '14px' }}>
102
+ Margin berhasil dipotong ({top}mm atas, {bottom}mm bawah, {left}mm kiri, {right}mm kanan).
103
+ </p>
104
+ <button className="btn-primary" onClick={handleSave}>Simpan File</button>
105
+ </div>
106
+ )}
107
+ {file && !processing && !result && !error && <button className="btn-primary" onClick={handleCrop}>Mulai Crop</button>}
108
+ </div>
109
+ </div>
110
+ </>
111
+ );
112
+ }
@@ -0,0 +1,151 @@
1
+ import { useState } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { invoke } from '@tauri-apps/api/core';
4
+ import { save } from '@tauri-apps/plugin-dialog';
5
+ import Dropzone, { type PdfFile } from '../components/Dropzone';
6
+ import FileItem from '../components/FileItem';
7
+ import ProgressBar from '../components/ProgressBar';
8
+
9
+ interface DeleteResult {
10
+ output_path: string;
11
+ output_size: number;
12
+ pages_deleted: number;
13
+ pages_remaining: number;
14
+ }
15
+
16
+ export default function PdfDeletePages() {
17
+ const navigate = useNavigate();
18
+ const [file, setFile] = useState<PdfFile | null>(null);
19
+ const [pagesToDelete, setPagesToDelete] = useState('');
20
+ const [totalPages, setTotalPages] = useState<number | null>(null);
21
+ const [processing, setProcessing] = useState(false);
22
+ const [progress, setProgress] = useState(0);
23
+ const [result, setResult] = useState<DeleteResult | null>(null);
24
+ const [error, setError] = useState('');
25
+
26
+ const handleFiles = async (files: PdfFile[]) => {
27
+ const f = files[0];
28
+ setFile(f);
29
+ setResult(null);
30
+ setError('');
31
+ try {
32
+ const meta = await invoke<{ pages: string }>('get_pdf_metadata', { inputPath: f.path });
33
+ setTotalPages(parseInt(meta.pages) || null);
34
+ } catch {
35
+ setTotalPages(null);
36
+ }
37
+ };
38
+
39
+ const removeFile = () => {
40
+ setFile(null);
41
+ setResult(null);
42
+ setPagesToDelete('');
43
+ setTotalPages(null);
44
+ setError('');
45
+ };
46
+
47
+ const handleDelete = async () => {
48
+ if (!file || !pagesToDelete.trim()) return;
49
+ setProcessing(true);
50
+ setProgress(20);
51
+ setError('');
52
+ setResult(null);
53
+ try {
54
+ const tempDir = await invoke<string>('get_temp_dir');
55
+ setProgress(50);
56
+ const res = await invoke<DeleteResult>('delete_pages', {
57
+ inputPath: file.path,
58
+ outputPath: `${tempDir}/deleted_${file.name}`,
59
+ pagesToDelete: pagesToDelete.trim(),
60
+ });
61
+ setProgress(100);
62
+ setResult(res);
63
+ } catch (err: any) {
64
+ setError(typeof err === 'string' ? err : err.message || 'Terjadi kesalahan.');
65
+ } finally {
66
+ setProcessing(false);
67
+ }
68
+ };
69
+
70
+ const handleSave = async () => {
71
+ if (!result) return;
72
+ const savePath = await save({
73
+ defaultPath: `deleted_${file?.name || 'output.pdf'}`,
74
+ filters: [{ name: 'PDF Files', extensions: ['pdf'] }],
75
+ });
76
+ if (savePath) {
77
+ await invoke('save_file_to', { source: result.output_path, destination: savePath });
78
+ }
79
+ };
80
+
81
+ return (
82
+ <>
83
+ <div className="page-header">
84
+ <button className="btn-back" onClick={() => navigate('/')}>← Kembali</button>
85
+ <h2>✕ Hapus Halaman</h2>
86
+ <p>Hapus halaman tertentu dari file PDF Anda.</p>
87
+ </div>
88
+ <div className="page-body">
89
+ <div className="tool-page">
90
+ {!file && <Dropzone onFilesSelected={handleFiles} />}
91
+ {file && <div className="file-list"><FileItem file={file} onRemove={removeFile} /></div>}
92
+
93
+ {file && !result && !error && (
94
+ <div className="options-panel animate-in">
95
+ <h4>Halaman yang Akan Dihapus</h4>
96
+ {totalPages && (
97
+ <p style={{ fontSize: '13px', color: 'var(--text-muted)', marginBottom: '8px' }}>
98
+ Dokumen ini memiliki <strong>{totalPages}</strong> halaman.
99
+ </p>
100
+ )}
101
+ <div className="input-group">
102
+ <label>Masukkan nomor halaman yang ingin dihapus</label>
103
+ <input
104
+ type="text"
105
+ placeholder="Contoh: 2,5,7-10"
106
+ value={pagesToDelete}
107
+ onChange={(e) => setPagesToDelete(e.target.value)}
108
+ />
109
+ <p className="page-range-hint">
110
+ Gunakan koma untuk beberapa halaman dan tanda hubung untuk rentang.
111
+ </p>
112
+ </div>
113
+ </div>
114
+ )}
115
+
116
+ {processing && <ProgressBar progress={progress} status="Menghapus halaman..." />}
117
+
118
+ {error && (
119
+ <div className="result-card animate-in" style={{ borderColor: 'rgba(225,112,85,0.3)' }}>
120
+ <h4>✕ Error</h4>
121
+ <p style={{ color: 'var(--danger)', fontSize: '14px' }}>{error}</p>
122
+ </div>
123
+ )}
124
+
125
+ {result && (
126
+ <div className="result-card success animate-in">
127
+ <h4>✓ Berhasil!</h4>
128
+ <div className="result-stats">
129
+ <div className="stat-item">
130
+ <div className="stat-value">{result.pages_deleted}</div>
131
+ <div className="stat-label">Halaman Dihapus</div>
132
+ </div>
133
+ <div className="stat-item">
134
+ <div className="stat-value">{result.pages_remaining}</div>
135
+ <div className="stat-label">Halaman Tersisa</div>
136
+ </div>
137
+ </div>
138
+ <button className="btn-primary" onClick={handleSave}>Simpan File</button>
139
+ </div>
140
+ )}
141
+
142
+ {file && !processing && !result && !error && (
143
+ <button className="btn-primary" onClick={handleDelete} disabled={!pagesToDelete.trim()}>
144
+ Hapus Halaman
145
+ </button>
146
+ )}
147
+ </div>
148
+ </div>
149
+ </>
150
+ );
151
+ }