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:
30
app/__init__.py
Normal file
30
app/__init__.py
Normal 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
1
app/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Routes package initialization
|
||||
131
app/routes/admin.py
Normal file
131
app/routes/admin.py
Normal 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
72
app/routes/auth.py
Normal 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
35
app/routes/health.py
Normal 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
84
app/routes/views.py
Normal 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
186
app/routes/webdav_proxy.py
Normal 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
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Services package initialization
|
||||
39
app/services/auth.py
Normal file
39
app/services/auth.py
Normal 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
350
app/services/nextcloud.py
Normal 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
768
app/static/css/style.css
Normal 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;
|
||||
}
|
||||
BIN
app/static/icons/icon-192.png
Normal file
BIN
app/static/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
BIN
app/static/icons/icon-512.png
Normal file
BIN
app/static/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
246
app/static/js/admin.js
Normal file
246
app/static/js/admin.js
Normal 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
111
app/static/js/app.js
Normal 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
160
app/static/js/auth.js
Normal 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
318
app/static/js/camera.js
Normal 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`;
|
||||
}
|
||||
};
|
||||
530
app/static/js/filebrowser.js
Normal file
530
app/static/js/filebrowser.js
Normal 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
300
app/static/js/polish.js
Normal 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
288
app/static/js/reviewer.js
Normal 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
152
app/static/js/storage.js
Normal 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
300
app/static/js/sync.js
Normal 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');
|
||||
242
app/static/js/sync_broken_backup.js
Normal file
242
app/static/js/sync_broken_backup.js
Normal 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);
|
||||
6
app/static/js/sync_fix.txt
Normal file
6
app/static/js/sync_fix.txt
Normal 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
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
27
app/static/manifest.json
Normal 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
171
app/static/sw.js
Normal 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
369
app/templates/admin.html
Normal 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
102
app/templates/base.html
Normal 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
484
app/templates/browser.html
Normal 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">×</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">‹</button>
|
||||
<button class="gallery-nav gallery-nav-right" id="gallery-next-btn">›</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
407
app/templates/capture.html
Normal 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
208
app/templates/login.html
Normal 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
617
app/templates/queue.html
Normal 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
306
app/templates/reviewer.html
Normal 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">←</button>
|
||||
<button class="nav-arrow nav-arrow-right" id="next-btn" aria-label="Next">→</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
66
app/templates/test.html
Normal 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>
|
||||
Reference in New Issue
Block a user