commit cad4118f72e490bf8a3c24336e5ccc9c6a858e21 Author: kamaji Date: Sat Feb 7 04:53:13 2026 -0600 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..652004f --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fa82513 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be452fd --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..cd3cdf9 --- /dev/null +++ b/DEPLOYMENT.md @@ -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 +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= +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: /issues +- Documentation: README.md +- Nextcloud Docs: https://docs.nextcloud.com + +## License + +See LICENSE file for details. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d1d861b --- /dev/null +++ b/Dockerfile @@ -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()"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4300a8a --- /dev/null +++ b/Makefile @@ -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))" diff --git a/POLISH.md b/POLISH.md new file mode 100644 index 0000000..87e63d3 --- /dev/null +++ b/POLISH.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a0a63a --- /dev/null +++ b/README.md @@ -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. diff --git a/TECHNICAL.md b/TECHNICAL.md new file mode 100644 index 0000000..8984af1 --- /dev/null +++ b/TECHNICAL.md @@ -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] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..6843cfc --- /dev/null +++ b/app/__init__.py @@ -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 diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..8d73796 --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1 @@ +# Routes package initialization diff --git a/app/routes/admin.py b/app/routes/admin.py new file mode 100644 index 0000000..f17b561 --- /dev/null +++ b/app/routes/admin.py @@ -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//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//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/', 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 diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..73ef7c4 --- /dev/null +++ b/app/routes/auth.py @@ -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 diff --git a/app/routes/health.py b/app/routes/health.py new file mode 100644 index 0000000..4a9e096 --- /dev/null +++ b/app/routes/health.py @@ -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 diff --git a/app/routes/views.py b/app/routes/views.py new file mode 100644 index 0000000..41d3709 --- /dev/null +++ b/app/routes/views.py @@ -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') diff --git a/app/routes/webdav_proxy.py b/app/routes/webdav_proxy.py new file mode 100644 index 0000000..3c71be5 --- /dev/null +++ b/app/routes/webdav_proxy.py @@ -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 diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..54fa46a --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +# Services package initialization diff --git a/app/services/auth.py b/app/services/auth.py new file mode 100644 index 0000000..8b163ad --- /dev/null +++ b/app/services/auth.py @@ -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 diff --git a/app/services/nextcloud.py b/app/services/nextcloud.py new file mode 100644 index 0000000..8a1f612 --- /dev/null +++ b/app/services/nextcloud.py @@ -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)} diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..f57f988 --- /dev/null +++ b/app/static/css/style.css @@ -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; +} diff --git a/app/static/icons/icon-192.png b/app/static/icons/icon-192.png new file mode 100644 index 0000000..8ac2218 Binary files /dev/null and b/app/static/icons/icon-192.png differ diff --git a/app/static/icons/icon-512.png b/app/static/icons/icon-512.png new file mode 100644 index 0000000..232b401 Binary files /dev/null and b/app/static/icons/icon-512.png differ diff --git a/app/static/js/admin.js b/app/static/js/admin.js new file mode 100644 index 0000000..efb6766 --- /dev/null +++ b/app/static/js/admin.js @@ -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 = 'No users found'; + 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 = ` + ${this.escapeHtml(user.id)} + ${this.escapeHtml(user.displayname || '-')} + ${this.escapeHtml(user.email || '-')} + + + ${user.enabled ? 'Enabled' : 'Disabled'} + + + +
+ ${user.enabled ? + `` : + `` + } + +
+ + `; + 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; diff --git a/app/static/js/app.js b/app/static/js/app.js new file mode 100644 index 0000000..618740f --- /dev/null +++ b/app/static/js/app.js @@ -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; diff --git a/app/static/js/auth.js b/app/static/js/auth.js new file mode 100644 index 0000000..7e0a097 --- /dev/null +++ b/app/static/js/auth.js @@ -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(); +} diff --git a/app/static/js/camera.js b/app/static/js/camera.js new file mode 100644 index 0000000..5372247 --- /dev/null +++ b/app/static/js/camera.js @@ -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 = '

No photos captured yet

'; + 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 += ` +
+ ${photo.filename} + +
+ `; + } + + 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`; + } +}; diff --git a/app/static/js/filebrowser.js b/app/static/js/filebrowser.js new file mode 100644 index 0000000..9559c32 --- /dev/null +++ b/app/static/js/filebrowser.js @@ -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 = '

