Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e55e9871cb | |||
| f9ba22ffba | |||
| 8288827c69 | |||
| 8f15d3202c | |||
| 34680480ed | |||
| c00f33fa7d | |||
| 0b55e97cad | |||
| ed13013e71 | |||
| 5b2f35f4ff | |||
| c83768785f | |||
| 1854cd2984 | |||
| 493bd9de96 | |||
| 96fa0745fe | |||
| 7ea0ed55e6 | |||
| decc396347 | |||
| d684240697 | |||
| b0aaf80c60 | |||
| faa206d44b | |||
| c65355a43b | |||
| 5579656279 | |||
| 87c9078a99 | |||
| a9da791766 | |||
| 98f03c9b22 | |||
| 9d59fac386 | |||
| 3e35978b73 | |||
| 733235c101 | |||
| 87ac7b0ce4 | |||
| e6a99a4141 | |||
| 4f694b1024 | |||
| 531f0cb3b3 | |||
| f5bd461fef | |||
| e1fa4fb6ad | |||
| c344590cb2 | |||
| 6f55ecb1d3 | |||
| 909c206490 | |||
| 2771d24262 | |||
| ef0edc0756 | |||
| cdac0b0cd9 | |||
| 4022cda357 | |||
| d7509daf0d | |||
| b9169cb939 | |||
| f060347f6e |
@@ -0,0 +1,47 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Runs on the forgerunner host: bundled Docker CLI + mounted /var/run/docker.sock.
|
||||
runs-on: host
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history so `git rev-list --count HEAD` yields the true commit count
|
||||
# driving both the app version (v1.NNN) and the org.alwisp.version label.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.alwisp.com
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and Push
|
||||
run: |
|
||||
IMAGE="registry.alwisp.com/${{ gitea.repository }}"
|
||||
GIT_SHA="$(git rev-parse --short HEAD)"
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
COMMIT_COUNT="$(git rev-list --count HEAD)"
|
||||
docker build \
|
||||
--build-arg GIT_SHA="${GIT_SHA}" \
|
||||
--build-arg BUILD_TIME="${BUILD_TIME}" \
|
||||
--build-arg COMMIT_COUNT="${COMMIT_COUNT}" \
|
||||
--label org.alwisp.git-sha="${{ gitea.sha }}" \
|
||||
--label org.alwisp.version="v1.$((COMMIT_COUNT-1))" \
|
||||
--label org.alwisp.repo="${{ gitea.repository }}" \
|
||||
-t "${IMAGE}:latest" .
|
||||
docker push "${IMAGE}:latest"
|
||||
|
||||
# Dangling-only prune: removes untagged leftovers from previous builds. Never
|
||||
# removes the tagged :latest or any image referenced by a running container.
|
||||
- name: Prune dangling images on host
|
||||
if: always()
|
||||
run: docker image prune -f 2>/dev/null || true
|
||||
@@ -1,167 +0,0 @@
|
||||
# Docker Build Fix - Simplified Dependency Installation
|
||||
|
||||
## Issue
|
||||
Docker build was failing with:
|
||||
```
|
||||
npm error The `npm ci` command can only install with an existing package-lock.json
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
The original Dockerfile used `npm ci` which requires fully resolved `package-lock.json` files. These lockfiles were missing and stub lockfiles don't work because `npm ci` requires the complete dependency tree.
|
||||
|
||||
## Solution Applied
|
||||
|
||||
### Simplified Approach
|
||||
The Dockerfile now uses **`npm install`** instead of `npm ci`. This is simpler and more reliable:
|
||||
|
||||
```dockerfile
|
||||
RUN npm install
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
- ✅ No lockfile required
|
||||
- ✅ Automatically resolves and installs all dependencies
|
||||
- ✅ Works consistently across all environments
|
||||
- ✅ No manual lockfile maintenance needed
|
||||
- ✅ Simpler Dockerfile = easier to maintain
|
||||
|
||||
### Trade-offs
|
||||
|
||||
| Approach | Speed | Lockfile Required | Deterministic | Maintenance |
|
||||
|----------|-------|-------------------|---------------|-------------|
|
||||
| `npm ci` | Fastest | ✅ Yes | ✅ Yes | High |
|
||||
| **`npm install`** | Fast | ❌ No | ⚠️ By version ranges | **Low** |
|
||||
|
||||
**Note:** While `npm install` resolves versions at build time (not 100% deterministic), your `package.json` uses caret ranges (e.g., `^4.19.0`) which only allow minor/patch updates, providing reasonable stability.
|
||||
|
||||
### Files Modified
|
||||
|
||||
1. **Dockerfile** - Simplified to use `npm install` in all three stages
|
||||
2. **.dockerignore** - Optimizes build context
|
||||
3. **backend/package.json** - Updated multer to v2.1.0 (v1.4.5 no longer exists)
|
||||
|
||||
### Dependency Updates
|
||||
|
||||
- **multer**: `^1.4.5` → `^2.1.0` (security fixes, v1.x removed from npm)
|
||||
- **@types/multer**: `^1.4.7` → `^2.1.0` (matching types)
|
||||
|
||||
## How It Works
|
||||
|
||||
### Build Flow
|
||||
|
||||
1. **Copy package.json** into build stage
|
||||
2. **Run npm install** - automatically resolves and installs all dependencies
|
||||
3. **Build the application**
|
||||
|
||||
No lockfile generation or validation needed!
|
||||
|
||||
## Usage
|
||||
|
||||
### Build Docker Image
|
||||
```bash
|
||||
docker build -t pnger:latest .
|
||||
```
|
||||
|
||||
The build will:
|
||||
- Install dependencies from npm registry
|
||||
- Build frontend and backend
|
||||
- Create production image with only runtime dependencies
|
||||
- Complete successfully every time
|
||||
|
||||
### Run Container
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e MAX_FILE_SIZE=10485760 \
|
||||
--name pnger \
|
||||
pnger:latest
|
||||
```
|
||||
|
||||
### Unraid Deployment
|
||||
The Docker image builds cleanly in Unraid without any configuration needed.
|
||||
|
||||
## Optional: Add Lockfiles for Deterministic Builds
|
||||
|
||||
If you want 100% deterministic builds with locked dependency versions:
|
||||
|
||||
```bash
|
||||
# Generate lockfiles locally
|
||||
cd frontend && npm install && cd ..
|
||||
cd backend && npm install && cd ..
|
||||
|
||||
# Commit them
|
||||
git add frontend/package-lock.json backend/package-lock.json
|
||||
git commit -m "Add lockfiles for deterministic builds"
|
||||
|
||||
# Update Dockerfile to use npm ci instead of npm install
|
||||
```
|
||||
|
||||
**Benefits of lockfiles:**
|
||||
- Guaranteed exact dependency versions
|
||||
- Slightly faster builds
|
||||
- Better for production environments
|
||||
|
||||
**Drawbacks:**
|
||||
- Must update lockfiles when changing dependencies
|
||||
- More complex maintenance
|
||||
|
||||
## Build Optimization
|
||||
|
||||
The `.dockerignore` file excludes:
|
||||
- `node_modules/` (prevents copying local dependencies)
|
||||
- Development files (`.env`, `.vscode/`, etc.)
|
||||
- Build artifacts
|
||||
- Documentation and test files
|
||||
|
||||
This keeps build context small and fast.
|
||||
|
||||
## Verification
|
||||
|
||||
Test the complete build:
|
||||
```bash
|
||||
# Build image
|
||||
docker build -t pnger:test .
|
||||
|
||||
# Run container
|
||||
docker run -d -p 3000:3000 --name pnger-test pnger:test
|
||||
|
||||
# Check health
|
||||
curl http://localhost:3000
|
||||
|
||||
# View logs
|
||||
docker logs pnger-test
|
||||
|
||||
# Cleanup
|
||||
docker stop pnger-test && docker rm pnger-test
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Multi-Stage Build
|
||||
|
||||
1. **frontend-builder**: Builds Svelte/Vite frontend with all dev dependencies
|
||||
2. **backend-builder**: Compiles TypeScript backend with all dev dependencies
|
||||
3. **production**: Combines compiled outputs with production dependencies only (`--omit=dev`)
|
||||
|
||||
### Security Features
|
||||
|
||||
- Runs as non-root user (`node`)
|
||||
- Health check endpoint configured
|
||||
- Minimal production dependencies
|
||||
- Alpine Linux base (smaller attack surface)
|
||||
- No unnecessary dev tools in production image
|
||||
|
||||
### Multer v2.1.0 Upgrade
|
||||
|
||||
Upgraded from v1.4.5 (no longer available) to v2.1.0:
|
||||
- ✅ Security fixes (CVE-2026-2359 and others)
|
||||
- ✅ Backward compatible API
|
||||
- ✅ Better performance
|
||||
- ✅ Active maintenance
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2026-03-08
|
||||
**Branch**: `fix/add-package-lockfiles`
|
||||
**Status**: Ready to merge
|
||||
**Issue**: Docker build failing ✅ RESOLVED
|
||||
+19
-9
@@ -32,6 +32,11 @@ RUN npm run build
|
||||
# Stage 3: Production Runtime
|
||||
FROM node:20-alpine
|
||||
|
||||
# Upgrade existing packages to fix base image vulnerabilities,
|
||||
# then install su-exec and shadow (for usermod/groupmod)
|
||||
RUN apk upgrade --no-cache && \
|
||||
apk add --no-cache su-exec shadow
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy backend package files
|
||||
@@ -48,17 +53,20 @@ COPY --from=backend-builder /app/backend/dist ./dist
|
||||
COPY --from=frontend-builder /app/frontend/dist ./dist/public
|
||||
|
||||
# Create temp upload directory
|
||||
RUN mkdir -p /app/temp && \
|
||||
chown -R node:node /app
|
||||
RUN mkdir -p /app/temp
|
||||
|
||||
# Switch to non-root user
|
||||
USER node
|
||||
# Copy entrypoint script
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Environment variables (can be overridden via Unraid UI)
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV MAX_FILE_SIZE=10485760
|
||||
ENV TEMP_DIR=/app/temp
|
||||
# Environment variables (Unraid defaults and App defaults)
|
||||
ENV PUID=99 \
|
||||
PGID=100 \
|
||||
TZ=UTC \
|
||||
NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
MAX_FILE_SIZE=10485760 \
|
||||
TEMP_DIR=/app/temp
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
@@ -67,5 +75,7 @@ EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
|
||||
# Start server
|
||||
CMD ["node", "dist/index.js"]
|
||||
-228
@@ -1,228 +0,0 @@
|
||||
# PNGer Development Instructions
|
||||
|
||||
## Project Overview
|
||||
|
||||
PNGer is a single-container web application for PNG editing and resizing, designed for deployment on Unraid with Gitea version control.
|
||||
|
||||
## Development Roadmap
|
||||
|
||||
### Phase 1: MVP Foundation (Current)
|
||||
- [x] Repository setup
|
||||
- [ ] Project structure initialization
|
||||
- [ ] Backend API scaffold
|
||||
- [ ] Frontend scaffold
|
||||
- [ ] Basic upload/download flow
|
||||
- [ ] Dockerfile configuration
|
||||
|
||||
### Phase 2: Core Features
|
||||
- [ ] Image resize functionality
|
||||
- [ ] PNG compression
|
||||
- [ ] Real-time preview
|
||||
- [ ] Responsive UI design
|
||||
- [ ] Error handling
|
||||
|
||||
### Phase 3: Polish & Deployment
|
||||
- [ ] Docker Compose for Unraid
|
||||
- [ ] Environment configuration
|
||||
- [ ] Documentation
|
||||
- [ ] Testing
|
||||
- [ ] Production build optimization
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Backend (Express + Sharp)
|
||||
|
||||
**Endpoints:**
|
||||
- `POST /api/upload` - Accept PNG file
|
||||
- `POST /api/process` - Resize/compress image
|
||||
- `GET /api/health` - Health check
|
||||
|
||||
**Key Dependencies:**
|
||||
- express: Web framework
|
||||
- multer: File upload handling
|
||||
- sharp: Image processing
|
||||
- cors: Cross-origin support
|
||||
|
||||
### Frontend (Svelte + Vite)
|
||||
|
||||
**Components:**
|
||||
- `App.svelte` - Main container
|
||||
- `Uploader.svelte` - Drag & drop interface
|
||||
- `Editor.svelte` - Resize controls
|
||||
- `Preview.svelte` - Real-time image preview
|
||||
- `Download.svelte` - Download button
|
||||
|
||||
**Key Dependencies:**
|
||||
- svelte: Reactive framework
|
||||
- vite: Build tool
|
||||
- axios: HTTP client
|
||||
|
||||
### Docker Strategy
|
||||
|
||||
**Multi-stage Build:**
|
||||
1. Stage 1: Build frontend (Vite build)
|
||||
2. Stage 2: Copy frontend + setup backend
|
||||
3. Final image: Alpine-based Node.js
|
||||
|
||||
**Image Size Target:** < 150MB
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 20+
|
||||
- npm or pnpm
|
||||
- Docker (optional)
|
||||
|
||||
### Initial Setup
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://git.alwisp.com/jason/pnger.git
|
||||
cd pnger
|
||||
|
||||
# Install root dependencies (if monorepo)
|
||||
npm install
|
||||
|
||||
# Setup backend
|
||||
cd backend
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# Setup frontend (new terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. **Feature Branch**: Create from `main`
|
||||
2. **Develop**: Make changes with hot-reload
|
||||
3. **Test**: Manual testing + health checks
|
||||
4. **Commit**: Descriptive commit messages
|
||||
5. **Push**: Push to Gitea
|
||||
6. **Review**: Self-review changes
|
||||
7. **Merge**: Merge to `main`
|
||||
|
||||
### Environment Variables
|
||||
|
||||
**Backend (.env):**
|
||||
```
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
MAX_FILE_SIZE=10
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
```
|
||||
|
||||
**Frontend (.env):**
|
||||
```
|
||||
VITE_API_URL=http://localhost:3000/api
|
||||
```
|
||||
|
||||
## Docker Build & Test
|
||||
|
||||
```bash
|
||||
# Build image
|
||||
docker build -t pnger:test .
|
||||
|
||||
# Run container
|
||||
docker run -p 8080:3000 --name pnger-test pnger:test
|
||||
|
||||
# Test endpoints
|
||||
curl http://localhost:8080/api/health
|
||||
|
||||
# View logs
|
||||
docker logs pnger-test
|
||||
|
||||
# Stop and remove
|
||||
docker stop pnger-test && docker rm pnger-test
|
||||
```
|
||||
|
||||
## Unraid Deployment
|
||||
|
||||
### Setup Steps
|
||||
|
||||
1. **SSH into Unraid**
|
||||
2. **Navigate to docker configs**: `/mnt/user/appdata/pnger`
|
||||
3. **Clone repository**:
|
||||
```bash
|
||||
git clone https://git.alwisp.com/jason/pnger.git
|
||||
cd pnger
|
||||
```
|
||||
4. **Build and run**:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
5. **Access**: http://[unraid-ip]:8080
|
||||
|
||||
### Update Process
|
||||
|
||||
```bash
|
||||
cd /mnt/user/appdata/pnger
|
||||
git pull origin main
|
||||
docker-compose down
|
||||
docker-compose build
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Code Standards
|
||||
|
||||
### JavaScript/Svelte
|
||||
- Use ES6+ features
|
||||
- Async/await for asynchronous operations
|
||||
- Descriptive variable names
|
||||
- Comments for complex logic only
|
||||
|
||||
### File Organization
|
||||
- One component per file
|
||||
- Group related utilities
|
||||
- Keep components under 200 lines
|
||||
|
||||
### Commit Messages
|
||||
- Format: `type: description`
|
||||
- Types: feat, fix, docs, style, refactor, test
|
||||
- Example: `feat: add drag and drop upload`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Port already in use:**
|
||||
```bash
|
||||
lsof -ti:3000 | xargs kill -9
|
||||
```
|
||||
|
||||
**Sharp installation issues:**
|
||||
```bash
|
||||
npm rebuild sharp
|
||||
```
|
||||
|
||||
**Docker build fails:**
|
||||
- Check Docker daemon is running
|
||||
- Verify Dockerfile syntax
|
||||
- Clear Docker cache: `docker builder prune`
|
||||
|
||||
## Performance Targets
|
||||
|
||||
- Upload handling: < 100ms (for 5MB file)
|
||||
- Image processing: < 2s (for 10MP image)
|
||||
- Download generation: < 500ms
|
||||
- UI response time: < 100ms
|
||||
- Docker image size: < 150MB
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- File type validation (PNG only)
|
||||
- File size limits (10MB default)
|
||||
- No persistent storage of user files
|
||||
- Memory cleanup after processing
|
||||
- CORS configuration
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Create backend folder structure
|
||||
2. Create frontend folder structure
|
||||
3. Initialize package.json files
|
||||
4. Create Dockerfile
|
||||
5. Create docker-compose.yml
|
||||
6. Begin MVP development
|
||||
@@ -1,213 +1,80 @@
|
||||
# PNGer - Modern PNG Editor & Resizer
|
||||
# PNGer — Modern PNG Editor & Resizer
|
||||
|
||||
A simple, reactive, modern PNG editor and resizer with direct upload and download features. Built with TypeScript and deployed as a single Docker container on Unraid.
|
||||
A sleek, self-hosted image resizer with **live preview**, **drag & drop**, **smart presets**, **keyboard shortcuts**, and **dark/light theming**. Drop an image in, watch the before/after and the file-size delta update as you drag the sliders, hit Enter, get the file. Nothing is stored on the server — uploads are processed in memory and streamed straight back as a download.
|
||||
|
||||
## Features
|
||||
Svelte + Vite on the front, Express + [Sharp](https://sharp.pixelplumbing.com/) on the back, shipped as one Docker container that serves both. Built for Unraid.
|
||||
|
||||
- **Drag & Drop Upload**: Intuitive file upload interface
|
||||
- **Resize Operations**: Width, height, and aspect ratio controls
|
||||
- **Crop to Fit**: Smart cropping with position control (center, top, bottom, etc.)
|
||||
- **Format Conversion**: PNG, WebP, and JPEG output
|
||||
- **Quality Control**: Adjustable compression settings
|
||||
- **Direct Download**: No server-side storage, immediate download
|
||||
- **Modern UI**: Sleek, responsive TypeScript/Svelte design
|
||||
Despite the name it is not PNG-only: it reads anything Sharp can decode and writes **PNG, WebP or JPEG**.
|
||||
|
||||
## Tech Stack
|
||||
## Docs
|
||||
|
||||
- **Frontend**: Svelte 4 + Vite + TypeScript
|
||||
- **Backend**: Node.js + Express + TypeScript
|
||||
- **Image Processing**: Sharp (high-performance image library)
|
||||
- **Container**: Docker (Alpine-based, multi-stage build)
|
||||
- **Deployment**: Unraid via Docker Compose
|
||||
- **[Installation & configuration guide](docs/INSTALL.md)** — start here. Local dev, Docker, `docker compose`, the full env-var table, CI, health checks, upgrades.
|
||||
- **[Unraid install](docs/UNRAID.md)** — step-by-step Docker GUI walkthrough (template fields, PUID/PGID/TZ, ports).
|
||||
- **[Development & technical notes](docs/INSTRUCTIONS.md)** — architecture, the live-preview design, code standards, troubleshooting.
|
||||
- **[Roadmap](docs/ROADMAP.md)** — what shipped in Sprints 0–1 and what is queued for Sprints 2–4.
|
||||
|
||||
## Quick Start
|
||||
## What it does
|
||||
|
||||
### Unraid Deployment (Recommended)
|
||||
- **Resizes and crops** — set a width, a height, or both. *Resize only* (`fit: inside`) preserves aspect ratio; *Crop to fit box* (`fit: cover`) fills the box and crops the overflow from one of **nine anchor positions** (center, the four edges, the four corners).
|
||||
- **Converts and compresses** — output as PNG (compression level 9), WebP or JPEG, with a 1–100 quality control.
|
||||
- **Previews client-side before it touches the server** — the browser re-renders the image on a `<canvas>` 300 ms after you stop typing, so you get a side-by-side original/result comparison plus an estimated file-size delta ("↓ 412.3 KB saved (68.1%)") with no round-trip. Only the final download hits the API.
|
||||
- **Eight smart presets** — Web Thumbnail (300×300 WebP), Social Media / Open Graph (1200×630), Profile Picture (400×400), Email Friendly (600 wide JPEG q70), HD Quality (1920 wide), Retina @2x (doubles whatever you have typed), Icon Small (64×64) and Icon Large (256×256). One click sets width, height, quality, format and fit.
|
||||
- **Gets images in three ways** — drag & drop onto the drop zone, click to browse, or paste from the clipboard with `Ctrl+V` / `Cmd+V`.
|
||||
- **Themes light or dark** — gold-accented design system driven by CSS custom properties, remembered in `localStorage`, defaulting to your OS `prefers-color-scheme`. The point is practical: toggle the background to check PNG transparency against black and white.
|
||||
- **Stores nothing** — `multer` keeps the upload in memory, Sharp transforms it, Express sends the buffer back with `Content-Disposition: attachment`. No database, no disk writes, no cleanup job.
|
||||
|
||||
1. **Clone or pull this repository to your Unraid server:**
|
||||
```bash
|
||||
cd /mnt/user/appdata
|
||||
git clone https://git.alwisp.com/jason/pnger.git
|
||||
cd pnger
|
||||
```
|
||||
## Keyboard shortcuts
|
||||
|
||||
2. **Build the Docker image:**
|
||||
```bash
|
||||
docker build -t pnger:latest .
|
||||
```
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `Ctrl+V` / `Cmd+V` | Paste an image from the clipboard |
|
||||
| `Enter` | Transform & download (when no input is focused) |
|
||||
| `Ctrl+Enter` / `Cmd+Enter` | Transform & download (works from anywhere) |
|
||||
| `?` | Toggle the shortcuts dialog |
|
||||
| `Esc` | Close the shortcuts dialog |
|
||||
|
||||
3. **Run via docker-compose:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
## Shape
|
||||
|
||||
4. **Access the application:**
|
||||
- Navigate to `http://[unraid-ip]:8080`
|
||||
- **Stack** — frontend: Svelte 4 + Vite 5 + TypeScript, no UI framework, no router, one `App.svelte` plus four `lib/` modules (`api`, `preview`, `presets`, `theme`). Backend: Node 20 + Express 4 + TypeScript, `sharp` for the pixels, `multer` (memory storage) for the upload, `cors`.
|
||||
- **API** — one endpoint. `POST /api/transform`, `multipart/form-data`, field `file`, with optional `width`, `height`, `quality` (default 80), `format` (`png` | `webp` | `jpeg`, default `png`), `fit` (default `inside`) and `position` (default `center`). Responds with the image bytes as an attachment, or `400` if no file / `500` if Sharp throws. Resizes use `withoutEnlargement: true`, so output is never larger than the source.
|
||||
- **Data** — none. Stateless; no volumes required.
|
||||
- **Serving** — in production the compiled frontend is copied to `dist/public` and served by the same Express process that answers `/api`, with an `app.get("*")` fallback to `index.html` for SPA routing. One port, one origin, no CORS in play.
|
||||
- **Hosting** — three-stage Docker build (frontend build → backend `tsc` → `node:20-alpine` runtime with `apk upgrade`, `su-exec` + `shadow`). `docker-entrypoint.sh` remaps the `node` user to `PUID`/`PGID` (Unraid defaults 99/100), chowns `/app`, then drops privileges. Listens on `3000`, `HEALTHCHECK` every 30 s, `EXPOSE 3000`. CI builds on every push to `main` and pushes `registry.alwisp.com/jason/pnger:latest`.
|
||||
|
||||
### Unraid Environment Variables (Configurable via UI)
|
||||
## Quick start
|
||||
|
||||
When setting up in Unraid Docker UI, you can configure:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `HOST_PORT` | `8080` | External port to access the app |
|
||||
| `MAX_FILE_SIZE` | `10485760` | Max upload size in bytes (10MB default) |
|
||||
| `MEM_LIMIT` | `512m` | Memory limit for container |
|
||||
| `CPU_LIMIT` | `1.0` | CPU limit (1.0 = 1 full core) |
|
||||
| `NODE_ENV` | `production` | Node environment |
|
||||
|
||||
### Unraid Docker Template Example
|
||||
|
||||
```xml
|
||||
<?xml version="1.0"?>
|
||||
<Container version="2">
|
||||
<Name>pnger</Name>
|
||||
<Repository>pnger:latest</Repository>
|
||||
<Network>bridge</Network>
|
||||
<Privileged>false</Privileged>
|
||||
<WebUI>http://[IP]:[PORT:8080]</WebUI>
|
||||
<Config Name="WebUI Port" Target="3000" Default="8080" Mode="tcp" Description="Port for web interface" Type="Port" Display="always" Required="true" Mask="false">8080</Config>
|
||||
<Config Name="Max File Size" Target="MAX_FILE_SIZE" Default="10485760" Mode="" Description="Maximum upload file size in bytes" Type="Variable" Display="advanced" Required="false" Mask="false">10485760</Config>
|
||||
<Config Name="Memory Limit" Target="" Default="512m" Mode="" Description="Container memory limit" Type="Variable" Display="advanced" Required="false" Mask="false">512m</Config>
|
||||
</Container>
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 20+
|
||||
- npm or yarn
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Install backend dependencies:**
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Install frontend dependencies:**
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Run development servers:**
|
||||
|
||||
Terminal 1 (Backend):
|
||||
```bash
|
||||
cd backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Terminal 2 (Frontend):
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
4. **Access dev server:**
|
||||
- Frontend: `http://localhost:5173`
|
||||
- Backend API: `http://localhost:3000/api`
|
||||
|
||||
### Building for Production
|
||||
**Docker (single container):**
|
||||
|
||||
```bash
|
||||
# Backend TypeScript compilation
|
||||
cd backend
|
||||
npm run build
|
||||
|
||||
# Frontend build
|
||||
cd frontend
|
||||
npm run build
|
||||
git clone https://git.alwisp.com/jason/pnger.git
|
||||
cd pnger
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Docker Deployment (Manual)
|
||||
Then open <http://localhost:8080> (host port is `HOST_PORT`, default `8080` → container `3000`).
|
||||
|
||||
**Local development** — two terminals, Node 20+:
|
||||
|
||||
```bash
|
||||
# Build the image (all dependencies and builds are handled internally)
|
||||
docker build -t pnger:latest .
|
||||
|
||||
# Run the container
|
||||
docker run -d \
|
||||
--name pnger \
|
||||
-p 8080:3000 \
|
||||
-e MAX_FILE_SIZE=10485760 \
|
||||
--restart unless-stopped \
|
||||
pnger:latest
|
||||
cd backend && npm install && npm run dev # tsc watch on :3000
|
||||
cd frontend && npm install && npm run dev # Vite on :5173
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
Open <http://localhost:5173>. In dev the frontend calls `http://localhost:3000/api` directly (`import.meta.env.DEV` branch in `lib/api.ts`); in a production build it calls the same-origin `/api`.
|
||||
|
||||
```
|
||||
pnger/
|
||||
├── frontend/ # Svelte + TypeScript application
|
||||
│ ├── src/
|
||||
│ │ ├── App.svelte # Main UI component
|
||||
│ │ ├── main.ts # Entry point
|
||||
│ │ └── lib/
|
||||
│ │ └── api.ts # API client
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── vite.config.ts
|
||||
├── backend/ # Express + TypeScript API server
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Express server
|
||||
│ │ ├── routes/
|
||||
│ │ │ └── image.ts # Image transform endpoint
|
||||
│ │ └── types/
|
||||
│ │ └── image.ts # TypeScript types
|
||||
│ ├── package.json
|
||||
│ └── tsconfig.json
|
||||
├── Dockerfile # Multi-stage build (frontend + backend)
|
||||
├── docker-compose.yml # Unraid deployment config
|
||||
└── INSTRUCTIONS.md # Development guide
|
||||
```
|
||||
Full details, env vars and the Unraid path: **[docs/INSTALL.md](docs/INSTALL.md)**.
|
||||
|
||||
## How It Works
|
||||
## Known rough edges
|
||||
|
||||
1. User uploads an image via the web interface
|
||||
2. Frontend sends image + transform parameters to backend API
|
||||
3. Backend processes image using Sharp (resize, crop, compress, convert format)
|
||||
4. Processed image is returned directly to browser
|
||||
5. Browser triggers automatic download
|
||||
6. No files stored on server (stateless operation)
|
||||
Documented rather than hidden — see [docs/INSTRUCTIONS.md](docs/INSTRUCTIONS.md) for the detail:
|
||||
|
||||
## API Endpoints
|
||||
- **The preview is an approximation.** It is a Canvas re-encode, not Sharp. Sizes are estimated from the data URL, PNG quality has no effect in Canvas (it is always lossless there) but does affect the real output, and the preview will happily upscale where the server will not.
|
||||
- **`MAX_FILE_SIZE` is not enforced.** It is set in the `Dockerfile`, compose file and Unraid docs, but the live upload middleware has no `limits`, so the value currently does nothing.
|
||||
- **`backend/src/server.js` is not the server.** It is an older standalone implementation (`/api/health`, `/api/process`, `/api/metadata`, helmet, PNG-only filter, size limits) that nothing compiles or runs. The live entrypoint is `backend/src/index.ts`.
|
||||
|
||||
### POST /api/transform
|
||||
---
|
||||
|
||||
Transform an image with resize, crop, and format conversion.
|
||||
**License**: MIT · **Repository**: <https://git.alwisp.com/jason/pnger>
|
||||
|
||||
**Request:**
|
||||
- Method: `POST`
|
||||
- Content-Type: `multipart/form-data`
|
||||
- Body:
|
||||
- `file`: Image file (PNG/JPEG/WebP)
|
||||
- `width`: Target width (optional)
|
||||
- `height`: Target height (optional)
|
||||
- `quality`: Quality 10-100 (optional, default: 80)
|
||||
- `format`: Output format `png|webp|jpeg` (optional, default: `png`)
|
||||
- `fit`: Resize mode `inside|cover` (optional, default: `inside`)
|
||||
- `position`: Crop position when `fit=cover` (optional, default: `center`)
|
||||
|
||||
**Response:**
|
||||
- Content-Type: `image/[format]`
|
||||
- Body: Transformed image binary
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is handled via environment variables passed through Docker/Unraid:
|
||||
|
||||
- `PORT`: Server port (default: `3000`, internal)
|
||||
- `MAX_FILE_SIZE`: Maximum upload size in bytes (default: `10485760` = 10MB)
|
||||
- `TEMP_DIR`: Temporary directory for uploads (default: `/app/temp`)
|
||||
- `NODE_ENV`: Node environment (default: `production`)
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See LICENSE file for details
|
||||
|
||||
## Repository
|
||||
|
||||
https://git.alwisp.com/jason/pnger
|
||||
---
|
||||
*MPM — Born to Innovate. Built to Last.*
|
||||
|
||||
+3
-2
@@ -10,10 +10,11 @@ services:
|
||||
ports:
|
||||
- "${HOST_PORT:-8080}:3000"
|
||||
environment:
|
||||
- PUID=${PUID:-99}
|
||||
- PGID=${PGID:-100}
|
||||
- TZ=${TZ:-UTC}
|
||||
- NODE_ENV=${NODE_ENV:-production}
|
||||
- PORT=3000
|
||||
- MAX_FILE_SIZE=${MAX_FILE_SIZE:-10485760}
|
||||
- TEMP_DIR=/app/temp
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Default to PUID 99 and PGID 100 if not specified
|
||||
PUID=${PUID:-99}
|
||||
PGID=${PGID:-100}
|
||||
|
||||
echo "Starting with UID: $PUID, GID: $PGID"
|
||||
|
||||
# Modify the 'node' user and group to match the provided PUID/PGID
|
||||
if [ "$(id -u node)" -ne "$PUID" ]; then
|
||||
usermod -o -u "$PUID" node
|
||||
fi
|
||||
|
||||
if [ "$(id -g node)" -ne "$PGID" ]; then
|
||||
groupmod -o -g "$PGID" node
|
||||
fi
|
||||
|
||||
# Ensure appropriate permissions on the application directory and temp dir
|
||||
chown -R node:node /app
|
||||
|
||||
# Drop privileges to 'node' user and execute the command passed to the container
|
||||
exec su-exec node "$@"
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
# Installation & configuration
|
||||
|
||||
PNGer is a single stateless container: Express serves both the API and the compiled Svelte frontend on one port. There is no database, no persistent volume, and nothing to migrate.
|
||||
|
||||
| Path | Use when | Where |
|
||||
| --- | --- | --- |
|
||||
| **Unraid Docker GUI** | Production on Unraid. | [UNRAID.md](UNRAID.md) — click-by-click. |
|
||||
| **`docker compose`** | Any Docker host. | §2 below. |
|
||||
| **Plain `docker run`** | Ad hoc. | §3 below. |
|
||||
| **Local Node** | Development. | §4 below. |
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
- **Docker** for the container paths. Nothing else — no runtime deps on the host.
|
||||
- **Node 20+ and npm** for local development. Sharp ships prebuilt binaries for common platforms; if `npm install` builds it from source you also need a C++ toolchain.
|
||||
- **A free host port.** The container listens on `3000`; the compose file publishes it on `8080` by default.
|
||||
|
||||
No volume is required. No reverse proxy is required either, though you will want one if you expose it beyond the LAN — PNGer has **no authentication of any kind**.
|
||||
|
||||
---
|
||||
|
||||
## 2. `docker compose`
|
||||
|
||||
```bash
|
||||
git clone https://git.alwisp.com/jason/pnger.git
|
||||
cd pnger
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Open <http://localhost:8080>.
|
||||
|
||||
`docker-compose.yml` builds locally, tags `pnger:latest`, publishes `${HOST_PORT:-8080}:3000`, sets `restart: unless-stopped`, caps the container at `512m` / `1.0` CPU (`MEM_LIMIT` / `CPU_LIMIT`), rotates JSON logs at 10 MB × 3, and runs the same healthcheck as the image. Override anything through a `.env` file next to the compose file or through the shell environment:
|
||||
|
||||
```bash
|
||||
HOST_PORT=9000 TZ=America/Chicago docker compose up -d
|
||||
```
|
||||
|
||||
The optional `/app/temp` volume mount is commented out in the compose file and is not needed — see [temp directory](#the-temp-directory-does-nothing) below.
|
||||
|
||||
---
|
||||
|
||||
## 3. Build and run the image directly
|
||||
|
||||
```bash
|
||||
docker build -t pnger:latest .
|
||||
docker run -d --name pnger -p 8080:3000 \
|
||||
-e PUID=99 -e PGID=100 -e TZ=America/Chicago \
|
||||
--restart unless-stopped pnger:latest
|
||||
```
|
||||
|
||||
The `Dockerfile` is a three-stage build:
|
||||
|
||||
1. **frontend-builder** — `npm install` then `vite build` in `frontend/`, producing `frontend/dist`.
|
||||
2. **backend-builder** — `npm install` then `tsc` in `backend/`, producing `backend/dist`.
|
||||
3. **runtime** — `node:20-alpine` with `apk upgrade --no-cache` and `su-exec` + `shadow` installed; production-only backend deps (`npm install --omit=dev`); the compiled backend at `/app/dist`; the built frontend at `/app/dist/public`; `/app/temp` created; `docker-entrypoint.sh` installed.
|
||||
|
||||
`npm install` is used rather than `npm ci` on purpose — the committed lockfiles are stubs, and caret ranges in `package.json` are what actually pin the tree.
|
||||
|
||||
At start, `docker-entrypoint.sh` runs as root: it `usermod`/`groupmod`s the built-in `node` user to the requested `PUID`/`PGID`, `chown -R node:node /app`, then `exec su-exec node "$@"`. The app itself never runs as root.
|
||||
|
||||
### CI
|
||||
|
||||
`.gitea/workflows/docker-build.yml` runs on every push to `main` (plus `workflow_dispatch`) on the `host`-labelled Gitea runner — bundled Docker CLI, mounted `/var/run/docker.sock`, legacy builder. It logs in to `registry.alwisp.com` with `REGISTRY_USER` / `REGISTRY_TOKEN`, builds with the labels `org.alwisp.git-sha`, `org.alwisp.version` (`v1.<commit-count-1>`) and `org.alwisp.repo`, pushes `registry.alwisp.com/jason/pnger:latest`, then prunes dangling images on the host.
|
||||
|
||||
Only `:latest` is published — there is no immutable per-SHA tag, so rollback means rebuilding from an older commit.
|
||||
|
||||
To run the published image instead of building locally, point `docker run` (or the Unraid **Repository** field) at `registry.alwisp.com/jason/pnger:latest` after a `docker login registry.alwisp.com`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Local development
|
||||
|
||||
Two terminals:
|
||||
|
||||
```bash
|
||||
# terminal 1 — API on :3000, ts-node-dev with --respawn
|
||||
cd backend
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# terminal 2 — Vite dev server on :5173 with HMR
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open <http://localhost:5173>.
|
||||
|
||||
`frontend/src/lib/api.ts` switches on `import.meta.env.DEV`: in dev it posts to `http://localhost:3000/api`, in a production build to the same-origin `/api`. There is no Vite proxy — the backend's `app.use(cors())` allows the cross-origin call.
|
||||
|
||||
| Script | Where | Does |
|
||||
| --- | --- | --- |
|
||||
| `npm run dev` | backend | `ts-node-dev --respawn --transpile-only src/index.ts` |
|
||||
| `npm run build` | backend | `tsc` → `dist/` |
|
||||
| `npm start` | backend | `node dist/index.js` |
|
||||
| `npm run dev` | frontend | Vite dev server on 5173 |
|
||||
| `npm run build` | frontend | `vite build` → `frontend/dist` |
|
||||
| `npm run preview` | frontend | Serve the production build locally |
|
||||
|
||||
There are no tests and no lint step. Verification is manual: resize a large PNG, convert it to WebP and JPEG, try each crop anchor, toggle the theme, paste from the clipboard.
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
Everything is optional — the image ships a working default for each. There is no `.env` file in the container; set them on the container.
|
||||
|
||||
| Variable | Default (image) | Read by | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `PORT` | `3000` | `backend/src/index.ts` | Express listen port. Changing it means changing the healthcheck and port mapping too — easier to leave it and remap on the host. |
|
||||
| `PUID` | `99` | `docker-entrypoint.sh` | UID the `node` user is remapped to. `99` = Unraid `nobody`. |
|
||||
| `PGID` | `100` | `docker-entrypoint.sh` | GID the `node` group is remapped to. `100` = Unraid `users`. |
|
||||
| `TZ` | `UTC` | base image | Container timezone, e.g. `America/Chicago`. Affects log timestamps only. |
|
||||
| `NODE_ENV` | `production` | Express / Node | Set to `development` locally. |
|
||||
| `MAX_FILE_SIZE` | `10485760` | **nothing** | ⚠️ Declared in the `Dockerfile`, the compose file and the Unraid docs, but the live upload middleware (`backend/src/routes/image.ts`) creates `multer` with no `limits`, so no size cap is applied. The only code that ever honoured it is the dead `backend/src/server.js`. Treat uploads as unbounded until this is wired up. |
|
||||
| `TEMP_DIR` | `/app/temp` | **nothing** | ⚠️ See below. |
|
||||
| `CORS_ORIGIN` | — | **nothing** | ⚠️ Listed in `backend/.env.example` and older docs. `index.ts` calls `app.use(cors())` with no options, so all origins are allowed and this variable is ignored. |
|
||||
| `HOST_PORT` | `8080` | `docker-compose.yml` | Host side of the port mapping. Compose only. |
|
||||
| `MEM_LIMIT` | `512m` | `docker-compose.yml` | Container memory cap. Compose only. |
|
||||
| `CPU_LIMIT` | `1.0` | `docker-compose.yml` | Container CPU cap. Compose only. |
|
||||
| `VITE_API_URL` | — | **nothing** | ⚠️ Documented historically; `lib/api.ts` derives the base URL from `import.meta.env.DEV` instead. Setting it has no effect. |
|
||||
|
||||
`backend/.env.example` also predates the current server: it lists `MAX_FILE_SIZE=10` (megabytes) while the `Dockerfile` sets `10485760` (bytes). Neither is read today.
|
||||
|
||||
### The temp directory does nothing
|
||||
|
||||
`/app/temp` is created by the `Dockerfile`, `TEMP_DIR` points at it, and `UNRAID.md` offers an optional volume mapping for it. Nothing writes there. Uploads live in memory (`multer.memoryStorage()`), Sharp transforms the buffer, Express returns it. Mounting a volume is harmless but pointless.
|
||||
|
||||
---
|
||||
|
||||
## Health checks
|
||||
|
||||
The image's `HEALTHCHECK` requests `http://localhost:3000/` every 30 s and passes on HTTP 200 — that is the SPA `index.html`, so it confirms the process is up and serving.
|
||||
|
||||
Be careful with `/api/health`: **it does not exist.** The router only defines `POST /api/transform`, so a GET to `/api/health` falls through to the `app.get("*")` SPA handler and returns `index.html` with a 200. Any monitor pointed at it will report healthy no matter what state the API is in. Use `POST /api/transform` with a small test image if you need a real probe.
|
||||
|
||||
---
|
||||
|
||||
## Upgrading
|
||||
|
||||
- **Compose / local build:** `git pull && docker compose up -d --build`.
|
||||
- **Registry image:** `docker pull registry.alwisp.com/jason/pnger:latest && docker restart pnger`, or Unraid → **Docker** tab → **Force update**.
|
||||
|
||||
There is no state, so upgrades and rollbacks are just container recreations. Nothing to back up — but note that only `:latest` is published, so rolling back means rebuilding from the older commit.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
| --- | --- |
|
||||
| Container exits immediately | Host port already in use, or `PUID`/`PGID` collide with an existing user in a way `usermod` rejects. `docker logs pnger`. |
|
||||
| `Transform failed` in the UI | The API returned non-200. Check `docker logs` — Sharp throws a 500 on files it cannot decode (the client only checks the MIME type starts with `image/`). |
|
||||
| Large upload hangs or the container OOMs | No upload size limit is enforced and processing is entirely in memory. Raise `MEM_LIMIT`, or put a body-size cap on your reverse proxy. |
|
||||
| Preview does not match the download | Expected. The preview is a Canvas re-encode, the output is Sharp. PNG quality is ignored by Canvas, and the preview will upscale where the server's `withoutEnlargement: true` will not. |
|
||||
| Preview never appears | Browser console — the preview path is pure Canvas API and fails silently into `console.error`. |
|
||||
| Port already in use in dev | `lsof -ti:3000 \| xargs kill -9` |
|
||||
| Sharp fails to load after a Node upgrade | `cd backend && npm rebuild sharp` |
|
||||
| Docker build fails oddly | `docker builder prune`, then rebuild. |
|
||||
|
||||
---
|
||||
*MPM — Born to Innovate. Built to Last.*
|
||||
@@ -0,0 +1,153 @@
|
||||
# PNGer Development & Technical Instructions
|
||||
|
||||
## Project Overview
|
||||
|
||||
PNGer is a single-container web application for PNG editing and resizing, designed for deployment on Unraid with Gitea version control. It features a modern Svelte frontend and a high-performance Node.js/Sharp backend.
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
PNGer uses a multi-layered architecture designed for responsiveness and efficiency:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[User Browser] --> B[Svelte Frontend]
|
||||
B --> C["Canvas API (live preview, client-side)"]
|
||||
B --> D["Express API (POST /api/transform)"]
|
||||
D --> E[Sharp Image Library]
|
||||
E --> F[Response buffer streamed back as a download]
|
||||
```
|
||||
|
||||
Nothing is persisted at any point: the upload lives in memory for the duration of the request and the result is streamed straight back.
|
||||
|
||||
### Backend (Express + Sharp)
|
||||
|
||||
The backend is built with Node.js and TypeScript, using Express for the API and Sharp for high-performance image processing.
|
||||
|
||||
**Endpoints** — there is exactly one:
|
||||
- `POST /api/transform` — `multipart/form-data`, field `file`, plus optional `width`, `height`, `quality` (default 80), `format` (`png` | `webp` | `jpeg`, default `png`), `fit` (default `inside`) and `position` (default `center`). Returns the image bytes with `Content-Disposition: attachment`; `400` if no file, `500` if Sharp throws.
|
||||
|
||||
> **There is no `GET /api/health`.** Earlier revisions of this document claimed one. The router defines only `POST /transform`, so a GET to `/api/health` falls through to the SPA catch-all and returns `index.html` with a 200 — which means a monitor pointed at it will always report healthy. The container's own `HEALTHCHECK` correctly probes `/` instead.
|
||||
|
||||
**Key Dependencies:**
|
||||
- `sharp`: High-performance image processing (handles resizing, cropping, and format conversion). Resizes use `withoutEnlargement: true`, so output never exceeds the source dimensions.
|
||||
- `multer`: Middleware for handling `multipart/form-data`, used for file uploads. Configured with `memoryStorage()` and **no `limits`** — see the dead-code note below.
|
||||
- `express`: Web framework for the API; also serves the built frontend from `dist/public` with an `app.get("*")` SPA fallback.
|
||||
- `cors`: Applied wide open (`app.use(cors())`) so the Vite dev server on `:5173` can reach the API on `:3000`. In production both are the same origin.
|
||||
|
||||
### Dead code you will trip over
|
||||
|
||||
- **`backend/src/server.js`** is not the server. The entrypoint is `src/index.ts` → `dist/index.js`. `server.js` is an older standalone CommonJS implementation with `/api/health`, `/api/process` and `/api/metadata`, `helmet`, a PNG-only file filter and a real `MAX_FILE_SIZE` limit. `tsconfig.json` has no `allowJs`, so `tsc` never compiles it and it never reaches `dist/`; it also `require`s `helmet` and `dotenv`, which are not in `package.json`. It is the source of most of the features these docs used to describe. Either port the good parts (size limit, MIME filter, real health endpoint) into `routes/image.ts` or delete it.
|
||||
- **`frontend/src/main.js` and `frontend/src/main.ts`** both exist. `index.html` loads `main.js`, and only `main.js` imports `./app.css` — "fixing" the script tag to point at `main.ts` would silently drop every style in the app.
|
||||
|
||||
### Frontend (Svelte + Vite)
|
||||
|
||||
The frontend is a reactive Svelte application that prioritizes real-time feedback and UX.
|
||||
|
||||
**Core Components & Modules:**
|
||||
- `App.svelte`: Main application container managing state and UI.
|
||||
- `lib/api.ts`: API client for backend communication.
|
||||
- `lib/preview.ts`: Client-side preview logic using the Canvas API for instant feedback.
|
||||
- `lib/theme.ts`: Dark/light theme management with persistent storage.
|
||||
- `lib/presets.ts`: Configuration for smart image presets.
|
||||
|
||||
### Design System (Dark/Light Modes)
|
||||
|
||||
The UI uses a modern design system with CSS custom properties for theming:
|
||||
- **Light Mode**: Clean white background with dark gold (`#b8860b`) accents.
|
||||
- **Dark Mode**: Deep black (`#0a0a0a`) background with light gold (`#daa520`) accents.
|
||||
- **Transparencies**: Dark/Light toggling allows users to inspect PNG transparency against different backgrounds.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Live Preview System
|
||||
|
||||
Live preview is implemented using a client-side Canvas-based approach to provide instant feedback (< 100ms) without server round-trips.
|
||||
|
||||
**How it works:**
|
||||
1. User uploads an image.
|
||||
2. The browser loads the image into an HTML5 Canvas.
|
||||
3. On any parameter change (width, quality, etc.), a debounced (300ms) update applies transformations to the canvas.
|
||||
4. The canvas content is displayed side-by-side with the original for comparison.
|
||||
5. File sizes are estimated from the Canvas data URL to provide immediate feedback on optimization savings.
|
||||
|
||||
**Fidelity caveats — the preview is an approximation, not the output:**
|
||||
- The preview is encoded by the browser's Canvas encoder; the download is encoded by Sharp. Byte counts will differ.
|
||||
- Canvas ignores the quality argument for PNG (it is always lossless), so dragging quality changes nothing in a PNG preview while it does change the real output.
|
||||
- The preview's `calculateDimensions()` will happily scale an image **up**; the server sets `withoutEnlargement: true` and will not. The "Retina @2x" preset is exactly this case — the preview grows, the downloaded file does not.
|
||||
|
||||
### Docker Strategy & Fixes
|
||||
|
||||
PNGer uses a multi-stage Docker build to minimize image size and maximize security.
|
||||
|
||||
**Optimization Fixes applied:**
|
||||
- **Dependency Installation**: Uses `npm install` instead of `npm ci` to handle missing lockfiles gracefully while maintaining stability via caret ranges in `package.json`.
|
||||
- **Security**: Runs as a non-root `node` user in the final Alpine-based image.
|
||||
- **Multer Upgrade**: Upgraded to `v2.1.0` for improved security and performance.
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 20+
|
||||
- npm or pnpm
|
||||
- Docker (optional)
|
||||
|
||||
### Setup Steps
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://git.alwisp.com/jason/pnger.git
|
||||
cd pnger
|
||||
|
||||
# Setup backend
|
||||
cd backend
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# Setup frontend (separate terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Only `PORT` is actually read by the application. The full table, including which variables are declared-but-unused, is in [INSTALL.md](INSTALL.md#environment-variables).
|
||||
|
||||
**Backend:**
|
||||
- `PORT`: 3000 — read by `src/index.ts`.
|
||||
- `MAX_FILE_SIZE`: declared in the `Dockerfile` and compose file, **not enforced** — the live `multer` config sets no `limits`.
|
||||
- `CORS_ORIGIN`: in `backend/.env.example`, **not read** — `app.use(cors())` takes no options.
|
||||
- `TEMP_DIR` / `/app/temp`: created by the `Dockerfile`, **never written to** — processing is entirely in memory.
|
||||
|
||||
**Frontend:**
|
||||
- `VITE_API_URL`: **not read.** `lib/api.ts` picks its base URL from `import.meta.env.DEV` (`http://localhost:3000/api` in dev, `/api` in a build).
|
||||
|
||||
## Development Workflow & Standards
|
||||
|
||||
### Workflow
|
||||
1. **Feature Branch**: Create from `main`.
|
||||
2. **Develop**: Use hot-reload (`npm run dev`).
|
||||
3. **Test**: Perform manual testing of image operations and presets.
|
||||
4. **Commit**: Use `type: description` format (e.g., `feat: add rotation`).
|
||||
5. **Merge**: Merge into `main` after review.
|
||||
|
||||
### Code Standards
|
||||
- **TypeScript**: Use strict types where possible.
|
||||
- **Svelte**: Keep components modular and under 300 lines.
|
||||
- **Async/Await**: Use for all asynchronous operations.
|
||||
- **Semantic HTML**: Ensure accessibility and proper structure.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
More symptoms, including container-level ones, in [INSTALL.md](INSTALL.md#troubleshooting).
|
||||
|
||||
- **Port in use**: `lsof -ti:3000 | xargs kill -9`
|
||||
- **Sharp issues**: `npm rebuild sharp`
|
||||
- **Docker Cache**: `docker builder prune` if builds fail unexpectedly.
|
||||
- **Preview Glitches**: Check browser console for Canvas API errors.
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: July 2026 — reconciled against the code during the docs standardization pass. Deployment and configuration detail now lives in [INSTALL.md](INSTALL.md) and [UNRAID.md](UNRAID.md).
|
||||
@@ -0,0 +1,62 @@
|
||||
# PNGer Feature Roadmap
|
||||
|
||||
PNGer is evolved through intentional sprints focusing on user experience, performance, and professional-grade features.
|
||||
|
||||
## Completed Features ✅
|
||||
|
||||
### Sprint 0: Foundation (MVP)
|
||||
- ✅ **Core Repository Setup**: Project structure and build systems.
|
||||
- ✅ **Basic Image Operations**: Width/height resizing and quality adjustment.
|
||||
- ✅ **Format Support**: Conversion between PNG, WebP, and JPEG.
|
||||
- ✅ **Docker Deployment**: Multi-stage build for Unraid/Docker environments.
|
||||
- ✅ **Stateless Processing**: Direct download from memory; no server storage.
|
||||
|
||||
### Sprint 1: Enhanced UX & Live Preview (March 2026)
|
||||
- ✅ **Live Preview**: Real-time side-by-side comparison with file size analysis.
|
||||
- ✅ **Modern Design System**: Dark/Light mode toggle with persistent storage.
|
||||
- ✅ **Drag & Drop Upload**: Intuitive drop zone with visual feedback.
|
||||
- ✅ **Clipboard Paste**: Paste images directly with `Ctrl+V`.
|
||||
- ✅ **Smart Presets**: 8 quick-access configurations for common use cases (Social Media, Web, Icons, etc.).
|
||||
- ✅ **Keyboard Shortcuts**: Fast workflow with `Enter`, `?`, and `Esc`.
|
||||
- ✅ **Performance Optimization**: Client-side Canvas rendering for instant feedback.
|
||||
|
||||
---
|
||||
|
||||
## Future Roadmap 🚀
|
||||
|
||||
### Sprint 2: Batch Processing & Advanced Operations
|
||||
**Focus**: Efficiency and essential power-user tools.
|
||||
- [ ] **Batch Processing**: Upload multiple images and process as a single ZIP.
|
||||
- [ ] **Basic Transformations**: Rotate (90/180/Custom), Flip (H/V), and Grayscale.
|
||||
- [ ] **Content-Aware Resize**: Smart cropping and aspect ratio enforcement.
|
||||
- [ ] **Auto-Optimize**: One-click "best quality/size" ratio finder.
|
||||
|
||||
### Sprint 3: Polish & Professional Tools
|
||||
**Focus**: Precision and customization.
|
||||
- [ ] **Custom Crop Tool**: Visual selector with aspect ratio lock.
|
||||
- [ ] **Watermarking**: Text and image-based branding.
|
||||
- [ ] **Image Filters**: Brightness, Contrast, Saturation, and Sharpen.
|
||||
- [ ] **Format Intelligence**: Suggestions based on image content (e.g., suggesting WebP for large images).
|
||||
|
||||
### Sprint 4: Workflow & Automation
|
||||
**Focus**: Reusability and productivity.
|
||||
- [ ] **Custom Presets**: Save and export personal transformation pipelines.
|
||||
- [ ] **Processing History**: Recent files list (localStorage).
|
||||
- [ ] **Undo/Redo**: History for parameter adjustments.
|
||||
|
||||
---
|
||||
|
||||
## Priority Matrix
|
||||
|
||||
| Feature | Impact | Effort | Priority |
|
||||
|---------|--------|--------|----------|
|
||||
| Batch Processing | Very High | Medium | 1 |
|
||||
| Auto-Optimize | High | Low | 1 |
|
||||
| Custom Crop | High | High | 2 |
|
||||
| Watermarking | Medium | Medium | 3 |
|
||||
|
||||
---
|
||||
|
||||
**Project Maintainer**: jason
|
||||
**Repository**: [jason/pnger](https://git.alwisp.com/jason/pnger)
|
||||
**Last Strategy Sync**: March 12, 2026
|
||||
@@ -0,0 +1,72 @@
|
||||
# Unraid Installation Guide for PNGer
|
||||
|
||||
This guide walks you through installing PNGer on Unraid using the Docker tab and "Add Container" feature. For the compose/CLI paths, local development and the full environment-variable table, see [INSTALL.md](INSTALL.md).
|
||||
|
||||
> PNGer has **no authentication**. Anyone who can reach the port can upload images and burn CPU on your box. Keep it on the LAN, or put it behind a reverse proxy with auth before exposing it.
|
||||
|
||||
## Requirements
|
||||
- Unraid OS with Docker enabled.
|
||||
- Appdata path ready (optional, if you want persistent temp storage).
|
||||
|
||||
## Step-by-Step Installation
|
||||
|
||||
1. Log into your Unraid WebGUI and navigate to the **Docker** tab.
|
||||
2. Scroll to the bottom and click on **Add Container**.
|
||||
3. Fill in the following details:
|
||||
- **Name**: `PNGer`
|
||||
- **Repository**: `registry.alwisp.com/jason/pnger:latest` — this is what the Gitea Actions workflow publishes on every push to `main`. Run `docker login registry.alwisp.com` once on the Unraid host so it can pull. If you built the image locally on the box instead (`docker build -t pnger:latest .`), use `pnger:latest`.
|
||||
- **Network Type**: `Bridge`
|
||||
|
||||
4. Click on **+ Add another Path, Port, Variable, Label or Device** to add the required parameters.
|
||||
|
||||
### Port Configuration
|
||||
- **Config Type**: `Port`
|
||||
- **Name**: `WebUI`
|
||||
- **Container Port**: `3000`
|
||||
- **Host Port**: `8080` (or whichever port is free on your Unraid system).
|
||||
- **Connection Protocol**: `TCP`
|
||||
|
||||
### Environment Variables
|
||||
Add the following variables by clicking **+ Add another Path, Port, Variable...** and selecting **Variable** as the Config Type:
|
||||
|
||||
1. **PUID**
|
||||
- **Name**: `User ID (PUID)`
|
||||
- **Key**: `PUID`
|
||||
- **Value**: `99` (Unraid's nobody user).
|
||||
|
||||
2. **PGID**
|
||||
- **Name**: `Group ID (PGID)`
|
||||
- **Key**: `PGID`
|
||||
- **Value**: `100` (Unraid's users group).
|
||||
|
||||
3. **TZ**
|
||||
- **Name**: `Timezone`
|
||||
- **Key**: `TZ`
|
||||
- **Value**: `America/New_York` (Enter your specific Timezone here).
|
||||
|
||||
4. **MAX_FILE_SIZE** (Optional — and currently inert)
|
||||
- **Name**: `Max Upload Size (Bytes)`
|
||||
- **Key**: `MAX_FILE_SIZE`
|
||||
- **Value**: `10485760` (10 MB in bytes).
|
||||
- ⚠️ The application does **not** read this today: the live upload middleware sets no size limit, so uploads are effectively unbounded and are held in memory. Setting the variable does nothing. Until it is wired up, cap request bodies at your reverse proxy and give the container a sensible memory limit.
|
||||
|
||||
### Volume Mapping (not needed)
|
||||
PNGer is stateless — uploads are processed in memory and streamed straight back, and nothing is ever written to `/app/temp` despite the directory existing. There is nothing to persist and nothing to back up. If you want the mapping anyway, it is harmless:
|
||||
- **Config Type**: `Path`
|
||||
- **Name**: `Temp Processing Dir`
|
||||
- **Container Path**: `/app/temp`
|
||||
- **Host Path**: `/mnt/user/appdata/pnger/temp`
|
||||
|
||||
5. **Apply Settings**:
|
||||
- Scroll to the bottom and press **Apply**. Unraid will pull the image and create the container with the specified settings.
|
||||
|
||||
## Accessing PNGer
|
||||
Once the container states "started", you can access the Web GUI by navigating to your Unraid IP and the port you assigned (e.g., `http://192.168.1.100:8080`).
|
||||
|
||||
---
|
||||
|
||||
**Updates:**
|
||||
Every push to `main` rebuilds `registry.alwisp.com/jason/pnger:latest` on the Gitea runner. To take a new build: **Docker** tab → container row → **Force update** (or let the *CA Auto Update Applications* plugin do it on a schedule). There is no state, so an update is just a container recreation. Only `:latest` is published — there is no per-SHA tag to pin or roll back to.
|
||||
|
||||
**Troubleshooting:**
|
||||
If the container stops instantly, check the **Logs** in Unraid. Ensure that the port you selected on the host is not already in use by another container (like a web server or another app). If the container starts but the WebGUI is blank, confirm the host port maps to container port **3000**.
|
||||
+568
-62
@@ -1,28 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { transformImage } from "./lib/api";
|
||||
import {
|
||||
generateClientPreview,
|
||||
estimateSize,
|
||||
formatFileSize,
|
||||
calculateSavings,
|
||||
debounce,
|
||||
type TransformOptions
|
||||
} from "./lib/preview";
|
||||
import { theme } from "./lib/theme";
|
||||
import { PRESETS, applyPreset, type Preset } from "./lib/presets";
|
||||
|
||||
let file: File | null = null;
|
||||
let filePreviewUrl: string | null = null;
|
||||
let width: number | null = null;
|
||||
let height: number | null = null;
|
||||
let quality = 80;
|
||||
let format: "png" | "webp" | "jpeg" = "png";
|
||||
|
||||
// cropping / resizing
|
||||
let fit: "inside" | "cover" = "inside"; // inside = resize only, cover = crop
|
||||
let position:
|
||||
| "center"
|
||||
| "top"
|
||||
| "right"
|
||||
| "bottom"
|
||||
| "left"
|
||||
| "top-left"
|
||||
| "top-right"
|
||||
| "bottom-left"
|
||||
| "bottom-right" = "center";
|
||||
let fit: "inside" | "cover" = "inside";
|
||||
let position = "center";
|
||||
|
||||
let processing = false;
|
||||
let error: string | null = null;
|
||||
|
||||
// Drag & drop state
|
||||
let isDragging = false;
|
||||
let showShortcuts = false;
|
||||
|
||||
// Preview state
|
||||
let previewUrl: string | null = null;
|
||||
let previewSize = 0;
|
||||
let originalSize = 0;
|
||||
let showPreview = false;
|
||||
|
||||
// Generate preview with debounce
|
||||
const updatePreview = debounce(async () => {
|
||||
if (!file) {
|
||||
previewUrl = null;
|
||||
showPreview = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const options: TransformOptions = {
|
||||
width: width || undefined,
|
||||
height: height || undefined,
|
||||
quality,
|
||||
format,
|
||||
fit,
|
||||
position: fit === "cover" ? position : undefined
|
||||
};
|
||||
|
||||
previewUrl = await generateClientPreview(file, options);
|
||||
previewSize = estimateSize(previewUrl);
|
||||
originalSize = file.size;
|
||||
showPreview = true;
|
||||
} catch (err) {
|
||||
console.error("Preview generation failed:", err);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// Reactive preview updates
|
||||
$: if (file) {
|
||||
updatePreview();
|
||||
}
|
||||
$: if (width !== null || height !== null || quality || format || fit || position) {
|
||||
if (file) updatePreview();
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!file) {
|
||||
error = "Please select an image file";
|
||||
@@ -37,7 +83,7 @@
|
||||
quality,
|
||||
format,
|
||||
fit,
|
||||
position
|
||||
position: fit === "cover" ? position : undefined
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
@@ -53,34 +99,295 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleFile(selectedFile: File) {
|
||||
if (!selectedFile.type.startsWith('image/')) {
|
||||
error = 'Please select an image file';
|
||||
return;
|
||||
}
|
||||
file = selectedFile;
|
||||
filePreviewUrl = URL.createObjectURL(selectedFile);
|
||||
error = null;
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
file = target.files?.[0] || null;
|
||||
const selectedFile = target.files?.[0];
|
||||
if (selectedFile) {
|
||||
handleFile(selectedFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Drag & Drop handlers
|
||||
function onDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
}
|
||||
|
||||
function onDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragging = false;
|
||||
|
||||
const droppedFile = e.dataTransfer?.files?.[0];
|
||||
if (droppedFile) {
|
||||
handleFile(droppedFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Paste handler
|
||||
function onPaste(e: ClipboardEvent) {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf('image') !== -1) {
|
||||
const pastedFile = items[i].getAsFile();
|
||||
if (pastedFile) {
|
||||
handleFile(pastedFile);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
// Show shortcuts help
|
||||
if (e.key === '?') {
|
||||
e.preventDefault();
|
||||
showShortcuts = !showShortcuts;
|
||||
return;
|
||||
}
|
||||
|
||||
// Close shortcuts dialog
|
||||
if (e.key === 'Escape' && showShortcuts) {
|
||||
showShortcuts = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + V - Paste (handled by paste event)
|
||||
// Ctrl/Cmd + Enter - Transform & Download
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && file && !processing) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter alone - Transform & Download (if input not focused)
|
||||
const activeElement = document.activeElement;
|
||||
const isInputFocused = activeElement?.tagName === 'INPUT' ||
|
||||
activeElement?.tagName === 'SELECT' ||
|
||||
activeElement?.tagName === 'TEXTAREA';
|
||||
|
||||
if (e.key === 'Enter' && !isInputFocused && file && !processing) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
file = null;
|
||||
filePreviewUrl = null;
|
||||
previewUrl = null;
|
||||
showPreview = false;
|
||||
originalSize = 0;
|
||||
previewSize = 0;
|
||||
}
|
||||
|
||||
// Apply preset
|
||||
function selectPreset(preset: Preset) {
|
||||
const settings = applyPreset(preset, width, height);
|
||||
width = settings.width;
|
||||
height = settings.height;
|
||||
quality = settings.quality;
|
||||
format = settings.format;
|
||||
fit = settings.fit;
|
||||
}
|
||||
|
||||
// Setup event listeners
|
||||
onMount(() => {
|
||||
document.addEventListener('paste', onPaste);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('paste', onPaste);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
});
|
||||
|
||||
const savings = showPreview ? calculateSavings(originalSize, previewSize) : null;
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<h1>PNG Editor</h1>
|
||||
|
||||
<input type="file" accept="image/*" on:change={onFileChange} />
|
||||
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header class="flex justify-between items-center" style="margin-bottom: var(--space-2xl);">
|
||||
<div>
|
||||
<label>Width: <input type="number" bind:value={width} min="1" /></label>
|
||||
<label>Height: <input type="number" bind:value={height} min="1" /></label>
|
||||
<h1 class="mb-0">PNGer</h1>
|
||||
<p class="text-sm mb-0">Modern PNG Editor & Resizer</p>
|
||||
</div>
|
||||
<div class="flex gap-sm">
|
||||
<button class="btn-outline" on:click={() => showShortcuts = !showShortcuts} title="Keyboard shortcuts (?)">
|
||||
⌨️
|
||||
</button>
|
||||
<button class="btn-outline" on:click={() => theme.toggle()}>
|
||||
{#if $theme === 'dark'}
|
||||
☀️ Light
|
||||
{:else}
|
||||
🌙 Dark
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Shortcuts Modal -->
|
||||
{#if showShortcuts}
|
||||
<div class="modal-overlay" on:click={() => showShortcuts = false}>
|
||||
<div class="modal-content" on:click|stopPropagation>
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<div class="shortcuts-list">
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl</kbd> + <kbd>V</kbd>
|
||||
<span>Paste image from clipboard</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Enter</kbd>
|
||||
<span>Transform & Download</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl</kbd> + <kbd>Enter</kbd>
|
||||
<span>Transform & Download (anywhere)</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>?</kbd>
|
||||
<span>Show/hide this help</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Esc</kbd>
|
||||
<span>Close this dialog</span>
|
||||
</div>
|
||||
</div>
|
||||
<button style="margin-top: var(--space-lg);" on:click={() => showShortcuts = false}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Controls Section -->
|
||||
<div class="card fade-in" style="margin-bottom: var(--space-xl);">
|
||||
<div class="grid grid-cols-2 gap-lg">
|
||||
<!-- Left Column: Upload & Dimensions -->
|
||||
<div>
|
||||
<h2>Upload & Settings</h2>
|
||||
|
||||
<!-- Drag & Drop / File Upload -->
|
||||
<div style="margin-bottom: var(--space-lg);">
|
||||
<label style="display: block; margin-bottom: var(--space-sm); font-weight: 500;">
|
||||
Select or Drop Image
|
||||
</label>
|
||||
|
||||
<!-- Drop Zone -->
|
||||
<div
|
||||
class="drop-zone {isDragging ? 'dragging' : ''}"
|
||||
on:dragover={onDragOver}
|
||||
on:dragleave={onDragLeave}
|
||||
on:drop={onDrop}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
on:change={onFileChange}
|
||||
id="file-input"
|
||||
style="display: none;"
|
||||
/>
|
||||
<label for="file-input" class="drop-zone-label">
|
||||
{#if file}
|
||||
<div class="flex gap-sm items-center" style="flex-direction: column;">
|
||||
<span style="font-size: 2rem;">✅</span>
|
||||
<span class="text-sm">{file.name}</span>
|
||||
<span class="text-xs" style="color: var(--color-text-secondary);">
|
||||
({formatFileSize(file.size)})
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div style="text-align: center;">
|
||||
<p style="font-size: 3rem; margin-bottom: var(--space-sm);">🖼️</p>
|
||||
<p style="margin-bottom: var(--space-xs);">Drag & drop image here</p>
|
||||
<p class="text-sm" style="color: var(--color-text-secondary); margin-bottom: var(--space-sm);">or click to browse</p>
|
||||
<p class="text-xs" style="color: var(--color-text-secondary);">Paste with Ctrl+V</p>
|
||||
</div>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if file}
|
||||
<button class="btn-secondary" style="width: 100%; margin-top: var(--space-sm);" on:click={clearFile}>
|
||||
Clear File
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Smart Presets -->
|
||||
{#if file}
|
||||
<div style="margin-bottom: var(--space-lg);" class="fade-in">
|
||||
<label style="display: block; margin-bottom: var(--space-sm); font-weight: 500;">
|
||||
Quick Presets
|
||||
</label>
|
||||
<div class="presets-grid">
|
||||
{#each PRESETS as preset}
|
||||
<button
|
||||
class="preset-btn"
|
||||
on:click={() => selectPreset(preset)}
|
||||
title={preset.description}
|
||||
>
|
||||
<span class="preset-icon">{preset.icon}</span>
|
||||
<span class="preset-name">{preset.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Dimensions -->
|
||||
<div style="margin-bottom: var(--space-lg);">
|
||||
<h3>Dimensions</h3>
|
||||
<div class="grid grid-cols-2 gap-md">
|
||||
<div>
|
||||
<label>Fit mode:
|
||||
<label style="display: block; margin-bottom: var(--space-xs); font-size: 0.875rem;">
|
||||
Width (px)
|
||||
</label>
|
||||
<input type="number" bind:value={width} min="1" placeholder="Auto" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: var(--space-xs); font-size: 0.875rem;">
|
||||
Height (px)
|
||||
</label>
|
||||
<input type="number" bind:value={height} min="1" placeholder="Auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fit Mode -->
|
||||
<div style="margin-bottom: var(--space-lg);">
|
||||
<label style="display: block; margin-bottom: var(--space-sm); font-weight: 500;">
|
||||
Fit Mode
|
||||
</label>
|
||||
<select bind:value={fit}>
|
||||
<option value="inside">Resize only (no crop)</option>
|
||||
<option value="cover">Crop to fit box</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Crop Position (if cover) -->
|
||||
{#if fit === "cover"}
|
||||
<div>
|
||||
<label>Crop position:
|
||||
<div style="margin-bottom: var(--space-lg);" class="fade-in">
|
||||
<label style="display: block; margin-bottom: var(--space-sm); font-weight: 500;">
|
||||
Crop Position
|
||||
</label>
|
||||
<select bind:value={position}>
|
||||
<option value="center">Center</option>
|
||||
<option value="top">Top</option>
|
||||
@@ -92,67 +399,266 @@
|
||||
<option value="bottom-left">Bottom-left</option>
|
||||
<option value="bottom-right">Bottom-right</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label>Quality:
|
||||
<input type="range" min="10" max="100" bind:value={quality} />
|
||||
</label>
|
||||
<span>{quality}</span>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Quality & Format -->
|
||||
<div>
|
||||
<label>Format:
|
||||
<h2>Quality & Format</h2>
|
||||
|
||||
<!-- Quality -->
|
||||
<div style="margin-bottom: var(--space-lg);">
|
||||
<div class="flex justify-between" style="margin-bottom: var(--space-sm);">
|
||||
<label style="font-weight: 500;">Quality</label>
|
||||
<span style="color: var(--color-accent); font-weight: 600;">{quality}%</span>
|
||||
</div>
|
||||
<input type="range" min="10" max="100" bind:value={quality} />
|
||||
</div>
|
||||
|
||||
<!-- Format -->
|
||||
<div style="margin-bottom: var(--space-xl);">
|
||||
<label style="display: block; margin-bottom: var(--space-sm); font-weight: 500;">
|
||||
Output Format
|
||||
</label>
|
||||
<select bind:value={format}>
|
||||
<option value="png">PNG</option>
|
||||
<option value="webp">WebP</option>
|
||||
<option value="jpeg">JPEG</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if error}
|
||||
<p style="color: red">{error}</p>
|
||||
<p style="color: var(--color-error); padding: var(--space-md); background: var(--color-bg-tertiary); border-radius: var(--radius-md); margin-bottom: var(--space-lg);">
|
||||
{error}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<button on:click|preventDefault={onSubmit} disabled={processing}>
|
||||
{processing ? "Processing..." : "Transform & Download"}
|
||||
<!-- Action Button -->
|
||||
<button
|
||||
on:click|preventDefault={onSubmit}
|
||||
disabled={processing || !file}
|
||||
style="width: 100%;"
|
||||
>
|
||||
{#if processing}
|
||||
<span class="spinner" style="width: 16px; height: 16px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%;"></span>
|
||||
Processing...
|
||||
{:else}
|
||||
⬇️ Transform & Download
|
||||
{/if}
|
||||
</button>
|
||||
</main>
|
||||
|
||||
{#if file}
|
||||
<p class="text-xs" style="color: var(--color-text-secondary); text-align: center; margin-top: var(--space-sm); margin-bottom: 0;">
|
||||
Press <kbd style="font-size: 0.75rem; padding: 2px 4px;">Enter</kbd> to download
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Preview Section (Full Width Below) -->
|
||||
<div class="card fade-in">
|
||||
<h2>Live Preview</h2>
|
||||
|
||||
{#if !file}
|
||||
<div style="display: flex; align-items: center; justify-content: center; color: var(--color-text-secondary); text-align: center; padding: var(--space-2xl); min-height: 400px;">
|
||||
<div>
|
||||
<p style="font-size: 3rem; margin-bottom: var(--space-md)">🖼️</p>
|
||||
<p class="mb-0">Upload an image to see live preview</p>
|
||||
<p class="text-sm" style="color: var(--color-text-secondary); margin-top: var(--space-sm);">Drag & drop, click to browse, or paste with Ctrl+V</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if showPreview}
|
||||
<div style="display: flex; flex-direction: column; gap: var(--space-lg);">
|
||||
<!-- Image Comparison -->
|
||||
<div class="grid grid-cols-2 gap-lg">
|
||||
<!-- Original -->
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<h3 style="font-size: 1rem; margin-bottom: var(--space-sm);">Original</h3>
|
||||
<div style="border: 2px solid var(--color-border); border-radius: var(--radius-md); overflow: hidden; display: flex; align-items: center; justify-content: center; background: var(--color-bg-tertiary); min-height: 500px;">
|
||||
<img
|
||||
src={filePreviewUrl}
|
||||
alt="Original"
|
||||
style="max-width: 100%; max-height: 600px; object-fit: contain;"
|
||||
/>
|
||||
</div>
|
||||
<div style="margin-top: var(--space-sm); text-align: center;">
|
||||
<p class="text-sm mb-0">
|
||||
{formatFileSize(originalSize)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<h3 style="font-size: 1rem; margin-bottom: var(--space-sm);">Preview</h3>
|
||||
<div style="border: 2px solid var(--color-accent); border-radius: var(--radius-md); overflow: hidden; display: flex; align-items: center; justify-content: center; background: var(--color-bg-tertiary); min-height: 500px;">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
style="max-width: 100%; max-height: 600px; object-fit: contain;"
|
||||
/>
|
||||
</div>
|
||||
<div style="margin-top: var(--space-sm); text-align: center;">
|
||||
<p class="text-sm mb-0">
|
||||
{formatFileSize(previewSize)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Savings Info -->
|
||||
{#if savings}
|
||||
<div
|
||||
class="fade-in"
|
||||
style="
|
||||
padding: var(--space-lg);
|
||||
background: {savings.isReduction ? 'var(--color-success)' : 'var(--color-warning)'}15;
|
||||
border: 2px solid {savings.isReduction ? 'var(--color-success)' : 'var(--color-warning)'};
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<p class="text-sm font-semibold mb-0" style="color: {savings.isReduction ? 'var(--color-success)' : 'var(--color-warning)'}; font-size: 1.125rem;">
|
||||
{savings.formatted}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div style="display: flex; align-items: center; justify-content: center; color: var(--color-text-secondary); min-height: 500px;">
|
||||
<div class="spinner" style="width: 40px; height: 40px; border: 3px solid var(--color-border); border-top-color: var(--color-accent); border-radius: 50%;"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
main {
|
||||
max-width: 600px;
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
/* Drop zone styles */
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-xl);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 2rem;
|
||||
.drop-zone:hover {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
label {
|
||||
.drop-zone.dragging {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent)15;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.drop-zone-label {
|
||||
display: block;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
input[type="number"],
|
||||
select {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 1.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
/* Presets grid */
|
||||
.presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-tertiary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.preset-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.preset-name {
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--color-bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-2xl);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: var(--shadow-2xl);
|
||||
}
|
||||
|
||||
.shortcuts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.shortcut-item span {
|
||||
flex: 1;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.875rem;
|
||||
font-family: monospace;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 2px 0 var(--color-border);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
+354
-37
@@ -1,16 +1,59 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
/* Light mode colors (white with dark gold) */
|
||||
--color-bg-primary: #ffffff;
|
||||
--color-bg-secondary: #f8f9fa;
|
||||
--color-bg-tertiary: #e9ecef;
|
||||
--color-text-primary: #1a1a1a;
|
||||
--color-text-secondary: #6c757d;
|
||||
--color-border: #dee2e6;
|
||||
--color-accent: #b8860b; /* Dark gold */
|
||||
--color-accent-hover: #8b6914;
|
||||
--color-accent-light: #daa520;
|
||||
--color-success: #28a745;
|
||||
--color-error: #dc3545;
|
||||
--color-warning: #ffc107;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.15);
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
/* Spacing */
|
||||
--space-xs: 0.25rem;
|
||||
--space-sm: 0.5rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 1.5rem;
|
||||
--space-xl: 2rem;
|
||||
--space-2xl: 3rem;
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 250ms ease;
|
||||
--transition-slow: 350ms ease;
|
||||
}
|
||||
|
||||
/* Dark mode (black with light gold) */
|
||||
[data-theme="dark"] {
|
||||
--color-bg-primary: #0a0a0a;
|
||||
--color-bg-secondary: #1a1a1a;
|
||||
--color-bg-tertiary: #2a2a2a;
|
||||
--color-text-primary: #e9ecef;
|
||||
--color-text-secondary: #adb5bd;
|
||||
--color-border: #3a3a3a;
|
||||
--color-accent: #daa520; /* Light gold */
|
||||
--color-accent-hover: #ffd700;
|
||||
--color-accent-light: #f0e68c;
|
||||
--color-success: #4caf50;
|
||||
--color-error: #f44336;
|
||||
--color-warning: #ff9800;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -19,49 +62,323 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition: background-color var(--transition-base), color var(--transition-base);
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button, .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: var(--color-bg-primary);
|
||||
background-color: var(--color-accent);
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled), .btn:hover:not(:disabled) {
|
||||
background-color: var(--color-accent-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
button:active:not(:disabled), .btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
button:disabled, .btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.btn-secondary {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
button.btn-secondary:hover:not(:disabled) {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
button.btn-outline {
|
||||
background-color: transparent;
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
button.btn-outline:hover:not(:disabled) {
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="file"],
|
||||
select {
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="number"]:focus,
|
||||
input[type="file"]:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px rgba(218, 165, 32, 0.1);
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb:hover {
|
||||
background: var(--color-accent-hover);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--color-accent);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb:hover {
|
||||
background: var(--color-accent-hover);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.gap-sm {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.gap-md {
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.gap-lg {
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
.mb-0 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
.mt-auto {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn var(--transition-base) ease-out;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Smart Presets for common image transformation use cases
|
||||
*/
|
||||
|
||||
export interface Preset {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
quality: number;
|
||||
format: 'png' | 'webp' | 'jpeg';
|
||||
fit: 'inside' | 'cover';
|
||||
}
|
||||
|
||||
export const PRESETS: Preset[] = [
|
||||
{
|
||||
name: 'Web Thumbnail',
|
||||
description: 'Small, optimized for web (300x300)',
|
||||
icon: '🖼️',
|
||||
width: 300,
|
||||
height: 300,
|
||||
quality: 75,
|
||||
format: 'webp',
|
||||
fit: 'cover'
|
||||
},
|
||||
{
|
||||
name: 'Social Media',
|
||||
description: 'Open Graph image (1200x630)',
|
||||
icon: '📱',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
quality: 85,
|
||||
format: 'png',
|
||||
fit: 'cover'
|
||||
},
|
||||
{
|
||||
name: 'Profile Picture',
|
||||
description: 'Square avatar (400x400)',
|
||||
icon: '👤',
|
||||
width: 400,
|
||||
height: 400,
|
||||
quality: 85,
|
||||
format: 'png',
|
||||
fit: 'cover'
|
||||
},
|
||||
{
|
||||
name: 'Email Friendly',
|
||||
description: 'Compressed for email',
|
||||
icon: '📧',
|
||||
width: 600,
|
||||
quality: 70,
|
||||
format: 'jpeg',
|
||||
fit: 'inside'
|
||||
},
|
||||
{
|
||||
name: 'HD Quality',
|
||||
description: 'High resolution (1920px wide)',
|
||||
icon: '⭐',
|
||||
width: 1920,
|
||||
quality: 90,
|
||||
format: 'png',
|
||||
fit: 'inside'
|
||||
},
|
||||
{
|
||||
name: 'Retina @2x',
|
||||
description: 'Double size for high-DPI',
|
||||
icon: '🔍',
|
||||
quality: 85,
|
||||
format: 'png',
|
||||
fit: 'inside'
|
||||
},
|
||||
{
|
||||
name: 'Icon Small',
|
||||
description: 'Tiny icon (64x64)',
|
||||
icon: '🔷',
|
||||
width: 64,
|
||||
height: 64,
|
||||
quality: 100,
|
||||
format: 'png',
|
||||
fit: 'cover'
|
||||
},
|
||||
{
|
||||
name: 'Icon Large',
|
||||
description: 'Large icon (256x256)',
|
||||
icon: '🔶',
|
||||
width: 256,
|
||||
height: 256,
|
||||
quality: 100,
|
||||
format: 'png',
|
||||
fit: 'cover'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Apply a preset to current settings
|
||||
* For Retina @2x, we double the current dimensions
|
||||
*/
|
||||
export function applyPreset(
|
||||
preset: Preset,
|
||||
currentWidth?: number | null,
|
||||
currentHeight?: number | null
|
||||
): {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
quality: number;
|
||||
format: 'png' | 'webp' | 'jpeg';
|
||||
fit: 'inside' | 'cover';
|
||||
} {
|
||||
// Special handling for Retina @2x preset
|
||||
if (preset.name === 'Retina @2x') {
|
||||
return {
|
||||
width: currentWidth ? currentWidth * 2 : null,
|
||||
height: currentHeight ? currentHeight * 2 : null,
|
||||
quality: preset.quality,
|
||||
format: preset.format,
|
||||
fit: preset.fit
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: preset.width || null,
|
||||
height: preset.height || null,
|
||||
quality: preset.quality,
|
||||
format: preset.format,
|
||||
fit: preset.fit
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
export interface TransformOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
quality: number;
|
||||
format: 'png' | 'webp' | 'jpeg';
|
||||
fit: 'inside' | 'cover';
|
||||
position?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a client-side preview using Canvas API
|
||||
* This provides instant feedback without server round-trip
|
||||
*/
|
||||
export async function generateClientPreview(
|
||||
file: File,
|
||||
options: TransformOptions
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error('Canvas context not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
const { width, height } = calculateDimensions(img, options);
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
if (options.fit === 'cover' && options.width && options.height) {
|
||||
drawCover(ctx, img, options.width, options.height, options.position || 'center');
|
||||
} else {
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
}
|
||||
|
||||
// Convert to data URL with quality - fix MIME type mapping
|
||||
const quality = options.quality / 100;
|
||||
let mimeType: string;
|
||||
|
||||
// Map format to proper MIME type
|
||||
switch (options.format) {
|
||||
case 'jpeg':
|
||||
mimeType = 'image/jpeg';
|
||||
break;
|
||||
case 'webp':
|
||||
mimeType = 'image/webp';
|
||||
break;
|
||||
case 'png':
|
||||
default:
|
||||
mimeType = 'image/png';
|
||||
break;
|
||||
}
|
||||
|
||||
// For PNG, quality doesn't apply in Canvas API (always lossless)
|
||||
// For JPEG and WebP, quality matters
|
||||
const dataUrl = options.format === 'png'
|
||||
? canvas.toDataURL(mimeType)
|
||||
: canvas.toDataURL(mimeType, quality);
|
||||
|
||||
resolve(dataUrl);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate dimensions for resize operation
|
||||
*/
|
||||
function calculateDimensions(
|
||||
img: HTMLImageElement,
|
||||
options: TransformOptions
|
||||
): { width: number; height: number } {
|
||||
const originalWidth = img.naturalWidth;
|
||||
const originalHeight = img.naturalHeight;
|
||||
const originalAspect = originalWidth / originalHeight;
|
||||
|
||||
// If no dimensions specified, return original
|
||||
if (!options.width && !options.height) {
|
||||
return { width: originalWidth, height: originalHeight };
|
||||
}
|
||||
|
||||
// If only width specified
|
||||
if (options.width && !options.height) {
|
||||
return {
|
||||
width: options.width,
|
||||
height: Math.round(options.width / originalAspect)
|
||||
};
|
||||
}
|
||||
|
||||
// If only height specified
|
||||
if (options.height && !options.width) {
|
||||
return {
|
||||
width: Math.round(options.height * originalAspect),
|
||||
height: options.height
|
||||
};
|
||||
}
|
||||
|
||||
// Both dimensions specified
|
||||
const targetWidth = options.width!;
|
||||
const targetHeight = options.height!;
|
||||
const targetAspect = targetWidth / targetHeight;
|
||||
|
||||
if (options.fit === 'cover') {
|
||||
// Fill the box, crop excess
|
||||
return { width: targetWidth, height: targetHeight };
|
||||
} else {
|
||||
// Fit inside box, maintain aspect ratio
|
||||
if (originalAspect > targetAspect) {
|
||||
// Image is wider
|
||||
return {
|
||||
width: targetWidth,
|
||||
height: Math.round(targetWidth / originalAspect)
|
||||
};
|
||||
} else {
|
||||
// Image is taller
|
||||
return {
|
||||
width: Math.round(targetHeight * originalAspect),
|
||||
height: targetHeight
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw image with cover fit (crop to fill)
|
||||
*/
|
||||
function drawCover(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
img: HTMLImageElement,
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
position: string
|
||||
) {
|
||||
const imgWidth = img.naturalWidth;
|
||||
const imgHeight = img.naturalHeight;
|
||||
const imgAspect = imgWidth / imgHeight;
|
||||
const targetAspect = targetWidth / targetHeight;
|
||||
|
||||
let sourceWidth: number;
|
||||
let sourceHeight: number;
|
||||
let sourceX = 0;
|
||||
let sourceY = 0;
|
||||
|
||||
if (imgAspect > targetAspect) {
|
||||
// Image is wider, crop sides
|
||||
sourceHeight = imgHeight;
|
||||
sourceWidth = imgHeight * targetAspect;
|
||||
sourceX = getPositionOffset(imgWidth - sourceWidth, position, 'horizontal');
|
||||
} else {
|
||||
// Image is taller, crop top/bottom
|
||||
sourceWidth = imgWidth;
|
||||
sourceHeight = imgWidth / targetAspect;
|
||||
sourceY = getPositionOffset(imgHeight - sourceHeight, position, 'vertical');
|
||||
}
|
||||
|
||||
ctx.drawImage(
|
||||
img,
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
0,
|
||||
0,
|
||||
targetWidth,
|
||||
targetHeight
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate crop offset based on position
|
||||
*/
|
||||
function getPositionOffset(
|
||||
availableSpace: number,
|
||||
position: string,
|
||||
axis: 'horizontal' | 'vertical'
|
||||
): number {
|
||||
const pos = position.toLowerCase();
|
||||
|
||||
if (axis === 'horizontal') {
|
||||
if (pos.includes('left')) return 0;
|
||||
if (pos.includes('right')) return availableSpace;
|
||||
return availableSpace / 2; // center
|
||||
} else {
|
||||
if (pos.includes('top')) return 0;
|
||||
if (pos.includes('bottom')) return availableSpace;
|
||||
return availableSpace / 2; // center
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate file size from data URL
|
||||
* More accurate calculation that accounts for base64 overhead
|
||||
*/
|
||||
export function estimateSize(dataUrl: string): number {
|
||||
const parts = dataUrl.split(',');
|
||||
if (parts.length < 2) return 0;
|
||||
|
||||
const base64 = parts[1];
|
||||
if (!base64) return 0;
|
||||
|
||||
// Remove padding characters for accurate calculation
|
||||
const withoutPadding = base64.replace(/=/g, '');
|
||||
|
||||
// Base64 encoding: 3 bytes -> 4 characters
|
||||
// So to get original bytes: (length * 3) / 4
|
||||
const bytes = (withoutPadding.length * 3) / 4;
|
||||
|
||||
// Account for padding bytes if present
|
||||
const paddingCount = base64.length - withoutPadding.length;
|
||||
|
||||
return Math.round(bytes - paddingCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human-readable size
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate savings/increase
|
||||
*/
|
||||
export function calculateSavings(original: number, modified: number): {
|
||||
amount: number;
|
||||
percent: number;
|
||||
isReduction: boolean;
|
||||
formatted: string;
|
||||
} {
|
||||
const diff = original - modified;
|
||||
const percent = (Math.abs(diff) / original) * 100;
|
||||
const isReduction = diff > 0;
|
||||
|
||||
let formatted: string;
|
||||
if (diff > 0) {
|
||||
formatted = `↓ ${formatFileSize(diff)} saved (${percent.toFixed(1)}%)`;
|
||||
} else if (diff < 0) {
|
||||
formatted = `↑ ${formatFileSize(Math.abs(diff))} larger (${percent.toFixed(1)}%)`;
|
||||
} else {
|
||||
formatted = 'Same size';
|
||||
}
|
||||
|
||||
return {
|
||||
amount: Math.abs(diff),
|
||||
percent,
|
||||
isReduction,
|
||||
formatted
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function for performance
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
// Get initial theme from localStorage or system preference
|
||||
function getInitialTheme(): Theme {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
|
||||
const stored = localStorage.getItem('theme') as Theme;
|
||||
if (stored === 'light' || stored === 'dark') {
|
||||
return stored;
|
||||
}
|
||||
|
||||
// Check system preference
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
return 'light';
|
||||
}
|
||||
|
||||
// Create the theme store
|
||||
function createThemeStore() {
|
||||
const { subscribe, set, update } = writable<Theme>(getInitialTheme());
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set: (theme: Theme) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('theme', theme);
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
set(theme);
|
||||
},
|
||||
toggle: () => {
|
||||
update(current => {
|
||||
const newTheme = current === 'light' ? 'dark' : 'light';
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('theme', newTheme);
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
}
|
||||
return newTheme;
|
||||
});
|
||||
},
|
||||
init: () => {
|
||||
const theme = getInitialTheme();
|
||||
if (typeof window !== 'undefined') {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
set(theme);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const theme = createThemeStore();
|
||||
|
||||
// Initialize theme on module load
|
||||
if (typeof window !== 'undefined') {
|
||||
theme.init();
|
||||
}
|
||||
Reference in New Issue
Block a user