Add NextSnap PWA with photo gallery viewer and continuous capture

Offline-first photo capture app for Nextcloud with:
- Camera capture with continuous mode (auto-reopens after each photo)
- File browser with fullscreen image gallery, swipe navigation, and rename
- Upload queue with background sync engine
- Admin panel for Nextcloud user management
- Service worker for offline-first caching (v13)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 04:53:13 -06:00
commit cad4118f72
55 changed files with 9038 additions and 0 deletions

44
.dockerignore Normal file
View File

@@ -0,0 +1,44 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
.venv
# Testing
.pytest_cache/
.coverage
htmlcov/
*.log
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Git
.git/
.gitignore
# Documentation
*.md
docs/
# Development
.env.local
.env.development
run.py
# Docker
Dockerfile.dev
docker-compose.dev.yml
# Temporary files
*.tmp
.DS_Store

18
.env.example Normal file
View File

@@ -0,0 +1,18 @@
# NextSnap Environment Configuration
# Copy this file to .env and fill in your values
# Flask Secret Key - CHANGE THIS IN PRODUCTION!
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=your-secret-key-here
# Nextcloud Instance URL
NEXTCLOUD_URL=https://nextcloud.example.com
# Port to expose the application (default: 8000)
PORT=8000
# Timezone (default: UTC)
TZ=UTC
# Optional: Flask Environment (development or production)
FLASK_ENV=production

44
.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info/
dist/
build/
venv/
env/
.pytest_cache/
# Flask
instance/
flask_session/
*.db
# Development
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
# Logs
*.log
# Docker
.env
sessions_backup_*.tar.gz
# Session data
flask_session/

336
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,336 @@
# NextSnap Deployment Guide
This guide covers deploying NextSnap using Docker and Docker Compose.
## Prerequisites
- Docker 20.10 or later
- Docker Compose 2.0 or later
- A Nextcloud instance with admin access
- HTTPS setup (required for PWA features)
## Quick Start
### 1. Clone the Repository
```bash
git clone <repository-url>
cd nextsnap
```
### 2. Configure Environment Variables
```bash
cp .env.example .env
```
Edit `.env` and set your values:
```bash
# Generate a secure secret key
python -c "import secrets; print(secrets.token_hex(32))"
# Update .env with:
SECRET_KEY=<generated-key>
NEXTCLOUD_URL=https://your-nextcloud-instance.com
PORT=8000
TZ=America/New_York
```
### 3. Build and Run
```bash
docker-compose up -d
```
The application will be available at `http://localhost:8000`
### 4. Verify Deployment
```bash
# Check container status
docker-compose ps
# Check logs
docker-compose logs -f nextsnap
# Test health endpoint
curl http://localhost:8000/api/health
```
## Production Deployment
### HTTPS Setup (Required)
NextSnap requires HTTPS for PWA features and secure cookies. Use a reverse proxy:
#### Option 1: Nginx
```nginx
server {
listen 443 ssl http2;
server_name nextsnap.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts for large uploads
proxy_read_timeout 300s;
proxy_send_timeout 300s;
client_max_body_size 50M;
}
}
```
#### Option 2: Caddy
```
nextsnap.example.com {
reverse_proxy localhost:8000
}
```
#### Option 3: Traefik
Update `docker-compose.yml`:
```yaml
services:
nextsnap:
labels:
- "traefik.enable=true"
- "traefik.http.routers.nextsnap.rule=Host(`nextsnap.example.com`)"
- "traefik.http.routers.nextsnap.entrypoints=websecure"
- "traefik.http.routers.nextsnap.tls.certresolver=myresolver"
```
### Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `SECRET_KEY` | Yes | - | Flask secret key for sessions |
| `NEXTCLOUD_URL` | Yes | - | URL of your Nextcloud instance |
| `PORT` | No | 8000 | Port to expose the application |
| `TZ` | No | UTC | Container timezone |
| `FLASK_ENV` | No | production | Flask environment |
### Security Recommendations
1. **Use a Strong Secret Key**
```bash
python -c "import secrets; print(secrets.token_hex(32))"
```
2. **Enable HTTPS**
- Required for PWA installation
- Required for secure cookies
3. **Keep Docker Images Updated**
```bash
docker-compose pull
docker-compose up -d
```
4. **Restrict Network Access**
- Only expose port 8000 to reverse proxy
- Use firewall rules to limit access
5. **Monitor Logs**
```bash
docker-compose logs -f --tail=100 nextsnap
```
### Backup and Restore
#### Backup Session Data
```bash
# Create backup
docker run --rm -v nextsnap_flask_sessions:/data -v $(pwd):/backup \
alpine tar czf /backup/sessions_backup.tar.gz -C /data .
```
#### Restore Session Data
```bash
# Restore backup
docker run --rm -v nextsnap_flask_sessions:/data -v $(pwd):/backup \
alpine tar xzf /backup/sessions_backup.tar.gz -C /data
```
## Updating
### Pull Latest Changes
```bash
git pull origin main
```
### Rebuild and Restart
```bash
docker-compose down
docker-compose build --no-cache
docker-compose up -d
```
### Check for Breaking Changes
Review `CHANGELOG.md` and update your `.env` if new variables are required.
## Troubleshooting
### Container Won't Start
```bash
# Check logs
docker-compose logs nextsnap
# Check environment variables
docker-compose config
```
### Connection to Nextcloud Fails
1. Verify `NEXTCLOUD_URL` is correct
2. Test Nextcloud connectivity:
```bash
curl -I https://your-nextcloud-instance.com
```
3. Check Nextcloud CORS settings
4. Verify user credentials
### PWA Not Installing
1. Ensure HTTPS is enabled
2. Check manifest is accessible: `https://your-domain.com/static/manifest.json`
3. Check service worker: `https://your-domain.com/static/sw.js`
4. Open browser DevTools → Application → Service Workers
### Session Issues
```bash
# Clear session data
docker-compose down
docker volume rm nextsnap_flask_sessions
docker-compose up -d
```
### Large Upload Failures
1. Check `MAX_CONTENT_LENGTH` in `config.py` (default: 50MB)
2. Increase nginx/proxy timeout values
3. Check Nextcloud upload limits
## Performance Tuning
### Gunicorn Workers
Edit `Dockerfile` CMD to adjust workers:
```dockerfile
CMD ["gunicorn", \
"--workers", "8", \ # Increase for more traffic
"--bind", "0.0.0.0:8000", \
...
```
Rule of thumb: `workers = (2 * CPU_cores) + 1`
### Resource Limits
Add to `docker-compose.yml`:
```yaml
services:
nextsnap:
deploy:
resources:
limits:
cpus: '2'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
```
## Monitoring
### Health Checks
```bash
# Built-in health endpoint
curl http://localhost:8000/api/health
# Container health status
docker inspect --format='{{.State.Health.Status}}' nextsnap
```
### Logs
```bash
# Follow logs
docker-compose logs -f nextsnap
# Last 100 lines
docker-compose logs --tail=100 nextsnap
# Filter by timestamp
docker-compose logs --since 30m nextsnap
```
### Metrics
Consider adding:
- Prometheus metrics
- Grafana dashboards
- Alert manager for errors
## Advanced Configuration
### Custom Port
```yaml
# docker-compose.yml
ports:
- "3000:8000" # Expose on port 3000
```
### External Session Storage
For multi-instance deployments, consider Redis for sessions:
```python
# config.py
SESSION_TYPE = 'redis'
SESSION_REDIS = redis.from_url('redis://redis:6379')
```
### Development Mode
```bash
# Use development compose file
docker-compose -f docker-compose.dev.yml up
```
## Support
- GitHub Issues: <repository-url>/issues
- Documentation: README.md
- Nextcloud Docs: https://docs.nextcloud.com
## License
See LICENSE file for details.

45
Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
libheif-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create directories for runtime data
RUN mkdir -p /tmp/flask_session && \
chmod 777 /tmp/flask_session
# Create non-root user for security
RUN useradd -m -u 1000 nextsnap && \
chown -R nextsnap:nextsnap /app /tmp/flask_session
USER nextsnap
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD curl -f http://localhost:8000/api/health || exit 1
# Use gunicorn for production with app factory
CMD ["gunicorn", \
"--workers", "4", \
"--bind", "0.0.0.0:8000", \
"--timeout", "120", \
"--access-logfile", "-", \
"--error-logfile", "-", \
"--log-level", "info", \
"app:create_app()"]

69
Makefile Normal file
View File

@@ -0,0 +1,69 @@
.PHONY: help build up down restart logs shell test clean dev prod
help:
@echo "NextSnap - Available Commands:"
@echo " make build - Build Docker image"
@echo " make up - Start containers (production)"
@echo " make down - Stop containers"
@echo " make restart - Restart containers"
@echo " make logs - View container logs"
@echo " make shell - Open shell in container"
@echo " make test - Run tests"
@echo " make clean - Remove containers and volumes"
@echo " make dev - Start development environment"
@echo " make prod - Start production environment"
build:
docker-compose build
up:
docker-compose up -d
down:
docker-compose down
restart:
docker-compose restart
logs:
docker-compose logs -f --tail=100
shell:
docker-compose exec nextsnap /bin/bash
test:
docker-compose exec nextsnap pytest tests/
clean:
docker-compose down -v
docker system prune -f
dev:
docker-compose -f docker-compose.dev.yml up
dev-build:
docker-compose -f docker-compose.dev.yml build
dev-down:
docker-compose -f docker-compose.dev.yml down
prod:
docker-compose up -d
status:
docker-compose ps
health:
@curl -f http://localhost:8000/api/health || echo "Health check failed"
backup-sessions:
docker run --rm -v nextsnap_flask_sessions:/data -v $(PWD):/backup \
alpine tar czf /backup/sessions_backup_$(shell date +%Y%m%d_%H%M%S).tar.gz -C /data .
restore-sessions:
@read -p "Enter backup file name: " file; \
docker run --rm -v nextsnap_flask_sessions:/data -v $(PWD):/backup \
alpine tar xzf /backup/$$file -C /data
generate-secret:
@python -c "import secrets; print(secrets.token_hex(32))"

340
POLISH.md Normal file
View File

