Add NextSnap PWA with photo gallery viewer and continuous capture

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

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

30
app/__init__.py Normal file
View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

View File

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

View File

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

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

File diff suppressed because one or more lines are too long

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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