Skip to content

Commit 02baf61

Browse files
committed
Last Sync: 2026-01-26 18:11 (Mobile)
1 parent 2e4dd98 commit 02baf61

13 files changed

Lines changed: 523 additions & 1 deletion

File tree

.github/workflows/android.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Build Android APK
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
8+
jobs:
9+
build:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- name: Checkout Code
13+
uses: actions/checkout@v3
14+
15+
- name: Setup Node.js
16+
uses: actions/setup-node@v3
17+
with:
18+
node-version: 18
19+
20+
- name: Setup Java
21+
uses: actions/setup-java@v3
22+
with:
23+
distribution: 'zulu'
24+
java-version: '17'
25+
26+
- name: Install Dependencies
27+
run: npm install
28+
29+
- name: Build Web Assets
30+
run: npm run build
31+
32+
- name: Initialize Capacitor Android
33+
run: |
34+
npx cap add android
35+
npx cap sync
36+
37+
- name: Build APK (Debug)
38+
working-directory: ./android
39+
run: ./gradlew assembleDebug
40+
41+
- name: Upload APK
42+
uses: actions/upload-artifact@v3
43+
with:
44+
name: paperknife-debug
45+
path: android/app/build/outputs/apk/debug/app-debug.apk

.gitignore

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Logs
2+
logs
3+
*.log
4+
npm-debug.log*
5+
yarn-debug.log*
6+
yarn-error.log*
7+
pnpm-debug.log*
8+
lerna-debug.log*
9+
10+
node_modules
11+
dist
12+
dist-ssr
13+
*.local
14+
15+
# Editor directories and files
16+
.vscode/*
17+
!.vscode/extensions.json
18+
.idea
19+
.DS_Store
20+
*.suo
21+
*.ntvs*
22+
*.njsproj
23+
*.sln
24+
*.sw?

App.tsx

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { Layers, Scissors, Zap, Moon, Sun, Info, ArrowRight, Download, Trash2, FileText, CheckCircle } from 'lucide-react';
3+
import { ToolCard } from './components/ToolCard';
4+
import { mergePDFs, createDownloadLink } from './utils/pdfOps';
5+
6+
enum ToolType {
7+
NONE,
8+
MERGE,
9+
SPLIT,
10+
COMPRESS
11+
}
12+
13+
const App: React.FC = () => {
14+
const [darkMode, setDarkMode] = useState(false);
15+
const [selectedTool, setSelectedTool] = useState<ToolType>(ToolType.NONE);
16+
const [files, setFiles] = useState<File[]>([]);
17+
const [isProcessing, setIsProcessing] = useState(false);
18+
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
19+
20+
useEffect(() => {
21+
if (darkMode) {
22+
document.documentElement.classList.add('dark');
23+
} else {
24+
document.documentElement.classList.remove('dark');
25+
}
26+
}, [darkMode]);
27+
28+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
29+
if (e.target.files) {
30+
setFiles(Array.from(e.target.files));
31+
setDownloadUrl(null);
32+
}
33+
};
34+
35+
const handleProcess = async () => {
36+
if (files.length === 0) return;
37+
setIsProcessing(true);
38+
39+
try {
40+
let resultData: Uint8Array | null = null;
41+
let filename = 'processed.pdf';
42+
43+
// NOTE: For this V1, only Merge is fully implemented in logic to save space.
44+
// Other tools show the UI flow but perform a pass-through or placeholder action.
45+
if (selectedTool === ToolType.MERGE) {
46+
resultData = await mergePDFs(files);
47+
filename = 'paperknife-merged.pdf';
48+
} else {
49+
// Placeholder for Split/Compress in V1
50+
alert("This Vibecoder is strictly MVP! Only Merge is live. Others coming in V2.");
51+
setIsProcessing(false);
52+
return;
53+
}
54+
55+
if (resultData) {
56+
const url = await createDownloadLink(resultData, filename);
57+
setDownloadUrl(url);
58+
}
59+
} catch (err) {
60+
console.error(err);
61+
alert("Failed to process PDF. It might be encrypted.");
62+
} finally {
63+
setIsProcessing(false);
64+
}
65+
};
66+
67+
const resetTool = () => {
68+
setSelectedTool(ToolType.NONE);
69+
setFiles([]);
70+
setDownloadUrl(null);
71+
};
72+
73+
return (
74+
<div className="min-h-screen flex flex-col font-sans selection:bg-swissRed selection:text-white">
75+
{/* Header */}
76+
<header className="sticky top-0 z-50 bg-white/80 dark:bg-black/80 backdrop-blur-md border-b-2 border-black dark:border-white">
77+
<div className="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
78+
<div className="flex items-center gap-3 cursor-pointer" onClick={resetTool}>
79+
<div className="bg-swissRed text-white p-2 rounded-md">
80+
<Zap size={24} strokeWidth={3} />
81+
</div>
82+
<h1 className="text-2xl font-extrabold tracking-tighter uppercase">PaperKnife</h1>
83+
</div>
84+
<button
85+
onClick={() => setDarkMode(!darkMode)}
86+
className="p-3 border-2 border-black dark:border-white rounded-full hover:bg-black hover:text-white dark:hover:bg-white dark:hover:text-black transition-colors"
87+
>
88+
{darkMode ? <Sun size={20} /> : <Moon size={20} />}
89+
</button>
90+
</div>
91+
</header>
92+
93+
{/* Main Content */}
94+
<main className="flex-grow px-6 py-8 max-w-4xl mx-auto w-full">
95+
96+
{/* Intro Text */}
97+
{selectedTool === ToolType.NONE && (
98+
<div className="mb-10 text-center space-y-4">
99+
<h2 className="text-4xl md:text-5xl font-black leading-tight">
100+
The Swiss Army Knife<br/>
101+
<span className="text-swissRed">For Your Documents.</span>
102+
</h2>
103+
<p className="text-lg font-medium text-gray-600 dark:text-gray-400 max-w-lg mx-auto">
104+
100% Client-side. No data leaves your device. Fast, private, and bloat-free.
105+
</p>
106+
</div>
107+
)}
108+
109+
{/* Bento Grid Selection */}
110+
{selectedTool === ToolType.NONE ? (
111+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
112+
<ToolCard
113+
title="Merge PDFs"
114+
description="Combine multiple files into one master document."
115+
icon={<Layers size={28} />}
116+
onClick={() => setSelectedTool(ToolType.MERGE)}
117+
/>
118+
<ToolCard
119+
title="Split PDF"
120+
description="Extract pages or cut a document in half."
121+
icon={<Scissors size={28} />}
122+
onClick={() => setSelectedTool(ToolType.SPLIT)}
123+
/>
124+
{/* Placeholder Visuals for V2 */}
125+
<div className="md:col-span-2 p-6 border-2 border-dashed border-gray-300 dark:border-zinc-700 rounded-xl flex items-center justify-center gap-3 text-gray-400 dark:text-zinc-600">
126+
<Info size={20} />
127+
<span className="font-bold">More tools (Compress, Sign, Convert) coming soon.</span>
128+
</div>
129+
</div>
130+
) : (
131+
/* Tool Workspace */
132+
<div className="animate-in fade-in slide-in-from-bottom-4 duration-300">
133+
<div className="flex items-center gap-2 mb-6 text-sm font-bold text-gray-500 uppercase tracking-widest">
134+
<button onClick={resetTool} className="hover:text-swissRed">Tools</button>
135+
<span>/</span>
136+
<span className="text-black dark:text-white">{selectedTool === ToolType.MERGE ? "Merge" : "Tool"}</span>
137+
</div>
138+
139+
<div className="border-2 border-black dark:border-white rounded-xl p-6 md:p-8 bg-white dark:bg-zinc-900 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] dark:shadow-[8px_8px_0px_0px_rgba(255,255,255,1)]">
140+
<h3 className="text-3xl font-black mb-6 flex items-center gap-3">
141+
{selectedTool === ToolType.MERGE && <Layers size={32} className="text-swissRed" />}
142+
{selectedTool === ToolType.MERGE ? "Merge PDFs" : "Process PDF"}
143+
</h3>
144+
145+
{!downloadUrl ? (
146+
<div className="space-y-6">
147+
{/* Upload Area */}
148+
<label className="block w-full cursor-pointer group">
149+
<input
150+
type="file"
151+
multiple={selectedTool === ToolType.MERGE}
152+
accept=".pdf"
153+
onChange={handleFileChange}
154+
className="hidden"
155+
/>
156+
<div className="border-4 border-dashed border-gray-200 dark:border-zinc-700 rounded-xl p-10 flex flex-col items-center justify-center gap-4 group-hover:border-swissRed group-hover:bg-red-50 dark:group-hover:bg-red-900/10 transition-colors">
157+
<div className="bg-black dark:bg-white text-white dark:text-black p-4 rounded-full">
158+
<FileText size={32} />
159+
</div>
160+
<div className="text-center">
161+
<p className="text-xl font-bold">Tap to select files</p>
162+
<p className="text-gray-500 mt-1">Select {selectedTool === ToolType.MERGE ? "multiple" : "a"} PDF{selectedTool === ToolType.MERGE && "s"}</p>
163+
</div>
164+
</div>
165+
</label>
166+
167+
{/* File List */}
168+
{files.length > 0 && (
169+
<div className="space-y-2">
170+
<h4 className="font-bold text-sm uppercase text-gray-500">Selected Files:</h4>
171+
<div className="flex flex-wrap gap-2">
172+
{files.map((f, i) => (
173+
<div key={i} className="flex items-center gap-2 bg-gray-100 dark:bg-zinc-800 px-3 py-2 rounded-md border border-gray-200 dark:border-zinc-700">
174+
<FileText size={16} />
175+
<span className="text-sm font-medium truncate max-w-[150px]">{f.name}</span>
176+
</div>
177+
))}
178+
<button onClick={() => setFiles([])} className="text-red-500 p-2 hover:bg-red-50 rounded-md">
179+
<Trash2 size={18} />
180+
</button>
181+
</div>
182+
</div>
183+
)}
184+
185+
{/* Action Button */}
186+
<button
187+
disabled={files.length === 0 || isProcessing}
188+
onClick={handleProcess}
189+
className="w-full py-4 bg-black dark:bg-white text-white dark:text-black text-lg font-bold rounded-lg hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-3 transition-all"
190+
>
191+
{isProcessing ? "Processing..." : "Run Tool"}
192+
{!isProcessing && <ArrowRight size={20} strokeWidth={3} />}
193+
</button>
194+
</div>
195+
) : (
196+
/* Success State */
197+
<div className="text-center py-10 space-y-6 animate-in zoom-in-95 duration-200">
198+
<div className="mx-auto w-20 h-20 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center text-green-600 dark:text-green-400">
199+
<CheckCircle size={40} strokeWidth={3} />
200+
</div>
201+
<h4 className="text-2xl font-bold">Ready to go!</h4>
202+
<a
203+
href={downloadUrl}
204+
download={selectedTool === ToolType.MERGE ? "paperknife-merged.pdf" : "processed.pdf"}
205+
className="block w-full py-4 bg-swissRed text-white text-lg font-bold rounded-lg hover:bg-red-700 flex items-center justify-center gap-3 shadow-lg"
206+
>
207+
<Download size={24} />
208+
Download File
209+
</a>
210+
<button onClick={resetTool} className="text-sm font-bold text-gray-500 hover:text-black dark:hover:text-white underline">
211+
Process another file
212+
</button>
213+
</div>
214+
)}
215+
</div>
216+
</div>
217+
)}
218+
</main>
219+
220+
{/* Footer */}
221+
<footer className="py-6 text-center border-t-2 border-black dark:border-white mt-auto">
222+
<p className="font-bold text-sm">PAPERKNIFE &copy; {new Date().getFullYear()}</p>
223+
<p className="text-xs text-gray-500 mt-1">Built with vibes. Zero Analytics.</p>
224+
</footer>
225+
</div>
226+
);
227+
};
228+
229+
export default App;

