Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
65 changes: 45 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -41,6 +44,9 @@ npm install
# Run dev server
npm run dev

# Run tests
npm test

# Build for production
npm run build
```
Expand All @@ -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
Expand Down
115 changes: 58 additions & 57 deletions app/compress/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,73 +8,74 @@ 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<File|null>(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<File | null>(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('') }

const handleCompress = useCallback(async () => {
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 (
<ToolLayout code="03 / COMPRESS" title="Compress PDF"
subtitle="Converts pages to compressed images. Best for scanned documents — text won't be selectable after.">
<div style={{display:'flex',flexDirection:'column',gap:14}}>
<div className="tool-stack">

{!file ? (
<DropZone accept=".pdf" onFiles={([f])=>setFile(f)} label="Drop a PDF file here" />
<DropZone accept=".pdf" onFiles={handleFiles} label="Drop a PDF file here" />
) : (
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius-sm)',overflow:'hidden',background:'var(--surface)'}}>
<div style={{display:'flex',alignItems:'center',gap:12,padding:'10px 14px'}}>
<div style={{flex:1,overflow:'hidden'}}>
<p style={{fontSize:13,color:'var(--text)',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{file.name}</p>
<p className="mono" style={{fontSize:10,color:'var(--text-3)',marginTop:2}}>{fmt(file.size)}</p>
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', overflow: 'hidden', background: 'var(--surface)' }}>
<div className="file-info-row" style={{ border: 'none', borderRadius: 0 }}>
<div className="file-info-row__body">
<p className="file-name">{file.name}</p>
<p className="mono file-size">{fmt(file.size)}</p>
</div>
<button onClick={reset} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-3)',fontSize:12,fontFamily:'inherit'}}>change</button>
<button onClick={reset} className="change-btn">change</button>
</div>
{file.size < 150*1024 && (
<div className="mono" style={{padding:'6px 14px',background:'var(--surface-2)',borderTop:'1px solid var(--border)',fontSize:10,color:'var(--text-3)'}}>
{file.size < 150 * 1024 && (
<div className="mono" style={{ padding: '6px 14px', background: 'var(--surface-2)', borderTop: '1px solid var(--border)', fontSize: 10, color: 'var(--text-3)' }}>
↑ file already small — compression benefit may be minimal
</div>
)}
Expand All @@ -83,45 +84,45 @@ export default function CompressPage() {

{file && (
<div>
<p style={{fontSize:12,color:'var(--text-2)',marginBottom:8,fontWeight:500}}>Quality preset</p>
<p className="section-label">Quality preset</p>
<div className="grid-4col">
{presets.map(p=>(
<button key={p.id} onClick={()=>setPreset(p.id)} style={presetBtnStyle(preset===p.id)}>
<p style={{fontSize:13,fontWeight:700,color:preset===p.id?'var(--accent)':'var(--text)',marginBottom:2}}>{p.label}</p>
<p className="mono" style={{fontSize:10,color:'var(--text-3)'}}>{p.note}</p>
{presets.map(p => (
<button key={p.id} onClick={() => setPreset(p.id)} className={`preset-btn${preset === p.id ? ' active' : ''}`}>
<p style={{ fontSize: 13, fontWeight: 700, color: preset === p.id ? 'var(--accent)' : 'var(--text)', marginBottom: 2 }}>{p.label}</p>
<p className="mono" style={{ fontSize: 10, color: 'var(--text-3)' }}>{p.note}</p>
</button>
))}
</div>

{preset==='custom' && (
<div style={{marginTop:12}}>
<div style={{display:'flex',justifyContent:'space-between',marginBottom:6}}>
<span style={{fontSize:12,color:'var(--text-2)'}}>Quality</span>
<span className="mono" style={{fontSize:11,color:'var(--accent)',fontWeight:600}}>{Math.round(customQ*100)}%</span>
{preset === 'custom' && (
<div style={{ marginTop: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>Quality</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--accent)', fontWeight: 600 }}>{Math.round(customQ * 100)}%</span>
</div>
<input type="range" min={0.1} max={1} step={0.05} value={customQ}
onChange={e=>setCustomQ(Number(e.target.value))}
style={{width:'100%',accentColor:'var(--accent)',cursor:'pointer'}} />
<div style={{display:'flex',justifyContent:'space-between',marginTop:4}}>
<span className="mono" style={{fontSize:10,color:'var(--text-3)'}}>smallest</span>
<span className="mono" style={{fontSize:10,color:'var(--text-3)'}}>best quality</span>
onChange={e => setCustomQ(Number(e.target.value))}
style={{ width: '100%', accentColor: 'var(--accent)', cursor: 'pointer' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
<span className="mono" style={{ fontSize: 10, color: 'var(--text-3)' }}>smallest</span>
<span className="mono" style={{ fontSize: 10, color: 'var(--text-3)' }}>best quality</span>
</div>
</div>
)}
</div>
)}

{status==='processing' && <ProgressBar current={progress.current} total={progress.total} label="Compressing pages" />}
{status==='error' && <Err msg={error} />}
{status==='done' && result && (
{status === 'processing' && <ProgressBar current={progress.current} total={progress.total} label="Compressing pages" />}
{status === 'error' && <Err msg={error} onRetry={file ? handleCompress : undefined} />}
{status === 'done' && result && (
<Ok msg={(() => {
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} />
)}

<ActionBtn onClick={handleCompress} disabled={!canCompress} loading={status==='processing'} hint="⌘ Enter">
<ActionBtn onClick={handleCompress} disabled={!canCompress} loading={status === 'processing'} hint="⌘ Enter">
Compress PDF →
</ActionBtn>
</div>
Expand Down
Loading
Loading