Skip to content

Repository files navigation

TinifyPWA

A Progressive Web App for compressing images via the TinyPNG API. Fast, beautiful, installable — no account required beyond your own API key.

What it does

  • Drag-and-drop (or file-picker) upload of PNG, JPEG, WebP, and AVIF images
  • Sends each image to TinyPNG for compression, shows live progress and savings percentage
  • Download individual compressed files, or zip all results with Download all
  • Copies the shareable TinyPNG CDN link to clipboard
  • Tracks your monthly compression quota (500/month on the free plan)
  • Installable as a PWA (works offline for the shell; compression requires network)
  • Dark / light mode with system preference detection

Bring Your Own Key

TinifyPWA uses a BYOK model. You supply your own TinyPNG API key:

  1. Open the app and click Add key in the nav bar
  2. Paste your API key — it is saved in localStorage and never leaves your browser except as an HTTP header to your own Nuxt server proxy
  3. The proxy forwards requests to TinyPNG; the key is never logged or persisted server-side

Tech stack

Concern Package
Framework Nuxt 4 (compatibilityVersion: 4), ssr: false
Styling CSS custom properties + scoped component CSS
Icons lucide-vue-next
Animation GSAP via @hypernym/nuxt-gsap
Zipping fflate (browser-side, level-0 store for pre-compressed images)
PWA @vite-pwa/nuxt
Dark/light mode @nuxtjs/color-mode
Utilities @vueuse/nuxt
TinyPNG SDK tinify (server-side, usage endpoint only)

Setup

npm install
cp .env.example .env   # not required — no server-side key needed
npm run dev

Open http://localhost:3000.

Project structure

TinifyPWA/
├── app/
│   ├── assets/css/main.css          # design tokens, global component classes
│   ├── components/
│   │   ├── AppNav.vue               # nav bar with key button and theme toggle
│   │   ├── Base/BaseModal.vue       # reusable modal (ESC closes, click-outside does not)
│   │   ├── CookieBanner.vue
│   │   ├── Upload/
│   │   │   ├── UploadZone.vue       # drag-and-drop + file picker
│   │   │   └── UploadStats.vue      # quota bar, formats, max size
│   │   ├── Process/
│   │   │   ├── ProcessPanel.vue     # results list header with Download all / Clear all
│   │   │   └── ImageCard.vue        # per-image card: progress, savings, download, share
│   │   └── Modals/
│   │       ├── ApiKeyModal.vue
│   │       ├── AboutModal.vue
│   │       ├── ImprintModal.vue
│   │       └── PrivacyModal.vue
│   ├── composables/
│   │   ├── useTinify.ts             # image state, sequential queue, compress, downloadAll
│   │   └── useApiKey.ts             # localStorage BYOK
│   ├── layouts/default.vue
│   └── pages/index.vue              # two-panel desktop / stacked mobile layout
├── server/api/
│   ├── compress.post.ts             # proxy: base64 JSON → TinyPNG /shrink
│   ├── download.get.ts              # proxy: authenticated download stream
│   └── usage.get.ts                 # proxy: validate key + read Compression-Count header
├── server/utils/tinify.ts           # Basic auth helper
├── public/icons/                    # PWA icons (192, 512, 512 maskable)
├── scripts/generate-icons.mjs      # icon generation via sharp
├── nuxt.config.ts
└── package.json

Commands

npm run dev        # development server with HMR
npm run build      # production build
npm run generate   # static site generation
npm run preview    # preview production build locally
node scripts/generate-icons.mjs   # regenerate PWA icons

Notes

  • Images are encoded as base64 JSON for the browser → Nitro leg of the request (avoids binary streaming issues in the dev server). The Nitro → TinyPNG leg sends raw application/octet-stream.
  • Compression runs through a sequential queue to avoid concurrent body-streaming issues.
  • The fflate zip uses level: 0 (store-only) because TinyPNG output is already compressed; re-compressing wastes CPU with no size benefit.
  • compressionCount is in-memory client state, refreshed from TinyPNG on page load and after each compression.

Releases

Packages

Contributors

Languages