Loading...

'; + + 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 = `

Failed to load directory: ${error.message}

`; + } + }, + + renderBreadcrumb(path) { + const breadcrumb = document.getElementById('breadcrumb'); + const parts = path.split('/').filter(p => p); + + let html = 'Home'; + + let currentPath = ''; + parts.forEach((part, index) => { + currentPath += '/' + part; + html += ' / '; + if (index === parts.length - 1) { + html += `${part}`; + } else { + html += `${part}`; + } + }); + + 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 = '

This folder is empty

'; + 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 = '
'; + + 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 += ` +
+ ${icon} +
+
${this.escapeHtml(item.name)}
+ ${sizeText ? `
${sizeText}
` : ''} +
+ ${item.type === 'directory' ? '' : ''} +
+ `; + }); + + html += '
'; + 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(); +} diff --git a/app/static/js/polish.js b/app/static/js/polish.js new file mode 100644 index 0000000..e9fba4e --- /dev/null +++ b/app/static/js/polish.js @@ -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 = ` + + `; + 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; diff --git a/app/static/js/reviewer.js b/app/static/js/reviewer.js new file mode 100644 index 0000000..e0f3f2a --- /dev/null +++ b/app/static/js/reviewer.js @@ -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 = '

No photos to review

'; + }, + + exit() { + if (this.isEditing) this.saveFilename(); + Object.values(this.preloadedImages).forEach(url => URL.revokeObjectURL(url)); + history.back(); + } +}; + +window.Reviewer = Reviewer; diff --git a/app/static/js/storage.js b/app/static/js/storage.js new file mode 100644 index 0000000..596a876 --- /dev/null +++ b/app/static/js/storage.js @@ -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; diff --git a/app/static/js/sync.js b/app/static/js/sync.js new file mode 100644 index 0000000..06415f1 --- /dev/null +++ b/app/static/js/sync.js @@ -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'); diff --git a/app/static/js/sync_broken_backup.js b/app/static/js/sync_broken_backup.js new file mode 100644 index 0000000..5e3e09d --- /dev/null +++ b/app/static/js/sync_broken_backup.js @@ -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); diff --git a/app/static/js/sync_fix.txt b/app/static/js/sync_fix.txt new file mode 100644 index 0000000..91335a4 --- /dev/null +++ b/app/static/js/sync_fix.txt @@ -0,0 +1,6 @@ + async updateRecentPhotos() { + // Delegate to Camera module if available + if (typeof Camera !== 'undefined' && Camera.updateRecentPhotos) { + await Camera.updateRecentPhotos(); + } + }, diff --git a/app/static/lib/dexie.min.js b/app/static/lib/dexie.min.js new file mode 100644 index 0000000..9a028fb --- /dev/null +++ b/app/static/lib/dexie.min.js @@ -0,0 +1,2 @@ +(function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).Dexie=t()})(this,function(){"use strict";var g=function(){return(g=Object.assign||function(e){for(var t,n=1,r=arguments.length;n.",pt="String expected.",yt=[],vt="undefined"!=typeof navigator&&/(MSIE|Trident|Edge)/.test(navigator.userAgent),mt=vt,gt=vt,bt="__dbnames",_t="readonly",wt="readwrite";function xt(e,t){return e?t?function(){return e.apply(this,arguments)&&t.apply(this,arguments)}:e:t}var kt={type:3,lower:-1/0,lowerOpen:!1,upper:[[]],upperOpen:!1};function Et(t){return"string"!=typeof t||/\./.test(t)?function(e){return e}:function(e){return void 0===e[t]&&t in e&&delete(e=A(e))[t],e}}var Pt=(Kt.prototype._trans=function(e,r,t){var n=this._tx||Oe.trans,i=this.name;function o(e,t,n){if(!n.schema[i])throw new J.NotFound("Table "+i+" not part of transaction");return r(n.idbtrans,n)}var a=qe();try{return n&&n.db===this.db?n===Oe.trans?n._promise(e,o,t):Je(function(){return n._promise(e,o,t)},{trans:n,transless:Oe.transless||Oe}):function t(n,r,i,o){if(n.idbdb&&(n._state.openComplete||Oe.letThrough||n._vip)){var a=n._createTransaction(r,i,n._dbSchema);try{a.create(),n._state.PR1398_maxLoop=3}catch(e){return e.name===Q.InvalidState&&n.isOpen()&&0<--n._state.PR1398_maxLoop?(console.warn("Dexie: Need to reopen db"),n._close(),n.open().then(function(){return t(n,r,i,o)})):lt(e)}return a._promise(r,function(e,t){return Je(function(){return Oe.trans=a,o(e,t,a)})}).then(function(e){return a._completion.then(function(){return e})})}if(n._state.openComplete)return lt(new J.DatabaseClosed(n._state.dbOpenError));if(!n._state.isBeingOpened){if(!n._options.autoOpen)return lt(new J.DatabaseClosed);n.open().catch(Z)}return n._state.dbReadyPromise.then(function(){return t(n,r,i,o)})}(this.db,e,[this.name],o)}finally{a&&Ue()}},Kt.prototype.get=function(t,e){var n=this;return t&&t.constructor===Object?this.where(t).first(e):this._trans("readonly",function(e){return n.core.get({trans:e,key:t}).then(function(e){return n.hook.reading.fire(e)})}).then(e)},Kt.prototype.where=function(o){if("string"==typeof o)return new this.db.WhereClause(this,o);if(b(o))return new this.db.WhereClause(this,"["+o.join("+")+"]");var n=x(o);if(1===n.length)return this.where(n[0]).equals(o[n[0]]);var e=this.schema.indexes.concat(this.schema.primKey).filter(function(t){return t.compound&&n.every(function(e){return 0<=t.keyPath.indexOf(e)})&&t.keyPath.every(function(e){return 0<=n.indexOf(e)})})[0];if(e&&this.db._maxKey!==ht)return this.where(e.name).equals(e.keyPath.map(function(e){return o[e]}));!e&&F&&console.warn("The query "+JSON.stringify(o)+" on "+this.name+" would benefit of a compound index ["+n.join("+")+"]");var a=this.schema.idxByName,r=this.db._deps.indexedDB;function u(e,t){try{return 0===r.cmp(e,t)}catch(e){return!1}}var t=n.reduce(function(e,t){var n=e[0],r=e[1],e=a[t],i=o[t];return[n||e,n||!e?xt(r,e&&e.multi?function(e){e=k(e,t);return b(e)&&e.some(function(e){return u(i,e)})}:function(e){return u(i,k(e,t))}):r]},[null,null]),i=t[0],t=t[1];return i?this.where(i.name).equals(o[i.keyPath]).filter(t):e?this.filter(t):this.where(n).equals("")},Kt.prototype.filter=function(e){return this.toCollection().and(e)},Kt.prototype.count=function(e){return this.toCollection().count(e)},Kt.prototype.offset=function(e){return this.toCollection().offset(e)},Kt.prototype.limit=function(e){return this.toCollection().limit(e)},Kt.prototype.each=function(e){return this.toCollection().each(e)},Kt.prototype.toArray=function(e){return this.toCollection().toArray(e)},Kt.prototype.toCollection=function(){return new this.db.Collection(new this.db.WhereClause(this))},Kt.prototype.orderBy=function(e){return new this.db.Collection(new this.db.WhereClause(this,b(e)?"["+e.join("+")+"]":e))},Kt.prototype.reverse=function(){return this.toCollection().reverse()},Kt.prototype.mapToClass=function(r){this.schema.mappedClass=r;function e(e){if(!e)return e;var t,n=Object.create(r.prototype);for(t in e)if(m(e,t))try{n[t]=e[t]}catch(e){}return n}return this.schema.readHook&&this.hook.reading.unsubscribe(this.schema.readHook),this.schema.readHook=e,this.hook("reading",e),r},Kt.prototype.defineClass=function(){return this.mapToClass(function(e){u(this,e)})},Kt.prototype.add=function(t,n){var r=this,e=this.schema.primKey,i=e.auto,o=e.keyPath,a=t;return o&&i&&(a=Et(o)(t)),this._trans("readwrite",function(e){return r.core.mutate({trans:e,type:"add",keys:null!=n?[n]:null,values:[a]})}).then(function(e){return e.numFailures?je.reject(e.failures[0]):e.lastResult}).then(function(e){if(o)try{E(t,o,e)}catch(e){}return e})},Kt.prototype.update=function(t,n){if("object"!=typeof t||b(t))return this.where(":id").equals(t).modify(n);var e=k(t,this.schema.primKey.keyPath);if(void 0===e)return lt(new J.InvalidArgument("Given object does not contain its primary key"));try{"function"!=typeof n?x(n).forEach(function(e){E(t,e,n[e])}):n(t,{value:t,primKey:e})}catch(e){}return this.where(":id").equals(e).modify(n)},Kt.prototype.put=function(t,n){var r=this,e=this.schema.primKey,i=e.auto,o=e.keyPath,a=t;return o&&i&&(a=Et(o)(t)),this._trans("readwrite",function(e){return r.core.mutate({trans:e,type:"put",values:[a],keys:null!=n?[n]:null})}).then(function(e){return e.numFailures?je.reject(e.failures[0]):e.lastResult}).then(function(e){if(o)try{E(t,o,e)}catch(e){}return e})},Kt.prototype.delete=function(t){var n=this;return this._trans("readwrite",function(e){return n.core.mutate({trans:e,type:"delete",keys:[t]})}).then(function(e){return e.numFailures?je.reject(e.failures[0]):void 0})},Kt.prototype.clear=function(){var t=this;return this._trans("readwrite",function(e){return t.core.mutate({trans:e,type:"deleteRange",range:kt})}).then(function(e){return e.numFailures?je.reject(e.failures[0]):void 0})},Kt.prototype.bulkGet=function(t){var n=this;return this._trans("readonly",function(e){return n.core.getMany({keys:t,trans:e}).then(function(e){return e.map(function(e){return n.hook.reading.fire(e)})})})},Kt.prototype.bulkAdd=function(r,e,t){var o=this,a=Array.isArray(e)?e:void 0,u=(t=t||(a?void 0:e))?t.allKeys:void 0;return this._trans("readwrite",function(e){var t=o.schema.primKey,n=t.auto,t=t.keyPath;if(t&&a)throw new J.InvalidArgument("bulkAdd(): keys argument invalid on tables with inbound keys");if(a&&a.length!==r.length)throw new J.InvalidArgument("Arguments objects and keys must have the same length");var i=r.length,t=t&&n?r.map(Et(t)):r;return o.core.mutate({trans:e,type:"add",keys:a,values:t,wantResults:u}).then(function(e){var t=e.numFailures,n=e.results,r=e.lastResult,e=e.failures;if(0===t)return u?n:r;throw new H(o.name+".bulkAdd(): "+t+" of "+i+" operations failed",e)})})},Kt.prototype.bulkPut=function(r,e,t){var o=this,a=Array.isArray(e)?e:void 0,u=(t=t||(a?void 0:e))?t.allKeys:void 0;return this._trans("readwrite",function(e){var t=o.schema.primKey,n=t.auto,t=t.keyPath;if(t&&a)throw new J.InvalidArgument("bulkPut(): keys argument invalid on tables with inbound keys");if(a&&a.length!==r.length)throw new J.InvalidArgument("Arguments objects and keys must have the same length");var i=r.length,t=t&&n?r.map(Et(t)):r;return o.core.mutate({trans:e,type:"put",keys:a,values:t,wantResults:u}).then(function(e){var t=e.numFailures,n=e.results,r=e.lastResult,e=e.failures;if(0===t)return u?n:r;throw new H(o.name+".bulkPut(): "+t+" of "+i+" operations failed",e)})})},Kt.prototype.bulkDelete=function(t){var r=this,i=t.length;return this._trans("readwrite",function(e){return r.core.mutate({trans:e,type:"delete",keys:t})}).then(function(e){var t=e.numFailures,n=e.lastResult,e=e.failures;if(0===t)return n;throw new H(r.name+".bulkDelete(): "+t+" of "+i+" operations failed",e)})},Kt);function Kt(){}function Ot(i){function t(e,t){if(t){for(var n=arguments.length,r=new Array(n-1);--n;)r[n-1]=arguments[n];return a[e].subscribe.apply(null,r),i}if("string"==typeof e)return a[e]}var a={};t.addEventType=u;for(var e=1,n=arguments.length;ec+l&&h(c+b)})})}return h(0).then(function(){if(0=s}).forEach(function(u){t.push(function(){var t=h,e=u._cfg.dbschema;En(f,t,l),En(f,e,l),h=f._dbSchema=e;var n=_n(t,e);n.add.forEach(function(e){wn(l,e[0],e[1].primKey,e[1].indexes)}),n.change.forEach(function(e){if(e.recreate)throw new J.Upgrade("Not yet support for changing primary key");var t=l.objectStore(e.name);e.add.forEach(function(e){return xn(t,e)}),e.change.forEach(function(e){t.deleteIndex(e.name),xn(t,e)}),e.del.forEach(function(e){return t.deleteIndex(e)})});var r=u._cfg.contentUpgrade;if(r&&u._cfg.version>s){yn(f,l),c._memoizedTables={},d=!0;var i=P(e);n.del.forEach(function(e){i[e]=t[e]}),mn(f,[f.Transaction.prototype]),vn(f,[f.Transaction.prototype],x(i),i),c.schema=i;var o,a=R(r);a&&$e();n=je.follow(function(){var e;(o=r(c))&&a&&(e=Ze.bind(null,null),o.then(e,e))});return o&&"function"==typeof o.then?je.resolve(o):n.then(function(){return o})}}),t.push(function(e){var t,n,r;d&&mt||(t=u._cfg.dbschema,n=t,r=e,[].slice.call(r.db.objectStoreNames).forEach(function(e){return null==n[e]&&r.db.deleteObjectStore(e)})),mn(f,[f.Transaction.prototype]),vn(f,[f.Transaction.prototype],f._storeNames,f._dbSchema),c.schema=f._dbSchema})}),function e(){return t.length?je.resolve(t.shift()(c.idbtrans)).then(e):je.resolve()}().then(function(){var t,n;n=l,x(t=h).forEach(function(e){n.db.objectStoreNames.contains(e)||wn(n,e,t[e].primKey,t[e].indexes)})}).catch(u))})}function _n(e,t){var n,r={del:[],add:[],change:[]};for(n in e)t[n]||r.del.push(n);for(n in t){var i=e[n],o=t[n];if(i){var a={name:n,def:o,recreate:!1,del:[],add:[],change:[]};if(""+(i.primKey.keyPath||"")!=""+(o.primKey.keyPath||"")||i.primKey.auto!==o.primKey.auto&&!vt)a.recreate=!0,r.change.push(a);else{var u=i.idxByName,s=o.idxByName,c=void 0;for(c in u)s[c]||a.del.push(c);for(c in s){var l=u[c],f=s[c];l?l.src!==f.src&&a.change.push(f):a.add.push(f)}(0Math.pow(2,62)?0:e.oldVersion,p=e<1,f._novip.idbdb=l.result,bn(f,e/10,d,n))},n),l.onsuccess=We(function(){d=null;var e,t,n,r,i,o=f._novip.idbdb=l.result,a=y(o.objectStoreNames);if(0 { + 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)) + ); + }) + ); + } +}); diff --git a/app/templates/admin.html b/app/templates/admin.html new file mode 100644 index 0000000..55a967f --- /dev/null +++ b/app/templates/admin.html @@ -0,0 +1,369 @@ +{% extends "base.html" %} + +{% block title %}Admin - NextSnap{% endblock %} + +{% block content %} +
+
+

Admin Panel

+ +
+ + +
+

Add New User

+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + + +
+
+ + +
+

Nextcloud Users

+ + + +
+ + + + + + + + + + + + + +
UsernameDisplay NameEmailStatusActions
Loading...
+
+
+
+ + + + + + +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + + + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..2b7f13d --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + {% block title %}NextSnap{% endblock %} + + {% block extra_css %}{% endblock %} + + + +
+
+

NextSnap

+
+
+ +
+
+
+ + +
+ {% block content %}{% endblock %} +
+ + + {% if show_nav %} + + {% endif %} + + + + + + + {% block extra_js %}{% endblock %} + + diff --git a/app/templates/browser.html b/app/templates/browser.html new file mode 100644 index 0000000..932e666 --- /dev/null +++ b/app/templates/browser.html @@ -0,0 +1,484 @@ +{% extends "base.html" %} + +{% block title %}Files - NextSnap{% endblock %} + +{% block content %} +
+
+

File Browser

+ +
+ + + +
+

Loading...

+
+ +
+ + + + + + +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/app/templates/capture.html b/app/templates/capture.html new file mode 100644 index 0000000..2a896b4 --- /dev/null +++ b/app/templates/capture.html @@ -0,0 +1,407 @@ +{% extends "base.html" %} + +{% block title %}Capture - NextSnap{% endblock %} + +{% block content %} +
+
+

+ Uploading to: / + Change +

+ + + + + + + +
+ +
+

Recent Photos

+
+

No photos captured yet

+
+
+ + +
+
+{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + + + + + +{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..ff0db1a --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,208 @@ +{% extends "base.html" %} + +{% block title %}Login - NextSnap{% endblock %} + +{% block content %} +
+ +
+{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/queue.html b/app/templates/queue.html new file mode 100644 index 0000000..7d17bbf --- /dev/null +++ b/app/templates/queue.html @@ -0,0 +1,617 @@ +{% extends "base.html" %} + +{% block title %}Queue - NextSnap{% endblock %} + +{% block content %} +
+
+

Upload Queue

+
+ +
+
+ +
+
+ 0 + Pending +
+
+ 0 + Uploading +
+
+ 0 MB + Total Size +
+
+ +
+

No photos in queue

+
+
+ + + +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + + + + + + +{% endblock %} diff --git a/app/templates/reviewer.html b/app/templates/reviewer.html new file mode 100644 index 0000000..5390471 --- /dev/null +++ b/app/templates/reviewer.html @@ -0,0 +1,306 @@ +{% extends "base.html" %} + +{% block title %}Review Photos - NextSnap{% endblock %} + +{% block content %} +
+ +
+ +
1 / 1
+
+
+ + +
+
+
+
+ Photo +
+ + + + + + +
+
+ + .jpg + +
+
+ + + +
+{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + + + + + + +{% endblock %} diff --git a/app/templates/test.html b/app/templates/test.html new file mode 100644 index 0000000..e959ac8 --- /dev/null +++ b/app/templates/test.html @@ -0,0 +1,66 @@ + + + + + + Camera Test + + + +

Camera Test Page

+
Click counter: 0
+ + + + + +
+ + + + diff --git a/config.py b/config.py new file mode 100644 index 0000000..300a5f0 --- /dev/null +++ b/config.py @@ -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 +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..6deb2d4 --- /dev/null +++ b/docker-compose.dev.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bdb0422 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/nextsnap.md b/nextsnap.md new file mode 100644 index 0000000..2bfcf9a --- /dev/null +++ b/nextsnap.md @@ -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 `` 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 `` 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//enable` — Enables a user. +- `PUT /api/admin/users//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 ``. +- On file selection: + 1. Read the file as an `Image` object. + 2. Detect EXIF orientation and apply rotation correction on a ``. + 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 `` 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. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c640e8c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +Flask==3.0.* +gunicorn==21.* +requests==2.31.* +Flask-Session==0.8.0 +Pillow==10.* +pytest==7.4.* diff --git a/run.py b/run.py new file mode 100644 index 0000000..8f0a49e --- /dev/null +++ b/run.py @@ -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) diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..4edc710 --- /dev/null +++ b/setup.sh @@ -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" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d4839a6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..bbfb1d9 --- /dev/null +++ b/tests/test_auth.py @@ -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 diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..9ac36c7 --- /dev/null +++ b/tests/test_health.py @@ -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' diff --git a/tests/test_webdav_proxy.py b/tests/test_webdav_proxy.py new file mode 100644 index 0000000..bf0032a --- /dev/null +++ b/tests/test_webdav_proxy.py @@ -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