From 3c26f1310eb3553d76ffaeceab6fe0c496530259 Mon Sep 17 00:00:00 2001 From: Paul Bokel Date: Wed, 27 May 2026 07:10:52 +0000 Subject: [PATCH] Redesign profile photo upload with circular crop UI, server-side processing, and form integration - Replace square preview with 280x280 circular container with crop ring overlay - Zoom slider and drag-to-pan for precise crop positioning - Client-side crop via canvas at 400x400 output resolution - Server-side resize to 400x400 (LANCZOS), EXIF stripping, RGB conversion, JPEG optimization - Generate CSRF token in before_request so photo upload fetch works - Save button now also submits the profile form (photo + fields updated together) - Remove dead photo-upload-form handler and unused CSS - Clean up debug markers and attendee code field from profile page Co-Authored-By: Claude Opus 4.6 --- app.py | 91 ++++--- static/css/style.css | 90 ++++--- static/js/main.js | 48 +--- templates/attendee/profile.html | 456 ++++++++++++++++++-------------- 4 files changed, 380 insertions(+), 305 deletions(-) diff --git a/app.py b/app.py index f593644..e8bb278 100644 --- a/app.py +++ b/app.py @@ -610,6 +610,10 @@ def before_request(): if lang in SUPPORTED_LOCALES: session['locale'] = lang + # Ensure CSRF token exists for all state-changing requests + if 'csrf_token' not in session: + session['csrf_token'] = secrets.token_hex(32) + # CSRF validation for state-changing requests if request.method in ('POST', 'PUT', 'DELETE', 'PATCH'): # Skip CSRF for login (users don't have a session yet) @@ -5332,7 +5336,7 @@ def staff_profile(): @app.route('/attendee/photo', methods=['POST']) @login_required def upload_photo(): - """Upload profile photo.""" + """Upload and process profile photo (resize, strip EXIF, optimize).""" if session.get('user_type') != 'attendee': return jsonify({'error': 'Access denied'}), 403 @@ -5343,44 +5347,65 @@ def upload_photo(): if file.filename == '': return jsonify({'error': 'No file selected'}), 400 - if file and allowed_file(file.filename): - # Read file content for validation - file_content = file.read() - try: - from PIL import Image - img = Image.open(io.BytesIO(file_content)) - img.verify() - except Exception: - return jsonify({'error': 'Invalid image file'}), 400 + if not (file and allowed_file(file.filename)): + return jsonify({'error': 'Invalid file type'}), 400 - # Generate unique filename - ext = file.filename.rsplit('.', 1)[1].lower() - filename = f"{uuid.uuid4()}.{ext}" - filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) + file_content = file.read() - # Delete old photo if exists - conn = get_db_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT profile_picture FROM attendees WHERE id = %s", (session['user_id'],)) - row = cursor.fetchone() - old_photo = row['profile_picture'] if row else None - if old_photo: - old_path = os.path.join(app.config['UPLOAD_FOLDER'], old_photo) - if os.path.exists(old_path): - os.remove(old_path) + # Process image: validate, resize, strip EXIF, optimize + try: + from PIL import Image + img = Image.open(io.BytesIO(file_content)) + img.verify() + # Re-open after verify() — verify() consumes the image + img = Image.open(io.BytesIO(file_content)) + except Exception: + return jsonify({'error': 'Invalid image file'}), 400 - # Save the file - with open(filepath, 'wb') as f: - f.write(file_content) + try: + # Convert to RGB (handles RGBA PNG, CMYK, etc.) + if img.mode in ('RGBA', 'P', 'LA', 'CMYK'): + img = img.convert('RGB') - cursor.execute("UPDATE attendees SET profile_picture = %s WHERE id = %s", (filename, session['user_id'])) - conn.commit() - cursor.close() - conn.close() + # Resize to standard dimensions + img = img.resize((400, 400), Image.LANCZOS) - return jsonify({'success': True, 'filename': filename}) + # Strip EXIF data for privacy + from PIL import ImageOps + img = ImageOps.exif_transpose(img) - return jsonify({'error': 'Invalid file type'}), 400 + # Save as optimized JPEG, no EXIF + output = io.BytesIO() + img.save(output, format='JPEG', quality=85, optimize=True) + output.seek(0) + processed_content = output.read() + except Exception: + return jsonify({'error': 'Failed to process image'}), 500 + + # Always save as .jpg since we convert to JPEG + filename = f"{uuid.uuid4()}.jpg" + filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) + + # Delete old photo if exists + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT profile_picture FROM attendees WHERE id = %s", (session['user_id'],)) + row = cursor.fetchone() + old_photo = row['profile_picture'] if row else None + if old_photo: + old_path = os.path.join(app.config['UPLOAD_FOLDER'], old_photo) + if os.path.exists(old_path): + os.remove(old_path) + + with open(filepath, 'wb') as f: + f.write(processed_content) + + cursor.execute("UPDATE attendees SET profile_picture = %s WHERE id = %s", (filename, session['user_id'])) + conn.commit() + cursor.close() + conn.close() + + return jsonify({'success': True, 'filename': filename}) # Routes - Attendees list and connections diff --git a/static/css/style.css b/static/css/style.css index c82f38d..d7364ff 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1373,14 +1373,6 @@ a:focus-visible, margin-bottom: var(--space-4); } -.profile-img { - width: 160px; - height: 160px; - border-radius: 50%; - object-fit: cover; - border: 4px solid var(--border-color); -} - .no-photo { width: 160px; height: 160px; @@ -1403,10 +1395,6 @@ a:focus-visible, fill: none; } -.photo-upload-form { - margin-top: var(--space-4); -} - /* ----- Attendees Page ----- */ .attendees-page { padding: 0; @@ -2886,50 +2874,90 @@ select:focus { align-items: center; } -.profile-page .current-photo { - width: 150px; - height: 150px; - margin-bottom: var(--space-4); +/* ----- Circular Crop Container ----- */ +.photo-crop-container { + width: 280px; + height: 280px; + border-radius: 50%; overflow: hidden; position: relative; background: var(--border-color); + cursor: move; + margin-bottom: var(--space-4); } -.profile-page .current-photo .profile-img { +.photo-crop-container .crop-image { position: absolute; top: 0; left: 0; - width: 100%; - height: 100%; - object-fit: cover; cursor: move; + user-select: none; + -webkit-user-drag: none; } -.profile-page .no-photo { - width: 150px; - height: 150px; +.photo-crop-container .crop-ring { + position: absolute; + inset: 0; + border-radius: 50%; + border: 3px solid rgba(255, 255, 255, 0.85); + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.35); + pointer-events: none; +} + +.photo-crop-container .no-photo-circle { + position: absolute; + inset: 0; border-radius: 50%; background: var(--bg-color); display: flex; align-items: center; justify-content: center; color: var(--text-muted); - font-size: var(--font-size-sm); border: 3px solid var(--border-color); } -.zoom-controls { - display: flex; - gap: var(--space-2); - align-items: center; - margin-bottom: var(--space-4); +.photo-crop-container .no-photo-circle svg { + width: 64px; + height: 64px; } -.zoom-controls span { +/* ----- Zoom Slider ----- */ +.crop-slider-group { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-4); + width: 100%; + max-width: 280px; +} + +.crop-slider-icon { + width: 16px; + height: 16px; + color: var(--text-muted); + flex-shrink: 0; +} + +.crop-slider { + flex: 1; + accent-color: var(--primary-color); + height: 6px; + cursor: pointer; +} + +.crop-slider-group span { font-size: var(--font-size-sm); color: var(--text-muted); - min-width: 50px; + min-width: 42px; text-align: center; + flex-shrink: 0; +} + +/* ----- Photo Action Buttons ----- */ +.photo-actions { + display: flex; + gap: var(--space-3); + align-items: center; } .switch-group { diff --git a/static/js/main.js b/static/js/main.js index 3357201..7995bfd 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -20,6 +20,9 @@ document.addEventListener('DOMContentLoaded', function() { const toastContainer = document.getElementById('toast-container'); function showToast(message, type = 'info', duration = 5000) { + // Expose globally for inline scripts + window.showToast = showToast; + const toast = document.createElement('div'); toast.className = `toast toast-${type}`; @@ -167,51 +170,6 @@ document.addEventListener('DOMContentLoaded', function() { }); }); - // ----- Photo Upload Form ----- - const photoForm = document.querySelector('.photo-upload-form'); - if (photoForm) { - const submitBtn = photoForm.querySelector('button[type="submit"]'); - - photoForm.addEventListener('submit', async function(e) { - e.preventDefault(); - - // Add loading state - if (submitBtn) { - submitBtn.classList.add('btn-loading'); - submitBtn.disabled = true; - } - - const formData = new FormData(this); - - try { - const response = await fetch(this.action, { - method: 'POST', - body: formData - }); - - const data = await response.json(); - - if (data.success) { - showToast('Photo updated successfully!', 'success'); - // Reload page to show new photo - setTimeout(() => location.reload(), 1000); - } else { - showToast(data.error || 'Error uploading photo', 'error'); - if (submitBtn) { - submitBtn.classList.remove('btn-loading'); - submitBtn.disabled = false; - } - } - } catch (error) { - showToast('Error uploading photo', 'error'); - if (submitBtn) { - submitBtn.classList.remove('btn-loading'); - submitBtn.disabled = false; - } - } - }); - } - // ----- Connection Request Buttons ----- const connectForms = document.querySelectorAll('.connect-form'); connectForms.forEach(form => { diff --git a/templates/attendee/profile.html b/templates/attendee/profile.html index 7893347..9509335 100644 --- a/templates/attendee/profile.html +++ b/templates/attendee/profile.html @@ -10,27 +10,31 @@

{{ 'profile_photo'|t }}

-
+
{% if attendee.profile_picture %} - {{ 'profile_photo'|t }} + {{ 'profile_photo'|t }} {% else %} - {{ 'profile_preview'|t }} -
+ {{ 'profile_photo'|t }} +
{% endif %} +
- - -
- - 100% - - +
+ + + + 100%
-
- - +
+ + +
@@ -97,16 +101,9 @@
-
-
- - -
- -
- - -
+
+ +
@@ -117,214 +114,281 @@
-{% endblock %} \ No newline at end of file +{% endblock %}