diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1580c01 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + ci: + name: Lint · Test · Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Test + run: npm test + + - name: Build + run: npm run build diff --git a/README.md b/README.md index 8eae87e..fd3ecd3 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,9 @@ A fast, privacy-first PDF toolkit that runs entirely in your browser. No uploads | **PDF → Image** | Render each page as JPEG or PNG at 72–216 dpi | | **Image → PDF** | Pack JPG/PNG files into a single PDF. Drag to set page order | | **Protect PDF** | Lock a PDF with a password. Encrypted output works in any PDF reader | +| **Rotate Pages** | Rotate all pages clockwise by 90°, 180°, or 270° | +| **Watermark PDF** | Stamp a text watermark on every page with custom opacity and angle | +| **Edit Metadata** | Read and update title, author, subject, keywords, and creator fields | ## Privacy @@ -26,7 +29,7 @@ All processing happens locally in the browser using WebAssembly and Canvas APIs. ## Tech Stack - **[Next.js 16](https://nextjs.org/)** — App Router, React 19 (requires Node.js 20+) -- **[pdf-lib](https://pdf-lib.js.org/)** — PDF creation, merging, splitting +- **[pdf-lib](https://pdf-lib.js.org/)** — PDF creation, merging, splitting, rotation, watermarking, metadata editing - **[pdfjs-dist](https://mozilla.github.io/pdf.js/)** — PDF rendering to canvas - **[browser-image-compression](https://github.com/Donaldcwl/browser-image-compression)** — JPEG compression for compress tool - **[jsPDF](https://github.com/parallax/jsPDF)** — PDF creation with password encryption for protect tool @@ -41,6 +44,9 @@ npm install # Run dev server npm run dev +# Run tests +npm test + # Build for production npm run build ``` @@ -49,53 +55,72 @@ Open [http://localhost:3000](http://localhost:3000). ## Deployment -### Static Hosting (Recommended) +### Node.js (cPanel / VPS) -Change `output` in `next.config.ts` to `'export'`, then: +Requires **Node.js 20+** on the server. ```bash -npm run build -# Upload contents of out/ to your web server +# Build, zip, and upload to FTP in one command +npm run deploy ``` -### Node.js (cPanel / VPS) - -Requires **Node.js 20+** on the server. +Credentials are read from `.env.ftp`: -```bash -npm run build -# Copy .next/standalone/ + .next/static/ + public/ to your server -# On the server, run npm install then: node server.js ``` +FTP_HOST=... +FTP_USER=... +FTP_PASS=... +FTP_REMOTE_PATH=... +``` + +The script builds a standalone bundle, assembles `standalone/ + public/ + .next/static/` into one folder, zips it, uploads `deploy.zip` to the FTP root, then cleans up locally. + +## CI/CD + +GitHub Actions runs on every push: **lint → test → build**. See `.github/workflows/ci.yml`. ## Project Structure ``` app/ -├── page.tsx # Landing page +├── page.tsx # Landing page with tool search ├── compress/page.tsx ├── merge/page.tsx ├── split/page.tsx ├── pdf-to-image/page.tsx ├── image-to-pdf/page.tsx -└── protect/page.tsx +├── protect/page.tsx +├── rotate/page.tsx +├── watermark/page.tsx +└── metadata/page.tsx components/ ├── Navbar/ # Responsive navbar with mobile hamburger menu ├── AppLoader/ # Preloads all PDF libraries before site renders -├── DropZone/ # Drag-and-drop file input +├── DropZone/ # Drag-and-drop file input (supports page-wide drop) ├── FileList/ # File list with drag-to-reorder -├── ProgressBar/ -├── ToolLayout/ # Shared page wrapper -├── ToolUI/ # Err, Ok, ActionBtn components -└── TopLoader/ # Page transition progress bar +├── ProgressBar/ # Animated shimmer progress bar +├── ToolLayout/ # Shared page wrapper, records recently used tools +├── ToolUI/ # Err, Ok, ActionBtn, PasswordStrength components +├── TopLoader/ # Page transition progress bar +└── PwaInit/ # Registers service worker for PWA support lib/ +├── compressPdf.ts ├── mergePdf.ts ├── splitPdf.ts -├── compressPdf.ts ├── pdfToImage.ts ├── imageToPdf.ts ├── protectPdf.ts +├── rotatePdf.ts +├── watermarkPdf.ts +├── editMetadata.ts +├── validate.ts # File size / format / password strength validation +├── usePreference.ts # localStorage preference hook +├── useRecentTools.ts # Recently used tools tracking └── useHotkey.ts # Cmd/Ctrl+Enter shortcut +tests/ +└── lib/ + ├── validate.test.ts + └── usePreference.test.ts ``` ## License diff --git a/app/compress/page.tsx b/app/compress/page.tsx index 42ba9d5..c010d55 100644 --- a/app/compress/page.tsx +++ b/app/compress/page.tsx @@ -8,31 +8,39 @@ import ProgressBar from '@/components/ProgressBar' import { Err, Ok, ActionBtn } from '@/components/ToolUI' import { useCmdEnter } from '@/lib/useHotkey' import { compressPdf } from '@/lib/compressPdf' +import { validatePdf } from '@/lib/validate' +import { usePreference } from '@/lib/usePreference' function fmt(b: number) { - if (b<1024) return b+' B' - if (b<1024*1024) return (b/1024).toFixed(0)+' KB' - return (b/(1024*1024)).toFixed(2)+' MB' + if (b < 1024) return b + ' B' + if (b < 1024 * 1024) return (b / 1024).toFixed(0) + ' KB' + return (b / (1024 * 1024)).toFixed(2) + ' MB' } const presets = [ - {id:'screen', label:'Screen', q:0.35, note:'smallest'}, - {id:'web', label:'Web', q:0.60, note:'balanced'}, - {id:'print', label:'Print', q:0.85, note:'sharp'}, - {id:'custom', label:'Custom', q:-1, note:'manual'}, + { id: 'screen', label: 'Screen', q: 0.35, note: 'smallest' }, + { id: 'web', label: 'Web', q: 0.60, note: 'balanced' }, + { id: 'print', label: 'Print', q: 0.85, note: 'sharp' }, + { id: 'custom', label: 'Custom', q: -1, note: 'manual' }, ] export default function CompressPage() { - const [file, setFile] = useState(null) - const [preset, setPreset] = useState('web') - const [customQ, setCustomQ] = useState(0.6) - const [status, setStatus] = useState<'idle'|'processing'|'done'|'error'>('idle') - const [progress, setProgress] = useState({current:0,total:0}) + const [file, setFile] = useState(null) + const [preset, setPreset] = usePreference('compress:preset', 'web') + const [customQ, setCustomQ] = usePreference('compress:customQ', 0.6) + const [status, setStatus] = useState<'idle' | 'processing' | 'done' | 'error'>('idle') + const [progress, setProgress] = useState({ current: 0, total: 0 }) const [error, setError] = useState('') - const [result, setResult] = useState<{orig:number;next:number}|null>(null) + const [result, setResult] = useState<{ orig: number; next: number } | null>(null) - const quality = preset==='custom' ? customQ : (presets.find(p=>p.id===preset)?.q ?? 0.6) - const canCompress = !!file && status!=='processing' + const quality = preset === 'custom' ? customQ : (presets.find(p => p.id === preset)?.q ?? 0.6) + const canCompress = !!file && status !== 'processing' + + function handleFiles([f]: File[]) { + const err = validatePdf(f) + if (err) { setError(err); setStatus('error'); return } + setFile(f); setStatus('idle'); setError(''); setResult(null) + } function reset() { setFile(null); setStatus('idle'); setResult(null); setError('') } @@ -40,41 +48,34 @@ export default function CompressPage() { if (!file) return setStatus('processing'); setError(''); setResult(null) try { - const bytes = await compressPdf(file, quality, (c,t)=>setProgress({current:c,total:t})) - const stem = file.name.replace(/\.pdf$/i, '') - saveAs(new Blob([bytes.buffer as ArrayBuffer],{type:'application/pdf'}),`${stem}_compressed.pdf`) - setResult({orig:file.size, next:bytes.length}) + const bytes = await compressPdf(file, quality, (c, t) => setProgress({ current: c, total: t })) + const stem = file.name.replace(/\.pdf$/i, '') + saveAs(new Blob([bytes.buffer as ArrayBuffer], { type: 'application/pdf' }), `${stem}_compressed.pdf`) + setResult({ orig: file.size, next: bytes.length }) setStatus('done') - } catch(e) { setError(String(e)); setStatus('error') } + } catch (e) { setError(String(e)); setStatus('error') } }, [file, quality]) useCmdEnter(handleCompress, canCompress) - const presetBtnStyle = (active: boolean): React.CSSProperties => ({ - padding:'10px 6px', textAlign:'center', cursor:'pointer', fontFamily:'inherit', - background: active ? 'rgba(255,68,0,0.06)' : 'var(--surface)', - border: `1px solid ${active ? 'rgba(255,68,0,0.35)' : 'var(--border)'}`, - borderRadius:'var(--radius-sm)', transition:'all 0.12s', - }) - return ( -
+
{!file ? ( - setFile(f)} label="Drop a PDF file here" /> + ) : ( -
-
-
-

{file.name}

-

{fmt(file.size)}

+
+
+
+

{file.name}

+

{fmt(file.size)}

- +
- {file.size < 150*1024 && ( -
+ {file.size < 150 * 1024 && ( +
↑ file already small — compression benefit may be minimal
)} @@ -83,45 +84,45 @@ export default function CompressPage() { {file && (
-

Quality preset

+

Quality preset

- {presets.map(p=>( - ))}
- {preset==='custom' && ( -
-
- Quality - {Math.round(customQ*100)}% + {preset === 'custom' && ( +
+
+ Quality + {Math.round(customQ * 100)}%
setCustomQ(Number(e.target.value))} - style={{width:'100%',accentColor:'var(--accent)',cursor:'pointer'}} /> -
- smallest - best quality + onChange={e => setCustomQ(Number(e.target.value))} + style={{ width: '100%', accentColor: 'var(--accent)', cursor: 'pointer' }} /> +
+ smallest + best quality
)}
)} - {status==='processing' && } - {status==='error' && } - {status==='done' && result && ( + {status === 'processing' && } + {status === 'error' && } + {status === 'done' && result && ( { - const pct = Math.round((1 - result.next / result.orig) * 100) + const pct = Math.round((1 - result.next / result.orig) * 100) const diff = pct > 0 ? `${pct}% smaller` : pct < 0 ? `${Math.abs(pct)}% larger` : 'same size' return `compressed.pdf saved · ${fmt(result.orig)} → ${fmt(result.next)} (${diff})` })()} onReset={reset} /> )} - + Compress PDF →
diff --git a/app/globals.css b/app/globals.css index 2b0b138..8421858 100644 --- a/app/globals.css +++ b/app/globals.css @@ -38,16 +38,22 @@ a { color: inherit; text-decoration: none; } .mono { font-family: var(--font-roboto-mono), 'SF Mono', monospace; } /* ─── Animations ─── */ -@keyframes fadeUp { from { opacity:0; transform:translateY(18px) } to { opacity:1; transform:translateY(0) } } -@keyframes fadeIn { from { opacity:0 } to { opacity:1 } } -@keyframes slideLeft{ from { opacity:0; transform:translateX(-14px) } to { opacity:1; transform:translateX(0) } } -@keyframes scaleIn { from { opacity:0; transform:scale(0.96) } to { opacity:1; transform:scale(1) } } -@keyframes spin { to { transform:rotate(360deg) } } +@keyframes fadeUp { from { opacity:0; transform:translateY(18px) } to { opacity:1; transform:translateY(0) } } +@keyframes fadeDown { from { opacity:0; transform:translateY(-10px) } to { opacity:1; transform:translateY(0) } } +@keyframes fadeIn { from { opacity:0 } to { opacity:1 } } +@keyframes slideLeft { from { opacity:0; transform:translateX(-14px) } to { opacity:1; transform:translateX(0) } } +@keyframes scaleIn { from { opacity:0; transform:scale(0.96) } to { opacity:1; transform:scale(1) } } +@keyframes spin { to { transform:rotate(360deg) } } +@keyframes shimmer { 0% { background-position:-200% center } 100% { background-position:200% center } } +@keyframes pulse-soft{ 0%,100% { opacity:1 } 50% { opacity:0.6 } } +@keyframes pop { 0% { transform:scale(0.85); opacity:0 } 60% { transform:scale(1.06) } 100% { transform:scale(1); opacity:1 } } .anim-fade-up { animation: fadeUp 0.5s cubic-bezier(0.22,1,0.36,1) both; } .anim-fade-in { animation: fadeIn 0.35s ease both; } +.anim-fade-down { animation: fadeDown 0.3s cubic-bezier(0.22,1,0.36,1) both; } .anim-slide-left { animation: slideLeft 0.45s cubic-bezier(0.22,1,0.36,1) both; } .anim-scale-in { animation: scaleIn 0.4s cubic-bezier(0.22,1,0.36,1) both; } +.anim-pop { animation: pop 0.4s cubic-bezier(0.22,1,0.36,1) both; } .delay-0 { animation-delay: 0ms; } .delay-1 { animation-delay: 70ms; } @@ -174,4 +180,150 @@ a { color: inherit; text-decoration: none; } .nav-desktop { display: none; } .nav-hamburger { display: flex; margin-left: auto; } .grid-4col { grid-template-columns: repeat(2, 1fr); } + .change-btn { min-height: 44px; padding: 4px 12px; } +} + +/* ─── Focus rings (accessibility) ─── */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +/* ─── Shared tool page primitives ─── */ +.tool-stack { display: flex; flex-direction: column; gap: 14px; } + +.file-info-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + overflow: hidden; +} + +.file-info-row__body { + flex: 1; + overflow: hidden; +} + +.file-name { + font-size: 13px; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-size { + font-size: 10px; + color: var(--text-3); + margin-top: 2px; +} + +.change-btn { + background: none; + border: none; + cursor: pointer; + color: var(--text-3); + font-size: 12px; + font-family: inherit; + padding: 4px 8px; + transition: color 0.12s; + flex-shrink: 0; +} +.change-btn:hover { color: var(--text); } + +.section-label { + font-size: 12px; + color: var(--text-2); + font-weight: 500; + margin-bottom: 6px; +} + +.form-input { + width: 100%; + padding: 9px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 14px; + outline: none; + font-family: inherit; + transition: border-color 0.15s; +} +.form-input:focus { border-color: var(--accent); } + +/* ─── Segment control ─── */ +.seg-control { + display: flex; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; + background: var(--surface); +} + +.seg-btn { + flex: 1; + padding: 9px; + border: none; + cursor: pointer; + font-size: 13px; + font-weight: 600; + background: var(--surface-2); + color: var(--text-2); + font-family: inherit; + transition: background 0.12s, color 0.12s; +} +.seg-btn:not(:last-child) { border-right: 1px solid var(--border); } +.seg-btn.active { background: var(--accent); color: #fff; } + +/* ─── Preset grid button ─── */ +.preset-btn { + padding: 10px 6px; + text-align: center; + cursor: pointer; + font-family: inherit; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + transition: all 0.12s; +} +.preset-btn.active { + background: rgba(255,68,0,0.06); + border-color: rgba(255,68,0,0.35); +} + +/* ─── Progress shimmer ─── */ +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent) 0%, #ff7040 50%, var(--accent) 100%); + background-size: 200% 100%; + border-radius: 2px; + transition: width 0.25s ease; + animation: shimmer 1.6s linear infinite; +} + +/* ─── Page-drop overlay ─── */ +.page-drop-overlay { + position: fixed; + inset: 0; + z-index: 9998; + background: rgba(249,248,244,0.94); + backdrop-filter: blur(10px); + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.12s ease both; + pointer-events: none; +} +.page-drop-overlay__inner { + border: 2px dashed var(--accent); + border-radius: var(--radius); + padding: 48px 64px; + text-align: center; + animation: scaleIn 0.2s cubic-bezier(0.22,1,0.36,1) both; } diff --git a/app/image-to-pdf/page.tsx b/app/image-to-pdf/page.tsx index 0ad83bb..1891cd1 100644 --- a/app/image-to-pdf/page.tsx +++ b/app/image-to-pdf/page.tsx @@ -8,54 +8,61 @@ import FileList from '@/components/FileList' import { Err, Ok, ActionBtn } from '@/components/ToolUI' import { useCmdEnter } from '@/lib/useHotkey' import { imagesToPdf } from '@/lib/imageToPdf' +import { validateImages } from '@/lib/validate' function fmt(b: number) { - if (b<1024*1024) return (b/1024).toFixed(0)+' KB' - return (b/(1024*1024)).toFixed(2)+' MB' + if (b < 1024 * 1024) return (b / 1024).toFixed(0) + ' KB' + return (b / (1024 * 1024)).toFixed(2) + ' MB' } export default function ImageToPdfPage() { - const [files, setFiles] = useState([]) - const [thumbs, setThumbs] = useState([]) - const [status, setStatus] = useState<'idle'|'processing'|'done'|'error'>('idle') - const [error, setError] = useState('') - const [result, setResult] = useState<{size:number;pages:number}|null>(null) + const [files, setFiles] = useState([]) + const [thumbs, setThumbs] = useState([]) + const [status, setStatus] = useState<'idle' | 'processing' | 'done' | 'error'>('idle') + const [error, setError] = useState('') + const [result, setResult] = useState<{ size: number; pages: number } | null>(null) const prevThumbs = useRef([]) useEffect(() => { prevThumbs.current.forEach(URL.revokeObjectURL) }, [thumbs]) function addFiles(newFiles: File[]) { - setFiles(p=>[...p,...newFiles]) - setThumbs(p=>[...p,...newFiles.map(f=>URL.createObjectURL(f))]) - if (status==='done') setStatus('idle') + const err = validateImages(newFiles) + if (err) { setError(err); setStatus('error'); return } + setFiles(p => [...p, ...newFiles]) + setThumbs(p => [...p, ...newFiles.map(f => URL.createObjectURL(f))]) + if (status === 'done' || status === 'error') setStatus('idle') + setError('') } + function removeFile(i: number) { URL.revokeObjectURL(thumbs[i]) - setFiles(p=>p.filter((_,j)=>j!==i)) - setThumbs(p=>p.filter((_,j)=>j!==i)) - if (status==='done') setStatus('idle') + setFiles(p => p.filter((_, j) => j !== i)) + setThumbs(p => p.filter((_, j) => j !== i)) + if (status === 'done') setStatus('idle') } + function reorder(from: number, to: number) { - const mv = (a: T[]) => { const b=[...a]; const [x]=b.splice(from,1); b.splice(to,0,x); return b } + const mv = (a: T[]) => { const b = [...a]; const [x] = b.splice(from, 1); b.splice(to, 0, x); return b } setFiles(mv); setThumbs(mv) } + function reset() { thumbs.forEach(URL.revokeObjectURL) setFiles([]); setThumbs([]); setStatus('idle'); setResult(null); setError('') } - const canConvert = files.length>0 && status!=='processing' + const canConvert = files.length > 0 && status !== 'processing' const handleConvert = useCallback(async () => { if (!files.length) return setStatus('processing'); setError('') try { const bytes = await imagesToPdf(files) - const stem = files[0].name.replace(/\.[^.]+$/, '') - saveAs(new Blob([bytes.buffer as ArrayBuffer],{type:'application/pdf'}),`${stem}.pdf`) - setResult({size:bytes.length, pages:files.length}) + const stem = files[0].name.replace(/\.[^.]+$/, '') + saveAs(new Blob([bytes.buffer as ArrayBuffer], { type: 'application/pdf' }), `${stem}.pdf`) + setResult({ size: bytes.length, pages: files.length }) setStatus('done') - } catch(e) { setError(String(e)); setStatus('error') } + } catch (e) { setError(String(e)); setStatus('error') } }, [files]) useCmdEnter(handleConvert, canConvert) @@ -63,20 +70,19 @@ export default function ImageToPdfPage() { return ( -
- +
- {files.length>0 && } + {files.length > 0 && } - {status==='error' && } - {status==='done' && result && ( - 1?'s':''} · ${fmt(result.size)}`} onReset={reset} /> + {status === 'error' && } + {status === 'done' && result && ( + 1 ? 's' : ''} · ${fmt(result.size)}`} onReset={reset} /> )} - - {files.length>0 ? `Build PDF from ${files.length} image${files.length>1?'s':''} →` : 'Build PDF →'} + + {files.length > 0 ? `Build PDF from ${files.length} image${files.length > 1 ? 's' : ''} →` : 'Build PDF →'}
diff --git a/app/layout.tsx b/app/layout.tsx index a6f10b9..63ece84 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,8 +1,9 @@ -import type { Metadata } from 'next' +import type { Metadata, Viewport } from 'next' import { Inter, Roboto_Mono } from 'next/font/google' import './globals.css' import TopLoader from '@/components/TopLoader' import AppLoader from '@/components/AppLoader' +import PwaInit from '@/components/PwaInit' const inter = Inter({ subsets: ['latin'], @@ -20,13 +21,32 @@ const robotoMono = Roboto_Mono({ export const metadata: Metadata = { title: 'pdfkit — free browser-side PDF tools', - description: 'Merge, split, compress, and convert PDFs. No uploads. No accounts. Runs in your browser.', + description: 'Merge, split, compress, rotate, watermark, and convert PDFs. No uploads. No accounts. Runs entirely in your browser.', + manifest: '/manifest.json', + icons: { icon: '/icon.svg', apple: '/icon.svg' }, + appleWebApp: { + capable: true, + statusBarStyle: 'default', + title: 'pdfkit', + }, + openGraph: { + title: 'pdfkit — free browser-side PDF tools', + description: 'No uploads. No accounts. Everything runs locally in your browser.', + type: 'website', + }, +} + +export const viewport: Viewport = { + themeColor: '#ff4400', + width: 'device-width', + initialScale: 1, } export default function RootLayout({ children }: { children: React.ReactNode }) { return ( + {children} diff --git a/app/merge/page.tsx b/app/merge/page.tsx index 3b0daf4..e0998ae 100644 --- a/app/merge/page.tsx +++ b/app/merge/page.tsx @@ -9,28 +9,41 @@ import ProgressBar from '@/components/ProgressBar' import { Err, Ok, ActionBtn } from '@/components/ToolUI' import { useCmdEnter } from '@/lib/useHotkey' import { mergePdfs } from '@/lib/mergePdf' +import { validatePdfs } from '@/lib/validate' export default function MergePage() { const [files, setFiles] = useState([]) - const [status, setStatus] = useState<'idle'|'processing'|'done'|'error'>('idle') + const [status, setStatus] = useState<'idle' | 'processing' | 'done' | 'error'>('idle') const [error, setError] = useState('') const [resultMsg, setResultMsg] = useState('') const canMerge = files.length >= 2 && status !== 'processing' - function addFiles(newFiles: File[]) { setFiles(p => [...p, ...newFiles]); if (status==='done') setStatus('idle') } - function removeFile(i: number) { setFiles(p => p.filter((_,j)=>j!==i)); if (status==='done') setStatus('idle') } + function addFiles(newFiles: File[]) { + const err = validatePdfs(newFiles) + if (err) { setError(err); setStatus('error'); return } + setFiles(p => [...p, ...newFiles]) + if (status === 'done' || status === 'error') setStatus('idle') + setError('') + } + + function removeFile(i: number) { + setFiles(p => p.filter((_, j) => j !== i)) + if (status === 'done') setStatus('idle') + } + function reorder(from: number, to: number) { - setFiles(p => { const a=[...p]; const [x]=a.splice(from,1); a.splice(to,0,x); return a }) + setFiles(p => { const a = [...p]; const [x] = a.splice(from, 1); a.splice(to, 0, x); return a }) } + function reset() { setFiles([]); setStatus('idle'); setError('') } const handleMerge = useCallback(async () => { if (!canMerge) return setStatus('processing'); setError('') try { - const bytes = await mergePdfs(files) - const stem = files[0].name.replace(/\.pdf$/i, '') + const bytes = await mergePdfs(files) + const stem = files[0].name.replace(/\.pdf$/i, '') const outName = `${stem}_merged.pdf` saveAs(new Blob([bytes.buffer as ArrayBuffer], { type: 'application/pdf' }), outName) setResultMsg(`${outName} saved — ${files.length} files combined`) @@ -42,23 +55,23 @@ export default function MergePage() { return ( -
+
{files.length > 0 && } {files.length === 1 && status === 'idle' && ( -

+

↑ add at least one more PDF to merge

)} {status === 'processing' && } - {status === 'error' && } + {status === 'error' && } {status === 'done' && } - + Merge {files.length >= 2 ? `${files.length} PDFs` : 'PDFs'} →
diff --git a/app/metadata/page.tsx b/app/metadata/page.tsx new file mode 100644 index 0000000..467f151 --- /dev/null +++ b/app/metadata/page.tsx @@ -0,0 +1,106 @@ +'use client' + +import { useState, useCallback } from 'react' +import { saveAs } from 'file-saver' +import ToolLayout from '@/components/ToolLayout' +import DropZone from '@/components/DropZone' +import { Err, Ok, ActionBtn } from '@/components/ToolUI' +import { useCmdEnter } from '@/lib/useHotkey' +import { getPdfMetadata, editMetadata, PdfMetadata } from '@/lib/editMetadata' +import { validatePdf } from '@/lib/validate' + +const FIELDS: { key: keyof PdfMetadata; label: string; placeholder: string }[] = [ + { key: 'title', label: 'Title', placeholder: 'Document title' }, + { key: 'author', label: 'Author', placeholder: 'Author name' }, + { key: 'subject', label: 'Subject', placeholder: 'Subject or description' }, + { key: 'keywords', label: 'Keywords', placeholder: 'keyword1, keyword2, ...' }, + { key: 'creator', label: 'Creator', placeholder: 'App or tool that created this' }, +] + +export default function MetadataPage() { + const [file, setFile] = useState(null) + const [meta, setMeta] = useState({}) + const [loading, setLoading] = useState(false) + const [status, setStatus] = useState<'idle' | 'processing' | 'done' | 'error'>('idle') + const [error, setError] = useState('') + + const canSave = !!file && status !== 'processing' && !loading + + function handleFiles([f]: File[]) { + const err = validatePdf(f) + if (err) { setError(err); setStatus('error'); return } + setFile(f); setStatus('idle'); setError(''); setMeta({}) + setLoading(true) + getPdfMetadata(f) + .then(m => setMeta(m)) + .catch(() => setMeta({})) + .finally(() => setLoading(false)) + } + + function reset() { setFile(null); setMeta({}); setStatus('idle'); setError('') } + + const handleSave = useCallback(async () => { + if (!file) return + setStatus('processing'); setError('') + try { + const bytes = await editMetadata(file, meta) + const stem = file.name.replace(/\.pdf$/i, '') + saveAs(new Blob([bytes.buffer as ArrayBuffer], { type: 'application/pdf' }), `${stem}_meta.pdf`) + setStatus('done') + } catch (e) { setError(String(e)); setStatus('error') } + }, [file, meta]) + + useCmdEnter(handleSave, canSave) + + function setField(key: keyof PdfMetadata, value: string) { + setMeta(m => ({ ...m, [key]: value })) + if (status === 'done') setStatus('idle') + } + + return ( + +
+ + {!file ? ( + + ) : ( +
+ {file.name} + +
+ )} + + {file && loading && ( +
+ reading metadata... +
+ )} + + {file && !loading && ( +
+ {FIELDS.map(f => ( +
+

{f.label}

+ setField(f.key, e.target.value)} + placeholder={f.placeholder} + className="form-input" + /> +
+ ))} +
+ )} + + {status === 'error' && } + {status === 'done' && } + + + Save Metadata → + +
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx index 9a35dd5..eff727e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,71 +1,101 @@ 'use client' import Link from 'next/link' -import { useRef, useEffect } from 'react' +import { useRef, useEffect, useState } from 'react' import Navbar from '@/components/Navbar' +import { useRecentTools } from '@/lib/useRecentTools' const tools = [ - { href: '/compress', code: '01', label: 'Compress PDF', desc: 'Reduce file size with quality presets. Best suited for scanned documents.', symbol: '⊘' }, - { href: '/merge', code: '02', label: 'Merge PDF', desc: 'Combine multiple PDFs into one file. Drag rows to set the order before merging.', symbol: '⊕' }, - { href: '/split', code: '03', label: 'Split PDF', desc: 'Extract individual pages or define custom ranges into separate files.', symbol: '⊗' }, - { href: '/pdf-to-image', code: '04', label: 'PDF → Image', desc: 'Render each PDF page as a JPEG or PNG image at any resolution.', symbol: '◫' }, - { href: '/image-to-pdf', code: '05', label: 'Image → PDF', desc: 'Pack JPG and PNG files into a single PDF. Drag to set page order.', symbol: '◨' }, - { href: '/protect', code: '06', label: 'Protect PDF', desc: 'Lock a PDF with a password. Encrypted output works in any PDF reader.', symbol: '⊛' }, + { href: '/compress', code: '01', label: 'Compress PDF', desc: 'Reduce file size with quality presets. Best suited for scanned documents.', symbol: '⊘' }, + { href: '/merge', code: '02', label: 'Merge PDF', desc: 'Combine multiple PDFs into one file. Drag rows to set the order before merging.', symbol: '⊕' }, + { href: '/split', code: '03', label: 'Split PDF', desc: 'Extract individual pages or define custom ranges into separate files.', symbol: '⊗' }, + { href: '/pdf-to-image', code: '04', label: 'PDF → Image', desc: 'Render each PDF page as a JPEG or PNG image at any resolution.', symbol: '◫' }, + { href: '/image-to-pdf', code: '05', label: 'Image → PDF', desc: 'Pack JPG and PNG files into a single PDF. Drag to set page order.', symbol: '◨' }, + { href: '/protect', code: '06', label: 'Protect PDF', desc: 'Lock a PDF with a password. Encrypted output works in any PDF reader.', symbol: '⊛' }, + { href: '/rotate', code: '07', label: 'Rotate Pages', desc: 'Rotate all pages in a PDF clockwise by 90°, 180°, or 270°.', symbol: '↻' }, + { href: '/watermark', code: '08', label: 'Watermark PDF', desc: 'Stamp a text watermark on every page. Control opacity and angle.', symbol: '◈' }, + { href: '/metadata', code: '09', label: 'Edit Metadata', desc: 'View and update embedded title, author, subject, keywords, and creator fields.', symbol: '⊞' }, ] -function ToolCard({ tool, index }: { tool: typeof tools[0]; index: number }) { +function highlight(text: string, query: string) { + if (!query) return <>{text} + const idx = text.toLowerCase().indexOf(query.toLowerCase()) + if (idx === -1) return <>{text} + return ( + <> + {text.slice(0, idx)} + + {text.slice(idx, idx + query.length)} + + {text.slice(idx + query.length)} + + ) +} + +function ToolCard({ tool, index, query }: { tool: typeof tools[0]; index: number; query: string }) { const ref = useRef(null) - const delayClass = (['delay-0','delay-1','delay-2','delay-3','delay-4'] as const)[index] + const delayClass = (['delay-0', 'delay-1', 'delay-2', 'delay-3', 'delay-4', 'delay-5'] as const)[Math.min(index, 5)] function onMouseMove(e: React.MouseEvent) { const el = ref.current; if (!el) return const r = el.getBoundingClientRect() - el.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%') + el.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%') el.style.setProperty('--my', ((e.clientY - r.top) / r.height * 100) + '%') } return ( - - {/* Top: number + symbol */} +
- - {tool.code} - - - {tool.symbol} - + {tool.code} + {tool.symbol}
- - {/* Middle: text */}

- {tool.label} + {highlight(tool.label, query)}

- {tool.desc} + {highlight(tool.desc, query)}

- - {/* Bottom: arrow */}
- - → - +
- ) } export default function HomePage() { + const [query, setQuery] = useState('') + const inputRef = useRef(null) + const [recents] = useRecentTools() + + const recentTools = recents.map(href => tools.find(t => t.href === href)).filter(Boolean) as typeof tools + + const filtered = query.trim() + ? tools.filter(t => { + const q = query.toLowerCase() + return t.label.toLowerCase().includes(q) || t.desc.toLowerCase().includes(q) + }) + : tools + + // "/" focuses search, Escape clears + useEffect(() => { + function handler(e: KeyboardEvent) { + if (e.key === '/' && document.activeElement !== inputRef.current) { + e.preventDefault() + inputRef.current?.focus() + } + if (e.key === 'Escape') { + setQuery('') + inputRef.current?.blur() + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, []) + useEffect(() => { - // Prefetch heavy libs used by compress + image tools import('pdfjs-dist') import('pdf-lib') }, []) @@ -76,7 +106,7 @@ export default function HomePage() {
- {/* Hero — smaller, tighter */} + {/* Hero */}

FREE · NO UPLOAD · NO ACCOUNT @@ -93,10 +123,109 @@ export default function HomePage() {

- {/* Inline cards */} -
- {tools.map((t, i) => )} + {/* Search bar */} +
+
+ + setQuery(e.target.value)} + placeholder="Search tools…" + aria-label="Search tools" + style={{ + width: '100%', padding: '9px 36px 9px 32px', + background: 'var(--surface)', border: '1px solid var(--border)', + borderRadius: 'var(--radius)', color: 'var(--text)', + fontSize: 14, outline: 'none', fontFamily: 'inherit', + transition: 'border-color 0.15s, box-shadow 0.15s', + }} + onFocus={e => { + e.target.style.borderColor = 'var(--accent)' + e.target.style.boxShadow = '0 0 0 3px rgba(255,68,0,0.08)' + }} + onBlur={e => { + e.target.style.borderColor = 'var(--border)' + e.target.style.boxShadow = 'none' + }} + /> + {query ? ( + + ) : ( + / + )} +
+ + {/* Recently used */} + {!query && recentTools.length > 0 && ( +
+ RECENT + {recentTools.map(t => ( + { + const el = e.currentTarget as HTMLAnchorElement + el.style.borderColor = 'rgba(255,68,0,0.3)' + el.style.color = 'var(--accent)' + el.style.background = 'rgba(255,68,0,0.04)' + }} + onMouseLeave={e => { + const el = e.currentTarget as HTMLAnchorElement + el.style.borderColor = 'var(--border)' + el.style.color = 'var(--text-2)' + el.style.background = 'var(--surface)' + }}> + {t.symbol} + {t.label} + + ))} +
+ )} + + {/* Tool cards */} + {filtered.length > 0 ? ( +
+ {filtered.map((t, i) => )} +
+ ) : ( +
+

+

+ No tools match “{query}” +

+ +
+ )}