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
Binary file removed .DS_Store
Binary file not shown.
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: CI

on:
push:
branches: [master]
pull_request:

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run build
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ Compressly is a **privacy-first file compression tool** that lets you reduce fil

## Screenshots

![Compressly - Main screen | haptichash](https://raw.githubusercontent.com/HapticHash/compressly/refs/heads/master/public/assets/compressly_screenshot1.png)
![Compressly - Files upload screen | haptichash](https://raw.githubusercontent.com/HapticHash/compressly/refs/heads/master/public/assets/compressly_screenshot2.png)
![Compressly - Files compressed screen | haptichash](https://raw.githubusercontent.com/HapticHash/compressly/refs/heads/master/public/assets/compressly_screenshot3.png)
![Compressly - Main screen | haptichash](docs/screenshots/compressly_screenshot1.png)
![Compressly - Files upload screen | haptichash](docs/screenshots/compressly_screenshot2.png)
![Compressly - Files compressed screen | haptichash](docs/screenshots/compressly_screenshot3.png)

---

Expand Down Expand Up @@ -67,7 +67,14 @@ npm install
npm run dev
```

The app will be available at `http://localhost:5173` by default.
The app will be available at `http://localhost:3000` by default.

### Checks

```bash
npm run lint # type-check
npm test # unit tests
```

### Building for Production

Expand Down Expand Up @@ -102,10 +109,14 @@ The `functions/api` directory is automatically picked up by Cloudflare Pages as

```
compressly/
├── docs/
│ └── screenshots/ # README images (not deployed)
├── functions/
│ └── api/ # Cloudflare Worker API routes
├── public/ # Static assets
├── src/ # Frontend source (TypeScript)
├── src/
│ ├── components/ # UI components
│ └── lib/ # Compression logic, loaded on demand per file type
├── index.html # App entry point
├── vite.config.ts # Vite configuration
├── wrangler.toml # Cloudflare Workers/Pages config
Expand Down
109 changes: 68 additions & 41 deletions functions/api/stats.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,83 @@
const STATS_KEY = 'stats';
// Upper bounds for a single report; anything larger is rejected as bogus.
const MAX_FILES_PER_REPORT = 1000;
const MAX_BYTES_PER_REPORT = 100 * 1024 ** 3; // 100 GB

interface Stats {
totalFilesCompressed: number;
totalDataSaved: number;
}

function json(data: unknown, init: ResponseInit = {}) {
return new Response(JSON.stringify(data), {
...init,
headers: { 'Content-Type': 'application/json', ...init.headers },
});
}

function isValidCount(value: unknown, max: number): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= max;
}

// Both totals live in one key, so each update costs a single KV write.
// Falls back to the legacy per-counter keys the first time it runs.
async function readStats(kv: any): Promise<Stats> {
const stored = await kv.get(STATS_KEY, 'json');
if (stored) return stored;
const [files, saved] = await Promise.all([
kv.get('totalFilesCompressed'),
kv.get('totalDataSaved'),
]);
return {
totalFilesCompressed: parseInt(files || '0', 10) || 0,
totalDataSaved: parseInt(saved || '0', 10) || 0,
};
}

export async function onRequestGet(context: any) {
try {
const { env } = context;
// COMPRESSLY_STATS is the KV namespace binding
let totalFilesCompressed = await env.COMPRESSLY_STATS.get('totalFilesCompressed');
let totalDataSaved = await env.COMPRESSLY_STATS.get('totalDataSaved');

return new Response(JSON.stringify({
totalFilesCompressed: parseInt(totalFilesCompressed || '0', 10),
totalDataSaved: parseInt(totalDataSaved || '0', 10)
}), {
const stats = await readStats(context.env.COMPRESSLY_STATS);
return json(stats, {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=60',
},
});
} catch (err) {
return new Response(JSON.stringify({ error: err.message }), { status: 500 });
} catch (err: any) {
return json({ error: err.message }, { status: 500 });
}
}

// Note: KV has no atomic increment, so concurrent reports can still overwrite
// each other. A Durable Object or D1 counter is needed for exact totals.
export async function onRequestPost(context: any) {
const { request, env } = context;
let body: any;
try {
const { request, env } = context;
const body = await request.json();
const { filesCount = 0, bytesSaved = 0 } = body;

let currentFiles = parseInt(await env.COMPRESSLY_STATS.get('totalFilesCompressed') || '0', 10);
let currentSaved = parseInt(await env.COMPRESSLY_STATS.get('totalDataSaved') || '0', 10);

const newFiles = currentFiles + filesCount;
const newSaved = currentSaved + bytesSaved;

await env.COMPRESSLY_STATS.put('totalFilesCompressed', newFiles.toString());
await env.COMPRESSLY_STATS.put('totalDataSaved', newSaved.toString());
body = await request.json();
} catch {
return json({ error: 'Invalid JSON' }, { status: 400 });
}

return new Response(JSON.stringify({ success: true }), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
});
} catch (err) {
return new Response(JSON.stringify({ error: err.message }), { status: 500 });
const { filesCount, bytesSaved } = body ?? {};
if (
!isValidCount(filesCount, MAX_FILES_PER_REPORT) ||
!isValidCount(bytesSaved, MAX_BYTES_PER_REPORT)
) {
return json({ error: 'Invalid stats' }, { status: 400 });
}
}

export async function onRequestOptions() {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
try {
const stats = await readStats(env.COMPRESSLY_STATS);
await env.COMPRESSLY_STATS.put(
STATS_KEY,
JSON.stringify({
totalFilesCompressed: stats.totalFilesCompressed + filesCount,
totalDataSaved: stats.totalDataSaved + bytesSaved,
}),
);
return json({ success: true });
} catch (err: any) {
return json({ error: err.message }, { status: 500 });
}
}
8 changes: 0 additions & 8 deletions gitignore.txt

This file was deleted.

8 changes: 6 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,12 @@
<meta name="application-name" content="Compressly">
<meta name="apple-mobile-web-app-title" content="Compressly">
<meta name="theme-color" content="#fefae0">

<title>Compressly – Premium File Compression</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23606c38'/%3E%3Ctext x='16' y='23' font-family='sans-serif' font-size='20' font-weight='700' fill='%23fefae0' text-anchor='middle'%3EC%3C/text%3E%3C/svg%3E">

<!-- Fonts: loaded here rather than via CSS @import so the request starts immediately -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" crossorigin href="https://fonts.googleapis.com/css2?family=Nunito+Sans:opsz,wght@6..12,300..700&display=swap">
</head>
<body>
<div id="root"></div>
Expand Down
Loading
Loading