Compare commits

...

43 Commits

Author SHA1 Message Date
decc396347 docker 2026-03-13 22:36:02 -05:00
d684240697 unraid 2026-03-13 22:33:36 -05:00
b0aaf80c60 Merge pull request 'cleanup' (#12) from review-opti into main
Reviewed-on: #12
2026-03-12 11:00:23 -05:00
jason
faa206d44b cleanup 2026-03-12 10:59:06 -05:00
c65355a43b Merge pull request 'docs: reorganize roadmap by utility, mark Sprint 1 complete, add high-value features' (#11) from feature/sprint1-dragdrop-presets-shortcuts into main
Reviewed-on: #11
2026-03-08 21:46:59 -05:00
5579656279 docs: reorganize roadmap by utility, mark Sprint 1 complete, add high-value features 2026-03-08 21:46:03 -05:00
87c9078a99 Merge pull request 'feature/sprint1-dragdrop-presets-shortcuts' (#10) from feature/sprint1-dragdrop-presets-shortcuts into main
Reviewed-on: #10
2026-03-08 21:40:08 -05:00
a9da791766 docs: update INSTRUCTIONS with Sprint 1 completed features 2026-03-08 21:38:49 -05:00
98f03c9b22 docs: update README with Sprint 1 features (drag/drop, presets, shortcuts) 2026-03-08 21:38:01 -05:00
9d59fac386 Merge pull request 'Fix preset button text color for proper theme contrast' (#9) from feature/sprint1-dragdrop-presets-shortcuts into main
Reviewed-on: #9
2026-03-08 17:16:04 -05:00
3e35978b73 Fix preset button text color for proper theme contrast
Added explicit color property to .preset-name CSS class to ensure
text is always visible in both light and dark themes.
2026-03-08 17:15:07 -05:00
733235c101 Merge pull request 'feature/sprint1-dragdrop-presets-shortcuts' (#8) from feature/sprint1-dragdrop-presets-shortcuts into main
Reviewed-on: #8
2026-03-08 17:10:08 -05:00
87ac7b0ce4 Add Sprint 1 changes documentation 2026-03-08 17:07:40 -05:00
e6a99a4141 Add drag & drop, smart presets, and keyboard shortcuts 2026-03-08 17:06:58 -05:00
4f694b1024 Add smart presets for common use cases 2026-03-08 17:05:53 -05:00
531f0cb3b3 Fix preview file size calculation and format conversion 2026-03-08 17:05:07 -05:00
f5bd461fef Update ROADMAP: mark Phase 1.1 complete, add next priorities 2026-03-08 16:58:53 -05:00
e1fa4fb6ad Merge pull request 'Move preview to full-width section below controls for more preview space' (#7) from feature/ui-upgrade-dark-mode-preview into main
Reviewed-on: #7
2026-03-08 16:52:05 -05:00
c344590cb2 Move preview to full-width section below controls for more preview space 2026-03-08 16:50:57 -05:00
6f55ecb1d3 Merge pull request 'feature/ui-upgrade-dark-mode-preview' (#6) from feature/ui-upgrade-dark-mode-preview into main
Reviewed-on: #6
2026-03-08 16:43:15 -05:00
909c206490 Update README with new UI features and live preview 2026-03-08 16:25:00 -05:00
2771d24262 Add comprehensive UI upgrade documentation 2026-03-08 16:24:12 -05:00
ef0edc0756 Add modern UI with dark mode, live preview, and sleek design 2026-03-08 16:23:11 -05:00
cdac0b0cd9 Add theme store for dark/light mode management 2026-03-08 16:22:33 -05:00
4022cda357 Add client-side preview utility functions 2026-03-08 16:22:21 -05:00
d7509daf0d Add modern design system with dark/light mode support 2026-03-08 16:21:55 -05:00
b9169cb939 Add live preview implementation guide 2026-03-08 16:14:57 -05:00
f060347f6e Add comprehensive feature roadmap 2026-03-08 16:14:13 -05:00
ead10d9009 Merge pull request 'Add missing app.css for frontend build' (#5) from fix/add-package-lockfiles into main
Reviewed-on: #5
2026-03-08 16:07:09 -05:00
564b0b763d Add missing app.css for frontend build 2026-03-08 16:06:23 -05:00
fed1f1d078 Merge pull request 'fix/add-package-lockfiles' (#4) from fix/add-package-lockfiles into main
Reviewed-on: #4
2026-03-08 16:05:24 -05:00
aeb729389f Update documentation for simplified npm install approach 2026-03-08 16:04:33 -05:00
ca0f4f10b3 Simplify Dockerfile to use npm install (works without lockfiles) 2026-03-08 16:04:05 -05:00
0eeacd8472 Merge pull request 'fix/add-package-lockfiles' (#3) from fix/add-package-lockfiles into main
Reviewed-on: #3
2026-03-08 16:02:56 -05:00
27780c36c1 Update backend package-lock.json with correct multer version 2026-03-08 16:01:38 -05:00
348e74497f Update multer to version 2.1.0 (fixes npm registry error) 2026-03-08 16:01:10 -05:00
04a078a1ad Merge pull request 'fix/add-package-lockfiles' (#1) from fix/add-package-lockfiles into main
Reviewed-on: #1
2026-03-08 15:52:19 -05:00
17868a55a9 Update documentation with auto-generation approach 2026-03-08 15:51:11 -05:00
5170cdfd19 Update Dockerfile to auto-generate lockfiles during build 2026-03-08 15:50:11 -05:00
f7641a4845 Add documentation for Docker build fix 2026-03-08 15:49:10 -05:00
882fad492c Add .dockerignore to optimize build context 2026-03-08 15:48:52 -05:00
6b7b97ff74 Add backend package-lock.json for npm ci support 2026-03-08 15:48:41 -05:00
7419eb7592 Add frontend package-lock.json for npm ci support 2026-03-08 15:48:31 -05:00
16 changed files with 1836 additions and 477 deletions

46
.dockerignore Normal file
View File

@@ -0,0 +1,46 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Development
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Git
.git/
.gitignore
# Build artifacts (we copy these explicitly when needed)
frontend/dist/
backend/dist/
# Temp files
temp/
*.tmp
# Documentation
README.md
*.md
!package*.json
# Test files
*.test.ts
*.test.js
*.spec.ts
*.spec.js
__tests__/
__mocks__/

View File

@@ -3,9 +3,12 @@ FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend WORKDIR /app/frontend
# Copy frontend package files and install ALL dependencies (including devDependencies for build) # Copy frontend package files
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm ci
# Install all dependencies (including devDependencies for build)
# Use npm install which works without lockfile
RUN npm install
# Copy frontend source and build # Copy frontend source and build
COPY frontend/ ./ COPY frontend/ ./
@@ -16,9 +19,11 @@ FROM node:20-alpine AS backend-builder
WORKDIR /app/backend WORKDIR /app/backend
# Copy backend package files and install ALL dependencies (including TypeScript) # Copy backend package files
COPY backend/package*.json ./ COPY backend/package*.json ./
RUN npm ci
# Install all dependencies (including TypeScript)
RUN npm install
# Copy backend source and compile TypeScript # Copy backend source and compile TypeScript
COPY backend/ ./ COPY backend/ ./
@@ -27,11 +32,18 @@ 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
# Install production dependencies only # Copy backend package files
COPY backend/package*.json ./ COPY backend/package*.json ./
RUN npm ci --only=production && \
# Install production dependencies only
RUN npm install --omit=dev && \
npm cache clean --force npm cache clean --force
# Copy compiled backend from builder # Copy compiled backend from builder
@@ -41,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
@@ -60,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"]

View File

@@ -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
View File

@@ -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
View 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
View 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).

25
backend/package-lock.json generated Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "png-editor-backend",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "png-editor-backend",
"version": "0.1.0",
"dependencies": {
"express": "^4.19.0",
"multer": "^2.1.0",
"sharp": "^0.33.0",
"cors": "^2.8.5"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/multer": "^2.1.0",
"@types/cors": "^2.8.17",
"ts-node-dev": "^2.0.0",
"typescript": "^5.6.0"
}
}
}
}

View File

@@ -10,13 +10,13 @@
}, },
"dependencies": { "dependencies": {
"express": "^4.19.0", "express": "^4.19.0",
"multer": "^1.4.5", "multer": "^2.1.0",
"sharp": "^0.33.0", "sharp": "^0.33.0",
"cors": "^2.8.5" "cors": "^2.8.5"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/multer": "^1.4.7", "@types/multer": "^2.1.0",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"ts-node-dev": "^2.0.0", "ts-node-dev": "^2.0.0",
"typescript": "^5.6.0" "typescript": "^5.6.0"

View File

@@ -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
View 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 "$@"

20
frontend/package-lock.json generated Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "png-editor-frontend",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "png-editor-frontend",
"version": "0.1.0",
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"svelte": "^4.2.0",
"svelte-check": "^4.0.0",
"svelte-preprocess": "^6.0.0",
"typescript": "^5.6.0",
"vite": "^5.0.0"
}
}
}
}

View File

@@ -1,27 +1,73 @@
<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) {
@@ -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
View 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
View 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
View 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
View 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();
}