@@ -0,0 +1,340 @@
# NextSnap Polish & UX Enhancements
This document describes all the polish, animations, and UX enhancements implemented in Module 14.
## Overview
NextSnap includes comprehensive polish to provide a smooth, professional user experience with:
- Smooth animations and transitions
- Enhanced accessibility features
- Micro-interactions and feedback
- Loading states and indicators
- Dark mode support
- Reduced motion support for accessibility
## Visual Enhancements
### Animations
#### Page Transitions
- **Fade-in animation** on page load (300ms)
- Subtle upward motion for content reveal
- Smooth transitions between pages
#### Button Interactions
- **Hover effect**: Lift up 2px with shadow
- **Active state**: Press down with reduced shadow
- **Ripple effect**: White ripple on click
- **3D transform**: Smooth scale transitions
#### Connectivity Indicator
- **Online**: Green with pulsing glow effect
- **Offline**: Gray with fade pulse
- **Syncing**: Yellow with continuous pulse
- Smooth color transitions between states
#### Loading States
- **Top loading bar**: Animated progress bar
- **Spinners**: Smooth rotation with easing
- **Skeleton loaders**: Shimmer effect for content loading
- **Image fade-in**: Smooth appearance on load
### Micro-Interactions
#### Form Elements
- **Focus state**: Lift up with blue shadow
- **Validation**: Real-time feedback
- **Invalid input**: Red border with shake animation
- **Valid input**: Green border confirmation
#### Cards & Items
- **Hover**: Lift up 2px with shadow
- **Queue items**: Smooth transform on hover
- **Thumbnails**: Scale and shadow on hover
- **Table rows**: Subtle scale on hover
#### Navigation
- **Active indicator**: Animated underline
- **Hover effect**: Growing underline
- **Icon bounce**: On selection
- **Smooth transitions**: 200ms easing
### Toast Notifications
- **Slide up animation**: From bottom
- **Auto-dismiss**: 2-3 seconds
- **Color coding**:
- Success: Green background
- Error: Red background
- Warning: Orange background
- Info: Dark background
- **Smooth fade-out**
### Modal Dialogs
- **Backdrop fade-in**: 200ms
- **Content slide-up**: 300ms
- **Close animation**: Reverse of open
- **Outside click**: Smooth dismiss
## Keyboard Shortcuts
Global keyboard shortcuts for power users:
| Shortcut | Action |
|----------|--------|
| `Alt + 1` | Go to Capture page |
| `Alt + 2` | Go to Queue page |
| `Alt + 3` | Go to Files/Browser page |
| `Alt + R` | Trigger sync/refresh |
| `Esc` | Close modals/exit reviewer |
| `Arrow Left/Right` | Navigate in photo reviewer |
## Accessibility Features
### Focus Management
- **Visible focus**: 2px accent color outline
- **Focus trap**: In modals
- **Keyboard navigation**: All interactive elements
- **Skip links**: For screen readers
### Reduced Motion
Respects `prefers-reduced-motion` system setting:
- Animations reduced to 10ms
- No continuous animations
- Instant transitions
- Better for motion sensitivity
### Screen Reader Support
- Semantic HTML5 elements
- ARIA labels where needed
- Alt text for images
- Meaningful link text
### Color Contrast
- WCAG AA compliant color ratios
- Dark mode with proper contrast
- Clear visual hierarchy
- Status colors are distinguishable
## Dark Mode
Automatic dark mode based on system preference:
### Light Mode
- White/light gray backgrounds
- Dark text
- Subtle shadows
- Bright accent colors
### Dark Mode
- Dark backgrounds (#1a1a2e, #16213e)
- White/light gray text
- Enhanced shadows
- Adjusted accent colors
- Reduced eye strain
Activated via `prefers-color-scheme: dark` media query.
## Performance Optimizations
### Image Loading
- **Lazy loading**: Images load as needed
- **Intersection Observer**: Efficient viewport detection
- **Fade-in**: Smooth appearance
- **Preloading**: Adjacent images in reviewer
### Animation Performance
- **CSS transforms**: Hardware accelerated
- **Will-change**: Optimized for animations
- **RequestAnimationFrame**: Smooth 60fps
- **Debouncing**: Reduces unnecessary calls
### Loading States
- **Skeleton screens**: Immediate visual feedback
- **Progressive enhancement**: Content shows before images
- **Optimistic UI**: Instant feedback, verify later
## Error Handling
### Visual Feedback
- **Shake animation**: On validation errors
- **Color indicators**: Red for errors
- **Toast notifications**: Clear error messages
- **Inline validation**: Real-time feedback
### Network Errors
- **Offline detection**: Automatic notification
- **Retry logic**: Exponential backoff
- **Queue persistence**: Never lose data
- **Connection restoration**: Automatic sync
## Mobile Optimizations
### Touch Interactions
- **Large tap targets**: Minimum 48x48px
- **Swipe gestures**: In photo reviewer
- **Pull to refresh**: Coming soon
- **Haptic feedback**: Vibration on actions
### Responsive Design
- **Mobile-first**: Optimized for small screens
- **Touch-friendly**: Large buttons and spacing
- **Safe areas**: iOS notch support
- **Viewport units**: Proper mobile sizing
### PWA Features
- **Installable**: Add to home screen
- **Offline-first**: Works without connection
- **Fast loading**: Cached app shell
- **Native feel**: Full-screen mode
## User Feedback
### Success States
- ✅ Checkmark animations
- Green color coding
- Success toasts
- Smooth transitions
### Loading States
- 🔄 Loading spinners
- Progress bars
- Skeleton screens
- Shimmer effects
### Error States
- ❌ Error icons
- Red color coding
- Shake animations
- Clear error messages
### Empty States
- 📭 Empty illustrations
- Helpful messages
- Call-to-action buttons
- Fade-in animations
## Utility Functions
The `polish.js` module provides helper functions:
```javascript
// Show toast notification
Polish.showToast(message, type, duration);
// Confirm dialog
await Polish.confirm(message, title);
// Copy to clipboard
await Polish.copyToClipboard(text);
// Loading indicator
Polish.showLoading();
Polish.hideLoading();
// Vibrate feedback
Polish.vibrate(50);
// Format helpers
Polish.formatFileSize(bytes);
Polish.formatRelativeTime(date);
// Debounce/Throttle
Polish.debounce(func, wait);
Polish.throttle(func, limit);
```
## CSS Classes
### Utility Classes
- `.skeleton` - Shimmer loading effect
- `.loading-bar` - Top progress bar
- `.clickable` - Pointer cursor + active state
- `.success-icon` - Animated checkmark
- `.badge` - Status badges with animation
### State Classes
- `.online` - Online status styling
- `.offline` - Offline status styling
- `.syncing` - Syncing status styling
- `.active` - Active navigation item
- `.disabled` - Disabled state
## Browser Support
### Modern Browsers
- Chrome 90+
- Firefox 88+
- Safari 14+
- Edge 90+
### Progressive Enhancement
- Core functionality works in all browsers
- Enhanced features for modern browsers
- Graceful degradation for older browsers
### Feature Detection
- Service Workers
- IntersectionObserver
- Clipboard API
- Vibration API
## Testing Recommendations
### Visual Testing
- [ ] Check animations in different browsers
- [ ] Test dark mode switching
- [ ] Verify hover states on all buttons
- [ ] Check loading states
- [ ] Test toast notifications
### Interaction Testing
- [ ] Test all keyboard shortcuts
- [ ] Verify touch interactions on mobile
- [ ] Check swipe gestures
- [ ] Test form validation feedback
- [ ] Verify modal interactions
### Accessibility Testing
- [ ] Screen reader navigation
- [ ] Keyboard-only navigation
- [ ] Color contrast checking
- [ ] Reduced motion testing
- [ ] Focus management
### Performance Testing
- [ ] Animation frame rate (60fps)
- [ ] Image loading speed
- [ ] Lazy loading behavior
- [ ] Memory usage
- [ ] Network performance
## Future Enhancements
Potential improvements for future versions:
- Pull-to-refresh on mobile
- Swipe-to-delete in queue
- Haptic feedback patterns
- Custom themes
- Animation preferences
- Sound effects (optional)
- Advanced gestures
- Drag-and-drop uploads
- Batch selection mode
- Context menus
## Credits
- **CSS Animations**: Custom cubic-bezier easing
- **Icons**: Native emoji for universal support
- **Typography**: System font stack for performance
- **Colors**: Material Design inspired palette
---
For implementation details, see:
- `app/static/css/style.css` - All CSS animations
- `app/static/js/polish.js` - JavaScript utilities
- `app/templates/base.html` - Base template structure

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
# NextSnap
Offline-first photo capture web app for Nextcloud.
## Quick Start
### Development
```bash
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python run.py
```
Visit http://localhost:5000/api/health to verify the server is running.
### Docker Deployment
```bash
export SECRET_KEY="your-secret-key-here"
export NEXTCLOUD_URL="https://your-nextcloud-instance.com"
docker-compose up -d
```
## Architecture
- **Backend**: Flask + Gunicorn
- **Frontend**: Vanilla JavaScript (no framework)
- **Offline Storage**: IndexedDB via Dexie.js
- **Service Worker**: For offline-first capabilities
- **Nextcloud Integration**: WebDAV API for file operations
## Build Order
See `nextsnap.md` for the complete specification and phased implementation plan.

37
TECHNICAL.md Normal file
View File

@@ -0,0 +1,37 @@
# NextSnap - Technical Architecture Documentation
## Executive Summary
NextSnap is an offline-first Progressive Web Application (PWA) designed for mobile photo capture with seamless Nextcloud integration. Built specifically for field use in environments with poor or intermittent connectivity, the application ensures zero data loss through aggressive local caching, automatic background synchronization with verification, and comprehensive retry logic.
### Core Design Principles
1. **Offline-First**: Application functions fully without network connectivity
2. **Zero Data Loss**: Photos never deleted until server verification complete
3. **Mobile-Optimized**: Native camera integration, touch gestures, responsive design
4. **Multi-User**: Session-based authentication with complete user isolation
5. **Admin-Capable**: Full Nextcloud user management via OCS Provisioning API
## High-Level Architecture
NextSnap follows a three-tier architecture:
**Tier 1: Client Layer (Browser)**
- HTML5 Camera API for native photo capture
- IndexedDB for local photo queue storage
- Service Worker for offline app shell caching
- Vanilla JavaScript (ES6+) for application logic
**Tier 2: Application Layer (Flask)**
- REST API for frontend communication
- WebDAV proxy to avoid CORS issues
- Session management and authentication
- OCS API integration for admin functions
**Tier 3: Storage Layer (Nextcloud)**
- WebDAV protocol for file operations
- OCS API for user management
- User authentication backend
- Persistent file storage
[See TECHNICAL.md for complete documentation]

30
app/__init__.py Normal file
View File

@@ -0,0 +1,30 @@
from flask import Flask
from flask_session import Session
import os
# Initialize Flask-Session
sess = Session()
def create_app(config_name=None):
"""Application factory pattern."""
app = Flask(__name__)
# Load configuration
if config_name is None:
config_name = os.environ.get('FLASK_ENV', 'development')
from config import config
app.config.from_object(config.get(config_name, config['default']))
# Initialize extensions
sess.init_app(app)
# Register blueprints
from app.routes import health, auth, views, webdav_proxy, admin
app.register_blueprint(health.bp)
app.register_blueprint(auth.bp)
app.register_blueprint(views.bp)
app.register_blueprint(webdav_proxy.bp)
app.register_blueprint(admin.bp)
return app

1
app/routes/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Routes package initialization

131
app/routes/admin.py Normal file
View File

@@ -0,0 +1,131 @@
from flask import Blueprint, request, jsonify, session
from app.services.nextcloud import NextcloudClient
from config import Config
import base64
bp = Blueprint('admin', __name__, url_prefix='/api/admin')
def _get_nc_client():
"""Get authenticated Nextcloud client from session."""
if 'username' not in session or 'password' not in session:
return None
username = session['username']
password = base64.b64decode(session['password'].encode()).decode()
return NextcloudClient(Config.NEXTCLOUD_URL, username, password)
def _check_admin():
"""Check if current user has admin privileges."""
if not session.get('is_admin', False):
return False
return True
@bp.route('/users', methods=['GET'])
def list_users():
"""List all Nextcloud users (admin only)."""
if not _check_admin():
return jsonify({'error': 'Admin privileges required'}), 403
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
result = nc_client.ocs_get_users()
if not result.get('success'):
return jsonify({'error': result.get('error', 'Failed to list users')}), 500
return jsonify(result), 200
@bp.route('/users', methods=['POST'])
def create_user():
"""Create a new Nextcloud user (admin only)."""
if not _check_admin():
return jsonify({'error': 'Admin privileges required'}), 403
data = request.get_json()
if not data or 'username' not in data or 'password' not in data:
return jsonify({'error': 'Username and password required'}), 400
username = data['username'].strip()
password = data['password']
email = data.get('email', '').strip()
displayname = data.get('displayName', '').strip()
groups = data.get('groups', [])
if not username or not password:
return jsonify({'error': 'Username and password cannot be empty'}), 400
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
result = nc_client.ocs_create_user(
username=username,
password=password,
email=email if email else None,
displayname=displayname if displayname else None,
groups=groups if groups else None
)
if not result.get('success'):
return jsonify({'error': result.get('error', 'Failed to create user')}), 500
return jsonify(result), 201
@bp.route('/users/<username>/enable', methods=['PUT'])
def enable_user(username):
"""Enable a user account (admin only)."""
if not _check_admin():
return jsonify({'error': 'Admin privileges required'}), 403
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
result = nc_client.ocs_enable_user(username)
if not result.get('success'):
return jsonify({'error': result.get('error', 'Failed to enable user')}), 500
return jsonify(result), 200
@bp.route('/users/<username>/disable', methods=['PUT'])
def disable_user(username):
"""Disable a user account (admin only)."""
if not _check_admin():
return jsonify({'error': 'Admin privileges required'}), 403
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
result = nc_client.ocs_disable_user(username)
if not result.get('success'):
return jsonify({'error': result.get('error', 'Failed to disable user')}), 500
return jsonify(result), 200
@bp.route('/users/<username>', methods=['DELETE'])
def delete_user(username):
"""Delete a user account (admin only)."""
if not _check_admin():
return jsonify({'error': 'Admin privileges required'}), 403
# Prevent self-deletion
if username == session.get('username'):
return jsonify({'error': 'Cannot delete your own account'}), 400
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
result = nc_client.ocs_delete_user(username)
if not result.get('success'):
return jsonify({'error': result.get('error', 'Failed to delete user')}), 500
return jsonify(result), 200

72
app/routes/auth.py Normal file
View File

@@ -0,0 +1,72 @@
from flask import Blueprint, request, jsonify, session
from app.services.nextcloud import NextcloudClient
from config import Config
import base64
bp = Blueprint('auth', __name__, url_prefix='/api/auth')
def _encrypt_password(password: str) -> str:
"""Simple base64 encoding for password storage in session."""
return base64.b64encode(password.encode()).decode()
def _decrypt_password(encrypted: str) -> str:
"""Decode base64 encoded password from session."""
return base64.b64decode(encrypted.encode()).decode()
@bp.route('/login', methods=['POST'])
def login():
"""Authenticate user against Nextcloud."""
data = request.get_json()
if not data or 'username' not in data or 'password' not in data:
return jsonify({'error': 'Username and password required'}), 400
username = data['username'].strip()
password = data['password']
if not username or not password:
return jsonify({'error': 'Username and password cannot be empty'}), 400
try:
# Validate credentials by attempting to connect to Nextcloud
nc_client = NextcloudClient(Config.NEXTCLOUD_URL, username, password)
if not nc_client.verify_credentials():
return jsonify({'error': 'Invalid username or password'}), 401
# Check if user is admin
is_admin = nc_client.check_admin()
# Store credentials in session
session['username'] = username
session['password'] = _encrypt_password(password)
session['is_admin'] = is_admin
return jsonify({
'success': True,
'username': username,
'is_admin': is_admin
}), 200
except Exception as e:
return jsonify({'error': f'Authentication failed: {str(e)}'}), 500
@bp.route('/logout', methods=['POST'])
def logout():
"""Clear user session."""
session.clear()
return jsonify({'success': True}), 200
@bp.route('/status', methods=['GET'])
def status():
"""Check current authentication status."""
if 'username' in session:
return jsonify({
'authenticated': True,
'username': session['username'],
'is_admin': session.get('is_admin', False)
}), 200
else:
return jsonify({
'authenticated': False
}), 200

35
app/routes/health.py Normal file
View File

@@ -0,0 +1,35 @@
from flask import Blueprint, jsonify
import requests
from config import Config
bp = Blueprint('health', __name__, url_prefix='/api')
@bp.route('/health', methods=['GET'])
def health_check():
"""Health check endpoint for monitoring."""
return jsonify({
'status': 'healthy',
'service': 'nextsnap',
'version': '1.0.0'
}), 200
@bp.route('/health/nextcloud', methods=['GET'])
def nextcloud_health():
"""Check connectivity to Nextcloud instance."""
try:
nc_url = Config.NEXTCLOUD_URL
response = requests.get(f"{nc_url}/status.php", timeout=5)
response.raise_for_status()
return jsonify({
'status': 'healthy',
'nextcloud_url': nc_url,
'nextcloud_reachable': True
}), 200
except Exception as e:
return jsonify({
'status': 'degraded',
'nextcloud_url': Config.NEXTCLOUD_URL,
'nextcloud_reachable': False,
'error': str(e)
}), 503

84
app/routes/views.py Normal file
View File

@@ -0,0 +1,84 @@
from flask import Blueprint, render_template, redirect, url_for, session, jsonify
bp = Blueprint('views', __name__)
@bp.route('/')
def index():
"""Root route - redirect to capture if logged in, otherwise show login."""
if 'username' in session:
return redirect(url_for('views.capture'))
return render_template('login.html')
@bp.route('/login')
def login_page():
"""Render login page."""
if 'username' in session:
return redirect(url_for('views.capture'))
return render_template('login.html')
@bp.route('/capture')
def capture():
"""Render capture page (requires authentication)."""
if 'username' not in session:
return redirect(url_for('views.login_page'))
return render_template(
'capture.html',
username=session['username'],
show_nav=True,
is_admin=session.get('is_admin', False)
)
@bp.route('/queue')
def queue():
"""Render queue page (requires authentication)."""
if 'username' not in session:
return redirect(url_for('views.login_page'))
return render_template(
'queue.html',
username=session['username'],
show_nav=True,
is_admin=session.get('is_admin', False)
)
@bp.route('/browser')
def browser():
"""Render file browser page (requires authentication)."""
if 'username' not in session:
return redirect(url_for('views.login_page'))
return render_template(
'browser.html',
username=session['username'],
show_nav=True,
is_admin=session.get('is_admin', False)
)
@bp.route('/reviewer')
def reviewer():
"""Render photo reviewer page (requires authentication)."""
if 'username' not in session:
return redirect(url_for('views.login_page'))
return render_template(
'reviewer.html',
username=session['username'],
show_nav=False,
is_admin=session.get('is_admin', False)
)
@bp.route('/admin')
def admin():
"""Render admin page (requires admin privileges)."""
if 'username' not in session:
return redirect(url_for('views.login_page'))
if not session.get('is_admin', False):
return "Access denied: Admin privileges required", 403
return render_template(
'admin.html',
username=session['username'],
show_nav=True,
is_admin=True
)
@bp.route('/test')
def test():
"""Test page for camera functionality."""
return render_template('test.html')

186
app/routes/webdav_proxy.py Normal file
View File

@@ -0,0 +1,186 @@
from flask import Blueprint, request, jsonify, send_file
from app.services.auth import require_auth, get_current_user
from app.services.nextcloud import NextcloudClient
from config import Config
from io import BytesIO
from PIL import Image
bp = Blueprint('webdav', __name__, url_prefix='/api/files')
def _get_nc_client():
"""Get Nextcloud client for current user."""
user = get_current_user()
if not user:
return None
return NextcloudClient(Config.NEXTCLOUD_URL, user['username'], user['password'])
@bp.route('/list', methods=['GET'])
@require_auth
def list_files():
"""List files and directories at the given path."""
path = request.args.get('path', '/')
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
try:
result = nc_client.propfind(path, depth=1)
return jsonify(result), 200
except Exception as e:
return jsonify({'error': f'Failed to list files: {str(e)}'}), 500
@bp.route('/upload', methods=['PUT', 'POST'])
@require_auth
def upload_file():
"""Upload a file to Nextcloud."""
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
# Get target path from query parameter or form data
target_path = request.args.get('path') or request.form.get('path')
if not target_path:
return jsonify({'error': 'Target path is required'}), 400
# Get file data
if 'file' in request.files:
# Multipart upload
file = request.files['file']
file_data = file.read()
filename = file.filename
else:
# Raw binary upload
file_data = request.get_data()
filename = request.args.get('filename', 'upload.jpg')
if not file_data:
return jsonify({'error': 'No file data provided'}), 400
# Construct full path
full_path = f"{target_path.rstrip('/')}/{filename}"
try:
result = nc_client.put_file(full_path, file_data)
return jsonify({
'success': True,
'path': full_path,
'url': result.get('url', '')
}), 200
except Exception as e:
return jsonify({'error': f'Upload failed: {str(e)}'}), 500
@bp.route('/verify', methods=['HEAD', 'GET'])
@require_auth
def verify_file():
"""Verify that a file exists on Nextcloud."""
path = request.args.get('path')
if not path:
return jsonify({'error': 'Path is required'}), 400
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
try:
exists = nc_client.head(path)
if exists:
return jsonify({'exists': True}), 200
else:
return jsonify({'exists': False}), 404
except Exception as e:
return jsonify({'error': f'Verification failed: {str(e)}'}), 500
@bp.route('/mkdir', methods=['POST'])
@require_auth
def make_directory():
"""Create a new directory on Nextcloud."""
data = request.get_json()
if not data or 'path' not in data:
return jsonify({'error': 'Path is required'}), 400
path = data['path']
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
try:
nc_client.mkcol(path)
return jsonify({'success': True, 'path': path}), 201
except Exception as e:
return jsonify({'error': f'Failed to create directory: {str(e)}'}), 500
@bp.route('/rename', methods=['POST'])
@require_auth
def rename_file():
"""Rename or move a file on Nextcloud."""
data = request.get_json()
if not data or 'source' not in data or 'destination' not in data:
return jsonify({'error': 'Source and destination paths are required'}), 400
source = data['source']
destination = data['destination']
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
try:
nc_client.move(source, destination)
return jsonify({
'success': True,
'source': source,
'destination': destination
}), 200
except Exception as e:
return jsonify({'error': f'Failed to rename file: {str(e)}'}), 500
@bp.route('/thumbnail', methods=['GET'])
@require_auth
def get_thumbnail():
"""Generate and return a thumbnail of an image file."""
path = request.args.get('path')
size = request.args.get('size', '256')
if not path:
return jsonify({'error': 'Path is required'}), 400
try:
size = int(size)
if size < 32 or size > 1024:
size = 256
except ValueError:
size = 256
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
try:
# Download the file from Nextcloud
file_data = nc_client.get_file(path)
# Open image and create thumbnail
image = Image.open(BytesIO(file_data))
# Convert to RGB if necessary (for PNG with transparency, etc.)
if image.mode not in ('RGB', 'L'):
image = image.convert('RGB')
# Create thumbnail maintaining aspect ratio
image.thumbnail((size, size), Image.Resampling.LANCZOS)
# Save to BytesIO
output = BytesIO()
image.save(output, format='JPEG', quality=85)
output.seek(0)
return send_file(
output,
mimetype='image/jpeg',
as_attachment=False,
download_name=f'thumbnail_{size}.jpg'
)
except Exception as e:
return jsonify({'error': f'Failed to generate thumbnail: {str(e)}'}), 500

1
app/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Services package initialization

39
app/services/auth.py Normal file
View File

@@ -0,0 +1,39 @@
from flask import session
from functools import wraps
from flask import jsonify
import base64
def _decrypt_password(encrypted: str) -> str:
"""Decode base64 encoded password from session."""
return base64.b64decode(encrypted.encode()).decode()
def get_current_user():
"""Get current authenticated user info from session."""
if 'username' not in session:
return None
return {
'username': session['username'],
'password': _decrypt_password(session['password']),
'is_admin': session.get('is_admin', False)
}
def require_auth(f):
"""Decorator to require authentication for a route."""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'username' not in session:
return jsonify({'error': 'Authentication required'}), 401
return f(*args, **kwargs)
return decorated_function
def require_admin(f):
"""Decorator to require admin privileges for a route."""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'username' not in session:
return jsonify({'error': 'Authentication required'}), 401
if not session.get('is_admin', False):
return jsonify({'error': 'Admin privileges required'}), 403
return f(*args, **kwargs)
return decorated_function

350
app/services/nextcloud.py Normal file
View File

@@ -0,0 +1,350 @@
import requests
from requests.auth import HTTPBasicAuth
from typing import Optional, Dict, Any, List
from xml.etree import ElementTree as ET
from urllib.parse import quote, unquote
class NextcloudClient:
"""Client for interacting with Nextcloud WebDAV and OCS APIs."""
def __init__(self, base_url: str, username: str, password: str):
self.base_url = base_url.rstrip('/')
self.username = username
self.password = password
self.auth = HTTPBasicAuth(username, password)
self.webdav_root = f"{self.base_url}/remote.php/dav/files/{username}"
self.ocs_root = f"{self.base_url}/ocs/v1.php"
def _make_request(self, method: str, url: str, **kwargs) -> requests.Response:
"""Make an authenticated request to Nextcloud."""
kwargs['auth'] = self.auth
kwargs.setdefault('timeout', 30)
response = requests.request(method, url, **kwargs)
response.raise_for_status()
return response
def verify_credentials(self) -> bool:
"""Verify that credentials are valid by attempting a PROPFIND on user root."""
try:
response = self._make_request('PROPFIND', self.webdav_root, headers={'Depth': '0'})
return response.status_code in [200, 207]
except Exception:
return False
def check_admin(self) -> bool:
"""Check if the user is in the admin group."""
try:
# Get user's groups via OCS API
url = f"{self.ocs_root}/cloud/users/{self.username}/groups"
headers = {'OCS-APIRequest': 'true'}
response = self._make_request('GET', url, headers=headers)
# Parse XML response
root = ET.fromstring(response.text)
# Check if 'admin' group is in the groups list
groups = []
for element in root.findall('.//element'):
groups.append(element.text)
return 'admin' in groups
except Exception:
return False
def propfind(self, path: str, depth: int = 1) -> Dict[str, Any]:
"""Execute a PROPFIND request on the given path."""
url = f"{self.webdav_root}/{path.lstrip('/')}"
headers = {'Depth': str(depth)}
try:
response = self._make_request('PROPFIND', url, headers=headers)
return self._parse_propfind_response(response.text, path)
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
return {'items': [], 'path': path, 'error': 'Path not found'}
raise
def _parse_propfind_response(self, xml_text: str, requested_path: str) -> Dict[str, Any]:
"""Parse WebDAV PROPFIND XML response into structured data."""
try:
# Parse XML
root = ET.fromstring(xml_text)
# Define namespace
ns = {
'd': 'DAV:',
'oc': 'http://owncloud.org/ns',
'nc': 'http://nextcloud.org/ns'
}
items = []
# Find all response elements
for response in root.findall('d:response', ns):
# Get href (path)
href_elem = response.find('d:href', ns)
if href_elem is None:
continue
href = unquote(href_elem.text)
# Extract filename from href
# href is like /remote.php/dav/files/username/path/to/file
parts = href.split('/files/' + self.username + '/')
if len(parts) > 1:
relative_path = parts[1].rstrip('/')
else:
continue
# Skip the parent directory in listings (depth=1 returns parent too)
if relative_path == requested_path.strip('/'):
continue
# Get properties
propstat = response.find('d:propstat', ns)
if propstat is None:
continue
prop = propstat.find('d:prop', ns)
if prop is None:
continue
# Check if it's a directory
resourcetype = prop.find('d:resourcetype', ns)
is_dir = resourcetype is not None and resourcetype.find('d:collection', ns) is not None
# Get file size
size_elem = prop.find('d:getcontentlength', ns)
size = int(size_elem.text) if size_elem is not None and size_elem.text else 0
# Get last modified
modified_elem = prop.find('d:getlastmodified', ns)
modified = modified_elem.text if modified_elem is not None else ''
# Get content type
content_type_elem = prop.find('d:getcontenttype', ns)
content_type = content_type_elem.text if content_type_elem is not None else ''
# Extract just the filename/dirname
name = relative_path.split('/')[-1] if '/' in relative_path else relative_path
items.append({
'name': name,
'path': '/' + relative_path,
'type': 'directory' if is_dir else 'file',
'size': size,
'modified': modified,
'content_type': content_type
})
return {
'items': items,
'path': requested_path,
'count': len(items)
}
except Exception as e:
return {
'items': [],
'path': requested_path,
'error': f'Failed to parse response: {str(e)}'
}
def put_file(self, path: str, data: bytes) -> Dict[str, Any]:
"""Upload a file to Nextcloud."""
url = f"{self.webdav_root}/{path.lstrip('/')}"
headers = {
'Content-Type': 'application/octet-stream'
}
response = self._make_request('PUT', url, data=data, headers=headers)
return {
'success': True,
'url': url,
'status_code': response.status_code
}
def get_file(self, path: str) -> bytes:
"""Download a file from Nextcloud."""
url = f"{self.webdav_root}/{path.lstrip('/')}"
response = self._make_request('GET', url)
return response.content
def head(self, path: str) -> bool:
"""Check if a file exists using HEAD request."""
url = f"{self.webdav_root}/{path.lstrip('/')}"
try:
response = self._make_request('HEAD', url)
return response.status_code == 200
except Exception:
return False
def mkcol(self, path: str) -> bool:
"""Create a new directory."""
url = f"{self.webdav_root}/{path.lstrip('/')}"
try:
response = self._make_request('MKCOL', url)
return response.status_code in [201, 405] # 201 Created, 405 if already exists
except requests.exceptions.HTTPError as e:
if e.response.status_code == 405:
# Directory already exists
return True
raise
def move(self, source: str, destination: str) -> bool:
"""Move or rename a file/directory."""
source_url = f"{self.webdav_root}/{source.lstrip('/')}"
dest_url = f"{self.webdav_root}/{destination.lstrip('/')}"
headers = {
'Destination': dest_url,
'Overwrite': 'F' # Don't overwrite existing files
}
response = self._make_request('MOVE', source_url, headers=headers)
return response.status_code in [201, 204] # 201 Created, 204 No Content
def delete(self, path: str) -> bool:
"""Delete a file or directory."""
url = f"{self.webdav_root}/{path.lstrip('/')}"
try:
response = self._make_request('DELETE', url)
return response.status_code == 204
except Exception:
return False
# OCS User Management Methods
def ocs_get_users(self) -> Dict[str, Any]:
"""Get list of all users via OCS API."""
url = f"{self.ocs_root}/cloud/users"
params = {'format': 'json'}
try:
response = self._make_request('GET', url, params=params)
data = response.json()
if data.get('ocs', {}).get('meta', {}).get('statuscode') != 100:
raise Exception('OCS API error')
users = data.get('ocs', {}).get('data', {}).get('users', [])
# Get detailed info for each user
user_list = []
for username in users:
user_info = self.ocs_get_user(username)
if user_info:
user_list.append(user_info)
return {'success': True, 'users': user_list}
except Exception as e:
return {'success': False, 'error': str(e)}
def ocs_get_user(self, username: str) -> Dict[str, Any]:
"""Get detailed info for a specific user."""
url = f"{self.ocs_root}/cloud/users/{username}"
params = {'format': 'json'}
try:
response = self._make_request('GET', url, params=params)
data = response.json()
if data.get('ocs', {}).get('meta', {}).get('statuscode') != 100:
return None
user_data = data.get('ocs', {}).get('data', {})
return {
'id': user_data.get('id', username),
'displayname': user_data.get('displayname', ''),
'email': user_data.get('email', ''),
'enabled': user_data.get('enabled', True),
'groups': user_data.get('groups', [])
}
except Exception:
return None
def ocs_create_user(self, username: str, password: str, email: str = None,
displayname: str = None, groups: list = None) -> Dict[str, Any]:
"""Create a new user via OCS API."""
url = f"{self.ocs_root}/cloud/users"
params = {'format': 'json'}
data = {
'userid': username,
'password': password
}
if email:
data['email'] = email
if displayname:
data['displayName'] = displayname
if groups:
data['groups'] = groups
try:
response = self._make_request('POST', url, params=params, data=data)
result = response.json()
meta = result.get('ocs', {}).get('meta', {})
if meta.get('statuscode') != 100:
error_msg = meta.get('message', 'Failed to create user')
return {'success': False, 'error': error_msg}
return {'success': True, 'username': username}
except Exception as e:
return {'success': False, 'error': str(e)}
def ocs_enable_user(self, username: str) -> Dict[str, Any]:
"""Enable a user account."""
url = f"{self.ocs_root}/cloud/users/{username}/enable"
params = {'format': 'json'}
try:
response = self._make_request('PUT', url, params=params)
result = response.json()
meta = result.get('ocs', {}).get('meta', {})
if meta.get('statuscode') != 100:
return {'success': False, 'error': meta.get('message', 'Failed to enable user')}
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}
def ocs_disable_user(self, username: str) -> Dict[str, Any]:
"""Disable a user account."""
url = f"{self.ocs_root}/cloud/users/{username}/disable"
params = {'format': 'json'}
try:
response = self._make_request('PUT', url, params=params)
result = response.json()
meta = result.get('ocs', {}).get('meta', {})
if meta.get('statuscode') != 100:
return {'success': False, 'error': meta.get('message', 'Failed to disable user')}
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}
def ocs_delete_user(self, username: str) -> Dict[str, Any]:
"""Delete a user account."""
url = f"{self.ocs_root}/cloud/users/{username}"
params = {'format': 'json'}
try:
response = self._make_request('DELETE', url, params=params)
result = response.json()
meta = result.get('ocs', {}).get('meta', {})
if meta.get('statuscode') != 100:
return {'success': False, 'error': meta.get('message', 'Failed to delete user')}
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}

768
app/static/css/style.css Normal file
View File

