Compare commits
30 Commits
fed1f1d078
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| ead10d9009 | |||
| 564b0b763d |
@@ -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
|
|
||||||
28
Dockerfile
28
Dockerfile
@@ -32,6 +32,11 @@ RUN npm run build
|
|||||||
# Stage 3: Production Runtime
|
# Stage 3: Production Runtime
|
||||||
FROM node:20-alpine
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy backend package files
|
# 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
|
COPY --from=frontend-builder /app/frontend/dist ./dist/public
|
||||||
|
|
||||||
# Create temp upload directory
|
# Create temp upload directory
|
||||||
RUN mkdir -p /app/temp && \
|
RUN mkdir -p /app/temp
|
||||||
chown -R node:node /app
|
|
||||||
|
|
||||||
# Switch to non-root user
|
# Copy entrypoint script
|
||||||
USER node
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
# Environment variables (can be overridden via Unraid UI)
|
# Environment variables (Unraid defaults and App defaults)
|
||||||
ENV NODE_ENV=production
|
ENV PUID=99 \
|
||||||
ENV PORT=3000
|
PGID=100 \
|
||||||
ENV MAX_FILE_SIZE=10485760
|
TZ=UTC \
|
||||||
ENV TEMP_DIR=/app/temp
|
NODE_ENV=production \
|
||||||
|
PORT=3000 \
|
||||||
|
MAX_FILE_SIZE=10485760 \
|
||||||
|
TEMP_DIR=/app/temp
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
@@ -67,5 +75,7 @@ EXPOSE 3000
|
|||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
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)})"
|
CMD node -e "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||||
|
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
|
||||||
# Start server
|
# Start server
|
||||||
CMD ["node", "dist/index.js"]
|
CMD ["node", "dist/index.js"]
|
||||||
252
INSTRUCTIONS.md
252
INSTRUCTIONS.md
@@ -1,70 +1,76 @@
|
|||||||
# PNGer Development Instructions
|
# PNGer Development & Technical Instructions
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
PNGer is a single-container web application for PNG editing and resizing, designed for deployment on Unraid with Gitea version control.
|
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.
|
||||||
|
|
||||||
## 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
|
## 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)]
|
||||||
|
B --> D[Express API (Backend)]
|
||||||
|
D --> E[Sharp Image Library]
|
||||||
|
D --> F[Runtime Environment (Docker)]
|
||||||
|
```
|
||||||
|
|
||||||
### Backend (Express + Sharp)
|
### Backend (Express + Sharp)
|
||||||
|
|
||||||
**Endpoints:**
|
The backend is built with Node.js and TypeScript, using Express for the API and Sharp for high-performance image processing.
|
||||||
- `POST /api/upload` - Accept PNG file
|
|
||||||
- `POST /api/process` - Resize/compress image
|
**Key Endpoints:**
|
||||||
|
- `POST /api/transform` - Transform image (resize, crop, compress, convert)
|
||||||
- `GET /api/health` - Health check
|
- `GET /api/health` - Health check
|
||||||
|
|
||||||
**Key Dependencies:**
|
**Key Dependencies:**
|
||||||
- express: Web framework
|
- `sharp`: High-performance image processing (handles resizing, cropping, and format conversion).
|
||||||
- multer: File upload handling
|
- `multer`: Middleware for handling `multipart/form-data`, used for file uploads.
|
||||||
- sharp: Image processing
|
- `express`: Web framework for the API.
|
||||||
- cors: Cross-origin support
|
|
||||||
|
|
||||||
### Frontend (Svelte + Vite)
|
### Frontend (Svelte + Vite)
|
||||||
|
|
||||||
**Components:**
|
The frontend is a reactive Svelte application that prioritizes real-time feedback and UX.
|
||||||
- `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:**
|
**Core Components & Modules:**
|
||||||
- svelte: Reactive framework
|
- `App.svelte`: Main application container managing state and UI.
|
||||||
- vite: Build tool
|
- `lib/api.ts`: API client for backend communication.
|
||||||
- axios: HTTP client
|
- `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.
|
||||||
|
|
||||||
### Docker Strategy
|
### Design System (Dark/Light Modes)
|
||||||
|
|
||||||
**Multi-stage Build:**
|
The UI uses a modern design system with CSS custom properties for theming:
|
||||||
1. Stage 1: Build frontend (Vite build)
|
- **Light Mode**: Clean white background with dark gold (`#b8860b`) accents.
|
||||||
2. Stage 2: Copy frontend + setup backend
|
- **Dark Mode**: Deep black (`#0a0a0a`) background with light gold (`#daa520`) accents.
|
||||||
3. Final image: Alpine-based Node.js
|
- **Transparencies**: Dark/Light toggling allows users to inspect PNG transparency against different backgrounds.
|
||||||
|
|
||||||
**Image Size Target:** < 150MB
|
## 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.
|
||||||
|
|
||||||
|
### 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
|
## Local Development Setup
|
||||||
|
|
||||||
@@ -73,156 +79,56 @@ PNGer is a single-container web application for PNG editing and resizing, design
|
|||||||
- npm or pnpm
|
- npm or pnpm
|
||||||
- Docker (optional)
|
- Docker (optional)
|
||||||
|
|
||||||
### Initial Setup
|
### Setup Steps
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone repository
|
# Clone repository
|
||||||
git clone https://git.alwisp.com/jason/pnger.git
|
git clone https://git.alwisp.com/jason/pnger.git
|
||||||
cd pnger
|
cd pnger
|
||||||
|
|
||||||
# Install root dependencies (if monorepo)
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Setup backend
|
# Setup backend
|
||||||
cd backend
|
cd backend
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# Setup frontend (new terminal)
|
# Setup frontend (separate terminal)
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
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
|
### Environment Variables
|
||||||
|
|
||||||
**Backend (.env):**
|
**Backend (.env):**
|
||||||
```
|
- `PORT`: 3000 (internal)
|
||||||
PORT=3000
|
- `MAX_FILE_SIZE`: 10485760 (10MB default)
|
||||||
NODE_ENV=development
|
- `CORS_ORIGIN`: http://localhost:5173
|
||||||
MAX_FILE_SIZE=10
|
|
||||||
CORS_ORIGIN=http://localhost:5173
|
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend (.env):**
|
**Frontend (.env):**
|
||||||
```
|
- `VITE_API_URL`: http://localhost:3000/api
|
||||||
VITE_API_URL=http://localhost:3000/api
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker Build & Test
|
## Development Workflow & Standards
|
||||||
|
|
||||||
```bash
|
### Workflow
|
||||||
# Build image
|
1. **Feature Branch**: Create from `main`.
|
||||||
docker build -t pnger:test .
|
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.
|
||||||
|
|
||||||
# Run container
|
### Code Standards
|
||||||
docker run -p 8080:3000 --name pnger-test pnger:test
|
- **TypeScript**: Use strict types where possible.
|
||||||
|
- **Svelte**: Keep components modular and under 300 lines.
|
||||||
# Test endpoints
|
- **Async/Await**: Use for all asynchronous operations.
|
||||||
curl http://localhost:8080/api/health
|
- **Semantic HTML**: Ensure accessibility and proper structure.
|
||||||
|
|
||||||
# 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
|
## Troubleshooting
|
||||||
|
|
||||||
### Common Issues
|
- **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.
|
||||||
|
|
||||||
**Port already in use:**
|
---
|
||||||
```bash
|
|
||||||
lsof -ti:3000 | xargs kill -9
|
|
||||||
```
|
|
||||||
|
|
||||||
**Sharp installation issues:**
|
**Last Updated**: March 12, 2026
|
||||||
```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
|
|
||||||
220
README.md
220
README.md
@@ -1,213 +1,49 @@
|
|||||||
# 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, modern PNG editor and resizer with **live preview**, **drag & drop**, **smart presets**, **keyboard shortcuts**, and **dark/light mode theming**. Built with TypeScript and optimized for deployment as a single Docker container.
|
||||||
|
|
||||||
## Features
|
## ✨ Features
|
||||||
|
|
||||||
- **Drag & Drop Upload**: Intuitive file upload interface
|
- **🎨 Modern UI**: Beautiful dark/light mode for inspecting transparency.
|
||||||
- **Resize Operations**: Width, height, and aspect ratio controls
|
- **⚡ Live Preview**: Instant side-by-side comparison with file size analysis.
|
||||||
- **Crop to Fit**: Smart cropping with position control (center, top, bottom, etc.)
|
- **🚀 Efficiency**: Drag & Drop upload, clipboard paste (`Ctrl+V`), and smart presets.
|
||||||
- **Format Conversion**: PNG, WebP, and JPEG output
|
- **🖼️ Precision**: Control width, height, quality, and crop positions (9 modes).
|
||||||
- **Quality Control**: Adjustable compression settings
|
- **📦 Reliable Deployment**: Multi-stage Docker build optimized for Unraid and Gitea.
|
||||||
- **Direct Download**: No server-side storage, immediate download
|
|
||||||
- **Modern UI**: Sleek, responsive TypeScript/Svelte design
|
|
||||||
|
|
||||||
## Tech Stack
|
## 🚀 Quick Start
|
||||||
|
|
||||||
- **Frontend**: Svelte 4 + Vite + TypeScript
|
### Docker/Unraid Deployment
|
||||||
- **Backend**: Node.js + Express + TypeScript
|
1. **Clone & Build**:
|
||||||
- **Image Processing**: Sharp (high-performance image library)
|
|
||||||
- **Container**: Docker (Alpine-based, multi-stage build)
|
|
||||||
- **Deployment**: Unraid via Docker Compose
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Unraid Deployment (Recommended)
|
|
||||||
|
|
||||||
1. **Clone or pull this repository to your Unraid server:**
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/user/appdata
|
|
||||||
git clone https://git.alwisp.com/jason/pnger.git
|
git clone https://git.alwisp.com/jason/pnger.git
|
||||||
cd pnger
|
cd pnger
|
||||||
```
|
|
||||||
|
|
||||||
2. **Build the Docker image:**
|
|
||||||
```bash
|
|
||||||
docker build -t pnger:latest .
|
docker build -t pnger:latest .
|
||||||
```
|
```
|
||||||
|
2. **Run**:
|
||||||
3. **Run via docker-compose:**
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
3. **Access**: `http://localhost:8080` (or your Unraid IP)
|
||||||
|
|
||||||
4. **Access the application:**
|
### Local Development
|
||||||
- Navigate to `http://[unraid-ip]:8080`
|
1. **Install & Run Backend**: `cd backend && npm install && npm run dev`
|
||||||
|
2. **Install & Run Frontend**: `cd frontend && npm install && npm run dev`
|
||||||
|
3. **Access**: `http://localhost:5173`
|
||||||
|
|
||||||
### Unraid Environment Variables (Configurable via UI)
|
## 📚 Documentation
|
||||||
|
|
||||||
When setting up in Unraid Docker UI, you can configure:
|
For more detailed information, please refer to:
|
||||||
|
- **[INSTRUCTIONS.md](./INSTRUCTIONS.md)**: Technical architecture, development setup, code standards, and troubleshooting.
|
||||||
|
- **[ROADMAP.md](./ROADMAP.md)**: Project history, sprint updates, and future feature plans.
|
||||||
|
|
||||||
| Variable | Default | Description |
|
## ⌨️ Keyboard Shortcuts
|
||||||
|----------|---------|-------------|
|
|
||||||
| `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
|
- `Ctrl+V`: Paste image from clipboard
|
||||||
|
- `Enter`: Download (when input not focused)
|
||||||
|
- `?`: Show shortcuts help
|
||||||
|
- `Esc`: Close dialogs
|
||||||
|
|
||||||
```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
|
**License**: MIT
|
||||||
|
**Repository**: [https://git.alwisp.com/jason/pnger](https://git.alwisp.com/jason/pnger)
|
||||||
### 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
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Backend TypeScript compilation
|
|
||||||
cd backend
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Frontend build
|
|
||||||
cd frontend
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker Deployment (Manual)
|
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### POST /api/transform
|
|
||||||
|
|
||||||
Transform an image with resize, crop, and format conversion.
|
|
||||||
|
|
||||||
**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
|
|
||||||
62
ROADMAP.md
Normal file
62
ROADMAP.md
Normal file
@@ -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
|
||||||
66
UNRAID.md
Normal file
66
UNRAID.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Unraid Installation Guide for PNGer
|
||||||
|
|
||||||
|
This guide walks you through installing PNGer on Unraid using the Docker tab and "Add Container" feature.
|
||||||
|
|
||||||
|
## 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**: `jason/pnger:latest` (or the repository you pushed the image to, e.g., `ghcr.io/yourusername/pnger:latest` if hosted, or `pnger:latest` if built locally).
|
||||||
|
- **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)
|
||||||
|
- **Name**: `Max Upload Size (Bytes)`
|
||||||
|
- **Key**: `MAX_FILE_SIZE`
|
||||||
|
- **Value**: `10485760` (Default is 10MB; 10485760 bytes).
|
||||||
|
|
||||||
|
### Volume Mapping (Optional)
|
||||||
|
If you require persistence for the temporary directory processing uploads (usually not required):
|
||||||
|
- **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`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**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).
|
||||||
@@ -10,10 +10,11 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${HOST_PORT:-8080}:3000"
|
- "${HOST_PORT:-8080}:3000"
|
||||||
environment:
|
environment:
|
||||||
|
- PUID=${PUID:-99}
|
||||||
|
- PGID=${PGID:-100}
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
- NODE_ENV=${NODE_ENV:-production}
|
- NODE_ENV=${NODE_ENV:-production}
|
||||||
- PORT=3000
|
|
||||||
- MAX_FILE_SIZE=${MAX_FILE_SIZE:-10485760}
|
- MAX_FILE_SIZE=${MAX_FILE_SIZE:-10485760}
|
||||||
- TEMP_DIR=/app/temp
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
||||||
|
|||||||
23
docker-entrypoint.sh
Normal file
23
docker-entrypoint.sh
Normal file
@@ -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 "$@"
|
||||||
@@ -1,28 +1,74 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { transformImage } from "./lib/api";
|
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 file: File | null = null;
|
||||||
|
let filePreviewUrl: string | null = null;
|
||||||
let width: number | null = null;
|
let width: number | null = null;
|
||||||
let height: number | null = null;
|
let height: number | null = null;
|
||||||
let quality = 80;
|
let quality = 80;
|
||||||
let format: "png" | "webp" | "jpeg" = "png";
|
let format: "png" | "webp" | "jpeg" = "png";
|
||||||
|
let fit: "inside" | "cover" = "inside";
|
||||||
// cropping / resizing
|
let position = "center";
|
||||||
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 processing = false;
|
let processing = false;
|
||||||
let error: string | null = null;
|
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() {
|
async function onSubmit() {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
error = "Please select an image file";
|
error = "Please select an image file";
|
||||||
@@ -37,7 +83,7 @@
|
|||||||
quality,
|
quality,
|
||||||
format,
|
format,
|
||||||
fit,
|
fit,
|
||||||
position
|
position: fit === "cover" ? position : undefined
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
@@ -53,106 +99,566 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function onFileChange(e: Event) {
|
||||||
const target = e.target as HTMLInputElement;
|
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>
|
</script>
|
||||||
|
|
||||||
<main>
|
<div class="container">
|
||||||
<h1>PNG Editor</h1>
|
<!-- Header -->
|
||||||
|
<header class="flex justify-between items-center" style="margin-bottom: var(--space-2xl);">
|
||||||
<input type="file" accept="image/*" on:change={onFileChange} />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label>Width: <input type="number" bind:value={width} min="1" /></label>
|
|
||||||
<label>Height: <input type="number" bind:value={height} min="1" /></label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label>Fit mode:
|
|
||||||
<select bind:value={fit}>
|
|
||||||
<option value="inside">Resize only (no crop)</option>
|
|
||||||
<option value="cover">Crop to fit box</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if fit === "cover"}
|
|
||||||
<div>
|
<div>
|
||||||
<label>Crop position:
|
<h1 class="mb-0">PNGer</h1>
|
||||||
<select bind:value={position}>
|
<p class="text-sm mb-0">Modern PNG Editor & Resizer</p>
|
||||||
<option value="center">Center</option>
|
</div>
|
||||||
<option value="top">Top</option>
|
<div class="flex gap-sm">
|
||||||
<option value="bottom">Bottom</option>
|
<button class="btn-outline" on:click={() => showShortcuts = !showShortcuts} title="Keyboard shortcuts (?)">
|
||||||
<option value="left">Left</option>
|
⌨️
|
||||||
<option value="right">Right</option>
|
</button>
|
||||||
<option value="top-left">Top-left</option>
|
<button class="btn-outline" on:click={() => theme.toggle()}>
|
||||||
<option value="top-right">Top-right</option>
|
{#if $theme === 'dark'}
|
||||||
<option value="bottom-left">Bottom-left</option>
|
☀️ Light
|
||||||
<option value="bottom-right">Bottom-right</option>
|
{:else}
|
||||||
</select>
|
🌙 Dark
|
||||||
</label>
|
{/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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div>
|
<!-- Controls Section -->
|
||||||
<label>Quality:
|
<div class="card fade-in" style="margin-bottom: var(--space-xl);">
|
||||||
<input type="range" min="10" max="100" bind:value={quality} />
|
<div class="grid grid-cols-2 gap-lg">
|
||||||
</label>
|
<!-- Left Column: Upload & Dimensions -->
|
||||||
<span>{quality}</span>
|
<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 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Crop Position (if cover) -->
|
||||||
|
{#if fit === "cover"}
|
||||||
|
<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>
|
||||||
|
<option value="bottom">Bottom</option>
|
||||||
|
<option value="left">Left</option>
|
||||||
|
<option value="right">Right</option>
|
||||||
|
<option value="top-left">Top-left</option>
|
||||||
|
<option value="top-right">Top-right</option>
|
||||||
|
<option value="bottom-left">Bottom-left</option>
|
||||||
|
<option value="bottom-right">Bottom-right</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Quality & Format -->
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
{#if error}
|
||||||
|
<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}
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
{#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>
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- Live Preview Section (Full Width Below) -->
|
||||||
<label>Format:
|
<div class="card fade-in">
|
||||||
<select bind:value={format}>
|
<h2>Live Preview</h2>
|
||||||
<option value="png">PNG</option>
|
|
||||||
<option value="webp">WebP</option>
|
{#if !file}
|
||||||
<option value="jpeg">JPEG</option>
|
<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;">
|
||||||
</select>
|
<div>
|
||||||
</label>
|
<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>
|
||||||
|
</div>
|
||||||
{#if error}
|
|
||||||
<p style="color: red">{error}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<button on:click|preventDefault={onSubmit} disabled={processing}>
|
|
||||||
{processing ? "Processing..." : "Transform & Download"}
|
|
||||||
</button>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
main {
|
/* Drop zone styles */
|
||||||
max-width: 600px;
|
.drop-zone {
|
||||||
margin: 2rem auto;
|
border: 2px dashed var(--color-border);
|
||||||
padding: 1rem;
|
border-radius: var(--radius-md);
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
padding: var(--space-xl);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.drop-zone:hover {
|
||||||
margin-bottom: 2rem;
|
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;
|
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;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
/* Presets grid */
|
||||||
opacity: 0.5;
|
.presets-grid {
|
||||||
cursor: not-allowed;
|
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>
|
</style>
|
||||||
384
frontend/src/app.css
Normal file
384
frontend/src/app.css
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
:root {
|
||||||
|
/* 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;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
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%;
|
||||||
|
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: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-xs {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-medium {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-semibold {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-0 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
128
frontend/src/lib/presets.ts
Normal file
128
frontend/src/lib/presets.ts
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
279
frontend/src/lib/preview.ts
Normal file
279
frontend/src/lib/preview.ts
Normal file
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
60
frontend/src/lib/theme.ts
Normal file
60
frontend/src/lib/theme.ts
Normal file
@@ -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