README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,20 @@
1-
# PaperKnife
1+
<div align="center">
2+
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
3+
</div>
4+
5+
# Run and deploy your AI Studio app
6+
7+
This contains everything you need to run your app locally.
8+
9+
View your app in AI Studio: https://ai.studio/apps/drive/1O4JnZamNVOPpCRc1ZkekRh5qheb0Dg3m
10+
11+
## Run Locally
12+
13+
**Prerequisites:** Node.js
14+
15+
16+
1. Install dependencies:
17+
`npm install`
18+
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
19+
3. Run the app:
20+
`npm run dev`

capacitor.config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { CapacitorConfig } from '@capacitor/cli';
2+
3+
const config: CapacitorConfig = {
4+
appId: 'com.paperknife.app',
5+
appName: 'PaperKnife',
6+
webDir: 'dist',
7+
server: {
8+
androidScheme: 'https'
9+
}
10+
};
11+
12+
export default config;

components/ToolCard.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import React from 'react';
2+
import { clsx, type ClassValue } from 'clsx';
3+
import { twMerge } from 'tailwind-merge';
4+
5+
function cn(...inputs: ClassValue[]) {
6+
return twMerge(clsx(inputs));
7+
}
8+
9+
interface ToolCardProps {
10+
title: string;
11+
description: string;
12+
icon: React.ReactNode;
13+
onClick: () => void;
14+
active?: boolean;
15+
}
16+
17+
export const ToolCard: React.FC<ToolCardProps> = ({ title, description, icon, onClick, active }) => {
18+
return (
19+
<button
20+
onClick={onClick}
21+
className={cn(
22+
"flex flex-col items-start p-6 w-full text-left transition-all duration-200",
23+
"border-2 border-black dark:border-white rounded-xl",
24+
"hover:translate-x-[-2px] hover:translate-y-[-2px] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,1)]",
25+
active
26+
? "bg-swissRed text-white border-swissRed dark:border-swissRed shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,1)]"
27+
: "bg-white dark:bg-zinc-900"
28+
)}
29+
>
30+
<div className={cn("mb-4 p-2 rounded-lg border-2 border-current", active ? "bg-white/20" : "bg-gray-100 dark:bg-zinc-800")}>
31+
{icon}
32+
</div>
33+
<h3 className="text-xl font-bold tracking-tight mb-1">{title}</h3>
34+
<p className={cn("text-sm font-medium", active ? "text-white/90" : "text-gray-500 dark:text-gray-400")}>
35+
{description}
36+
</p>
37+
</button>
38+
);
39+
};

0 commit comments

Comments
 (0)