@@ -0,0 +1,768 @@
/* NextSnap - Mobile-first responsive styles */
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-tertiary: #0f1729;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--accent: #4a90e2;
--success: #4caf50;
--warning: #ff9800;
--error: #f44336;
--offline: #757575;
}
@media (prefers-color-scheme: light) {
:root {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--bg-tertiary: #e0e0e0;
--text-primary: #212121;
--text-secondary: #757575;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
}
#app {
max-width: 480px;
margin: 0 auto;
min-height: 100vh;
}
/* Utility classes */
.hidden {
display: none !important;
}
.container {
padding: 1rem;
}
.btn {
display: block;
width: 100%;
padding: 1rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
text-align: center;
transition: all 0.2s;
min-height: 48px;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:active {
opacity: 0.8;
transform: scale(0.98);
}
/* Top Bar */
.top-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
background: var(--bg-secondary);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
z-index: 1000;
height: 56px;
}
.top-bar-content {
max-width: 480px;
margin: 0 auto;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
}
.app-title {
font-size: 1.25rem;
font-weight: 700;
margin: 0;
}
.top-bar-indicators {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Connectivity Indicator */
.connectivity-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
position: relative;
}
.connectivity-indicator.online {
background: var(--success);
box-shadow: 0 0 8px var(--success);
}
.connectivity-indicator.offline {
background: var(--offline);
}
.connectivity-indicator.syncing {
background: var(--warning);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.2);
}
}
/* Pending Count Badge */
.pending-count {
background: var(--error);
color: white;
font-size: 0.75rem;
font-weight: 700;
padding: 0.25rem 0.5rem;
border-radius: 12px;
min-width: 24px;
text-align: center;
}
/* Main Content - Account for top bar and bottom nav */
#app {
padding-top: 56px;
padding-bottom: 70px;
}
/* Bottom Navigation */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--bg-secondary);
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-around;
z-index: 1000;
padding-bottom: env(safe-area-inset-bottom);
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem 0;
text-decoration: none;
color: var(--text-secondary);
transition: color 0.2s;
min-height: 56px;
justify-content: center;
}
.nav-item:active {
background: rgba(255, 255, 255, 0.05);
}
.nav-item.active {
color: var(--accent);
}
.nav-icon {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
.nav-label {
font-size: 0.75rem;
font-weight: 500;
}
/* Responsive adjustments for larger screens */
@media (min-width: 480px) {
.top-bar-content,
.bottom-nav {
max-width: 480px;
margin: 0 auto;
}
}
/* Queue-specific improvements */
.btn-outline:active:not(:disabled) {
opacity: 0.8;
transform: scale(0.98);
}
.btn-outline:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Scrollbar styling for webkit browsers */
.queue-list::-webkit-scrollbar {
width: 8px;
}
.queue-list::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 4px;
}
.queue-list::-webkit-scrollbar-thumb {
background: var(--text-secondary);
border-radius: 4px;
}
.queue-list::-webkit-scrollbar-thumb:hover {
background: var(--accent);
}
/* Loading animation */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.btn-icon.spinning {
display: inline-block;
animation: spin 1s linear infinite;
}
/* ============================================
POLISH & ANIMATIONS - Module 14
============================================ */
/* Smooth page transitions */
#app {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Button interactions */
.btn {
position: relative;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Ripple effect for buttons */
.btn::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.btn:active::before {
width: 300px;
height: 300px;
}
/* Enhanced connectivity indicator pulse */
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
box-shadow: 0 0 0 0 var(--warning);
}
50% {
opacity: 0.8;
transform: scale(1.1);
box-shadow: 0 0 0 8px transparent;
}
}
/* Smooth connectivity state transitions */
.connectivity-indicator {
transition: all 0.3s ease;
}
/* Loading spinner enhancement */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Skeleton loader for content */
@keyframes shimmer {
0% {
background-position: -468px 0;
}
100% {
background-position: 468px 0;
}
}
.skeleton {
background: linear-gradient(
90deg,
var(--bg-secondary) 0px,
rgba(255, 255, 255, 0.1) 40px,
var(--bg-secondary) 80px
);
background-size: 800px 100px;
animation: shimmer 2s infinite;
border-radius: 4px;
}
/* Form input enhancements */
input:not([type="file"]),
textarea,
select {
transition: all 0.2s ease;
}
input:focus:not([type="file"]),
textarea:focus,
select:focus {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(74, 144, 226, 0.2);
}
/* Card hover effects */
.queue-item,
.thumbnail,
.file-item {
transition: all 0.2s ease;
}
.queue-item:hover,
.thumbnail:hover,
.file-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
/* Toast notification slide-in */
.toast {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translate(-50%, 20px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
/* Modal fade-in */
.modal {
animation: modalFadeIn 0.2s ease-out;
}
@keyframes modalFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-content {
animation: modalSlideUp 0.3s ease-out;
}
@keyframes modalSlideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Navigation item active state */
.nav-item {
position: relative;
transition: all 0.2s ease;
}
.nav-item::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
width: 0;
height: 2px;
background: var(--accent);
transform: translateX(-50%);
transition: width 0.3s ease;
}
.nav-item.active::after,
.nav-item:hover::after {
width: 80%;
}
/* Focus visible for accessibility */
*:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Improve button focus states */
button:focus-visible,
.btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Smooth scroll behavior */
html {
scroll-behavior: smooth;
}
/* Loading bar at top of page */
.loading-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--accent);
transform-origin: left;
animation: loadingBar 1s ease-in-out infinite;
z-index: 9999;
}
@keyframes loadingBar {
0% {
transform: scaleX(0);
}
50% {
transform: scaleX(0.5);
}
100% {
transform: scaleX(1);
}
}
/* Status badge animations */
.badge,
.status-badge {
animation: badgeFadeIn 0.3s ease-out;
}
@keyframes badgeFadeIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Image fade-in on load */
img {
animation: imageFadeIn 0.4s ease-out;
}
@keyframes imageFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Thumbnail grid stagger animation */
.photo-thumbnails .thumbnail {
animation: staggerFadeIn 0.4s ease-out backwards;
}
.photo-thumbnails .thumbnail:nth-child(1) { animation-delay: 0.05s; }
.photo-thumbnails .thumbnail:nth-child(2) { animation-delay: 0.1s; }
.photo-thumbnails .thumbnail:nth-child(3) { animation-delay: 0.15s; }
.photo-thumbnails .thumbnail:nth-child(4) { animation-delay: 0.2s; }
.photo-thumbnails .thumbnail:nth-child(5) { animation-delay: 0.25s; }
@keyframes staggerFadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Enhanced empty state */
.empty-state {
animation: emptyStateFadeIn 0.5s ease-out;
}
@keyframes emptyStateFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Pending count badge bounce */
.pending-count {
animation: badgeBounce 0.5s ease-out;
}
@keyframes badgeBounce {
0% {
transform: scale(0);
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
}
}
/* Enhanced spinner */
.spinner,
.spinner-small {
animation: spin 1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
/* Delete button shake on hover */
.queue-item-delete:hover,
.btn-danger:hover {
animation: shake 0.5s;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-2px); }
75% { transform: translateX(2px); }
}
/* Success checkmark animation */
@keyframes checkmark {
0% {
transform: scale(0) rotate(0deg);
}
50% {
transform: scale(1.2) rotate(180deg);
}
100% {
transform: scale(1) rotate(360deg);
}
}
.success-icon {
animation: checkmark 0.6s ease-out;
}
/* Table row hover effect */
.user-table tr {
transition: all 0.2s ease;
}
.user-table tr:hover {
transform: scale(1.01);
}
/* Form validation feedback */
input:invalid:not(:placeholder-shown) {
border-color: var(--error);
animation: inputShake 0.3s;
}
input:valid:not(:placeholder-shown) {
border-color: var(--success);
}
@keyframes inputShake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
/* Reduced motion for accessibility */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Dark mode enhancements */
@media (prefers-color-scheme: dark) {
.btn:hover:not(:disabled) {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
input:focus:not([type="file"]),
textarea:focus,
select:focus {
box-shadow: 0 4px 12px rgba(74, 144, 226, 0.3);
}
}
/* Progress bar for uploads */
.upload-progress {
position: relative;
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
}
.upload-progress-bar {
height: 100%;
background: var(--accent);
transition: width 0.3s ease;
position: relative;
}
.upload-progress-bar::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
animation: shimmer 1.5s infinite;
}
/* Improved disabled state */
button:disabled,
.btn:disabled,
input:disabled {
cursor: not-allowed;
opacity: 0.5;
filter: grayscale(50%);
}
/* Connection status pulse enhancement */
.connectivity-indicator.online {
animation: successPulse 2s ease-in-out infinite;
}
@keyframes successPulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.7);
}
50% {
box-shadow: 0 0 0 8px rgba(76, 175, 80, 0);
}
}
/* Error state pulse */
.connectivity-indicator.offline {
animation: errorPulse 2s ease-in-out infinite;
}
@keyframes errorPulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Micro-interaction for clickable items */
[role="button"],
.clickable {
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
}
[role="button"]:active,
.clickable:active {
transform: scale(0.97);
}
/* Smooth color transitions */
* {
transition-property: color, background-color, border-color;
transition-duration: 0.2s;
transition-timing-function: ease;
}
/* Override for elements that shouldn't have color transitions */
button,
.btn,
input,
textarea,
select {
transition-property: all;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

246
app/static/js/admin.js Normal file
View File

@@ -0,0 +1,246 @@
// NextSnap - Admin Panel Logic
'use strict';
const Admin = {
users: [],
async init() {
await this.loadUsers();
this.setupEventListeners();
},
setupEventListeners() {
document.getElementById('add-user-form').addEventListener('submit', (e) => {
e.preventDefault();
this.createUser();
});
document.getElementById('refresh-btn').addEventListener('click', () => {
this.loadUsers();
});
},
async loadUsers() {
const userList = document.getElementById('user-list');
const loadingMsg = document.getElementById('loading-msg');
const errorMsg = document.getElementById('error-msg');
loadingMsg.style.display = 'block';
errorMsg.style.display = 'none';
userList.innerHTML = '';
try {
const response = await fetch('/api/admin/users');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to load users');
}
const data = await response.json();
this.users = data.users || [];
loadingMsg.style.display = 'none';
if (this.users.length === 0) {
userList.innerHTML = '<tr><td colspan="5" class="empty-state">No users found</td></tr>';
return;
}
this.users.forEach(user => {
const row = this.createUserRow(user);
userList.appendChild(row);
});
} catch (error) {
console.error('Error loading users:', error);
loadingMsg.style.display = 'none';
errorMsg.textContent = error.message;
errorMsg.style.display = 'block';
}
},
createUserRow(user) {
const row = document.createElement('tr');
row.innerHTML = `
<td>${this.escapeHtml(user.id)}</td>
<td>${this.escapeHtml(user.displayname || '-')}</td>
<td>${this.escapeHtml(user.email || '-')}</td>
<td>
<span class="badge ${user.enabled ? 'badge-success' : 'badge-danger'}">
${user.enabled ? 'Enabled' : 'Disabled'}
</span>
</td>
<td>
<div class="action-buttons">
${user.enabled ?
`<button class="btn-action btn-warning" onclick="Admin.disableUser('${user.id}')">Disable</button>` :
`<button class="btn-action btn-success" onclick="Admin.enableUser('${user.id}')">Enable</button>`
}
<button class="btn-action btn-danger" onclick="Admin.confirmDeleteUser('${user.id}')">Delete</button>
</div>
</td>
`;
return row;
},
async createUser() {
const form = document.getElementById('add-user-form');
const submitBtn = document.getElementById('submit-btn');
const formError = document.getElementById('form-error');
const formSuccess = document.getElementById('form-success');
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
const email = document.getElementById('new-email').value.trim();
const displayName = document.getElementById('new-displayname').value.trim();
formError.style.display = 'none';
formSuccess.style.display = 'none';
if (!username || !password) {
formError.textContent = 'Username and password are required';
formError.style.display = 'block';
return;
}
submitBtn.disabled = true;
submitBtn.textContent = 'Creating...';
try {
const response = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username,
password: password,
email: email || null,
displayName: displayName || null
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Failed to create user');
}
formSuccess.textContent = `User "${username}" created successfully!`;
formSuccess.style.display = 'block';
form.reset();
setTimeout(() => {
this.loadUsers();
}, 1000);
} catch (error) {
console.error('Error creating user:', error);
formError.textContent = error.message;
formError.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Create User';
}
},
async enableUser(username) {
if (!confirm(`Enable user "${username}"?`)) return;
try {
const response = await fetch(`/api/admin/users/${username}/enable`, {
method: 'PUT'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to enable user');
}
this.showToast(`User "${username}" enabled`, 'success');
this.loadUsers();
} catch (error) {
console.error('Error enabling user:', error);
this.showToast(error.message, 'error');
}
},
async disableUser(username) {
if (!confirm(`Disable user "${username}"?`)) return;
try {
const response = await fetch(`/api/admin/users/${username}/disable`, {
method: 'PUT'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to disable user');
}
this.showToast(`User "${username}" disabled`, 'success');
this.loadUsers();
} catch (error) {
console.error('Error disabling user:', error);
this.showToast(error.message, 'error');
}
},
confirmDeleteUser(username) {
const modal = document.getElementById('delete-modal');
const confirmBtn = document.getElementById('confirm-delete');
document.getElementById('delete-username').textContent = username;
modal.style.display = 'flex';
confirmBtn.onclick = () => {
this.deleteUser(username);
this.hideDeleteModal();
};
},
hideDeleteModal() {
document.getElementById('delete-modal').style.display = 'none';
},
async deleteUser(username) {
try {
const response = await fetch(`/api/admin/users/${username}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete user');
}
this.showToast(`User "${username}" deleted`, 'success');
this.loadUsers();
} catch (error) {
console.error('Error deleting user:', error);
this.showToast(error.message, 'error');
}
},
showToast(message, type) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast ${type}`;
toast.style.display = 'block';
setTimeout(() => {
toast.style.display = 'none';
}, 3000);
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
};
window.Admin = Admin;

111
app/static/js/app.js Normal file
View File

@@ -0,0 +1,111 @@
// NextSnap - Main application logic
'use strict';
const NextSnap = {
version: '1.0.0',
isOnline: navigator.onLine,
init() {
console.log(`NextSnap v${this.version} initializing...`);
this.setupConnectivityMonitoring();
this.setupServiceWorkerMessaging();
this.checkHealth();
},
setupConnectivityMonitoring() {
// Update online status on events
window.addEventListener('online', () => {
console.log('Network: Online');
this.isOnline = true;
this.updateConnectivityIndicator();
// Sync is handled by SyncEngine's own online listener in sync.js
});
window.addEventListener('offline', () => {
console.log('Network: Offline');
this.isOnline = false;
this.updateConnectivityIndicator();
});
// Initial status
this.updateConnectivityIndicator();
},
updateConnectivityIndicator() {
const indicator = document.querySelector('.connectivity-indicator');
if (!indicator) return;
indicator.classList.remove('online', 'offline', 'syncing');
if (this.isOnline) {
indicator.classList.add('online');
indicator.title = 'Online';
} else {
indicator.classList.add('offline');
indicator.title = 'Offline';
}
},
setSyncingStatus(isSyncing) {
const indicator = document.querySelector('.connectivity-indicator');
if (!indicator) return;
if (isSyncing) {
indicator.classList.add('syncing');
indicator.title = 'Syncing...';
} else {
indicator.classList.remove('syncing');
indicator.title = this.isOnline ? 'Online' : 'Offline';
}
},
setupServiceWorkerMessaging() {
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
// Listen for messages from service worker
navigator.serviceWorker.addEventListener('message', (event) => {
console.log('Message from SW:', event.data);
});
}
},
// Force service worker update
async updateServiceWorker() {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.getRegistration();
if (registration) {
await registration.update();
console.log('Service worker update check triggered');
}
}
},
// Clear all caches (for debugging)
async clearCache() {
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'CLEAR_CACHE'
});
console.log('Cache clear requested');
}
},
async checkHealth() {
try {
const response = await fetch('/api/health');
const data = await response.json();
console.log('Health check:', data);
} catch (error) {
console.error('Health check failed:', error);
}
}
};
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => NextSnap.init());
} else {
NextSnap.init();
}
// Make NextSnap globally available
window.NextSnap = NextSnap;

160
app/static/js/auth.js Normal file
View File

@@ -0,0 +1,160 @@
// NextSnap - Authentication logic
'use strict';
const Auth = {
init() {
const loginForm = document.getElementById('login-form');
if (loginForm) {
loginForm.addEventListener('submit', this.handleLogin.bind(this));
// Add input validation
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
if (usernameInput) {
usernameInput.addEventListener('blur', () => this.validateUsername());
usernameInput.addEventListener('input', () => this.clearFieldError('username'));
}
if (passwordInput) {
passwordInput.addEventListener('input', () => this.clearFieldError('password'));
}
}
},
validateUsername() {
const username = document.getElementById('username').value.trim();
const usernameError = document.getElementById('username-error');
const usernameInput = document.getElementById('username');
if (!username) {
usernameError.textContent = 'Username is required';
usernameInput.classList.add('error');
return false;
}
if (username.length < 2) {
usernameError.textContent = 'Username must be at least 2 characters';
usernameInput.classList.add('error');
return false;
}
usernameError.textContent = '';
usernameInput.classList.remove('error');
return true;
},
validatePassword() {
const password = document.getElementById('password').value;
const passwordError = document.getElementById('password-error');
const passwordInput = document.getElementById('password');
if (!password) {
passwordError.textContent = 'Password is required';
passwordInput.classList.add('error');
return false;
}
passwordError.textContent = '';
passwordInput.classList.remove('error');
return true;
},
clearFieldError(field) {
const errorElement = document.getElementById(`${field}-error`);
const inputElement = document.getElementById(field);
if (errorElement) errorElement.textContent = '';
if (inputElement) inputElement.classList.remove('error');
},
async handleLogin(event) {
event.preventDefault();
// Validate fields
const usernameValid = this.validateUsername();
const passwordValid = this.validatePassword();
if (!usernameValid || !passwordValid) {
return;
}
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const errorMessage = document.getElementById('error-message');
const loginBtn = document.getElementById('login-btn');
const loginBtnText = document.getElementById('login-btn-text');
const loginBtnLoading = document.getElementById('login-btn-loading');
// Clear previous error
errorMessage.classList.add('hidden');
errorMessage.textContent = '';
// Disable button and show loading state
loginBtn.disabled = true;
loginBtnText.classList.add('hidden');
loginBtnLoading.classList.remove('hidden');
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok && data.success) {
// Login successful - redirect to capture page
window.location.href = '/capture';
} else {
// Login failed - show error
const errorText = data.error || 'Login failed. Please check your credentials and try again.';
errorMessage.textContent = errorText;
errorMessage.classList.remove('hidden');
// Focus back on username for retry
document.getElementById('username').focus();
}
} catch (error) {
console.error('Login error:', error);
errorMessage.textContent = 'Network error. Please check your connection and try again.';
errorMessage.classList.remove('hidden');
} finally {
// Re-enable button and restore normal state
loginBtn.disabled = false;
loginBtnText.classList.remove('hidden');
loginBtnLoading.classList.add('hidden');
}
},
async checkStatus() {
try {
const response = await fetch('/api/auth/status');
const data = await response.json();
return data.authenticated ? data : null;
} catch (error) {
console.error('Auth status check failed:', error);
return null;
}
},
async logout() {
try {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.href = '/';
} catch (error) {
console.error('Logout error:', error);
// Redirect anyway
window.location.href = '/';
}
}
};
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => Auth.init());
} else {
Auth.init();
}

318
app/static/js/camera.js Normal file
View File

@@ -0,0 +1,318 @@
// NextSnap - Camera Capture and JPEG Conversion
'use strict';
const Camera = {
input: null,
currentUsername: null,
init(username) {
this.currentUsername = username;
this.input = document.getElementById('camera-input');
const captureBtn = document.getElementById('capture-btn');
if (this.input && captureBtn) {
captureBtn.addEventListener('click', () => this.triggerCapture());
this.input.addEventListener('change', (e) => this.handleCapture(e));
}
},
triggerCapture() {
if (this.input) {
this.input.click();
}
},
async handleCapture(event) {
const file = event.target.files[0];
if (!file) return;
try {
// Show loading state
this.showCaptureLoading();
// Convert to JPEG
const jpegBlob = await this.convertToJPEG(file);
// Generate filename
const filename = this.generateFilename();
// Get target path
const targetPath = localStorage.getItem('nextsnap_upload_path') || '/';
// Save to IndexedDB
const photoId = await Storage.savePhoto({
username: this.currentUsername,
timestamp: Date.now(),
filename: filename,
targetPath: targetPath,
blob: jpegBlob,
status: 'pending'
});
// Show success feedback
this.showCaptureSuccess(jpegBlob);
// Update recent photos display
this.updateRecentPhotos();
// Update pending count
this.updatePendingCount();
// Clear input for next capture
event.target.value = '';
// Trigger sync if online (will be implemented in Phase 7)
if (navigator.onLine && typeof Sync !== 'undefined') {
Sync.triggerSync();
}
} catch (error) {
console.error('Error capturing photo:', error);
this.showCaptureError(error.message);
}
},
async convertToJPEG(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const img = new Image();
img.onload = async () => {
try {
// Get EXIF orientation
const orientation = await this.getOrientation(file);
// Create canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Set canvas dimensions based on orientation
let width = img.width;
let height = img.height;
if (orientation >= 5 && orientation <= 8) {
// Swap dimensions for rotated images
canvas.width = height;
canvas.height = width;
} else {
canvas.width = width;
canvas.height = height;
}
// Apply orientation transformation
this.applyOrientation(ctx, orientation, width, height);
// Draw image
ctx.drawImage(img, 0, 0, width, height);
// Convert to JPEG blob
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to convert image to JPEG'));
}
},
'image/jpeg',
0.92
);
} catch (error) {
reject(error);
}
};
img.onerror = () => {
reject(new Error('Failed to load image'));
};
img.src = e.target.result;
} catch (error) {
reject(error);
}
};
reader.onerror = () => {
reject(new Error('Failed to read file'));
};
reader.readAsDataURL(file);
});
},
async getOrientation(file) {
// Simple EXIF orientation detection
// For production, consider using a library like exif-js
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const view = new DataView(e.target.result);
if (view.getUint16(0, false) !== 0xFFD8) {
resolve(1); // Not a JPEG
return;
}
const length = view.byteLength;
let offset = 2;
while (offset < length) {
if (view.getUint16(offset + 2, false) <= 8) {
resolve(1);
return;
}
const marker = view.getUint16(offset, false);
offset += 2;
if (marker === 0xFFE1) {
// EXIF marker
const little = view.getUint16(offset + 8, false) === 0x4949;
offset += 10;
const tags = view.getUint16(offset, little);
offset += 2;
for (let i = 0; i < tags; i++) {
if (view.getUint16(offset + (i * 12), little) === 0x0112) {
resolve(view.getUint16(offset + (i * 12) + 8, little));
return;
}
}
} else if ((marker & 0xFF00) !== 0xFF00) {
break;
} else {
offset += view.getUint16(offset, false);
}
}
resolve(1); // Default orientation
};
reader.onerror = () => resolve(1);
reader.readAsArrayBuffer(file.slice(0, 64 * 1024));
});
},
applyOrientation(ctx, orientation, width, height) {
switch (orientation) {
case 2:
// Horizontal flip
ctx.transform(-1, 0, 0, 1, width, 0);
break;
case 3:
// 180° rotate
ctx.transform(-1, 0, 0, -1, width, height);
break;
case 4:
// Vertical flip
ctx.transform(1, 0, 0, -1, 0, height);
break;
case 5:
// Vertical flip + 90° rotate
ctx.transform(0, 1, 1, 0, 0, 0);
break;
case 6:
// 90° rotate
ctx.transform(0, 1, -1, 0, height, 0);
break;
case 7:
// Horizontal flip + 90° rotate
ctx.transform(0, -1, -1, 0, height, width);
break;
case 8:
// 270° rotate
ctx.transform(0, -1, 1, 0, 0, width);
break;
default:
// No transformation
break;
}
},
generateFilename() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${this.currentUsername}_${year}${month}${day}_${hours}${minutes}${seconds}.jpg`;
},
showCaptureLoading() {
const btn = document.getElementById('capture-btn');
if (btn) {
btn.disabled = true;
btn.textContent = '⏳ Processing...';
}
},
showCaptureSuccess(blob) {
const btn = document.getElementById('capture-btn');
if (btn) {
btn.textContent = '✓ Photo Saved!';
setTimeout(() => {
btn.disabled = false;
btn.textContent = '📷 Take Photo';
}, 1500);
}
},
showCaptureError(message) {
const btn = document.getElementById('capture-btn');
if (btn) {
btn.textContent = '❌ Error';
setTimeout(() => {
btn.disabled = false;
btn.textContent = '📷 Take Photo';
}, 2000);
}
alert('Failed to capture photo: ' + message);
},
async updateRecentPhotos() {
if (!this.currentUsername) return;
const thumbnailsContainer = document.getElementById('photo-thumbnails');
if (!thumbnailsContainer) return;
const recentPhotos = await Storage.getRecentPhotos(this.currentUsername, 5);
if (recentPhotos.length === 0) {
thumbnailsContainer.innerHTML = '<p class="empty-state">No photos captured yet</p>';
return;
}
let html = '';
for (const photo of recentPhotos) {
const url = URL.createObjectURL(photo.blob);
const statusClass = photo.status === 'verified' ? 'verified' :
photo.status === 'pending' ? 'pending' :
photo.status === 'uploading' ? 'uploading' : '';
html += `
<div class="thumbnail">
<img src="${url}" alt="${photo.filename}">
<span class="status-badge ${statusClass}"></span>
</div>
`;
}
thumbnailsContainer.innerHTML = html;
},
async updatePendingCount() {
if (!this.currentUsername) return;
const countElement = document.getElementById('pending-count');
if (!countElement) return;
const count = await Storage.getPhotoCount(this.currentUsername, 'pending');
countElement.textContent = `${count} pending`;
}
};

View File

@@ -0,0 +1,530 @@
// NextSnap - File Browser Logic with Photo Gallery
'use strict';
const FileBrowser = {
currentPath: '/',
// Gallery state
galleryOpen: false,
galleryImages: [],
galleryIndex: 0,
galleryTouchStartX: 0,
galleryTouchStartY: 0,
galleryTouchEndX: 0,
galleryTouchEndY: 0,
galleryPreloaded: {},
init() {
this.loadCurrentPath();
this.loadDirectory(this.currentPath);
this.setupEventListeners();
},
setupEventListeners() {
const newFolderBtn = document.getElementById('new-folder-btn');
if (newFolderBtn) {
newFolderBtn.addEventListener('click', () => this.createNewFolder());
}
// Gallery controls
document.getElementById('gallery-close-btn').addEventListener('click', () => this.closeGallery());
document.getElementById('gallery-prev-btn').addEventListener('click', () => this.galleryPrev());
document.getElementById('gallery-next-btn').addEventListener('click', () => this.galleryNext());
document.getElementById('gallery-rename-btn').addEventListener('click', () => this.openRenameModal());
// Rename modal
document.getElementById('gallery-rename-cancel').addEventListener('click', () => this.closeRenameModal());
document.getElementById('gallery-rename-save').addEventListener('click', () => this.saveRename());
document.getElementById('gallery-rename-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.saveRename();
} else if (e.key === 'Escape') {
this.closeRenameModal();
}
});
document.getElementById('gallery-rename-input').addEventListener('input', () => {
document.getElementById('gallery-rename-error').textContent = '';
});
// Touch events for swipe on gallery image container
const container = document.getElementById('gallery-image-container');
container.addEventListener('touchstart', (e) => {
this.galleryTouchStartX = e.changedTouches[0].screenX;
this.galleryTouchStartY = e.changedTouches[0].screenY;
}, { passive: true });
container.addEventListener('touchend', (e) => {
this.galleryTouchEndX = e.changedTouches[0].screenX;
this.galleryTouchEndY = e.changedTouches[0].screenY;
this.handleGallerySwipe();
}, { passive: true });
// Keyboard navigation
document.addEventListener('keydown', (e) => {
if (!this.galleryOpen) return;
if (e.key === 'ArrowLeft') this.galleryPrev();
else if (e.key === 'ArrowRight') this.galleryNext();
else if (e.key === 'Escape') this.closeGallery();
});
},
isImageFile(item) {
if (item.type !== 'file') return false;
if (item.content_type && item.content_type.startsWith('image/')) return true;
// Extension fallback
const ext = (item.name || '').split('.').pop().toLowerCase();
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif', 'bmp', 'tiff', 'tif', 'svg'].includes(ext);
},
async loadDirectory(path) {
const fileList = document.getElementById('file-list');
fileList.innerHTML = '<p class="loading">Loading...</p>';
try {
const response = await fetch(`/api/files/list?path=${encodeURIComponent(path)}`);
if (!response.ok) {
throw new Error('Failed to load directory');
}
const data = await response.json();
this.currentPath = path;
this.saveCurrentPath();
this.renderBreadcrumb(path);
this.renderFileList(data.items || []);
} catch (error) {
console.error('Error loading directory:', error);
fileList.innerHTML = `<p class="error-state">Failed to load directory: ${error.message}</p>`;
}
},
renderBreadcrumb(path) {
const breadcrumb = document.getElementById('breadcrumb');
const parts = path.split('/').filter(p => p);
let html = '<a href="#" class="breadcrumb-item" data-path="/">Home</a>';
let currentPath = '';
parts.forEach((part, index) => {
currentPath += '/' + part;
html += ' / ';
if (index === parts.length - 1) {
html += `<span class="breadcrumb-item current">${part}</span>`;
} else {
html += `<a href="#" class="breadcrumb-item" data-path="${currentPath}">${part}</a>`;
}
});
breadcrumb.innerHTML = html;
// Add click handlers to breadcrumb links
breadcrumb.querySelectorAll('a.breadcrumb-item').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const path = link.dataset.path;
this.loadDirectory(path);
});
});
},
renderFileList(items) {
const fileList = document.getElementById('file-list');
if (items.length === 0) {
fileList.innerHTML = '<p class="empty-state">This folder is empty</p>';
this.renderSelectButton();
return;
}
// Sort: directories first, then files, alphabetically
const sortedItems = items.sort((a, b) => {
if (a.type === b.type) {
return a.name.localeCompare(b.name);
}
return a.type === 'directory' ? -1 : 1;
});
// Build gallery images list from sorted items
this.galleryImages = [];
const imageIndexMap = {};
sortedItems.forEach((item, i) => {
if (this.isImageFile(item)) {
imageIndexMap[i] = this.galleryImages.length;
this.galleryImages.push(item);
}
});
let html = '<div class="file-items">';
sortedItems.forEach((item, i) => {
const isImage = this.isImageFile(item);
let icon;
if (item.type === 'directory') {
icon = '📁';
} else if (isImage) {
icon = '🖼️';
} else {
icon = '📄';
}
const sizeText = item.type === 'file' ? this.formatSize(item.size) : '';
const galleryAttr = isImage ? ` data-gallery-index="${imageIndexMap[i]}"` : '';
html += `
<div class="file-item ${item.type}" data-path="${item.path}" data-type="${item.type}"${galleryAttr}>
<span class="file-icon">${icon}</span>
<div class="file-info">
<div class="file-name">${this.escapeHtml(item.name)}</div>
${sizeText ? `<div class="file-size">${sizeText}</div>` : ''}
</div>
${item.type === 'directory' ? '<span class="chevron"></span>' : ''}
</div>
`;
});
html += '</div>';
fileList.innerHTML = html;
// Add click handlers
fileList.querySelectorAll('.file-item').forEach(item => {
item.addEventListener('click', () => {
const path = item.dataset.path;
const type = item.dataset.type;
if (type === 'directory') {
this.loadDirectory(path);
} else if (item.dataset.galleryIndex !== undefined) {
this.openGallery(parseInt(item.dataset.galleryIndex, 10));
}
});
});
this.renderSelectButton();
},
renderSelectButton() {
const fileList = document.getElementById('file-list');
const selectBtn = document.createElement('button');
selectBtn.className = 'btn btn-primary btn-select-folder';
selectBtn.textContent = 'Select This Folder as Upload Destination';
selectBtn.addEventListener('click', () => this.selectCurrentFolder());
fileList.appendChild(selectBtn);
},
async selectCurrentFolder() {
localStorage.setItem('nextsnap_upload_path', this.currentPath);
const selectBtn = document.querySelector('.btn-select-folder');
const originalText = selectBtn.textContent;
selectBtn.textContent = '✓ Selected!';
selectBtn.disabled = true;
setTimeout(() => {
window.location.href = '/capture';
}, 1000);
},
async createNewFolder() {
const folderName = prompt('Enter folder name:');
if (!folderName || !folderName.trim()) {
return;
}
const trimmedName = folderName.trim();
if (/[\/\\?*"|<>:]/.test(trimmedName)) {
alert('Folder name contains invalid characters');
return;
}
const newPath = this.currentPath === '/'
? '/' + trimmedName
: this.currentPath + '/' + trimmedName;
try {
const response = await fetch('/api/files/mkdir', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ path: newPath })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to create folder');
}
this.loadDirectory(this.currentPath);
} catch (error) {
console.error('Error creating folder:', error);
alert('Failed to create folder: ' + error.message);
}
},
// ---- Gallery methods ----
openGallery(index) {
if (this.galleryImages.length === 0) return;
this.galleryOpen = true;
this.galleryIndex = index;
this.galleryPreloaded = {};
document.body.classList.add('gallery-open');
document.getElementById('gallery-overlay').style.display = 'flex';
this.displayGalleryImage(index);
},
closeGallery() {
this.galleryOpen = false;
document.body.classList.remove('gallery-open');
document.getElementById('gallery-overlay').style.display = 'none';
// Revoke preloaded object URLs
Object.values(this.galleryPreloaded).forEach(url => {
if (url && url.startsWith('blob:')) URL.revokeObjectURL(url);
});
this.galleryPreloaded = {};
// Clear image src
const img = document.getElementById('gallery-image');
if (img.src && img.src.startsWith('blob:')) URL.revokeObjectURL(img.src);
img.src = '';
},
async displayGalleryImage(index) {
if (index < 0 || index >= this.galleryImages.length) return;
this.galleryIndex = index;
const item = this.galleryImages[index];
const img = document.getElementById('gallery-image');
const spinner = document.getElementById('gallery-spinner');
// Show spinner, hide image
spinner.style.display = 'flex';
img.style.display = 'none';
// Update counter and filename
document.getElementById('gallery-counter').textContent = (index + 1) + ' / ' + this.galleryImages.length;
document.getElementById('gallery-filename').textContent = item.name;
// Update nav button states
document.getElementById('gallery-prev-btn').disabled = (index === 0);
document.getElementById('gallery-next-btn').disabled = (index === this.galleryImages.length - 1);
// Load image
let src = this.galleryPreloaded[index];
if (!src) {
src = await this.fetchGalleryImage(item);
this.galleryPreloaded[index] = src;
}
// Only update if we're still on this index (user may have swiped away)
if (this.galleryIndex !== index) return;
img.onload = () => {
spinner.style.display = 'none';
img.style.display = 'block';
};
img.onerror = () => {
spinner.style.display = 'none';
img.style.display = 'block';
};
img.src = src;
// Preload adjacent
this.preloadGalleryAdjacent(index);
},
async fetchGalleryImage(item) {
const path = item.path || (this.currentPath + '/' + item.name);
const url = '/api/files/thumbnail?path=' + encodeURIComponent(path) + '&size=1024';
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch image');
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch (error) {
console.error('Error fetching gallery image:', error);
return '';
}
},
preloadGalleryAdjacent(index) {
const toPreload = [index - 1, index + 1, index + 2];
toPreload.forEach(async (i) => {
if (i >= 0 && i < this.galleryImages.length && !this.galleryPreloaded[i]) {
this.galleryPreloaded[i] = await this.fetchGalleryImage(this.galleryImages[i]);
}
});
},
galleryPrev() {
if (this.galleryIndex > 0) {
this.displayGalleryImage(this.galleryIndex - 1);
}
},
galleryNext() {
if (this.galleryIndex < this.galleryImages.length - 1) {
this.displayGalleryImage(this.galleryIndex + 1);
}
},
handleGallerySwipe() {
const deltaX = this.galleryTouchEndX - this.galleryTouchStartX;
const deltaY = this.galleryTouchEndY - this.galleryTouchStartY;
// Ignore if vertical swipe is dominant
if (Math.abs(deltaY) > Math.abs(deltaX)) return;
// Ignore if too small
if (Math.abs(deltaX) < 50) return;
if (deltaX > 0) this.galleryPrev();
else this.galleryNext();
},
openRenameModal() {
const item = this.galleryImages[this.galleryIndex];
if (!item) return;
const name = item.name;
const lastDot = name.lastIndexOf('.');
let basename, ext;
if (lastDot > 0) {
basename = name.substring(0, lastDot);
ext = name.substring(lastDot);
} else {
basename = name;
ext = '';
}
document.getElementById('gallery-rename-input').value = basename;
document.getElementById('gallery-rename-ext').textContent = ext;
document.getElementById('gallery-rename-error').textContent = '';
document.getElementById('gallery-rename-backdrop').style.display = 'flex';
// Focus input after display
setTimeout(() => {
const input = document.getElementById('gallery-rename-input');
input.focus();
input.select();
}, 100);
},
closeRenameModal() {
document.getElementById('gallery-rename-backdrop').style.display = 'none';
},
async saveRename() {
const item = this.galleryImages[this.galleryIndex];
if (!item) return;
const input = document.getElementById('gallery-rename-input');
const ext = document.getElementById('gallery-rename-ext').textContent;
const errorEl = document.getElementById('gallery-rename-error');
const newBasename = input.value.trim();
// Validate
if (!newBasename) {
errorEl.textContent = 'Filename cannot be empty';
return;
}
if (/[\/\\?*"|<>:]/.test(newBasename)) {
errorEl.textContent = 'Invalid characters in filename';
return;
}
if (newBasename.length > 200) {
errorEl.textContent = 'Filename too long (max 200 characters)';
return;
}
const newName = newBasename + ext;
// If name hasn't changed, just close
if (newName === item.name) {
this.closeRenameModal();
return;
}
const saveBtn = document.getElementById('gallery-rename-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
try {
const sourcePath = item.path || (this.currentPath + '/' + item.name);
const destPath = this.currentPath + '/' + newName;
const response = await fetch('/api/files/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: sourcePath, destination: destPath })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Rename failed');
}
// Update local state
item.name = newName;
item.path = destPath;
document.getElementById('gallery-filename').textContent = newName;
this.closeRenameModal();
this.showGalleryToast('File renamed', 'success');
// Refresh the file list in the background
this.loadDirectory(this.currentPath);
} catch (error) {
console.error('Error renaming file:', error);
errorEl.textContent = error.message;
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save';
}
},
showGalleryToast(msg, type) {
const toast = document.getElementById('gallery-toast');
toast.textContent = msg;
toast.className = 'gallery-toast ' + type;
toast.style.display = 'block';
setTimeout(() => {
toast.style.display = 'none';
}, 2500);
},
formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
saveCurrentPath() {
localStorage.setItem('nextsnap_current_browse_path', this.currentPath);
},
loadCurrentPath() {
const saved = localStorage.getItem('nextsnap_current_browse_path');
if (saved) {
this.currentPath = saved;
}
}
};
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => FileBrowser.init());
} else {
FileBrowser.init();
}

300
app/static/js/polish.js Normal file
View File

@@ -0,0 +1,300 @@
// NextSnap - Polish & UX Enhancements
'use strict';
const Polish = {
init() {
this.setupLoadingIndicator();
this.setupSmoothScroll();
this.setupFormValidation();
this.setupKeyboardShortcuts();
this.setupOfflineDetection();
this.setupImageLazyLoading();
console.log('Polish enhancements loaded');
},
// Global loading indicator
setupLoadingIndicator() {
this.loadingBar = document.createElement('div');
this.loadingBar.className = 'loading-bar';
this.loadingBar.style.display = 'none';
document.body.appendChild(this.loadingBar);
},
showLoading() {
if (this.loadingBar) {
this.loadingBar.style.display = 'block';
}
},
hideLoading() {
if (this.loadingBar) {
this.loadingBar.style.display = 'none';
}
},
// Smooth scroll for anchor links
setupSmoothScroll() {
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', (e) => {
const href = anchor.getAttribute('href');
if (href === '#') return;
e.preventDefault();
const target = document.querySelector(href);
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
},
// Enhanced form validation
setupFormValidation() {
document.querySelectorAll('form').forEach(form => {
form.addEventListener('submit', (e) => {
const invalidInputs = form.querySelectorAll(':invalid');
if (invalidInputs.length > 0) {
invalidInputs[0].focus();
this.shake(invalidInputs[0]);
}
});
});
// Real-time validation feedback
document.querySelectorAll('input[required], textarea[required]').forEach(input => {
input.addEventListener('blur', () => {
if (!input.validity.valid) {
this.shake(input);
}
});
});
},
// Shake animation for errors
shake(element) {
element.style.animation = 'inputShake 0.3s';
setTimeout(() => {
element.style.animation = '';
}, 300);
},
// Keyboard shortcuts
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Alt+1: Go to capture
if (e.altKey && e.key === '1') {
e.preventDefault();
window.location.href = '/capture';
}
// Alt+2: Go to queue
if (e.altKey && e.key === '2') {
e.preventDefault();
window.location.href = '/queue';
}
// Alt+3: Go to files
if (e.altKey && e.key === '3') {
e.preventDefault();
window.location.href = '/browser';
}
// Alt+R: Refresh/sync
if (e.altKey && e.key === 'r') {
e.preventDefault();
if (window.SyncEngine && window.SyncEngine.triggerSync) {
window.SyncEngine.triggerSync();
}
}
});
},
// Enhanced offline detection
setupOfflineDetection() {
let wasOffline = !navigator.onLine;
window.addEventListener('online', () => {
if (wasOffline) {
this.showToast('✓ Back online', 'success', 2000);
wasOffline = false;
}
});
window.addEventListener('offline', () => {
this.showToast('⚠️ You are offline', 'warning', 3000);
wasOffline = true;
});
},
// Lazy loading for images
setupImageLazyLoading() {
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute('data-src');
imageObserver.unobserve(img);
}
}
});
});
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
}
},
// Toast notification helper
showToast(message, type = 'info', duration = 3000) {
let toast = document.getElementById('global-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'global-toast';
toast.className = 'toast';
document.body.appendChild(toast);
}
toast.textContent = message;
toast.className = `toast ${type}`;
toast.style.display = 'block';
setTimeout(() => {
toast.style.display = 'none';
}, duration);
},
// Confirm dialog with better UX
async confirm(message, title = 'Confirm') {
return new Promise((resolve) => {
const modal = this.createConfirmModal(message, title);
document.body.appendChild(modal);
modal.querySelector('.confirm-yes').onclick = () => {
document.body.removeChild(modal);
resolve(true);
};
modal.querySelector('.confirm-no').onclick = () => {
document.body.removeChild(modal);
resolve(false);
};
modal.onclick = (e) => {
if (e.target === modal) {
document.body.removeChild(modal);
resolve(false);
}
};
});
},
createConfirmModal(message, title) {
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content">
<h3>${this.escapeHtml(title)}</h3>
<p>${this.escapeHtml(message)}</p>
<div class="modal-actions">
<button class="btn btn-secondary confirm-no">Cancel</button>
<button class="btn btn-primary confirm-yes">Confirm</button>
</div>
</div>
`;
return modal;
},
// Copy to clipboard with feedback
async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
this.showToast('✓ Copied to clipboard', 'success', 1500);
return true;
} catch (err) {
this.showToast('❌ Failed to copy', 'error', 2000);
return false;
}
},
// Debounce helper
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
// Throttle helper
throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
},
// Escape HTML
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
// Format file size
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
},
// Format date relative
formatRelativeTime(date) {
const now = new Date();
const diffMs = now - date;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins} min ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
return date.toLocaleDateString();
},
// Vibrate if supported (for mobile feedback)
vibrate(pattern = 50) {
if ('vibrate' in navigator) {
navigator.vibrate(pattern);
}
}
};
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => Polish.init());
} else {
Polish.init();
}
// Make Polish globally available
window.Polish = Polish;

288
app/static/js/reviewer.js Normal file
View File

@@ -0,0 +1,288 @@
// NextSnap - Photo Reviewer with Swipe Navigation and Rename
'use strict';
const Reviewer = {
mode: null,
photos: [],
currentIndex: 0,
username: null,
currentPath: null,
touchStartX: 0,
touchStartY: 0,
touchEndX: 0,
touchEndY: 0,
swipeThreshold: 50,
preloadedImages: {},
isEditing: false,
originalFilename: null,
init(mode, username, photos, currentPath) {
this.mode = mode;
this.username = username;
this.photos = photos;
this.currentPath = currentPath || null;
this.currentIndex = 0;
if (this.photos.length === 0) {
this.showEmptyState();
return;
}
this.setupEventListeners();
this.displayPhoto(0);
this.preloadAdjacentPhotos(0);
this.updatePosition();
},
setupEventListeners() {
const viewer = document.getElementById('photo-viewer');
viewer.addEventListener('touchstart', (e) => {
this.touchStartX = e.changedTouches[0].screenX;
this.touchStartY = e.changedTouches[0].screenY;
}, { passive: true });
viewer.addEventListener('touchend', (e) => {
this.touchEndX = e.changedTouches[0].screenX;
this.touchEndY = e.changedTouches[0].screenY;
this.handleSwipe();
}, { passive: true });
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') this.previousPhoto();
else if (e.key === 'ArrowRight') this.nextPhoto();
else if (e.key === 'Escape') this.exit();
});
document.getElementById('prev-btn').addEventListener('click', () => this.previousPhoto());
document.getElementById('next-btn').addEventListener('click', () => this.nextPhoto());
document.getElementById('done-btn').addEventListener('click', () => this.exit());
const filenameInput = document.getElementById('filename-input');
filenameInput.addEventListener('focus', () => {
this.isEditing = true;
this.originalFilename = filenameInput.value;
});
filenameInput.addEventListener('blur', () => {
this.saveFilename();
});
filenameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
filenameInput.blur();
} else if (e.key === 'Escape') {
filenameInput.value = this.originalFilename;
filenameInput.blur();
this.isEditing = false;
}
});
viewer.addEventListener('click', (e) => {
if (this.isEditing && !filenameInput.contains(e.target)) {
filenameInput.blur();
}
});
},
handleSwipe() {
const deltaX = this.touchEndX - this.touchStartX;
const deltaY = this.touchEndY - this.touchStartY;
if (Math.abs(deltaY) > Math.abs(deltaX)) return;
if (Math.abs(deltaX) < this.swipeThreshold) return;
if (deltaX > 0) this.previousPhoto();
else this.nextPhoto();
},
async displayPhoto(index) {
if (index < 0 || index >= this.photos.length) return;
if (this.isEditing) await this.saveFilename();
this.currentIndex = index;
const photo = this.photos[index];
const img = document.getElementById('current-photo');
const spinner = document.getElementById('photo-spinner');
spinner.style.display = 'block';
img.style.display = 'none';
if (this.mode === 'local') {
img.src = URL.createObjectURL(photo.blob);
} else {
img.src = this.preloadedImages[index] || await this.fetchThumbnail(photo);
}
img.onload = () => {
spinner.style.display = 'none';
img.style.display = 'block';
};
const filenameInput = document.getElementById('filename-input');
filenameInput.value = photo.filename.replace(/\.jpg$/i, '');
this.updatePosition();
this.updateNavigationButtons();
this.preloadAdjacentPhotos(index);
},
async fetchThumbnail(photo) {
const path = this.currentPath + '/' + photo.filename;
const url = '/api/files/thumbnail?path=' + encodeURIComponent(path) + '&size=512';
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch thumbnail');
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch (error) {
console.error('Error fetching thumbnail:', error);
return '/static/icons/icon-192.png';
}
},
preloadAdjacentPhotos(index) {
const toPreload = [index - 1, index + 1];
if (this.mode === 'remote') toPreload.push(index - 2, index + 2);
toPreload.forEach(async (i) => {
if (i >= 0 && i < this.photos.length && !this.preloadedImages[i]) {
const photo = this.photos[i];
if (this.mode === 'local') {
this.preloadedImages[i] = URL.createObjectURL(photo.blob);
} else {
this.preloadedImages[i] = await this.fetchThumbnail(photo);
}
}
});
},
updatePosition() {
const position = document.getElementById('photo-position');
position.textContent = (this.currentIndex + 1) + ' / ' + this.photos.length;
},
updateNavigationButtons() {
document.getElementById('prev-btn').disabled = this.currentIndex === 0;
document.getElementById('next-btn').disabled = this.currentIndex === this.photos.length - 1;
},
previousPhoto() {
if (this.currentIndex > 0) this.displayPhoto(this.currentIndex - 1);
},
nextPhoto() {
if (this.currentIndex < this.photos.length - 1) this.displayPhoto(this.currentIndex + 1);
},
async saveFilename() {
if (!this.isEditing) return;
const filenameInput = document.getElementById('filename-input');
const newBasename = filenameInput.value.trim();
const photo = this.photos[this.currentIndex];
const currentBasename = photo.filename.replace(/\.jpg$/i, '');
if (newBasename === currentBasename) {
this.isEditing = false;
return;
}
const validation = this.validateFilename(newBasename);
if (!validation.valid) {
this.showError(validation.error);
filenameInput.value = currentBasename;
this.isEditing = false;
return;
}
const newFilename = newBasename + '.jpg';
const saveSpinner = document.getElementById('save-spinner');
saveSpinner.style.display = 'inline-block';
filenameInput.disabled = true;
try {
if (this.mode === 'local') {
await Storage.updatePhoto(photo.id, { filename: newFilename });
photo.filename = newFilename;
console.log('Updated local filename to: ' + newFilename);
this.showSuccess('Filename updated');
} else {
if (!navigator.onLine) throw new Error('Offline - cannot rename remote files');
const oldPath = this.currentPath + '/' + photo.filename;
const newPath = this.currentPath + '/' + newFilename;
const response = await fetch('/api/files/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourcePath: oldPath, destPath: newPath })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Rename failed');
}
photo.filename = newFilename;
console.log('Renamed remote file to: ' + newFilename);
this.showSuccess('File renamed on server');
}
} catch (error) {
console.error('Error saving filename:', error);
this.showError(error.message);
filenameInput.value = currentBasename;
} finally {
saveSpinner.style.display = 'none';
filenameInput.disabled = false;
this.isEditing = false;
}
},
validateFilename(filename) {
if (!filename || filename.length === 0) {
return { valid: false, error: 'Filename cannot be empty' };
}
const invalidChars = /[\/\\?*"|<>:]/;
if (invalidChars.test(filename)) {
return { valid: false, error: 'Invalid characters in filename' };
}
if (filename.length > 200) {
return { valid: false, error: 'Filename too long (max 200 characters)' };
}
return { valid: true };
},
showError(message) {
const toast = document.getElementById('toast');
toast.textContent = '❌ ' + message;
toast.className = 'toast error';
toast.style.display = 'block';
setTimeout(() => { toast.style.display = 'none'; }, 3000);
},
showSuccess(message) {
const toast = document.getElementById('toast');
toast.textContent = '✓ ' + message;
toast.className = 'toast success';
toast.style.display = 'block';
setTimeout(() => { toast.style.display = 'none'; }, 2000);
},
showEmptyState() {
const viewer = document.getElementById('photo-viewer');
viewer.innerHTML = '<div class="empty-state"><p>No photos to review</p><button class="btn btn-primary" onclick="history.back()">Go Back</button></div>';
},
exit() {
if (this.isEditing) this.saveFilename();
Object.values(this.preloadedImages).forEach(url => URL.revokeObjectURL(url));
history.back();
}
};
window.Reviewer = Reviewer;

152
app/static/js/storage.js Normal file
View File

@@ -0,0 +1,152 @@
// NextSnap - IndexedDB Storage using Dexie.js
'use strict';
const Storage = {
db: null,
init() {
// Initialize Dexie database
this.db = new Dexie('nextsnap');
// Define schema with compound indexes
this.db.version(1).stores({
photos: '++id, username, timestamp, filename, targetPath, status, [username+status], [username+timestamp]',
settings: '++id, username, [username+key]'
});
return this.db.open();
},
async savePhoto(photoData) {
/**
* Save a photo to IndexedDB
* photoData: {
* username: string,
* timestamp: number,
* filename: string,
* targetPath: string,
* blob: Blob,
* status: 'pending' | 'uploading' | 'uploaded' | 'verified'
* retryCount: number,
* lastError: string
* }
*/
const id = await this.db.photos.add({
username: photoData.username,
timestamp: photoData.timestamp,
filename: photoData.filename,
targetPath: photoData.targetPath,
blob: photoData.blob,
status: photoData.status || 'pending',
retryCount: photoData.retryCount || 0,
lastError: photoData.lastError || null
});
return id;
},
async getPhoto(id) {
return await this.db.photos.get(id);
},
async getAllPhotos(username = null) {
if (username) {
return await this.db.photos
.where('username').equals(username)
.reverse()
.sortBy('timestamp');
}
return await this.db.photos.reverse().sortBy('timestamp');
},
async getPendingPhotos(username = null) {
if (username) {
return await this.db.photos
.where('[username+status]')
.equals([username, 'pending'])
.sortBy('timestamp');
}
return await this.db.photos
.where('status').equals('pending')
.sortBy('timestamp');
},
async getRecentPhotos(username, limit = 5) {
return await this.db.photos
.where('username').equals(username)
.reverse()
.limit(limit)
.sortBy('timestamp');
},
async updatePhoto(id, updates) {
return await this.db.photos.update(id, updates);
},
async deletePhoto(id) {
return await this.db.photos.delete(id);
},
async getPhotoCount(username = null, status = null) {
let collection = this.db.photos;
if (username && status) {
return await collection
.where('[username+status]')
.equals([username, status])
.count();
} else if (username) {
return await collection
.where('username').equals(username)
.count();
} else if (status) {
return await collection
.where('status').equals(status)
.count();
}
return await collection.count();
},
async saveSetting(username, key, value) {
// Check if setting exists
const existing = await this.db.settings
.where('[username+key]')
.equals([username, key])
.first();
if (existing) {
await this.db.settings.update(existing.id, { value: value });
} else {
await this.db.settings.add({
username: username,
key: key,
value: value
});
}
},
async getSetting(username, key, defaultValue = null) {
const setting = await this.db.settings
.where('[username+key]')
.equals([username, key])
.first();
return setting ? setting.value : defaultValue;
},
async clearVerifiedPhotos(username = null) {
if (username) {
return await this.db.photos
.where('[username+status]')
.equals([username, 'verified'])
.delete();
}
return await this.db.photos
.where('status').equals('verified')
.delete();
}
};
// Make Storage globally available
window.Storage = Storage;

300
app/static/js/sync.js Normal file
View File

@@ -0,0 +1,300 @@
// NextSnap - Sync Engine
'use strict';
console.log('[SYNC] Loading sync.js...');
const Sync = {
currentUsername: null,
isOnline: navigator.onLine,
isSyncing: false,
MAX_RETRIES: 5,
VERIFY_DELAY_MS: 800,
init(username) {
this.currentUsername = username;
this.setupEventListeners();
console.log('[SYNC] Initialized for:', username);
if (this.isOnline) {
this.triggerSync();
}
},
setupEventListeners() {
window.addEventListener('online', () => {
console.log('[SYNC] Network online');
this.isOnline = true;
this.triggerSync();
});
window.addEventListener('offline', () => {
console.log('[SYNC] Network offline');
this.isOnline = false;
this.isSyncing = false;
});
},
// Prevent page navigation during active upload
_setUploading(active) {
if (active) {
this._beforeUnloadHandler = (e) => {
e.preventDefault();
e.returnValue = 'Upload in progress - leaving will cancel it.';
return e.returnValue;
};
window.addEventListener('beforeunload', this._beforeUnloadHandler);
} else {
if (this._beforeUnloadHandler) {
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
this._beforeUnloadHandler = null;
}
}
},
async triggerSync() {
if (!this.isOnline || this.isSyncing) {
console.log('[SYNC] Skip sync - online:', this.isOnline, 'syncing:', this.isSyncing);
return;
}
console.log('[SYNC] Starting sync...');
this.isSyncing = true;
try {
await this.processQueue();
} catch (error) {
console.error('[SYNC] Error:', error);
} finally {
this.isSyncing = false;
this._setUploading(false);
}
},
async processQueue() {
const pendingPhotos = await Storage.db.photos
.where('username').equals(this.currentUsername)
.and(photo => photo.status === 'pending' || photo.status === 'uploading')
.sortBy('timestamp');
console.log('[SYNC] Found', pendingPhotos.length, 'photos to process');
if (pendingPhotos.length === 0) {
return;
}
for (const photo of pendingPhotos) {
if (!this.isOnline) {
console.log('[SYNC] Lost connection, stopping');
break;
}
// Skip photos that have exceeded max retries
const retryCount = photo.retryCount || 0;
if (retryCount >= this.MAX_RETRIES) {
console.warn('[SYNC] Skipping photo (max retries reached):', photo.filename, 'retries:', retryCount);
await Storage.updatePhoto(photo.id, { status: 'failed' });
continue;
}
await this.uploadPhoto(photo);
}
},
async uploadPhoto(photo) {
const retryCount = photo.retryCount || 0;
try {
console.log('[SYNC] Uploading:', photo.filename, '(attempt', retryCount + 1, 'of', this.MAX_RETRIES + ')');
// Validate blob before attempting upload
if (!photo.blob || !(photo.blob instanceof Blob) || photo.blob.size === 0) {
throw new Error('Photo data is missing or corrupted - please delete and re-capture');
}
await Storage.updatePhoto(photo.id, { status: 'uploading' });
// Prevent page navigation during upload
this._setUploading(true);
// Only check for duplicates on retries (skip on first attempt to reduce latency)
if (retryCount > 0) {
const fullPath = photo.targetPath.replace(/\/$/, '') + '/' + photo.filename;
const alreadyExists = await this.checkFileExists(fullPath);
if (alreadyExists) {
console.log('[SYNC] File already exists on server, skipping upload:', fullPath);
await Storage.updatePhoto(photo.id, {
status: 'verified',
blob: null,
completedAt: Date.now()
});
await this.pruneHistory();
this._setUploading(false);
return;
}
}
// Resize if over 10MB
let uploadBlob = photo.blob;
if (uploadBlob.size > 10 * 1024 * 1024) {
console.log('[SYNC] Photo too large (' + (uploadBlob.size / 1024 / 1024).toFixed(1) + 'MB), resizing...');
uploadBlob = await this.resizeImage(uploadBlob, 10 * 1024 * 1024);
console.log('[SYNC] Resized to ' + (uploadBlob.size / 1024 / 1024).toFixed(1) + 'MB');
}
const formData = new FormData();
formData.append('file', uploadBlob, photo.filename);
const uploadUrl = '/api/files/upload?path=' + encodeURIComponent(photo.targetPath);
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
body: formData
});
if (!uploadResponse.ok) {
let errorMsg = 'Upload failed';
try {
const errData = await uploadResponse.json();
errorMsg = errData.error || errorMsg;
} catch (e) {
errorMsg = 'Upload failed (HTTP ' + uploadResponse.status + ')';
}
throw new Error(errorMsg);
}
const uploadResult = await uploadResponse.json();
console.log('[SYNC] Upload successful:', uploadResult.path);
await Storage.updatePhoto(photo.id, { status: 'uploaded' });
// Wait before verifying to allow server-side processing
await this.delay(this.VERIFY_DELAY_MS);
const verifyUrl = '/api/files/verify?path=' + encodeURIComponent(uploadResult.path);
const verifyResponse = await fetch(verifyUrl);
if (!verifyResponse.ok) {
throw new Error('Verification failed');
}
const verifyResult = await verifyResponse.json();
if (!verifyResult.exists) {
throw new Error('File not found on server');
}
console.log('[SYNC] Verified:', uploadResult.path);
// Keep record but strip blob to save storage
await Storage.updatePhoto(photo.id, {
status: 'verified',
blob: null,
completedAt: Date.now()
});
console.log('[SYNC] Upload complete:', photo.id);
// Prune old completed entries beyond 20
await this.pruneHistory();
// Clear navigation guard after successful upload
this._setUploading(false);
} catch (error) {
this._setUploading(false);
console.error('[SYNC] Upload failed:', error, '(attempt', retryCount + 1 + ')');
await Storage.updatePhoto(photo.id, {
status: 'pending',
retryCount: retryCount + 1,
lastError: error.message,
error: error.message
});
}
},
async resizeImage(blob, maxBytes) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(blob);
img.onload = () => {
URL.revokeObjectURL(url);
let quality = 0.85;
let scale = 1.0;
const attempt = () => {
const canvas = document.createElement('canvas');
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((result) => {
if (!result) {
reject(new Error('Failed to resize image'));
return;
}
if (result.size <= maxBytes || (quality <= 0.4 && scale <= 0.3)) {
resolve(result);
} else {
// Reduce quality first, then scale down
if (quality > 0.5) {
quality -= 0.1;
} else {
scale *= 0.8;
quality = 0.7;
}
attempt();
}
}, 'image/jpeg', quality);
};
attempt();
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load image for resize'));
};
img.src = url;
});
},
async pruneHistory() {
// Keep only the last 20 completed (verified/failed) entries per user
const completed = await Storage.db.photos
.where('username').equals(this.currentUsername)
.and(p => p.status === 'verified' || p.status === 'failed')
.sortBy('timestamp');
if (completed.length > 20) {
const toDelete = completed.slice(0, completed.length - 20);
for (const photo of toDelete) {
await Storage.deletePhoto(photo.id);
}
console.log('[SYNC] Pruned', toDelete.length, 'old history entries');
}
},
async checkFileExists(path) {
try {
const verifyUrl = '/api/files/verify?path=' + encodeURIComponent(path);
const response = await fetch(verifyUrl);
if (!response.ok) return false;
const result = await response.json();
return result.exists === true;
} catch (e) {
// If we can't check, assume it doesn't exist and proceed with upload
return false;
}
},
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
};
window.SyncEngine = Sync;
console.log('[SYNC] SyncEngine exported successfully');

View File

@@ -0,0 +1,242 @@
// SYNC.JS VERSION 8 - LOADING
console.log("[SYNC] Loading sync.js v8...");
// NextSnap - Sync Engine with Upload Queue and Retry Logic
'use strict';
const Sync = {
currentUsername: null,
isOnline: navigator.onLine,
isSyncing: false,
currentUpload: null,
retryTimeouts: {},
// Exponential backoff delays (in milliseconds)
retryDelays: [5000, 15000, 45000, 120000, 300000], // 5s, 15s, 45s, 2m, 5m
maxRetryDelay: 300000, // Cap at 5 minutes
init(username) {
this.currentUsername = username;
this.setupEventListeners();
// Check for pending uploads on init
if (this.isOnline) {
this.triggerSync();
}
},
setupEventListeners() {
// Listen for online/offline events
window.addEventListener('online', () => {
console.log('Network online - triggering sync');
this.isOnline = true;
this.updateConnectivityUI();
this.triggerSync();
});
window.addEventListener('offline', () => {
console.log('Network offline');
this.isOnline = false;
this.isSyncing = false;
this.updateConnectivityUI();
});
},
async triggerSync() {
if (!this.isOnline || this.isSyncing) {
return;
}
console.log('Starting sync...');
this.isSyncing = true;
this.updateConnectivityUI();
try {
await this.processQueue();
} catch (error) {
console.error('Sync error:', error);
} finally {
this.isSyncing = false;
this.updateConnectivityUI();
}
},
async processQueue() {
// Get pending and uploading photos (retry stalled uploads)
const pendingPhotos = await Storage.db.photos
.where('username').equals(this.currentUsername)
.and(photo => photo.status === 'pending' || photo.status === 'uploading')
.sortBy('timestamp');
if (pendingPhotos.length === 0) {
console.log('No pending photos to upload');
return;
}
console.log(`Found ${pendingPhotos.length} photos to upload`);
// Process uploads sequentially (one at a time)
for (const photo of pendingPhotos) {
if (!this.isOnline) {
console.log('Lost connection - stopping sync');
break;
}
await this.uploadPhoto(photo);
}
// Update UI
this.updatePendingCount();
this.updateRecentPhotos();
},
async uploadPhoto(photo) {
this.currentUpload = photo;
try {
console.log(`Uploading ${photo.filename}...`);
// Update status to uploading
await Storage.updatePhoto(photo.id, { status: 'uploading' });
this.updatePendingCount();
// Upload file
const formData = new FormData();
formData.append('file', photo.blob, photo.filename);
const uploadUrl = `/api/files/upload?path=${encodeURIComponent(photo.targetPath)}`;
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
body: formData
});
if (!uploadResponse.ok) {
const error = await uploadResponse.json();
throw new Error(error.error || 'Upload failed');
}
const uploadResult = await uploadResponse.json();
console.log(`Upload successful: ${uploadResult.path}`);
// Update status to uploaded
await Storage.updatePhoto(photo.id, { status: 'uploaded' });
// Verify file exists on server
const verifyUrl = `/api/files/verify?path=${encodeURIComponent(uploadResult.path)}`;
const verifyResponse = await fetch(verifyUrl);
if (!verifyResponse.ok) {
throw new Error('Verification failed - file not found on server');
}
const verifyResult = await verifyResponse.json();
if (!verifyResult.exists) {
throw new Error('Verification failed - file does not exist');
}
console.log(`Verification successful: ${uploadResult.path}`);
// Update status to verified
await Storage.updatePhoto(photo.id, { status: 'verified' });
// Delete from IndexedDB (only after verification!)
await Storage.deletePhoto(photo.id);
console.log(`Deleted photo ${photo.id} from IndexedDB`);
// Clear any pending retry
if (this.retryTimeouts[photo.id]) {
clearTimeout(this.retryTimeouts[photo.id]);
delete this.retryTimeouts[photo.id];
}
} catch (error) {
console.error(`Error uploading ${photo.filename}:`, error);
// Handle upload failure
await this.handleUploadFailure(photo, error.message);
} finally {
this.currentUpload = null;
}
},
async handleUploadFailure(photo, errorMessage) {
const retryCount = (photo.retryCount || 0) + 1;
// Update photo with error info
await Storage.updatePhoto(photo.id, {
status: 'pending',
retryCount: retryCount,
lastError: errorMessage
});
// Calculate retry delay using exponential backoff
const delayIndex = Math.min(retryCount - 1, this.retryDelays.length - 1);
const delay = this.retryDelays[delayIndex];
console.log(`Scheduling retry #${retryCount} in ${delay / 1000}s for ${photo.filename}`);
// Schedule retry
if (this.retryTimeouts[photo.id]) {
clearTimeout(this.retryTimeouts[photo.id]);
}
this.retryTimeouts[photo.id] = setTimeout(() => {
delete this.retryTimeouts[photo.id];
if (this.isOnline) {
console.log(`Retrying upload for ${photo.filename}`);
this.uploadPhoto(photo);
}
}, delay);
},
async updateRecentPhotos() {
if (typeof Camera !== 'undefined' && Camera.updateRecentPhotos) {
await Camera.updateRecentPhotos();
}
},
updateConnectivityUI() {
const indicator = document.querySelector(".connectivity-indicator");
if (!indicator) return;
indicator.classList.remove("online", "offline", "syncing");
if (!this.isOnline) {
indicator.classList.add("offline");
indicator.title = "Offline";
} else if (this.isSyncing) {
indicator.classList.add("syncing");
indicator.title = "Syncing...";
} else {
indicator.classList.add("online");
indicator.title = "Online";
}
},
async updatePendingCount() {
if (!this.currentUsername) return;
const countElement = document.getElementById("pendingCount");
const countValueElement = document.getElementById("pendingCountValue");
if (!countElement || !countValueElement) return;
const count = await Storage.getPhotoCount(this.currentUsername, "pending");
if (count > 0) {
countValueElement.textContent = count;
countElement.style.display = "block";
} else {
countElement.style.display = "none";
}
},
},
getState() {
return {
isOnline: this.isOnline,
isSyncing: this.isSyncing,
};
}
// Make Sync globally available as SyncEngine
window.SyncEngine = Sync;
console.log("[SYNC] SyncEngine exported:", typeof window.SyncEngine);

View File

@@ -0,0 +1,6 @@
async updateRecentPhotos() {
// Delegate to Camera module if available
if (typeof Camera !== 'undefined' && Camera.updateRecentPhotos) {
await Camera.updateRecentPhotos();
}
},

2
app/static/lib/dexie.min.js vendored Normal file

File diff suppressed because one or more lines are too long

27
app/static/manifest.json Normal file
View File

@@ -0,0 +1,27 @@
{
name: NextSnap,
short_name: NextSnap,
description: Offline-first photo capture for Nextcloud,
start_url: /,
display: standalone,
orientation: portrait,
background_color: #1a1a2e,
theme_color: #16213e,
icons: [
{
src: /static/icons/icon-192.png,
sizes: 192x192,
type: image/png,
purpose: any maskable
},
{
src: /static/icons/icon-512.png,
sizes: 512x512,
type: image/png,
purpose: any maskable
}
],
categories: [productivity, photo],
scope: /,
prefer_related_applications: false
}

171
app/static/sw.js Normal file
View File

@@ -0,0 +1,171 @@
// NextSnap Service Worker
// Provides offline-first caching for the app shell
const CACHE_VERSION = 'nextsnap-v13';
const APP_SHELL_CACHE = 'nextsnap-shell-v9';
const RUNTIME_CACHE = 'nextsnap-runtime-v9';
// Assets to cache on install
const APP_SHELL_ASSETS = [
'/',
'/static/css/style.css',
'/static/js/app.js',
'/static/js/auth.js',
'/static/js/camera.js',
'/static/js/storage.js',
'/static/js/sync.js',
'/static/js/filebrowser.js',
'/static/lib/dexie.min.js',
'/static/manifest.json',
'/static/icons/icon-192.png',
'/static/icons/icon-512.png'
];
// Install event - precache app shell
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker...');
event.waitUntil(
caches.open(APP_SHELL_CACHE)
.then((cache) => {
console.log('[SW] Caching app shell');
return cache.addAll(APP_SHELL_ASSETS);
})
.then(() => {
console.log('[SW] App shell cached successfully');
return self.skipWaiting(); // Activate immediately
})
.catch((error) => {
console.error('[SW] Failed to cache app shell:', error);
})
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker...');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== APP_SHELL_CACHE && cacheName !== RUNTIME_CACHE) {
console.log('[SW] Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
.then(() => {
console.log('[SW] Service worker activated');
return self.clients.claim(); // Take control immediately
})
);
});
// Fetch event - cache-first for static assets, network-first for API
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// API requests - network-first with offline fallback
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(request)
.then((response) => {
// Clone response for cache
const responseClone = response.clone();
// Only cache successful responses (not errors or auth failures)
if (response.status === 200) {
caches.open(RUNTIME_CACHE).then((cache) => {
// Don't cache file uploads or large responses
if (!url.pathname.includes('/upload') &&
!url.pathname.includes('/thumbnail')) {
cache.put(request, responseClone);
}
});
}
return response;
})
.catch(() => {
// Network failed - try cache, then offline response
return caches.match(request)
.then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
// Return offline fallback for API
return new Response(
JSON.stringify({
error: 'offline',
message: 'You are offline. This feature requires connectivity.'
}),
{
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'application/json' }
}
);
});
})
);
return;
}
// Static assets - cache-first strategy
event.respondWith(
caches.match(request)
.then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
// Not in cache - fetch from network and cache it
return fetch(request)
.then((response) => {
// Don't cache non-successful responses
if (!response || response.status !== 200 || response.type === 'error') {
return response;
}
// Clone response for cache
const responseClone = response.clone();
caches.open(RUNTIME_CACHE).then((cache) => {
cache.put(request, responseClone);
});
return response;
})
.catch(() => {
// Network failed and not in cache
// For HTML pages, could return offline page here
return new Response('Offline', { status: 503 });
});
})
);
});
// Listen for messages from clients
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
if (event.data && event.data.type === 'CLEAR_CACHE') {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => caches.delete(cacheName))
);
})
);
}
});

369
app/templates/admin.html Normal file
View File

@@ -0,0 +1,369 @@
{% extends "base.html" %}
{% block title %}Admin - NextSnap{% endblock %}
{% block content %}
<div class="container">
<div class="admin-header">
<h2>Admin Panel</h2>
<button class="btn btn-secondary btn-small" id="refresh-btn">
<span>🔄</span> Refresh
</button>
</div>
<!-- Add User Form -->
<div class="admin-section">
<h3>Add New User</h3>
<form id="add-user-form" class="user-form">
<div class="form-row">
<div class="form-group">
<label for="new-username">Username *</label>
<input type="text" id="new-username" required placeholder="username">
</div>
<div class="form-group">
<label for="new-password">Password *</label>
<input type="password" id="new-password" required placeholder="••••••••">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="new-email">Email</label>
<input type="email" id="new-email" placeholder="user@example.com">
</div>
<div class="form-group">
<label for="new-displayname">Display Name</label>
<input type="text" id="new-displayname" placeholder="John Doe">
</div>
</div>
<div class="form-error" id="form-error" style="display: none;"></div>
<div class="form-success" id="form-success" style="display: none;"></div>
<button type="submit" class="btn btn-primary" id="submit-btn">Create User</button>
</form>
</div>
<!-- User List -->
<div class="admin-section">
<h3>Nextcloud Users</h3>
<div class="loading-msg" id="loading-msg" style="display: none;">Loading users...</div>
<div class="error-msg" id="error-msg" style="display: none;"></div>
<div class="table-container">
<table class="user-table">
<thead>
<tr>
<th>Username</th>
<th>Display Name</th>
<th>Email</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="user-list">
<tr><td colspan="5" class="empty-state">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal" id="delete-modal" style="display: none;">
<div class="modal-content">
<h3>Delete User?</h3>
<p>Are you sure you want to delete user <strong id="delete-username"></strong>?</p>
<p class="warning-text">⚠️ This action cannot be undone. All user data will be permanently deleted.</p>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="Admin.hideDeleteModal()">Cancel</button>
<button class="btn btn-danger" id="confirm-delete">Delete User</button>
</div>
</div>
</div>
<!-- Toast Notification -->
<div id="toast" class="toast" style="display: none;"></div>
{% endblock %}
{% block extra_css %}
<style>
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid var(--bg-tertiary);
margin-bottom: 2rem;
}
.admin-header h2 {
margin: 0;
}
.admin-section {
background: var(--bg-secondary);
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.admin-section h3 {
margin-top: 0;
margin-bottom: 1.5rem;
font-size: 1.2rem;
}
.user-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
margin-bottom: 0.5rem;
font-weight: 600;
font-size: 0.9rem;
}
.form-group input {
padding: 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--bg-tertiary);
border-radius: 6px;
color: var(--text-primary);
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.form-error {
background: var(--error);
color: white;
padding: 0.75rem;
border-radius: 6px;
font-size: 0.9rem;
}
.form-success {
background: var(--success);
color: white;
padding: 0.75rem;
border-radius: 6px;
font-size: 0.9rem;
}
.loading-msg,
.error-msg {
padding: 1rem;
text-align: center;
font-size: 0.9rem;
}
.error-msg {
background: rgba(244, 67, 54, 0.1);
color: var(--error);
border-radius: 6px;
}
.table-container {
overflow-x: auto;
}
.user-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.user-table thead {
background: var(--bg-tertiary);
}
.user-table th {
padding: 0.75rem;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
}
.user-table td {
padding: 0.75rem;
border-top: 1px solid var(--bg-tertiary);
}
.user-table tr:hover {
background: var(--bg-tertiary);
}
.empty-state {
text-align: center;
color: var(--text-secondary);
padding: 2rem !important;
}
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 600;
}
.badge-success {
background: var(--success);
color: white;
}
.badge-danger {
background: var(--error);
color: white;
}
.action-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn-action {
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
transition: opacity 0.2s;
}
.btn-action:active {
opacity: 0.7;
}
.btn-warning {
background: var(--warning);
color: var(--bg-primary);
}
.btn-success {
background: var(--success);
color: white;
}
.btn-danger {
background: var(--error);
color: white;
}
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1rem;
}
.modal-content {
background: var(--bg-secondary);
padding: 1.5rem;
border-radius: 12px;
max-width: 400px;
width: 100%;
}
.modal-content h3 {
margin-top: 0;
margin-bottom: 1rem;
}
.modal-content p {
margin-bottom: 1rem;
color: var(--text-secondary);
}
.warning-text {
color: var(--warning) !important;
font-weight: 600;
}
.modal-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
}
.modal-actions .btn {
flex: 1;
}
.toast {
position: fixed;
bottom: 8rem;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 1rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
z-index: 10000;
max-width: 90%;
text-align: center;
}
.toast.success {
background: var(--success);
}
.toast.error {
background: var(--error);
}
@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
}
.user-table {
font-size: 0.85rem;
}
.user-table th,
.user-table td {
padding: 0.5rem;
}
}
</style>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
<script>
Admin.init();
// Close delete modal on outside click
document.getElementById('delete-modal').addEventListener('click', (e) => {
if (e.target.id === 'delete-modal') {
Admin.hideDeleteModal();
}
});
</script>
{% endblock %}

102
app/templates/base.html Normal file
View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#16213e">
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<!-- iOS PWA Support -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="NextSnap">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='icons/icon-192.png') }}">
<title>{% block title %}NextSnap{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- Top Bar -->
<header class="top-bar">
<div class="top-bar-content">
<h1 class="app-title">NextSnap</h1>
<div class="top-bar-indicators">
<div class="connectivity-indicator online" title="Online"></div>
<div class="pending-count" id="pendingCount" style="display: none;">
<span id="pendingCountValue">0</span>
</div>
</div>
</div>
</header>
<!-- Main App Content -->
<main id="app">
{% block content %}{% endblock %}
</main>
<!-- Bottom Navigation (for authenticated pages) -->
{% if show_nav %}
<nav class="bottom-nav">
<a href="/capture" class="nav-item {% if request.path == '/capture' %}active{% endif %}">
<span class="nav-icon">📷</span>
<span class="nav-label">Capture</span>
</a>
<a href="/queue" class="nav-item {% if request.path == '/queue' %}active{% endif %}">
<span class="nav-icon">📤</span>
<span class="nav-label">Queue</span>
</a>
<a href="/browser" class="nav-item {% if request.path == '/browser' %}active{% endif %}">
<span class="nav-icon">📁</span>
<span class="nav-label">Files</span>
</a>
{% if is_admin %}
<a href="/admin" class="nav-item {% if request.path == '/admin' %}active{% endif %}">
<span class="nav-icon">⚙️</span>
<span class="nav-label">Admin</span>
</a>
{% endif %}
</nav>
{% endif %}
<!-- Service Worker Registration -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/sw.js')
.then((registration) => {
console.log('Service Worker registered:', registration.scope);
// Check for updates periodically
setInterval(() => {
registration.update();
}, 60000); // Check every minute
// Handle updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New service worker available
console.log('New service worker available');
// Could show update notification here
}
});
});
})
.catch((error) => {
console.error('Service Worker registration failed:', error);
});
});
} else {
console.warn('Service Workers not supported in this browser');
}
</script>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script src="{{ url_for('static', filename='js/polish.js') }}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

484
app/templates/browser.html Normal file
View File

@@ -0,0 +1,484 @@
{% extends "base.html" %}
{% block title %}Files - NextSnap{% endblock %}
{% block content %}
<div class="container">
<div class="header">
<h1>File Browser</h1>
<button class="btn btn-primary btn-small" id="new-folder-btn">+ New Folder</button>
</div>
<div class="breadcrumb" id="breadcrumb">
<span class="breadcrumb-item">Loading...</span>
</div>
<div class="file-list" id="file-list">
<p class="loading">Loading...</p>
</div>
</div>
<!-- Gallery Overlay -->
<div class="gallery-overlay" id="gallery-overlay" style="display:none">
<div class="gallery-header">
<button class="gallery-btn gallery-close-btn" id="gallery-close-btn">&times;</button>
<span class="gallery-counter" id="gallery-counter">1 / 1</span>
<button class="gallery-btn gallery-rename-btn" id="gallery-rename-btn">Rename</button>
</div>
<div class="gallery-image-container" id="gallery-image-container">
<div class="gallery-spinner" id="gallery-spinner">
<div class="spinner"></div>
</div>
<img class="gallery-image" id="gallery-image" alt="" />
</div>
<button class="gallery-nav gallery-nav-left" id="gallery-prev-btn">&#8249;</button>
<button class="gallery-nav gallery-nav-right" id="gallery-next-btn">&#8250;</button>
<div class="gallery-filename-bar" id="gallery-filename-bar">
<span id="gallery-filename"></span>
</div>
<!-- Gallery toast -->
<div class="gallery-toast" id="gallery-toast"></div>
</div>
<!-- Rename Modal -->
<div class="gallery-rename-backdrop" id="gallery-rename-backdrop" style="display:none">
<div class="gallery-rename-modal">
<h3>Rename File</h3>
<div class="gallery-rename-input-row">
<input type="text" id="gallery-rename-input" class="gallery-rename-input" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
<span class="gallery-rename-ext" id="gallery-rename-ext">.jpg</span>
</div>
<div class="gallery-rename-error" id="gallery-rename-error"></div>
<div class="gallery-rename-buttons">
<button class="btn gallery-rename-cancel" id="gallery-rename-cancel">Cancel</button>
<button class="btn btn-primary gallery-rename-save" id="gallery-rename-save">Save</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid var(--bg-tertiary);
margin-bottom: 1rem;
}
.btn-small {
padding: 0.5rem 1rem;
font-size: 0.9rem;
min-height: auto;
}
.breadcrumb {
padding: 0.75rem 0;
font-size: 0.9rem;
color: var(--text-secondary);
overflow-x: auto;
white-space: nowrap;
}
.breadcrumb-item {
color: var(--accent);
text-decoration: none;
cursor: pointer;
}
.breadcrumb-item:hover {
text-decoration: underline;
}
.breadcrumb-item.current {
color: var(--text-primary);
cursor: default;
}
.breadcrumb-item.current:hover {
text-decoration: none;
}
.file-list {
margin-bottom: 5rem;
}
.file-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.file-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--bg-secondary);
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.file-item:hover {
background: var(--bg-tertiary);
}
.file-item:active {
transform: scale(0.98);
}
.file-icon {
font-size: 2rem;
flex-shrink: 0;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 0.85rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
.chevron {
font-size: 1.5rem;
color: var(--text-secondary);
}
.btn-select-folder {
margin-top: 1rem;
position: sticky;
bottom: 5rem;
}
.loading,
.empty-state,
.error-state {
text-align: center;
color: var(--text-secondary);
padding: 2rem 1rem;
}
.error-state {
color: var(--error);
}
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-width: 480px;
margin: 0 auto;
background: var(--bg-secondary);
border-top: 1px solid var(--bg-tertiary);
display: flex;
justify-content: space-around;
padding: 0.5rem 0;
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.5rem;
color: var(--text-secondary);
text-decoration: none;
}
.nav-item.active {
color: var(--accent);
}
.nav-icon {
font-size: 1.5rem;
}
.nav-label {
font-size: 0.75rem;
}
/* Gallery Overlay */
body.gallery-open {
overflow: hidden;
}
.gallery-overlay {
position: fixed;
inset: 0;
background: #000;
z-index: 9999;
display: flex;
flex-direction: column;
}
.gallery-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
padding-top: max(0.75rem, env(safe-area-inset-top));
background: rgba(0,0,0,0.7);
z-index: 2;
flex-shrink: 0;
}
.gallery-btn {
background: none;
border: none;
color: #fff;
font-size: 1rem;
padding: 0.5rem;
min-width: 48px;
min-height: 48px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.gallery-close-btn {
font-size: 2rem;
line-height: 1;
}
.gallery-counter {
color: #fff;
font-size: 1rem;
font-weight: 500;
}
.gallery-rename-btn {
font-size: 0.95rem;
color: var(--accent, #4dabf7);
}
.gallery-image-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
touch-action: pan-y;
}
.gallery-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
}
.gallery-spinner {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
}
.gallery-spinner .spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255,255,255,0.2);
border-top-color: #fff;
border-radius: 50%;
animation: gallery-spin 0.8s linear infinite;
}
@keyframes gallery-spin {
to { transform: rotate(360deg); }
}
.gallery-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0,0,0,0.4);
border: none;
color: #fff;
font-size: 2.5rem;
padding: 1rem 0.5rem;
min-width: 48px;
min-height: 48px;
cursor: pointer;
z-index: 3;
border-radius: 4px;
line-height: 1;
}
.gallery-nav:disabled {
opacity: 0.2;
cursor: default;
}
.gallery-nav-left {
left: 0.5rem;
}
.gallery-nav-right {
right: 0.5rem;
}
.gallery-filename-bar {
padding: 0.75rem 1rem;
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
background: rgba(0,0,0,0.7);
color: #ccc;
font-size: 0.9rem;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
z-index: 2;
}
.gallery-toast {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.9rem;
z-index: 10001;
display: none;
white-space: nowrap;
}
.gallery-toast.success {
background: rgba(40, 167, 69, 0.9);
color: #fff;
}
.gallery-toast.error {
background: rgba(220, 53, 69, 0.9);
color: #fff;
}
/* Rename Modal */
.gallery-rename-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.gallery-rename-modal {
background: var(--bg-secondary, #2a2a3e);
border-radius: 12px;
padding: 1.5rem;
width: 100%;
max-width: 360px;
}
.gallery-rename-modal h3 {
margin: 0 0 1rem;
color: var(--text-primary, #fff);
font-size: 1.1rem;
}
.gallery-rename-input-row {
display: flex;
align-items: center;
gap: 0;
margin-bottom: 0.5rem;
}
.gallery-rename-input {
flex: 1;
padding: 0.75rem;
border: 1px solid var(--bg-tertiary, #444);
border-radius: 8px 0 0 8px;
background: var(--bg-primary, #1a1a2e);
color: var(--text-primary, #fff);
font-size: 1rem;
outline: none;
}
.gallery-rename-input:focus {
border-color: var(--accent, #4dabf7);
}
.gallery-rename-ext {
padding: 0.75rem 0.75rem;
background: var(--bg-tertiary, #444);
color: var(--text-secondary, #aaa);
border: 1px solid var(--bg-tertiary, #444);
border-left: none;
border-radius: 0 8px 8px 0;
font-size: 1rem;
white-space: nowrap;
}
.gallery-rename-error {
color: var(--error, #ff6b6b);
font-size: 0.85rem;
min-height: 1.25rem;
margin-bottom: 0.5rem;
}
.gallery-rename-buttons {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.gallery-rename-cancel,
.gallery-rename-save {
padding: 0.6rem 1.25rem;
border-radius: 8px;
font-size: 0.95rem;
cursor: pointer;
min-height: 44px;
}
.gallery-rename-cancel {
background: var(--bg-tertiary, #444);
color: var(--text-primary, #fff);
border: none;
}
.gallery-rename-save {
min-width: 80px;
}
</style>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/filebrowser.js') }}"></script>
<script>
document.getElementById('logout-btn').addEventListener('click', async (e) => {
e.preventDefault();
if (confirm('Are you sure you want to logout?')) {
await Auth.logout();
}
});
</script>
{% endblock %}

407
app/templates/capture.html Normal file
View File

@@ -0,0 +1,407 @@
{% extends "base.html" %}
{% block title %}Capture - NextSnap{% endblock %}
{% block content %}
<div class="container">
<div class="capture-section">
<p class="upload-path">
Uploading to: <strong><span id="current-path">/</span></strong>
<a href="/browser" class="change-link">Change</a>
</p>
<button class="btn btn-primary btn-capture" id="capture-btn">
📷 Take Photo
</button>
<input
type="file"
id="camera-input"
accept="image/*"
capture="environment"
style="display: none;"
>
<!-- Session counter shown during continuous capture -->
<div class="capture-session-bar" id="capture-session-bar" style="display:none">
<span id="session-count">0</span> photos this session
</div>
</div>
<div class="recent-photos" id="recent-photos">
<h3>Recent Photos</h3>
<div class="photo-thumbnails" id="photo-thumbnails">
<p class="empty-state">No photos captured yet</p>
</div>
</div>
<!-- Capture toast -->
<div class="capture-toast" id="capture-toast"></div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.capture-section {
text-align: center;
margin: 2rem 0;
}
.upload-path {
margin-bottom: 1.5rem;
color: var(--text-secondary);
}
.upload-path strong {
color: var(--text-primary);
}
.change-link {
color: var(--accent);
margin-left: 0.5rem;
text-decoration: none;
}
.change-link:hover {
text-decoration: underline;
}
.btn-capture {
font-size: 1.25rem;
padding: 1.5rem;
margin: 1rem 0;
}
.btn-capture:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.capture-session-bar {
margin-top: 0.75rem;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border-radius: 8px;
color: var(--accent);
font-weight: 500;
font-size: 0.95rem;
display: inline-block;
}
.capture-toast {
position: fixed;
top: 70px;
left: 50%;
transform: translateX(-50%);
padding: 0.6rem 1.25rem;
border-radius: 8px;
font-size: 0.9rem;
z-index: 9998;
display: none;
white-space: nowrap;
background: rgba(40, 167, 69, 0.9);
color: #fff;
pointer-events: none;
}
.recent-photos {
margin-top: 3rem;
padding-bottom: 2rem;
}
.recent-photos h3 {
font-size: 1.1rem;
margin-bottom: 1rem;
}
.photo-thumbnails {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 0.5rem;
}
.photo-thumbnails .empty-state {
grid-column: 1 / -1;
text-align: center;
color: var(--text-secondary);
padding: 2rem 1rem;
font-size: 0.9rem;
}
.thumbnail {
aspect-ratio: 1;
background: var(--bg-secondary);
border-radius: 8px;
overflow: hidden;
position: relative;
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail .status-badge {
position: absolute;
top: 4px;
right: 4px;
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid var(--bg-primary);
}
.thumbnail .status-badge.pending {
background: var(--warning);
}
.thumbnail .status-badge.uploading {
background: var(--accent);
animation: pulse 1s infinite;
}
.thumbnail .status-badge.verified {
background: var(--success);
}
</style>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='lib/dexie.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/storage.js') }}"></script>
<script src="{{ url_for('static', filename='js/sync.js') }}"></script>
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
<script>
const currentUsername = '{{ username }}';
function updateUploadPath() {
const savedPath = localStorage.getItem('nextsnap_upload_path') || '/';
document.getElementById('current-path').textContent = savedPath;
}
updateUploadPath();
let storageReady = false;
Storage.init().then(() => {
storageReady = true;
SyncEngine.init(currentUsername);
loadRecentPhotos();
}).catch(err => console.error('Storage init failed:', err));
const captureBtn = document.getElementById('capture-btn');
const cameraInput = document.getElementById('camera-input');
// Continuous capture state
let sessionCount = 0;
let continuousCapture = false;
let processingPhoto = false;
captureBtn.addEventListener('click', function() {
sessionCount = 0;
updateSessionCounter();
cameraInput.click();
});
cameraInput.addEventListener('change', async function(e) {
const file = e.target.files[0];
if (!file) {
// User cancelled the camera — stop continuous capture
continuousCapture = false;
if (sessionCount > 0) {
showCaptureToast(sessionCount + ' photo' + (sessionCount !== 1 ? 's' : '') + ' captured');
}
return;
}
processingPhoto = true;
captureBtn.disabled = true;
captureBtn.textContent = '⏳ Processing...';
try {
const jpegBlob = await convertToJPEG(file);
const now = new Date();
const timestamp = now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0') + '_' +
String(now.getHours()).padStart(2, '0') +
String(now.getMinutes()).padStart(2, '0') +
String(now.getSeconds()).padStart(2, '0');
const filename = currentUsername + '_' + timestamp + '.jpg';
const targetPath = localStorage.getItem('nextsnap_upload_path') || '/';
if (storageReady) {
await Storage.savePhoto({
username: currentUsername,
timestamp: Date.now(),
filename: filename,
targetPath: targetPath,
blob: jpegBlob,
status: 'pending'
});
if (navigator.onLine && typeof SyncEngine !== 'undefined') {
SyncEngine.triggerSync();
}
loadRecentPhotos();
}
sessionCount++;
continuousCapture = true;
updateSessionCounter();
showCaptureToast('Photo saved');
captureBtn.disabled = false;
captureBtn.textContent = '📷 Take Photo';
} catch (error) {
console.error('Error:', error);
captureBtn.textContent = '❌ Error';
continuousCapture = false;
setTimeout(() => {
captureBtn.disabled = false;
captureBtn.textContent = '📷 Take Photo';
}, 2000);
alert('Failed: ' + error.message);
}
processingPhoto = false;
e.target.value = '';
// Re-open camera automatically for next photo
if (continuousCapture) {
setTimeout(() => {
cameraInput.click();
}, 300);
}
});
function updateSessionCounter() {
const bar = document.getElementById('capture-session-bar');
const countEl = document.getElementById('session-count');
if (sessionCount > 0) {
countEl.textContent = sessionCount;
bar.style.display = 'inline-block';
} else {
bar.style.display = 'none';
}
}
function showCaptureToast(msg) {
const toast = document.getElementById('capture-toast');
toast.textContent = '✓ ' + msg;
toast.style.display = 'block';
setTimeout(() => { toast.style.display = 'none'; }, 1500);
}
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10MB
function convertToJPEG(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
let quality = 0.92;
let scale = 1.0;
const attempt = () => {
const canvas = document.createElement('canvas');
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('Failed to convert'));
return;
}
if (blob.size <= MAX_UPLOAD_BYTES || quality <= 0.3) {
console.log('Photo: ' + (blob.size / 1024 / 1024).toFixed(1) + 'MB, ' + canvas.width + 'x' + canvas.height + ', q=' + quality.toFixed(2));
resolve(blob);
} else {
console.log('Photo too large (' + (blob.size / 1024 / 1024).toFixed(1) + 'MB), reducing...');
if (quality > 0.5) {
quality -= 0.1;
} else {
scale *= 0.8;
quality = 0.7;
}
attempt();
}
}, 'image/jpeg', quality);
};
attempt();
};
img.onerror = () => reject(new Error('Failed to load'));
img.src = e.target.result;
};
reader.onerror = () => reject(new Error('Failed to read'));
reader.readAsDataURL(file);
});
}
async function loadRecentPhotos() {
if (!storageReady) return;
const container = document.getElementById('photo-thumbnails');
const photos = await Storage.getRecentPhotos(currentUsername, 5);
if (photos.length === 0) {
container.innerHTML = '<p class="empty-state">No photos captured yet</p>';
return;
}
container.innerHTML = '';
for (const photo of photos) {
const url = URL.createObjectURL(photo.blob);
const statusClass = photo.status === 'verified' ? 'verified' :
photo.status === 'uploading' ? 'uploading' : 'pending';
const div = document.createElement('div');
div.className = 'thumbnail';
div.innerHTML = '<img src="' + url + '"><span class="status-badge ' + statusClass + '"></span>';
container.appendChild(div);
}
}
console.log('Capture page loaded');
// Debug panel
setTimeout(() => {
const debugDiv = document.createElement('div');
debugDiv.id = 'debug-panel';
debugDiv.style.cssText = 'position:fixed;bottom:80px;right:10px;z-index:9999;background:black;color:lime;padding:10px;border-radius:5px;font-family:monospace;font-size:10px;max-width:300px;';
const status = [];
status.push('Storage: ' + (typeof Storage !== 'undefined' ? 'OK' : 'FAIL'));
status.push('SyncEngine: ' + (typeof SyncEngine !== 'undefined' ? 'OK' : 'FAIL'));
status.push('Dexie: ' + (typeof Dexie !== 'undefined' ? 'OK' : 'FAIL'));
debugDiv.innerHTML = '<div style="margin-bottom:5px;">' + status.join('<br>') + '</div>' +
'<button onclick="manualSync()" style="background:lime;color:black;border:none;padding:10px;border-radius:5px;cursor:pointer;font-weight:bold;width:100%;">FORCE SYNC</button>' +
'<div id="sync-status" style="margin-top:5px;color:yellow;"></div>';
document.body.appendChild(debugDiv);
}, 2000);
window.manualSync = function() {
const statusDiv = document.getElementById('sync-status');
statusDiv.textContent = 'Checking...';
if (typeof SyncEngine === 'undefined') {
statusDiv.textContent = 'ERROR: SyncEngine not loaded!';
return;
}
statusDiv.textContent = 'Triggering sync...';
try {
SyncEngine.triggerSync();
statusDiv.textContent = 'Sync triggered!';
} catch (e) {
statusDiv.textContent = 'ERROR: ' + e.message;
}
};
</script>
{% endblock %}

208
app/templates/login.html Normal file
View File

@@ -0,0 +1,208 @@
{% extends "base.html" %}
{% block title %}Login - NextSnap{% endblock %}
{% block content %}
<div class="container">
<div class="login-container">
<div class="login-header">
<div class="app-icon">📷</div>
<h1 class="login-title">NextSnap</h1>
<p class="login-subtitle">Offline-first photo capture for Nextcloud</p>
</div>
<form id="login-form" class="login-form">
<div class="form-group">
<label for="username">Nextcloud Username</label>
<input
type="text"
id="username"
name="username"
class="form-input"
placeholder="Enter your username"
autocomplete="username"
autocapitalize="none"
autocorrect="off"
autofocus
required
>
<span class="field-error" id="username-error"></span>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
class="form-input"
placeholder="Enter your password"
autocomplete="current-password"
required
>
<span class="field-error" id="password-error"></span>
</div>
<div id="error-message" class="error-message hidden"></div>
<button type="submit" class="btn btn-primary btn-login" id="login-btn">
<span id="login-btn-text">Login</span>
<span id="login-btn-loading" class="hidden">
<span class="spinner"></span> Logging in...
</span>
</button>
</form>
<div class="login-footer">
<p class="help-text">
<strong>Tip:</strong> Use your Nextcloud credentials to login
</p>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
body {
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
}
.login-container {
max-width: 400px;
margin: 2rem auto;
padding: 2rem;
background: var(--bg-secondary);
border-radius: 16px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.app-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.login-title {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.login-subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 600;
font-size: 0.9rem;
}
.form-input {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
border: 2px solid var(--bg-tertiary);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-primary);
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: var(--accent);
}
.form-input.error {
border-color: var(--error);
}
.field-error {
color: var(--error);
font-size: 0.85rem;
min-height: 1.2rem;
}
.error-message {
padding: 0.75rem;
background: var(--error);
color: white;
border-radius: 8px;
font-size: 0.9rem;
}
.btn-login {
margin-top: 0.5rem;
position: relative;
}
.btn-login:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.login-footer {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--bg-tertiary);
}
.help-text {
text-align: center;
color: var(--text-secondary);
font-size: 0.85rem;
}
/* Mobile responsiveness */
@media (max-width: 480px) {
.login-container {
margin: 1rem;
padding: 1.5rem;
}
.app-icon {
font-size: 3rem;
}
.login-title {
font-size: 1.75rem;
}
}
</style>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
{% endblock %}

617
app/templates/queue.html Normal file
View File

@@ -0,0 +1,617 @@
{% extends "base.html" %}
{% block title %}Queue - NextSnap{% endblock %}
{% block content %}
<div class="container">
<div class="queue-header">
<h2>Upload Queue</h2>
<div class="queue-actions">
<button class="btn btn-secondary btn-small" id="sync-now-btn" disabled>
<span class="btn-icon">🔄</span> Sync Now
</button>
</div>
</div>
<div class="queue-stats" id="queue-stats">
<div class="stat">
<span class="stat-value" id="pending-stat">0</span>
<span class="stat-label">Pending</span>
</div>
<div class="stat">
<span class="stat-value" id="uploading-stat">0</span>
<span class="stat-label">Uploading</span>
</div>
<div class="stat">
<span class="stat-value" id="total-size-stat">0 MB</span>
<span class="stat-label">Total Size</span>
</div>
</div>
<div class="queue-list" id="queue-list">
<p class="empty-state" id="empty-state">No photos in queue</p>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal" id="delete-modal" style="display: none;">
<div class="modal-content">
<h3>Delete Photo?</h3>
<p>Are you sure you want to delete this photo from the queue?</p>
<p class="warning-text">⚠️ This action cannot be undone.</p>
<div class="modal-actions">
<button class="btn btn-secondary" id="cancel-delete-btn">Cancel</button>
<button class="btn btn-danger" id="confirm-delete-btn">Delete</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid var(--bg-tertiary);
}
.queue-header h2 {
font-size: 1.5rem;
margin: 0;
}
.queue-actions {
display: flex;
gap: 0.5rem;
}
.btn-small {
padding: 0.5rem 1rem;
font-size: 0.9rem;
min-height: auto;
}
.btn-icon {
display: inline-block;
margin-right: 0.25rem;
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--accent);
}
.btn-secondary:active:not(:disabled) {
opacity: 0.8;
transform: scale(0.98);
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-outline {
background: transparent;
color: var(--text-primary);
border: 1px solid var(--text-secondary);
}
.btn-danger {
background: var(--error);
color: white;
}
.queue-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin: 1.5rem 0;
}
.stat {
background: var(--bg-secondary);
padding: 1rem;
border-radius: 8px;
text-align: center;
}
.stat-value {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: var(--accent);
margin-bottom: 0.25rem;
}
.stat-label {
display: block;
font-size: 0.85rem;
color: var(--text-secondary);
}
.queue-list {
margin-bottom: 2rem;
}
.empty-state {
text-align: center;
color: var(--text-secondary);
padding: 3rem 1rem;
}
.queue-item {
background: var(--bg-secondary);
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 0.75rem;
display: flex;
gap: 0.75rem;
align-items: center;
position: relative;
}
.queue-item.uploading {
border: 2px solid var(--accent);
}
.queue-item.verified {
border: 1px solid var(--success, #4caf50);
}
.queue-item.error {
border: 2px solid var(--error);
}
.queue-item.completed {
opacity: 0.7;
}
.queue-divider {
text-align: center;
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 1rem 0 0.5rem;
margin-top: 0.5rem;
border-top: 1px solid var(--bg-tertiary);
}
.thumbnail-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 2rem;
}
.queue-item-thumbnail {
width: 80px;
height: 80px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
background: var(--bg-tertiary);
}
.queue-item-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.queue-item-info {
flex: 1;
min-width: 0;
}
.queue-item-filename {
font-weight: 600;
margin-bottom: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.queue-item-meta {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.queue-item-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.status-badge.pending {
background: var(--warning);
color: var(--bg-primary);
}
.status-badge.uploading {
background: var(--accent);
color: white;
}
.status-badge.verified {
background: var(--success, #4caf50);
color: white;
}
.status-badge.failed {
background: var(--error);
color: white;
}
.status-badge.error {
background: var(--error);
color: white;
}
.retry-info {
color: var(--text-secondary);
font-size: 0.75rem;
}
.error-message {
color: var(--error);
font-size: 0.75rem;
margin-top: 0.25rem;
}
.queue-item-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.queue-item-delete {
background: var(--error);
color: white;
border: none;
padding: 0.5rem;
border-radius: 6px;
cursor: pointer;
font-size: 1.25rem;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.queue-item-delete:active {
opacity: 0.8;
transform: scale(0.95);
}
.queue-item-retry {
background: var(--accent);
color: white;
border: none;
padding: 0.5rem;
border-radius: 50%;
cursor: pointer;
font-size: 1.2rem;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.queue-item-retry:active {
transform: scale(0.95);
opacity: 0.8;
}
/* Modal */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1rem;
}
.modal-content {
background: var(--bg-secondary);
padding: 1.5rem;
border-radius: 12px;
max-width: 400px;
width: 100%;
}
.modal-content h3 {
margin-bottom: 1rem;
}
.modal-content p {
margin-bottom: 1rem;
color: var(--text-secondary);
}
.warning-text {
color: var(--warning) !important;
font-weight: 600;
}
.modal-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
}
.modal-actions .btn {
flex: 1;
}
</style>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='lib/dexie.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/storage.js') }}"></script>
<script src="{{ url_for('static', filename='js/sync.js') }}"></script>
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
<script>
const currentUsername = '{{ username }}';
let deletePhotoId = null;
// Initialize storage and load queue
Storage.init().then(() => {
SyncEngine.init(currentUsername);
loadQueue();
updateStats();
});
async function loadQueue() {
const queueList = document.getElementById('queue-list');
const emptyState = document.getElementById('empty-state');
const photos = await Storage.db.photos
.where('username').equals(currentUsername)
.sortBy('timestamp');
// Split into active (pending/uploading) and completed (verified/failed)
const activePhotos = photos.filter(p => p.status === 'pending' || p.status === 'uploading');
const completedPhotos = photos.filter(p => p.status === 'verified' || p.status === 'failed');
if (photos.length === 0) {
emptyState.style.display = 'block';
queueList.innerHTML = '';
queueList.appendChild(emptyState);
document.getElementById('sync-now-btn').disabled = true;
return;
}
emptyState.style.display = 'none';
queueList.innerHTML = '';
// Show active uploads first (newest first)
for (const photo of activePhotos.reverse()) {
queueList.appendChild(createQueueItem(photo, false));
}
// Show completed history (newest first, last 20 kept by pruneHistory)
if (completedPhotos.length > 0) {
const divider = document.createElement('div');
divider.className = 'queue-divider';
divider.textContent = 'Recent Uploads';
queueList.appendChild(divider);
for (const photo of completedPhotos.reverse()) {
queueList.appendChild(createQueueItem(photo, true));
}
}
document.getElementById('sync-now-btn').disabled = activePhotos.length === 0 || !navigator.onLine;
}
function createQueueItem(photo, isCompleted) {
const item = document.createElement('div');
item.className = 'queue-item';
if (isCompleted) {
item.classList.add('completed');
}
if (photo.status === 'uploading') {
item.classList.add('uploading');
} else if (photo.status === 'verified') {
item.classList.add('verified');
} else if (photo.status === 'failed' || photo.lastError || photo.error) {
item.classList.add('error');
}
// Create thumbnail (use placeholder if blob was stripped)
const thumbnail = document.createElement('div');
thumbnail.className = 'queue-item-thumbnail';
if (photo.blob && photo.blob.size > 0) {
const img = document.createElement('img');
img.src = URL.createObjectURL(photo.blob);
thumbnail.appendChild(img);
} else {
thumbnail.innerHTML = '<span class="thumbnail-placeholder">' +
(photo.status === 'verified' ? '\u2705' : '\u274C') + '</span>';
}
// Create info section
const info = document.createElement('div');
info.className = 'queue-item-info';
const filename = document.createElement('div');
filename.className = 'queue-item-filename';
filename.textContent = photo.filename;
const meta = document.createElement('div');
meta.className = 'queue-item-meta';
const date = new Date(photo.completedAt || photo.timestamp).toLocaleString();
if (photo.blob && photo.blob.size > 0) {
const size = (photo.blob.size / 1024 / 1024).toFixed(2);
meta.textContent = size + ' MB \u2022 ' + date;
} else {
meta.textContent = date;
}
const status = document.createElement('div');
status.className = 'queue-item-status';
const badge = document.createElement('span');
badge.className = 'status-badge ' + photo.status;
badge.textContent = photo.status.charAt(0).toUpperCase() + photo.status.slice(1);
status.appendChild(badge);
if (photo.retryCount > 0 && !isCompleted) {
const retry = document.createElement('span');
retry.className = 'retry-info';
retry.textContent = 'Retry #' + photo.retryCount;
status.appendChild(retry);
}
info.appendChild(filename);
info.appendChild(meta);
info.appendChild(status);
const errorMsg = photo.lastError || photo.error;
if (errorMsg && photo.status !== 'verified') {
const error = document.createElement('div');
error.className = 'error-message';
error.textContent = errorMsg;
info.appendChild(error);
}
item.appendChild(thumbnail);
item.appendChild(info);
// Only show action buttons for active (non-completed) photos
if (!isCompleted) {
const actions = document.createElement('div');
actions.className = 'queue-item-actions';
if (photo.status === 'failed' || photo.status === 'pending' || photo.lastError || photo.error) {
const retryBtn = document.createElement('button');
retryBtn.className = 'queue-item-retry';
retryBtn.textContent = '\uD83D\uDD04';
retryBtn.title = 'Retry upload';
retryBtn.addEventListener('click', async () => {
retryBtn.textContent = '\u23F3';
retryBtn.disabled = true;
await Storage.updatePhoto(photo.id, { status: 'pending', lastError: null, error: null, retryCount: 0 });
if (typeof SyncEngine !== 'undefined') {
SyncEngine.triggerSync();
}
setTimeout(() => loadQueue(), 500);
});
actions.appendChild(retryBtn);
}
const deleteBtn = document.createElement('button');
deleteBtn.className = 'queue-item-delete';
deleteBtn.textContent = '\uD83D\uDDD1\uFE0F';
deleteBtn.title = 'Delete photo';
deleteBtn.addEventListener('click', () => showDeleteModal(photo.id));
actions.appendChild(deleteBtn);
item.appendChild(actions);
}
return item;
}
async function updateStats() {
const photos = await Storage.db.photos
.where('username').equals(currentUsername)
.toArray();
const pendingCount = photos.filter(p => p.status === 'pending').length;
const uploadingCount = photos.filter(p => p.status === 'uploading').length;
const totalSize = photos
.filter(p => p.blob && p.blob.size)
.reduce((sum, p) => sum + p.blob.size, 0) / 1024 / 1024;
document.getElementById('pending-stat').textContent = pendingCount;
document.getElementById('uploading-stat').textContent = uploadingCount;
document.getElementById('total-size-stat').textContent = totalSize.toFixed(1) + ' MB';
}
function showDeleteModal(photoId) {
deletePhotoId = photoId;
document.getElementById('delete-modal').style.display = 'flex';
}
function hideDeleteModal() {
deletePhotoId = null;
document.getElementById('delete-modal').style.display = 'none';
}
// Event Listeners
document.getElementById('sync-now-btn').addEventListener('click', async () => {
if (navigator.onLine) {
await SyncEngine.triggerSync();
setTimeout(() => {
loadQueue();
updateStats();
}, 500);
}
});
document.getElementById('cancel-delete-btn').addEventListener('click', hideDeleteModal);
document.getElementById('confirm-delete-btn').addEventListener('click', async () => {
if (deletePhotoId) {
await Storage.deletePhoto(deletePhotoId);
hideDeleteModal();
loadQueue();
updateStats();
}
});
// Close modal on outside click
document.getElementById('delete-modal').addEventListener('click', (e) => {
if (e.target.id === 'delete-modal') {
hideDeleteModal();
}
});
// Listen for sync updates
window.addEventListener('online', () => {
document.getElementById('sync-now-btn').disabled = false;
});
window.addEventListener('offline', () => {
document.getElementById('sync-now-btn').disabled = true;
});
// Refresh queue periodically
setInterval(() => {
loadQueue();
updateStats();
}, 5000);
</script>
{% endblock %}

306
app/templates/reviewer.html Normal file
View File

@@ -0,0 +1,306 @@
{% extends "base.html" %}
{% block title %}Review Photos - NextSnap{% endblock %}
{% block content %}
<div id="photo-viewer" class="photo-viewer">
<!-- Top Controls -->
<div class="viewer-header">
<button class="viewer-btn" id="done-btn">Done</button>
<div class="photo-position" id="photo-position">1 / 1</div>
<div></div>
</div>
<!-- Main Photo Display -->
<div class="photo-container">
<div class="photo-spinner" id="photo-spinner">
<div class="spinner"></div>
</div>
<img id="current-photo" class="current-photo" alt="Photo">
</div>
<!-- Navigation Buttons -->
<button class="nav-arrow nav-arrow-left" id="prev-btn" aria-label="Previous">&larr;</button>
<button class="nav-arrow nav-arrow-right" id="next-btn" aria-label="Next">&rarr;</button>
<!-- Filename Editor -->
<div class="filename-editor">
<div class="filename-input-wrapper">
<input
type="text"
id="filename-input"
class="filename-input"
placeholder="Enter filename"
>
<span class="filename-extension">.jpg</span>
<div class="save-spinner" id="save-spinner" style="display: none;">
<div class="spinner-small"></div>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div id="toast" class="toast" style="display: none;"></div>
</div>
{% endblock %}
{% block extra_css %}
<style>
body {
overflow: hidden;
}
.photo-viewer {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #000;
z-index: 9999;
display: flex;
flex-direction: column;
}
.viewer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.8);
z-index: 10;
}
.viewer-btn {
background: transparent;
color: white;
border: 1px solid white;
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
}
.viewer-btn:active {
opacity: 0.7;
}
.photo-position {
color: white;
font-size: 1.1rem;
font-weight: 600;
}
.photo-container {
flex: 1;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
touch-action: pan-y pinch-zoom;
}
.current-photo {
max-width: 100%;
max-height: 100%;
object-fit: contain;
user-select: none;
}
.photo-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.nav-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.6);
color: white;
border: none;
font-size: 2rem;
padding: 1rem;
cursor: pointer;
z-index: 5;
transition: background 0.2s;
}
.nav-arrow:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.8);
}
.nav-arrow:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.nav-arrow-left {
left: 0;
}
.nav-arrow-right {
right: 0;
}
.filename-editor {
background: rgba(0, 0, 0, 0.9);
padding: 1rem;
z-index: 10;
}
.filename-input-wrapper {
max-width: 480px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--bg-secondary);
border-radius: 8px;
padding: 0.75rem 1rem;
}
.filename-input {
flex: 1;
background: transparent;
border: none;
color: white;
font-size: 1rem;
outline: none;
min-width: 0;
}
.filename-extension {
color: var(--text-secondary);
font-size: 1rem;
flex-shrink: 0;
}
.save-spinner {
flex-shrink: 0;
}
.spinner-small {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.toast {
position: fixed;
bottom: 8rem;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 1rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
z-index: 10000;
max-width: 90%;
text-align: center;
}
.toast.success {
background: var(--success);
color: white;
}
.toast.error {
background: var(--error);
color: white;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: white;
}
.empty-state p {
font-size: 1.2rem;
margin-bottom: 1.5rem;
}
/* Mobile optimizations */
@media (max-width: 480px) {
.viewer-header {
padding: 0.75rem;
}
.nav-arrow {
font-size: 1.5rem;
padding: 0.75rem;
width: 60px;
height: 60px;
}
.filename-input {
font-size: 0.95rem;
}
}
</style>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='lib/dexie.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/storage.js') }}"></script>
<script src="{{ url_for('static', filename='js/reviewer.js') }}"></script>
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
<script>
// Get parameters from URL
const urlParams = new URLSearchParams(window.location.search);
const mode = urlParams.get('mode') || 'local';
const path = urlParams.get('path') || null;
const currentUsername = '{{ username }}';
async function initReviewer() {
await Storage.init();
let photos = [];
if (mode === 'local') {
// Load pending photos from IndexedDB
photos = await Storage.db.photos
.where('username').equals(currentUsername)
.and(p => p.status === 'pending' || p.status === 'uploading')
.sortBy('timestamp');
} else {
// Load photos from Nextcloud folder
// This would be called from the file browser with a list of photos
const photoList = sessionStorage.getItem('reviewer_photos');
if (photoList) {
photos = JSON.parse(photoList);
sessionStorage.removeItem('reviewer_photos');
}
}
Reviewer.init(mode, currentUsername, photos, path);
}
initReviewer();
</script>
{% endblock %}

66
app/templates/test.html Normal file
View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Camera Test</title>
<style>
body {
font-family: sans-serif;
padding: 20px;
text-align: center;
}
#test-btn {
font-size: 24px;
padding: 30px 60px;
margin: 20px;
background: blue;
color: white;
border: none;
border-radius: 10px;
cursor: pointer;
}
#status {
margin: 20px;
padding: 10px;
background: #f0f0f0;
}
</style>
</head>
<body>
<h1>Camera Test Page</h1>
<div id="status">Click counter: <span id="count">0</span></div>
<button id="test-btn">📷 TEST BUTTON</button>
<input type="file" id="camera-input" accept="image/*" capture="environment" style="display:none;">
<div id="result"></div>
<script>
let clickCount = 0;
const btn = document.getElementById('test-btn');
const input = document.getElementById('camera-input');
const countEl = document.getElementById('count');
const resultEl = document.getElementById('result');
btn.addEventListener('click', function() {
clickCount++;
countEl.textContent = clickCount;
console.log('Button clicked!', clickCount);
input.click();
console.log('Input clicked');
});
input.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
resultEl.textContent = 'File: ' + file.name + ', Size: ' + file.size;
console.log('File selected:', file);
}
});
console.log('Test page loaded');
</script>
</body>
</html>

43
config.py Normal file
View File

@@ -0,0 +1,43 @@
import os
class Config:
"""Application configuration loaded from environment variables."""
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
NEXTCLOUD_URL = os.environ.get('NEXTCLOUD_URL', 'https://nextcloud.sdanywhere.com')
# Session configuration
SESSION_TYPE = 'filesystem'
SESSION_FILE_DIR = '/tmp/flask_session'
SESSION_PERMANENT = False
SESSION_USE_SIGNER = True
SESSION_COOKIE_SAMESITE = 'Strict'
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = True # Set to False for local development
# Upload configuration
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB max upload
# Development mode
DEBUG = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
class DevelopmentConfig(Config):
"""Development-specific configuration."""
DEBUG = True
SESSION_COOKIE_SECURE = False # Allow HTTP in development
class ProductionConfig(Config):
"""Production-specific configuration."""
DEBUG = False
# Ensure these are set in production
def __init__(self):
super().__init__()
if self.SECRET_KEY == 'dev-secret-key-change-in-production':
raise ValueError("SECRET_KEY must be set in production!")
# Configuration dictionary
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}

38
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,38 @@
version: '3.8'
services:
nextsnap-dev:
build:
context: .
dockerfile: Dockerfile
container_name: nextsnap-dev
ports:
- "${PORT:-5000}:5000"
environment:
- FLASK_ENV=development
- FLASK_DEBUG=true
- SECRET_KEY=dev-secret-key-not-for-production
- NEXTCLOUD_URL=${NEXTCLOUD_URL:-https://nextcloud.sdanywhere.com}
- TZ=${TZ:-UTC}
volumes:
- ./app:/app/app
- ./config.py:/app/config.py
- ./run.py:/app/run.py
- flask_sessions_dev:/tmp/flask_session
restart: unless-stopped
command: ["python", "run.py"]
networks:
- nextsnap-dev-network
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "2"
volumes:
flask_sessions_dev:
driver: local
networks:
nextsnap-dev-network:
driver: bridge

39
docker-compose.yml Normal file
View File

@@ -0,0 +1,39 @@
version: '3.8'
services:
nextsnap:
build:
context: .
dockerfile: Dockerfile
container_name: nextsnap
ports:
- "${PORT:-8000}:8000"
environment:
- FLASK_ENV=production
- SECRET_KEY=${SECRET_KEY:?SECRET_KEY must be set}
- NEXTCLOUD_URL=${NEXTCLOUD_URL:?NEXTCLOUD_URL must be set}
- TZ=${TZ:-UTC}
volumes:
- flask_sessions:/tmp/flask_session
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- nextsnap-network
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes:
flask_sessions:
driver: local
networks:
nextsnap-network:
driver: bridge

421
nextsnap.md Normal file
View File

@@ -0,0 +1,421 @@
# CLAUDE.md — NextSnap: Offline-First Photo Capture for Nextcloud
## Project Overview
NextSnap is a mobile-optimized, offline-first web application for capturing photos on smartphones and syncing them to a user's Nextcloud instance. It is designed for field use in environments with poor or no connectivity. Photos are cached locally in the browser and only removed from cache after confirmed upload to the Nextcloud server. The app supports multiple users, each authenticated against Nextcloud credentials, and provides an admin panel for managing Nextcloud user accounts.
---
## Core Requirements
### 1. Offline-First Architecture
- The app MUST function fully offline for photo capture and local caching.
- Use a **Service Worker** to cache the application shell and all static assets for offline use.
- Use **IndexedDB** (via a library like Dexie.js) to store captured photos as blobs with metadata until upload is confirmed.
- A photo is NEVER removed from IndexedDB until the app has verified (via HTTP response or a follow-up check) that the file exists on the Nextcloud server.
- Implement automatic background sync: when connectivity is detected, the app should begin uploading queued photos without user intervention.
- Provide a manual "Sync Now" button for user-triggered uploads.
- Display a clear connectivity indicator (online/offline/syncing) in the UI at all times.
- Display a count of photos pending upload.
### 2. Photo Capture
- Use the device's native camera via `<input type="file" accept="image/*" capture="environment">` for maximum compatibility across iOS Safari and Android Chrome.
- Do NOT attempt to use `getUserMedia` or a custom camera viewfinder — use the native camera intent for reliability.
- Accept full-resolution images from the device camera.
- Convert all captured images to **JPEG format** on the client side before storage/upload. Use a `<canvas>` element to read the image and export as `image/jpeg` at quality 0.92.
- Preserve EXIF orientation where possible; if the canvas strips EXIF, detect and correct rotation before export.
- File naming convention: `{username}_{timestamp}.jpg` where timestamp is `YYYYMMDD_HHmmss` in local time (e.g., `waldo_20260206_143022.jpg`).
### 3. Nextcloud Integration
- All file operations use the **Nextcloud WebDAV API** (`/remote.php/dav/files/{username}/`).
- Authentication: Use Nextcloud username/password credentials. The backend proxies all WebDAV requests to avoid CORS issues and to keep credentials secure.
- **Directory Browsing**: Users can browse their full Nextcloud directory tree to select an upload destination folder. Use `PROPFIND` requests via the backend proxy to list directories.
- **Upload**: Use `PUT` requests via the backend proxy to upload JPEG files to the selected directory.
- **Upload Verification**: After each upload, perform a `PROPFIND` or `HEAD` request on the uploaded file to confirm it exists on the server before removing it from IndexedDB.
- Allow users to create new directories within their Nextcloud file tree from the app.
### 4. User Authentication & Management
- Users log in with their Nextcloud credentials (username + password).
- The backend validates credentials against the Nextcloud instance using a WebDAV or OCS API call.
- User sessions are managed via server-side sessions (Flask session with a secret key) or JWT tokens stored in the browser.
- **Admin Panel**: Accessible only to users with Nextcloud admin privileges.
- List existing Nextcloud users (via OCS Provisioning API: `GET /ocs/v1.php/cloud/users`).
- Create new Nextcloud users (via OCS Provisioning API: `POST /ocs/v1.php/cloud/users`).
- Disable/enable Nextcloud users.
- Admin status is determined by checking Nextcloud group membership (the `admin` group).
### 5. Multi-User Support
- Each user has their own session, upload queue, and selected destination directory.
- IndexedDB stores are keyed/partitioned by username to prevent data leakage between users on shared devices.
- Logging out clears the session but does NOT delete pending uploads from IndexedDB (to prevent data loss). On next login by the same user, pending uploads resume.
### 6. Batch Photo Review & Rename
- Users can enter a full-screen **photo review mode** to swipe through photos in a folder and rename them individually before or after upload.
- **Entry points**: From the Queue screen (for local pending photos) or from the File Browser (for photos already uploaded to a Nextcloud folder).
- **Review UI**: Full-screen image viewer optimized for mobile. Displays one photo at a time with the current filename shown in an editable text field overlaid at the bottom.
- **Navigation**: Horizontal swipe gestures (touch swipe on mobile) to move between photos. Also provide left/right arrow buttons for accessibility. Display a position indicator (e.g., "3 / 17").
- **Renaming**:
- Tap the filename field to edit it. The `.jpg` extension is shown but not editable to prevent accidental removal.
- For **local photos** (pending in IndexedDB): Update the `filename` field in IndexedDB immediately on change. If the photo has not yet been uploaded, the new name is used for upload.
- For **remote photos** (already on Nextcloud): Issue a WebDAV `MOVE` request via the backend proxy to rename the file on the server. Show a spinner during the rename and confirm success/failure with a toast.
- Validate filenames: disallow empty names, `/`, `\`, and other characters invalid in Nextcloud paths. Auto-trim whitespace.
- **Batch workflow**: The user can rapidly swipe through dozens of photos, renaming each as needed, without leaving the review screen. Changes are saved per-photo as the user swipes away (auto-save on swipe/navigate).
- **Offline behavior**: Renaming local/pending photos works fully offline. Renaming remote photos requires connectivity — if offline, show a message that remote renames require a connection, but still allow browsing/viewing.
---
## Tech Stack
| Layer | Technology |
|-------|------------|
| Backend | Python 3.11+ / Flask |
| WSGI Server | Gunicorn |
| Frontend | Vanilla JavaScript (ES6+), no framework |
| CSS | Minimal custom CSS, mobile-first responsive design |
| Offline Storage | IndexedDB via Dexie.js |
| Offline Shell | Service Worker (Workbox or hand-rolled) |
| Image Processing | Client-side Canvas API for JPEG conversion |
| Nextcloud API | WebDAV (file ops), OCS Provisioning API (user management) |
| Deployment | Docker / Docker Compose |
---
## Project Structure
```
nextsnap/
├── CLAUDE.md
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
├── config.py # App configuration (Nextcloud URL, secret key, etc.)
├── app/
│ ├── __init__.py # Flask app factory
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── auth.py # Login/logout endpoints
│ │ ├── webdav_proxy.py # Proxy endpoints for Nextcloud WebDAV
│ │ ├── admin.py # Admin panel API endpoints
│ │ └── health.py # Health check endpoint
│ ├── services/
│ │ ├── __init__.py
│ │ ├── nextcloud.py # Nextcloud WebDAV and OCS API client
│ │ └── auth.py # Credential validation, session management
│ ├── static/
│ │ ├── manifest.json # PWA manifest
│ │ ├── sw.js # Service worker
│ │ ├── js/
│ │ │ ├── app.js # Main application logic
│ │ │ ├── camera.js # Photo capture and JPEG conversion
│ │ │ ├── storage.js # IndexedDB / Dexie.js wrapper
│ │ │ ├── sync.js # Upload queue and sync engine
│ │ │ ├── reviewer.js # Batch photo review & rename swiper
│ │ │ ├── filebrowser.js # Nextcloud directory browser
│ │ │ └── admin.js # Admin panel logic
│ │ ├── css/
│ │ │ └── style.css # Mobile-first styles
│ │ ├── icons/ # PWA icons (192x192, 512x512)
│ │ └── lib/
│ │ └── dexie.min.js # Dexie.js (vendored for offline use)
│ └── templates/
│ ├── base.html # Base template with nav and connectivity indicator
│ ├── login.html # Login page
│ ├── capture.html # Main camera/capture page
│ ├── queue.html # Upload queue / pending photos view
│ ├── reviewer.html # Full-screen batch photo review & rename
│ ├── browser.html # Nextcloud file browser for selecting destination
│ └── admin.html # Admin panel for user management
└── tests/
├── test_auth.py
├── test_webdav_proxy.py
└── test_admin.py
```
---
## Detailed Implementation Guide
### Backend (Flask)
#### `config.py`
```python
import os
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY', 'change-me-in-production')
NEXTCLOUD_URL = os.environ.get('NEXTCLOUD_URL', 'https://cloud.example.com')
SESSION_TYPE = 'filesystem'
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB max upload
```
#### `app/routes/auth.py`
- `POST /api/auth/login` — Accepts `{username, password}`. Validates against Nextcloud by attempting a `PROPFIND` on the user's WebDAV root. On success, stores `username` and `password` (encrypted or in server session) and returns user info including admin status.
- `POST /api/auth/logout` — Clears the session.
- `GET /api/auth/status` — Returns current auth state and admin flag.
#### `app/routes/webdav_proxy.py`
- `GET /api/files/list?path=/` — Proxies a `PROPFIND` (Depth: 1) to Nextcloud WebDAV. Returns JSON list of files/directories with name, type, size, modified date.
- `PUT /api/files/upload` — Accepts multipart file upload + `path` parameter. Proxies the file to Nextcloud via `PUT`. Returns success with the file's WebDAV href.
- `HEAD /api/files/verify?path=/path/to/file.jpg` — Proxies a `HEAD` or `PROPFIND` (Depth: 0) to check file existence. Returns 200 if exists, 404 if not.
- `MKCOL /api/files/mkdir` — Creates a new directory via WebDAV `MKCOL`.
- `POST /api/files/rename` — Accepts `{sourcePath, destPath}`. Proxies a WebDAV `MOVE` request to rename/move a file on Nextcloud. Used by the batch rename feature. Returns 201 on success.
- `GET /api/files/thumbnail?path=/path/to/file.jpg&size=256` — Returns a downsized JPEG thumbnail of a remote file for use in the photo review swiper. Fetches the full image from Nextcloud and resizes server-side using Pillow. Cache thumbnails briefly in memory or on disk to avoid repeated fetches during a swipe session.
#### `app/routes/admin.py`
- All admin endpoints check that the logged-in user is in the Nextcloud `admin` group.
- `GET /api/admin/users` — Lists Nextcloud users via OCS API.
- `POST /api/admin/users` — Creates a new Nextcloud user. Accepts `{username, password, email?, displayName?, groups?}`.
- `PUT /api/admin/users/<username>/enable` — Enables a user.
- `PUT /api/admin/users/<username>/disable` — Disables a user.
#### `app/services/nextcloud.py`
- Wraps all Nextcloud API calls using the `requests` library.
- Methods: `propfind(path, depth)`, `put_file(path, data)`, `head(path)`, `mkcol(path)`, `move(source, dest)`, `get_file(path)`, `ocs_get_users()`, `ocs_create_user(data)`, `ocs_enable_user(username)`, `ocs_disable_user(username)`, `check_admin(username)`.
- All methods accept user credentials as parameters (pulled from the session by the route handlers).
- Handle and translate HTTP errors from Nextcloud into meaningful JSON error responses.
### Frontend
#### Service Worker (`sw.js`)
- On install, precache all static assets: HTML templates, CSS, JS files, vendored libraries, icons.
- Use a cache-first strategy for static assets.
- Use a network-first strategy for API calls, falling back to a "you're offline" JSON response.
- The service worker should NOT cache API responses that contain file data — only the app shell.
#### IndexedDB Schema (`storage.js`)
```javascript
const db = new Dexie('nextsnap');
db.version(1).stores({
photos: '++id, username, timestamp, filename, targetPath, status',
// status: 'pending' | 'uploading' | 'uploaded' | 'verified'
settings: 'username'
// stores: { username, targetPath (last selected directory) }
});
```
- `photos` table stores: `id`, `username`, `timestamp`, `filename`, `targetPath`, `status`, `blob` (the JPEG image data), `retryCount`, `lastError`.
- `settings` table stores per-user preferences like last selected upload directory.
#### Camera Module (`camera.js`)
- Trigger native camera via a hidden `<input type="file" accept="image/*" capture="environment">`.
- On file selection:
1. Read the file as an `Image` object.
2. Detect EXIF orientation and apply rotation correction on a `<canvas>`.
3. Export canvas as JPEG blob (`canvas.toBlob(callback, 'image/jpeg', 0.92)`).
4. Generate filename: `{username}_{YYYYMMDD_HHmmss}.jpg`.
5. Store the blob + metadata in IndexedDB with status `pending`.
6. Show a brief thumbnail preview/confirmation toast.
7. If online, trigger the sync engine.
#### Sync Engine (`sync.js`)
- On initialization and on `online` event, query IndexedDB for all photos with status `pending` or `uploading` (retry stalled uploads).
- Upload process for each photo:
1. Set status to `uploading`.
2. `POST` the JPEG blob to `/api/files/upload` with the target path.
3. On success, set status to `uploaded`.
4. Verify: `HEAD /api/files/verify?path={targetPath}/{filename}`.
5. On verification success, set status to `verified`, then **delete the blob from IndexedDB**.
6. On any failure, increment `retryCount`, set `lastError`, revert status to `pending`, and schedule retry with exponential backoff (5s, 15s, 45s, 2min, 5min, cap at 5min).
- Process uploads sequentially (one at a time) to avoid overwhelming limited bandwidth.
- Expose observable state for the UI: `{ pendingCount, currentUpload, isOnline, isSyncing }`.
#### File Browser (`filebrowser.js`)
- Fetches directory listing from `/api/files/list?path={currentPath}`.
- Renders a simple list/tree of directories with tap-to-navigate.
- When browsing a folder containing images, show a **"Review Photos"** button that opens the photo reviewer for all JPEG files in that directory.
- Shows a "Select This Folder" button to set the upload destination.
- Shows a "New Folder" button with a name prompt to create directories.
- Caches the last-used directory path in IndexedDB settings per user.
#### Photo Reviewer (`reviewer.js`)
- A full-screen swipeable photo viewer for batch reviewing and renaming photos.
- **Two modes**:
- **Local mode**: Reviews pending photos from IndexedDB (entered from Queue screen). Loads blobs directly from IndexedDB — fully offline capable.
- **Remote mode**: Reviews photos in a Nextcloud folder (entered from File Browser). Loads thumbnails via `/api/files/thumbnail` and full images on demand via `/api/files/list` + direct fetch.
- **Swipe navigation**: Implement touch-based horizontal swipe detection. Track `touchstart`, `touchmove`, `touchend` events. A swipe threshold of 50px triggers navigation. Also support left/right arrow tap areas on screen edges and keyboard arrow keys.
- **Preloading**: Preload the next and previous image while viewing the current one to ensure smooth swiping. In remote mode, preload thumbnails for ±3 images.
- **Filename editor**:
- Displayed as an editable text input overlaid on the bottom of the photo.
- Shows filename without `.jpg` extension during editing; extension is appended automatically on save.
- Auto-saves on: swipe to next/previous photo, tap outside the input, or pressing Enter.
- For local photos: updates IndexedDB `filename` field synchronously.
- For remote photos: calls `POST /api/files/rename` with old and new paths. Shows a brief spinner overlay during the request. On failure, reverts the displayed name and shows an error toast.
- **Position indicator**: Shows "3 / 17" style counter at the top of the screen.
- **Exit**: "Done" button or swipe-down gesture returns to the originating screen (Queue or File Browser).
- **Filename validation**: Reject empty names, names containing `/`, `\`, `?`, `*`, `"`, `<`, `>`, `|`, or `:`. Show inline validation error. Prevent save until corrected.
#### Admin Panel (`admin.js`)
- Only rendered/accessible if the user has admin status.
- Lists users in a simple table with username, display name, email, enabled status.
- "Add User" form: username (required), password (required), email (optional), display name (optional).
- Enable/disable toggle per user.
### UI/UX Requirements
#### Layout
- Single-column, mobile-first layout. Maximum width 480px centered on larger screens.
- Bottom navigation bar with 3-4 tabs: **Capture**, **Queue**, **Files**, **Admin** (admin only).
- Top bar: app name, connectivity indicator (green dot = online, red dot = offline, spinning = syncing), pending upload count badge.
#### Capture Screen
- Large, prominent "Take Photo" button (full-width, tall, easy to tap with gloves or in a hurry).
- Below the button: currently selected upload folder path with a "Change" link to the file browser.
- Below that: thumbnail strip of the last 5 captured photos (from IndexedDB) with status indicators.
#### Queue Screen
- List of all pending/uploading photos with thumbnails, filenames, status, and retry count.
- "Sync Now" button at the top.
- **"Review & Rename" button** — opens the photo reviewer in local mode for all pending photos, allowing the user to swipe through and rename before upload.
- Swipe-to-delete or delete button per photo (with confirmation dialog).
- "Clear Verified" button to clean up any verified entries still showing.
#### File Browser Screen
- Breadcrumb path at top.
- Directory listing with folder icons, tap to navigate.
- When a folder contains images, show a **"Review Photos"** button to enter the swipe reviewer for that folder's images.
- "Select This Folder" fixed button at bottom.
- "New Folder" button.
#### Photo Reviewer Screen
- Full-screen image display with dark/black background for focus.
- Editable filename field pinned to the bottom of the screen with a semi-transparent background bar.
- Position counter ("3 / 17") at the top center.
- Left/right tap zones on screen edges as swipe alternatives.
- "Done" button (top-left corner) to exit back to the originating screen.
- Brief spinner overlay when a remote rename is in progress.
- Toast notifications for rename success/failure.
#### Admin Screen
- User list table.
- "Add User" form.
- Enable/disable toggles.
#### Styling
- High contrast, large touch targets (minimum 48x48px).
- System font stack for fast rendering.
- Dark mode support via `prefers-color-scheme` media query.
- Status colors: green (verified/online), yellow (uploading/syncing), red (error/offline), gray (pending).
---
## PWA Configuration
### `manifest.json`
```json
{
"name": "NextSnap",
"short_name": "NextSnap",
"description": "Offline-first photo capture for Nextcloud",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#1a1a2e",
"theme_color": "#16213e",
"icons": [
{ "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
```
### iOS Compatibility
- Add `<meta name="apple-mobile-web-app-capable" content="yes">` and related meta tags.
- Note: iOS Safari has limited Service Worker and IndexedDB persistence. The app should warn iOS users to open the app regularly to prevent cache eviction, and should never rely solely on client-side storage for long-term data — the upload-and-verify cycle is critical.
---
## Docker Deployment
### `Dockerfile`
```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "app:create_app()"]
```
### `docker-compose.yml`
```yaml
version: '3.8'
services:
nextsnap:
build: .
ports:
- "8000:8000"
environment:
- SECRET_KEY=your-secret-key-here
- NEXTCLOUD_URL=https://your-nextcloud-instance.com
volumes:
- flask_sessions:/app/flask_session
restart: unless-stopped
volumes:
flask_sessions:
```
### `requirements.txt`
```
Flask==3.0.*
gunicorn==21.*
requests==2.31.*
Flask-Session==0.5.*
Pillow==10.*
```
---
## Security Considerations
- **Never store Nextcloud passwords in IndexedDB or localStorage.** Credentials are held only in the server-side session.
- Use HTTPS in production (enforce via reverse proxy like Nginx/Caddy).
- Set `SameSite=Strict` and `HttpOnly` on session cookies.
- Rate-limit login attempts to prevent brute force.
- The backend proxy ensures Nextcloud credentials are never exposed to the browser's JavaScript.
- Validate and sanitize all file paths to prevent directory traversal attacks.
- Admin endpoints must verify admin group membership on every request (do not cache admin status client-side as the sole check).
---
## Error Handling & Resilience
- **Network errors during upload**: Catch all fetch/XHR errors. Revert photo status to `pending`. Log the error in the photo's `lastError` field. Schedule retry with exponential backoff.
- **Partial uploads**: If the upload appears to succeed but verification fails, keep the photo in the queue and retry the full upload.
- **IndexedDB quota**: Monitor storage usage. If approaching quota, warn the user and prioritize uploading. Never delete unverified photos to free space.
- **Session expiry**: If a 401 is received from the backend, redirect to login. Do NOT clear the upload queue.
- **Nextcloud downtime**: Queue continues to accumulate. When Nextcloud returns, uploads resume automatically.
---
## Testing Priorities
1. **Offline capture and cache**: Disconnect network, take photos, verify they're stored in IndexedDB.
2. **Upload and verification cycle**: Reconnect, verify photos upload and are removed from queue only after server-side verification.
3. **Multi-user isolation**: Log in as two different users on the same device, verify queues are separate.
4. **Admin operations**: Create user, verify the new user can log in and use the app.
5. **iOS Safari and Android Chrome**: Test on real devices — emulators don't accurately represent camera input and Service Worker behavior.
6. **Slow/intermittent connectivity**: Use browser DevTools network throttling to simulate edge cases.
7. **Batch rename (local)**: Enter reviewer from Queue, swipe through photos, rename several, verify IndexedDB filenames update and uploads use new names.
8. **Batch rename (remote)**: Enter reviewer from File Browser on an uploaded folder, rename a file, verify the file is renamed on Nextcloud via WebDAV.
---
## Build Order
Implement in this order to enable incremental testing:
1. **Flask skeleton** — App factory, config, health endpoint, static file serving.
2. **Auth** — Login/logout routes, Nextcloud credential validation, session management.
3. **WebDAV proxy** — File listing, upload, verify, mkdir, rename, and thumbnail endpoints.
4. **Frontend login page** — Basic login form and session handling.
5. **File browser** — Directory navigation and folder selection.
6. **Camera capture** — Native camera input, JPEG conversion, IndexedDB storage.
7. **Sync engine** — Upload queue, retry logic, verification loop.
8. **Service Worker** — App shell caching for offline access.
9. **Queue UI** — Pending photos list, manual sync, delete.
10. **Photo reviewer** — Full-screen swipe viewer with inline rename for both local and remote photos.
11. **Admin panel** — User listing, creation, enable/disable.
12. **PWA manifest and icons** — Installability.
13. **Docker packaging** — Dockerfile, docker-compose.
14. **Polish** — Dark mode, connectivity indicator animations, error toasts, iOS meta tags.

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
Flask==3.0.*
gunicorn==21.*
requests==2.31.*
Flask-Session==0.8.0
Pillow==10.*
pytest==7.4.*

9
run.py Normal file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env python3
"""Development server runner."""
from app import create_app
import os
if __name__ == '__main__':
app = create_app('development')
port = int(os.environ.get('PORT', 5000))
app.run(host='0.0.0.0', port=port, debug=True)

53
setup.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/bin/bash
# NextSnap Setup Script
set -e
echo "NextSnap Setup"
echo "=============="
echo ""
# Check if .env exists
if [ -f .env ]; then
echo "✓ .env file already exists"
read -p "Do you want to overwrite it? (y/N) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Keeping existing .env file"
exit 0
fi
fi
# Copy .env.example
cp .env.example .env
echo "✓ Created .env file"
# Generate secret key
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
sed -i "s/your-secret-key-here/$SECRET_KEY/" .env
echo "✓ Generated SECRET_KEY"
# Get Nextcloud URL
read -p "Enter your Nextcloud URL (e.g., https://cloud.example.com): " NEXTCLOUD_URL
if [ -n "$NEXTCLOUD_URL" ]; then
sed -i "s|https://nextcloud.example.com|$NEXTCLOUD_URL|" .env
echo "✓ Set NEXTCLOUD_URL"
fi
# Get port
read -p "Enter port to expose (default: 8000): " PORT
if [ -n "$PORT" ]; then
sed -i "s/PORT=8000/PORT=$PORT/" .env
echo "✓ Set PORT"
fi
echo ""
echo "Setup complete! Configuration saved to .env"
echo ""
echo "Next steps:"
echo " 1. Review and adjust .env if needed"
echo " 2. Run: make build"
echo " 3. Run: make up"
echo " 4. Visit: http://localhost:${PORT:-8000}"
echo ""
echo "For production deployment, see DEPLOYMENT.md"

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Tests package

133
tests/test_auth.py Normal file
View File

@@ -0,0 +1,133 @@
import pytest
from app import create_app
from unittest.mock import patch, MagicMock
import tempfile
import os
@pytest.fixture
def app():
"""Create application for testing."""
app = create_app('development')
app.config['TESTING'] = True
# Use simple session for testing
app.config['SECRET_KEY'] = 'test-secret-key'
app.config['SESSION_TYPE'] = 'null' # Disable server-side sessions for testing
return app
@pytest.fixture
def client(app):
"""Create test client with session support."""
with app.test_client() as client:
with client.session_transaction() as sess:
# Initialize session
pass
yield client
def test_login_missing_credentials(client):
"""Test login with missing credentials."""
response = client.post('/api/auth/login', json={})
assert response.status_code == 400
data = response.get_json()
assert 'error' in data
def test_login_empty_credentials(client):
"""Test login with empty credentials."""
response = client.post('/api/auth/login', json={'username': '', 'password': ''})
assert response.status_code == 400
data = response.get_json()
assert 'error' in data
@patch('app.routes.auth.NextcloudClient')
def test_login_invalid_credentials(mock_nc_client, client):
"""Test login with invalid credentials."""
# Mock NextcloudClient to return False for verify_credentials
mock_instance = MagicMock()
mock_instance.verify_credentials.return_value = False
mock_nc_client.return_value = mock_instance
response = client.post('/api/auth/login', json={'username': 'testuser', 'password': 'wrongpass'})
assert response.status_code == 401
data = response.get_json()
assert 'error' in data
@patch('app.routes.auth.NextcloudClient')
def test_login_success(mock_nc_client, client):
"""Test successful login."""
# Mock NextcloudClient to return True for verify_credentials
mock_instance = MagicMock()
mock_instance.verify_credentials.return_value = True
mock_instance.check_admin.return_value = False
mock_nc_client.return_value = mock_instance
response = client.post('/api/auth/login', json={'username': 'testuser', 'password': 'testpass'})
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
assert data['username'] == 'testuser'
assert data['is_admin'] is False
@patch('app.routes.auth.NextcloudClient')
def test_login_admin_user(mock_nc_client, client):
"""Test login with admin user."""
mock_instance = MagicMock()
mock_instance.verify_credentials.return_value = True
mock_instance.check_admin.return_value = True
mock_nc_client.return_value = mock_instance
response = client.post('/api/auth/login', json={'username': 'admin', 'password': 'adminpass'})
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
assert data['is_admin'] is True
def test_status_not_authenticated(client):
"""Test status endpoint when not authenticated."""
response = client.get('/api/auth/status')
assert response.status_code == 200
data = response.get_json()
assert data['authenticated'] is False
@patch('app.routes.auth.NextcloudClient')
def test_status_authenticated(mock_nc_client, client):
"""Test status endpoint when authenticated."""
# First login
mock_instance = MagicMock()
mock_instance.verify_credentials.return_value = True
mock_instance.check_admin.return_value = False
mock_nc_client.return_value = mock_instance
client.post('/api/auth/login', json={'username': 'testuser', 'password': 'testpass'})
# Check status
response = client.get('/api/auth/status')
assert response.status_code == 200
data = response.get_json()
assert data['authenticated'] is True
assert data['username'] == 'testuser'
@patch('app.routes.auth.NextcloudClient')
def test_logout(mock_nc_client, client):
"""Test logout functionality."""
# First login
mock_instance = MagicMock()
mock_instance.verify_credentials.return_value = True
mock_instance.check_admin.return_value = False
mock_nc_client.return_value = mock_instance
client.post('/api/auth/login', json={'username': 'testuser', 'password': 'testpass'})
# Verify logged in
response = client.get('/api/auth/status')
data = response.get_json()
assert data['authenticated'] is True
# Logout
response = client.post('/api/auth/logout')
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
# Verify logged out
response = client.get('/api/auth/status')
data = response.get_json()
assert data['authenticated'] is False

22
tests/test_health.py Normal file
View File

@@ -0,0 +1,22 @@
import pytest
from app import create_app
@pytest.fixture
def app():
"""Create application for testing."""
app = create_app('development')
app.config['TESTING'] = True
return app
@pytest.fixture
def client(app):
"""Create test client."""
return app.test_client()
def test_health_endpoint(client):
"""Test the health check endpoint."""
response = client.get('/api/health')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'healthy'
assert data['service'] == 'nextsnap'

195
tests/test_webdav_proxy.py Normal file
View File

@@ -0,0 +1,195 @@
import pytest
from app import create_app
from unittest.mock import patch, MagicMock
import base64
@pytest.fixture
def app():
"""Create application for testing."""
app = create_app('development')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'test-secret-key'
app.config['SESSION_TYPE'] = 'null'
return app
@pytest.fixture
def client(app):
"""Create test client with authenticated session."""
with app.test_client() as client:
# Set up authenticated session
with client.session_transaction() as sess:
sess['username'] = 'testuser'
sess['password'] = base64.b64encode(b'testpass').decode()
sess['is_admin'] = False
yield client
def test_list_files_not_authenticated(app):
"""Test list files without authentication."""
with app.test_client() as client:
response = client.get('/api/files/list?path=/')
assert response.status_code == 401
@patch('app.routes.webdav_proxy.NextcloudClient')
def test_list_files_success(mock_nc_client, client):
"""Test successful file listing."""
mock_instance = MagicMock()
mock_instance.propfind.return_value = {
'items': [
{'name': 'Documents', 'path': '/Documents', 'type': 'directory', 'size': 0},
{'name': 'photo.jpg', 'path': '/photo.jpg', 'type': 'file', 'size': 12345}
],
'path': '/',
'count': 2
}
mock_nc_client.return_value = mock_instance
response = client.get('/api/files/list?path=/')
assert response.status_code == 200
data = response.get_json()
assert 'items' in data
assert len(data['items']) == 2
assert data['items'][0]['name'] == 'Documents'
assert data['items'][1]['name'] == 'photo.jpg'
@patch('app.routes.webdav_proxy.NextcloudClient')
def test_upload_file_success(mock_nc_client, client):
"""Test successful file upload."""
mock_instance = MagicMock()
mock_instance.put_file.return_value = {
'success': True,
'url': 'http://nextcloud/remote.php/dav/files/testuser/photo.jpg'
}
mock_nc_client.return_value = mock_instance
# Test with form data
response = client.post(
'/api/files/upload?path=/uploads',
data={
'path': '/uploads',
'file': (BytesIO(b'fake image data'), 'photo.jpg')
},
content_type='multipart/form-data'
)
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
assert 'photo.jpg' in data['path']
def test_upload_file_no_path(client):
"""Test upload without target path."""
response = client.post('/api/files/upload')
assert response.status_code == 400
data = response.get_json()
assert 'error' in data
@patch('app.routes.webdav_proxy.NextcloudClient')
def test_verify_file_exists(mock_nc_client, client):
"""Test file verification - file exists."""
mock_instance = MagicMock()
mock_instance.head.return_value = True
mock_nc_client.return_value = mock_instance
response = client.get('/api/files/verify?path=/photo.jpg')
assert response.status_code == 200
data = response.get_json()
assert data['exists'] is True
@patch('app.routes.webdav_proxy.NextcloudClient')
def test_verify_file_not_exists(mock_nc_client, client):
"""Test file verification - file does not exist."""
mock_instance = MagicMock()
mock_instance.head.return_value = False
mock_nc_client.return_value = mock_instance
response = client.get('/api/files/verify?path=/nonexistent.jpg')
assert response.status_code == 404
data = response.get_json()
assert data['exists'] is False
def test_verify_file_no_path(client):
"""Test verification without path."""
response = client.get('/api/files/verify')
assert response.status_code == 400
data = response.get_json()
assert 'error' in data
@patch('app.routes.webdav_proxy.NextcloudClient')
def test_mkdir_success(mock_nc_client, client):
"""Test successful directory creation."""
mock_instance = MagicMock()
mock_instance.mkcol.return_value = True
mock_nc_client.return_value = mock_instance
response = client.post(
'/api/files/mkdir',
json={'path': '/NewFolder'}
)
assert response.status_code == 201
data = response.get_json()
assert data['success'] is True
assert data['path'] == '/NewFolder'
def test_mkdir_no_path(client):
"""Test mkdir without path."""
response = client.post('/api/files/mkdir', json={})
assert response.status_code == 400
data = response.get_json()
assert 'error' in data
@patch('app.routes.webdav_proxy.NextcloudClient')
def test_rename_file_success(mock_nc_client, client):
"""Test successful file rename."""
mock_instance = MagicMock()
mock_instance.move.return_value = True
mock_nc_client.return_value = mock_instance
response = client.post(
'/api/files/rename',
json={
'source': '/old_name.jpg',
'destination': '/new_name.jpg'
}
)
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
assert data['source'] == '/old_name.jpg'
assert data['destination'] == '/new_name.jpg'
def test_rename_file_missing_params(client):
"""Test rename without required parameters."""
response = client.post(
'/api/files/rename',
json={'source': '/old_name.jpg'}
)
assert response.status_code == 400
data = response.get_json()
assert 'error' in data
@patch('app.routes.webdav_proxy.NextcloudClient')
@patch('app.routes.webdav_proxy.Image')
def test_thumbnail_success(mock_image, mock_nc_client, client):
"""Test successful thumbnail generation."""
# Mock Nextcloud client
mock_instance = MagicMock()
mock_instance.get_file.return_value = b'fake image data'
mock_nc_client.return_value = mock_instance
# Mock PIL Image
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_image.open.return_value = mock_img
response = client.get('/api/files/thumbnail?path=/photo.jpg&size=256')
assert response.status_code == 200
assert response.content_type == 'image/jpeg'
def test_thumbnail_no_path(client):
"""Test thumbnail without path."""
response = client.get('/api/files/thumbnail')
assert response.status_code == 400
data = response.get_json()
assert 'error' in data
# Import BytesIO for upload test
from io import BytesIO