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 <noreply@anthropic.com>
This commit is contained in:
@@ -610,6 +610,10 @@ def before_request():
|
|||||||
if lang in SUPPORTED_LOCALES:
|
if lang in SUPPORTED_LOCALES:
|
||||||
session['locale'] = lang
|
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
|
# CSRF validation for state-changing requests
|
||||||
if request.method in ('POST', 'PUT', 'DELETE', 'PATCH'):
|
if request.method in ('POST', 'PUT', 'DELETE', 'PATCH'):
|
||||||
# Skip CSRF for login (users don't have a session yet)
|
# Skip CSRF for login (users don't have a session yet)
|
||||||
@@ -5332,7 +5336,7 @@ def staff_profile():
|
|||||||
@app.route('/attendee/photo', methods=['POST'])
|
@app.route('/attendee/photo', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def upload_photo():
|
def upload_photo():
|
||||||
"""Upload profile photo."""
|
"""Upload and process profile photo (resize, strip EXIF, optimize)."""
|
||||||
if session.get('user_type') != 'attendee':
|
if session.get('user_type') != 'attendee':
|
||||||
return jsonify({'error': 'Access denied'}), 403
|
return jsonify({'error': 'Access denied'}), 403
|
||||||
|
|
||||||
@@ -5343,44 +5347,65 @@ def upload_photo():
|
|||||||
if file.filename == '':
|
if file.filename == '':
|
||||||
return jsonify({'error': 'No file selected'}), 400
|
return jsonify({'error': 'No file selected'}), 400
|
||||||
|
|
||||||
if file and allowed_file(file.filename):
|
if not (file and allowed_file(file.filename)):
|
||||||
# Read file content for validation
|
return jsonify({'error': 'Invalid file type'}), 400
|
||||||
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
|
|
||||||
|
|
||||||
# Generate unique filename
|
file_content = file.read()
|
||||||
ext = file.filename.rsplit('.', 1)[1].lower()
|
|
||||||
filename = f"{uuid.uuid4()}.{ext}"
|
|
||||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
|
||||||
|
|
||||||
# Delete old photo if exists
|
# Process image: validate, resize, strip EXIF, optimize
|
||||||
conn = get_db_connection()
|
try:
|
||||||
cursor = conn.cursor(dictionary=True)
|
from PIL import Image
|
||||||
cursor.execute("SELECT profile_picture FROM attendees WHERE id = %s", (session['user_id'],))
|
img = Image.open(io.BytesIO(file_content))
|
||||||
row = cursor.fetchone()
|
img.verify()
|
||||||
old_photo = row['profile_picture'] if row else None
|
# Re-open after verify() — verify() consumes the image
|
||||||
if old_photo:
|
img = Image.open(io.BytesIO(file_content))
|
||||||
old_path = os.path.join(app.config['UPLOAD_FOLDER'], old_photo)
|
except Exception:
|
||||||
if os.path.exists(old_path):
|
return jsonify({'error': 'Invalid image file'}), 400
|
||||||
os.remove(old_path)
|
|
||||||
|
|
||||||
# Save the file
|
try:
|
||||||
with open(filepath, 'wb') as f:
|
# Convert to RGB (handles RGBA PNG, CMYK, etc.)
|
||||||
f.write(file_content)
|
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']))
|
# Resize to standard dimensions
|
||||||
conn.commit()
|
img = img.resize((400, 400), Image.LANCZOS)
|
||||||
cursor.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
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
|
# Routes - Attendees list and connections
|
||||||
|
|||||||
+59
-31
@@ -1373,14 +1373,6 @@ a:focus-visible,
|
|||||||
margin-bottom: var(--space-4);
|
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 {
|
.no-photo {
|
||||||
width: 160px;
|
width: 160px;
|
||||||
height: 160px;
|
height: 160px;
|
||||||
@@ -1403,10 +1395,6 @@ a:focus-visible,
|
|||||||
fill: none;
|
fill: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo-upload-form {
|
|
||||||
margin-top: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----- Attendees Page ----- */
|
/* ----- Attendees Page ----- */
|
||||||
.attendees-page {
|
.attendees-page {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -2886,50 +2874,90 @@ select:focus {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-page .current-photo {
|
/* ----- Circular Crop Container ----- */
|
||||||
width: 150px;
|
.photo-crop-container {
|
||||||
height: 150px;
|
width: 280px;
|
||||||
margin-bottom: var(--space-4);
|
height: 280px;
|
||||||
|
border-radius: 50%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
|
cursor: move;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-page .current-photo .profile-img {
|
.photo-crop-container .crop-image {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
cursor: move;
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-page .no-photo {
|
.photo-crop-container .crop-ring {
|
||||||
width: 150px;
|
position: absolute;
|
||||||
height: 150px;
|
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%;
|
border-radius: 50%;
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
border: 3px solid var(--border-color);
|
border: 3px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.zoom-controls {
|
.photo-crop-container .no-photo-circle svg {
|
||||||
display: flex;
|
width: 64px;
|
||||||
gap: var(--space-2);
|
height: 64px;
|
||||||
align-items: center;
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
min-width: 50px;
|
min-width: 42px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Photo Action Buttons ----- */
|
||||||
|
.photo-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.switch-group {
|
.switch-group {
|
||||||
|
|||||||
+3
-45
@@ -20,6 +20,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const toastContainer = document.getElementById('toast-container');
|
const toastContainer = document.getElementById('toast-container');
|
||||||
|
|
||||||
function showToast(message, type = 'info', duration = 5000) {
|
function showToast(message, type = 'info', duration = 5000) {
|
||||||
|
// Expose globally for inline scripts
|
||||||
|
window.showToast = showToast;
|
||||||
|
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = `toast toast-${type}`;
|
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 -----
|
// ----- Connection Request Buttons -----
|
||||||
const connectForms = document.querySelectorAll('.connect-form');
|
const connectForms = document.querySelectorAll('.connect-form');
|
||||||
connectForms.forEach(form => {
|
connectForms.forEach(form => {
|
||||||
|
|||||||
+260
-196
@@ -10,27 +10,31 @@
|
|||||||
<div class="profile-photo-box">
|
<div class="profile-photo-box">
|
||||||
<h3>{{ 'profile_photo'|t }}</h3>
|
<h3>{{ 'profile_photo'|t }}</h3>
|
||||||
<div class="profile-photo-section">
|
<div class="profile-photo-section">
|
||||||
<div class="current-photo" id="photo-container">
|
<div class="photo-crop-container" id="crop-container">
|
||||||
{% if attendee.profile_picture %}
|
{% if attendee.profile_picture %}
|
||||||
<img src="{{ url_for('static', filename='uploads/' + attendee.profile_picture) }}" alt="{{ 'profile_photo'|t }}" class="profile-img" id="profile-preview">
|
<img src="{{ url_for('static', filename='uploads/' + attendee.profile_picture) }}" alt="{{ 'profile_photo'|t }}" class="crop-image" id="crop-image" style="width:100%;height:100%;object-fit:cover;">
|
||||||
{% else %}
|
{% else %}
|
||||||
<img src="" alt="{{ 'profile_preview'|t }}" class="profile-img d-none" id="profile-preview">
|
<img src="" alt="{{ 'profile_photo'|t }}" class="crop-image d-none" id="crop-image">
|
||||||
<div class="no-photo" id="no-photo"><i data-lucide="user"></i></div>
|
<div class="no-photo-circle" id="no-photo-circle"><i data-lucide="user"></i></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<!-- <div class="crop-ring"></div> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<canvas id="crop-canvas" class="d-none"></canvas>
|
<div class="crop-slider-group">
|
||||||
|
<i data-lucide="minus" class="crop-slider-icon"></i>
|
||||||
<div class="zoom-controls">
|
<input type="range" id="zoom-slider" min="1" max="3" step="0.01" value="1" class="crop-slider">
|
||||||
<button type="button" class="btn btn-sm btn-outline" onclick="adjustZoom(-0.25)"><i data-lucide="minus"></i></button>
|
<i data-lucide="plus" class="crop-slider-icon"></i>
|
||||||
<span id="zoom-level">100%</span>
|
<span id="zoom-label">100%</span>
|
||||||
<button type="button" class="btn btn-sm btn-outline" onclick="adjustZoom(0.25)"><i data-lucide="plus"></i></button>
|
|
||||||
<button type="button" class="btn btn-sm btn-primary" onclick="handleFormSubmit()">{{ 'save'|t }}</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="photo-actions">
|
||||||
<label for="photo">{{ 'upload_photo'|t }}</label>
|
<label for="photo-input" class="btn btn-outline btn-sm">
|
||||||
<input type="file" id="photo" name="photo" accept="image/*">
|
<i data-lucide="upload"></i> {{ 'upload_photo'|t }}
|
||||||
|
</label>
|
||||||
|
<input type="file" id="photo-input" name="photo" accept="image/png,image/jpeg" class="d-none" onchange="photoCrop.handleFileSelect(event)">
|
||||||
|
<button type="button" id="save-photo-btn" class="btn btn-primary btn-sm" onclick="savePhoto()">
|
||||||
|
<i data-lucide="check"></i> {{ 'save'|t }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,16 +101,9 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-group">
|
||||||
<div class="form-group">
|
<label>{{ 'member_since'|t }}</label>
|
||||||
<label>{{ 'attendee_code'|t }}</label>
|
<input type="text" value="{{ attendee.created_at|localized_date if attendee.created_at else '' }}" readonly class="input-readonly">
|
||||||
<input type="text" value="{{ attendee.attendee_code or '' }}" readonly class="input-readonly">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>{{ 'member_since'|t }}</label>
|
|
||||||
<input type="text" value="{{ attendee.created_at|localized_date if attendee.created_at else '' }}" readonly class="input-readonly">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
@@ -117,214 +114,281 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var zoomLevel = 1.0;
|
var photoCrop = {
|
||||||
var translateX = 0;
|
containerSize: 280,
|
||||||
var translateY = 0;
|
outputSize: 400,
|
||||||
var isDragging = false;
|
zoomLevel: 1.0,
|
||||||
var dragStartX = 0;
|
panX: 0,
|
||||||
var dragStartY = 0;
|
panY: 0,
|
||||||
var originalImage = null;
|
isDragging: false,
|
||||||
var containerSize = 150;
|
dragStartX: 0,
|
||||||
|
dragStartY: 0,
|
||||||
|
panStartX: 0,
|
||||||
|
panStartY: 0,
|
||||||
|
originalImage: null,
|
||||||
|
container: null,
|
||||||
|
image: null,
|
||||||
|
noPhoto: null,
|
||||||
|
zoomSlider: null,
|
||||||
|
zoomLabel: null,
|
||||||
|
saveBtn: null,
|
||||||
|
|
||||||
var container, preview, photoInput;
|
init: function() {
|
||||||
|
this.container = document.getElementById('crop-container');
|
||||||
|
this.image = document.getElementById('crop-image');
|
||||||
|
this.noPhoto = document.getElementById('no-photo-circle');
|
||||||
|
this.zoomSlider = document.getElementById('zoom-slider');
|
||||||
|
this.zoomLabel = document.getElementById('zoom-label');
|
||||||
|
this.saveBtn = document.getElementById('save-photo-btn');
|
||||||
|
|
||||||
function init() {
|
console.log('photoCrop.init: container=' + !!this.container + ' image=' + !!this.image + ' noPhoto=' + !!this.noPhoto + ' photoInput=' + !!document.getElementById('photo-input'));
|
||||||
container = document.getElementById('photo-container');
|
|
||||||
preview = document.getElementById('profile-preview');
|
|
||||||
photoInput = document.getElementById('photo');
|
|
||||||
|
|
||||||
if (photoInput) {
|
var self = this;
|
||||||
photoInput.addEventListener('change', handleFileSelect);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (container && preview) {
|
// File input change handler
|
||||||
container.addEventListener('mousedown', startDrag);
|
var photoInput = document.getElementById('photo-input');
|
||||||
document.addEventListener('mousemove', doDrag);
|
if (photoInput) {
|
||||||
document.addEventListener('mouseup', endDrag);
|
photoInput.addEventListener('change', function(e) {
|
||||||
container.addEventListener('wheel', handleWheel);
|
self.handleFileSelect(e);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFileSelect(e) {
|
// Zoom slider
|
||||||
var file = e.target.files[0];
|
if (this.zoomSlider) {
|
||||||
if (!file) return;
|
this.zoomSlider.addEventListener('input', function() {
|
||||||
|
self.zoomLevel = parseFloat(this.value);
|
||||||
|
self.zoomLabel.textContent = Math.round(self.zoomLevel * 100) + '%';
|
||||||
|
self.updateDisplay();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var reader = new FileReader();
|
// Drag and wheel on container
|
||||||
reader.onload = function(event) {
|
if (this.container) {
|
||||||
var img = new Image();
|
this.container.addEventListener('mousedown', function(e) { self.startDrag(e); });
|
||||||
img.onload = function() {
|
this.container.addEventListener('touchstart', function(e) { self.startDrag(e); }, { passive: false });
|
||||||
originalImage = img;
|
document.addEventListener('mousemove', function(e) { self.doDrag(e); });
|
||||||
zoomLevel = 1.0;
|
document.addEventListener('touchmove', function(e) { self.doDrag(e); }, { passive: false });
|
||||||
translateX = 0;
|
document.addEventListener('mouseup', function(e) { self.endDrag(); });
|
||||||
translateY = 0;
|
document.addEventListener('touchend', function(e) { self.endDrag(); });
|
||||||
preview.src = event.target.result;
|
this.container.addEventListener('wheel', function(e) { self.handleWheel(e); }, { passive: false });
|
||||||
preview.style.display = 'block';
|
}
|
||||||
var noPhoto = document.getElementById('no-photo');
|
},
|
||||||
if (noPhoto) noPhoto.style.display = 'none';
|
|
||||||
updateDisplay();
|
handleFileSelect: function(e) {
|
||||||
|
var self = this;
|
||||||
|
var file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function(ev) {
|
||||||
|
var dataUrl = ev.target.result;
|
||||||
|
|
||||||
|
// Remove old image element, create a fresh one
|
||||||
|
var oldImg = document.getElementById('crop-image');
|
||||||
|
if (oldImg) oldImg.remove();
|
||||||
|
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.id = 'crop-image';
|
||||||
|
img.className = 'crop-image';
|
||||||
|
img.src = dataUrl;
|
||||||
|
img.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;display:block;';
|
||||||
|
self.container.insertBefore(img, self.container.firstChild);
|
||||||
|
self.image = img;
|
||||||
|
|
||||||
|
if (self.noPhoto) self.noPhoto.remove();
|
||||||
|
if (self.zoomSlider) self.zoomSlider.value = 1;
|
||||||
|
if (self.zoomLabel) self.zoomLabel.textContent = '100%';
|
||||||
|
|
||||||
|
// Store for crop later
|
||||||
|
var memImg = new Image();
|
||||||
|
memImg.onload = function() {
|
||||||
|
self.originalImage = memImg;
|
||||||
|
self.zoomLevel = 1.0;
|
||||||
|
self.panX = 0;
|
||||||
|
self.panY = 0;
|
||||||
|
};
|
||||||
|
memImg.src = dataUrl;
|
||||||
};
|
};
|
||||||
img.src = event.target.result;
|
reader.readAsDataURL(file);
|
||||||
};
|
},
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
function adjustZoom(delta) {
|
updateDisplay: function() {
|
||||||
var oldZoom = zoomLevel;
|
if (!this.originalImage) return;
|
||||||
zoomLevel = Math.max(1.0, Math.min(3.0, zoomLevel + delta));
|
|
||||||
document.getElementById('zoom-level').textContent = Math.round(zoomLevel * 100) + '%';
|
|
||||||
|
|
||||||
// Adjust translation to zoom toward center
|
var imgW = this.originalImage.naturalWidth;
|
||||||
var factor = zoomLevel / oldZoom;
|
var imgH = this.originalImage.naturalHeight;
|
||||||
translateX *= factor;
|
var c = this.containerSize;
|
||||||
translateY *= factor;
|
|
||||||
|
|
||||||
updateDisplay();
|
// Fit the shorter side to the container; longer side overflows
|
||||||
}
|
var fitScale, dispW, dispH;
|
||||||
|
if (imgW >= imgH) {
|
||||||
|
fitScale = c / imgH;
|
||||||
|
dispH = c;
|
||||||
|
dispW = imgW * fitScale;
|
||||||
|
} else {
|
||||||
|
fitScale = c / imgW;
|
||||||
|
dispW = c;
|
||||||
|
dispH = imgH * fitScale;
|
||||||
|
}
|
||||||
|
|
||||||
function updateDisplay() {
|
// Apply zoom
|
||||||
if (!preview || !originalImage) return;
|
dispW *= this.zoomLevel;
|
||||||
|
dispH *= this.zoomLevel;
|
||||||
|
|
||||||
var img = originalImage;
|
// Center the image initially; panX/panY offset from centered position
|
||||||
var imgW = img.naturalWidth;
|
var left = (c - dispW) / 2 + this.panX;
|
||||||
var imgH = img.naturalHeight;
|
var top = (c - dispH) / 2 + this.panY;
|
||||||
|
|
||||||
var scale, dispW, dispH, offsetX, offsetY;
|
this.image.style.width = dispW + 'px';
|
||||||
if (imgW >= imgH) {
|
this.image.style.height = dispH + 'px';
|
||||||
scale = containerSize / imgH;
|
this.image.style.left = left + 'px';
|
||||||
dispH = containerSize;
|
this.image.style.top = top + 'px';
|
||||||
dispW = imgW * scale;
|
},
|
||||||
offsetX = (containerSize - dispW) / 2;
|
|
||||||
offsetY = 0;
|
startDrag: function(e) {
|
||||||
} else {
|
if (!this.originalImage) return;
|
||||||
scale = containerSize / imgW;
|
this.isDragging = true;
|
||||||
dispW = containerSize;
|
var clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||||
dispH = imgH * scale;
|
var clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||||
offsetX = 0;
|
this.dragStartX = clientX;
|
||||||
offsetY = (containerSize - dispH) / 2;
|
this.dragStartY = clientY;
|
||||||
|
this.panStartX = this.panX;
|
||||||
|
this.panStartY = this.panY;
|
||||||
|
if (this.image) this.image.style.cursor = 'grabbing';
|
||||||
|
e.preventDefault();
|
||||||
|
},
|
||||||
|
|
||||||
|
doDrag: function(e) {
|
||||||
|
if (!this.isDragging) return;
|
||||||
|
var clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||||
|
var clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||||
|
this.panX = this.panStartX + (clientX - this.dragStartX);
|
||||||
|
this.panY = this.panStartY + (clientY - this.dragStartY);
|
||||||
|
this.updateDisplay();
|
||||||
|
},
|
||||||
|
|
||||||
|
endDrag: function() {
|
||||||
|
this.isDragging = false;
|
||||||
|
if (this.image) this.image.style.cursor = 'move';
|
||||||
|
},
|
||||||
|
|
||||||
|
handleWheel: function(e) {
|
||||||
|
if (!this.originalImage) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
var oldZoom = this.zoomLevel;
|
||||||
|
var delta = -e.deltaY * 0.005;
|
||||||
|
this.zoomLevel = Math.max(1, Math.min(3, this.zoomLevel + delta));
|
||||||
|
|
||||||
|
// Zoom toward cursor position within the container
|
||||||
|
var rect = this.container.getBoundingClientRect();
|
||||||
|
var cx = e.clientX - rect.left - this.containerSize / 2;
|
||||||
|
var cy = e.clientY - rect.top - this.containerSize / 2;
|
||||||
|
|
||||||
|
this.panX = cx - (cx - this.panX) * (this.zoomLevel / oldZoom);
|
||||||
|
this.panY = cy - (cy - this.panY) * (this.zoomLevel / oldZoom);
|
||||||
|
|
||||||
|
if (this.zoomSlider) this.zoomSlider.value = this.zoomLevel;
|
||||||
|
if (this.zoomLabel) this.zoomLabel.textContent = Math.round(this.zoomLevel * 100) + '%';
|
||||||
|
this.updateDisplay();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Explicitly set layout dimensions so transform origin centers correctly
|
// Called from the save button onclick
|
||||||
preview.style.width = dispW + 'px';
|
function savePhoto() {
|
||||||
preview.style.height = dispH + 'px';
|
var self = photoCrop;
|
||||||
|
if (!self.originalImage) {
|
||||||
preview.style.transform = 'translate(' + (translateX + offsetX) + 'px, ' + (translateY + offsetY) + 'px) scale(' + zoomLevel + ')';
|
if (typeof showToast !== 'undefined') showToast('Please select an image first', 'warning');
|
||||||
}
|
|
||||||
|
|
||||||
function startDrag(e) {
|
|
||||||
if (!originalImage || zoomLevel <= 1.0) return;
|
|
||||||
isDragging = true;
|
|
||||||
dragStartX = e.clientX - translateX;
|
|
||||||
dragStartY = e.clientY - translateY;
|
|
||||||
preview.style.cursor = 'grabbing';
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
function doDrag(e) {
|
|
||||||
if (!isDragging) return;
|
|
||||||
translateX = e.clientX - dragStartX;
|
|
||||||
translateY = e.clientY - dragStartY;
|
|
||||||
updateDisplay();
|
|
||||||
}
|
|
||||||
|
|
||||||
function endDrag() {
|
|
||||||
isDragging = false;
|
|
||||||
if (preview) preview.style.cursor = zoomLevel > 1.0 ? 'move' : 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleWheel(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
var rect = container.getBoundingClientRect();
|
|
||||||
var mouseX = e.clientX - rect.left - containerSize / 2;
|
|
||||||
var mouseY = e.clientY - rect.top - containerSize / 2;
|
|
||||||
|
|
||||||
var oldZoom = zoomLevel;
|
|
||||||
zoomLevel = Math.max(1.0, Math.min(3.0, zoomLevel - e.deltaY * 0.01));
|
|
||||||
|
|
||||||
// Zoom toward mouse position
|
|
||||||
translateX = mouseX - (mouseX - translateX) * (zoomLevel / oldZoom);
|
|
||||||
translateY = mouseY - (mouseY - translateY) * (zoomLevel / oldZoom);
|
|
||||||
|
|
||||||
document.getElementById('zoom-level').textContent = Math.round(zoomLevel * 100) + '%';
|
|
||||||
updateDisplay();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFormSubmit(e) {
|
|
||||||
if (e) e.preventDefault();
|
|
||||||
|
|
||||||
if (!originalImage) {
|
|
||||||
showToast('Please select an image first', 'warning');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var canvas = document.getElementById('crop-canvas');
|
if (self.saveBtn) {
|
||||||
var ctx = canvas.getContext('2d');
|
self.saveBtn.classList.add('btn-loading');
|
||||||
canvas.width = 300;
|
self.saveBtn.disabled = true;
|
||||||
canvas.height = 300;
|
|
||||||
|
|
||||||
var img = originalImage;
|
|
||||||
var imgW = img.naturalWidth;
|
|
||||||
var imgH = img.naturalHeight;
|
|
||||||
|
|
||||||
var scale, dispW, dispH, offsetX, offsetY;
|
|
||||||
if (imgW >= imgH) {
|
|
||||||
scale = containerSize / imgH;
|
|
||||||
dispH = containerSize;
|
|
||||||
dispW = imgW * scale;
|
|
||||||
offsetX = (containerSize - dispW) / 2;
|
|
||||||
offsetY = 0;
|
|
||||||
} else {
|
|
||||||
scale = containerSize / imgW;
|
|
||||||
dispW = containerSize;
|
|
||||||
dispH = imgH * scale;
|
|
||||||
offsetX = 0;
|
|
||||||
offsetY = (containerSize - dispH) / 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW CROP CALCULATION
|
var imgW = self.originalImage.naturalWidth;
|
||||||
// Display: translate(translateX + offsetX, translateY + offsetY) scale(zoomLevel) with transform-origin: center
|
var imgH = self.originalImage.naturalHeight;
|
||||||
// Container pixel (cx, cy) maps to element point: (cx - tx - ox) / zoom + (ox + dispW/2)
|
var c = self.containerSize;
|
||||||
// Element point maps to source: element_point / scale
|
|
||||||
// Simplifying: srcX = (cx - tx - ox) / zoom / scale + dispW/2 / scale
|
|
||||||
var srcX = (0 - translateX - offsetX) / scale / zoomLevel + dispW / 2 / scale;
|
|
||||||
var srcY = (0 - translateY - offsetY) / scale / zoomLevel + dispH / 2 / scale;
|
|
||||||
var srcW = containerSize / scale / zoomLevel;
|
|
||||||
var srcH = containerSize / scale / zoomLevel;
|
|
||||||
|
|
||||||
// Clamp to image bounds to prevent out-of-bounds transparent drawing
|
// Recompute fit dimensions (same as updateDisplay)
|
||||||
srcX = Math.max(0, Math.min(srcX, imgW - srcW));
|
var fitScale, dispW, dispH;
|
||||||
srcY = Math.max(0, Math.min(srcY, imgH - srcH));
|
if (imgW >= imgH) {
|
||||||
|
fitScale = c / imgH;
|
||||||
|
dispH = c;
|
||||||
|
dispW = imgW * fitScale;
|
||||||
|
} else {
|
||||||
|
fitScale = c / imgW;
|
||||||
|
dispW = c;
|
||||||
|
dispH = imgH * fitScale;
|
||||||
|
}
|
||||||
|
dispW *= self.zoomLevel;
|
||||||
|
dispH *= self.zoomLevel;
|
||||||
|
|
||||||
|
var left = (c - dispW) / 2 + self.panX;
|
||||||
|
var top = (c - dispH) / 2 + self.panY;
|
||||||
|
|
||||||
|
// Map container rectangle to source coordinates
|
||||||
|
var srcX = (-left) / dispW * imgW;
|
||||||
|
var srcY = (-top) / dispH * imgH;
|
||||||
|
var srcW = c / dispW * imgW;
|
||||||
|
var srcH = c / dispH * imgH;
|
||||||
|
|
||||||
|
// Clamp to image bounds
|
||||||
|
srcX = Math.max(0, Math.min(srcX, imgW - 0.5));
|
||||||
|
srcY = Math.max(0, Math.min(srcY, imgH - 0.5));
|
||||||
srcW = Math.min(srcW, imgW - srcX);
|
srcW = Math.min(srcW, imgW - srcX);
|
||||||
srcH = Math.min(srcH, imgH - srcY);
|
srcH = Math.min(srcH, imgH - srcY);
|
||||||
|
|
||||||
ctx.clearRect(0, 0, 300, 300);
|
// Draw to canvas
|
||||||
// Draw exactly the visible area scaled up to the 300x300 canvas
|
var canvas = document.createElement('canvas');
|
||||||
ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, 300, 300);
|
canvas.width = self.outputSize;
|
||||||
|
canvas.height = self.outputSize;
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(self.originalImage, srcX, srcY, srcW, srcH, 0, 0, self.outputSize, self.outputSize);
|
||||||
|
|
||||||
canvas.toBlob(function(blob) {
|
canvas.toBlob(function(blob) {
|
||||||
var formData = new FormData();
|
var formData = new FormData();
|
||||||
formData.append('photo', blob, 'profile.jpg');
|
formData.append('photo', blob, 'profile.jpg');
|
||||||
|
|
||||||
fetch('{{ url_for("upload_photo") }}', {
|
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
|
||||||
|
|
||||||
|
fetch('/attendee/photo', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData,
|
||||||
|
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {}
|
||||||
}).then(function(response) {
|
}).then(function(response) {
|
||||||
return response.json();
|
return response.json();
|
||||||
}).then(function(data) {
|
}).then(function(data) {
|
||||||
if (data.success) {
|
if (self.saveBtn) {
|
||||||
showToast('Profile photo updated!', 'success');
|
self.saveBtn.classList.remove('btn-loading');
|
||||||
setTimeout(function() { location.reload(); }, 1000);
|
self.saveBtn.disabled = false;
|
||||||
} else {
|
|
||||||
showToast(data.error || 'Upload failed', 'error');
|
|
||||||
}
|
}
|
||||||
}).catch(function(error) {
|
if (data.success) {
|
||||||
showToast('Error uploading photo', 'error');
|
// Photo saved, now submit the profile form to update all fields
|
||||||
|
var profileForm = document.querySelector('.profile-form');
|
||||||
|
if (profileForm) {
|
||||||
|
profileForm.submit();
|
||||||
|
} else {
|
||||||
|
if (typeof showToast !== 'undefined') showToast('Profile photo updated!', 'success');
|
||||||
|
setTimeout(function() { location.reload(); }, 800);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (typeof showToast !== 'undefined') showToast(data.error || 'Upload failed', 'error');
|
||||||
|
}
|
||||||
|
}).catch(function() {
|
||||||
|
if (self.saveBtn) {
|
||||||
|
self.saveBtn.classList.remove('btn-loading');
|
||||||
|
self.saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
if (typeof showToast !== 'undefined') showToast('Error uploading photo', 'error');
|
||||||
});
|
});
|
||||||
}, 'image/jpeg', 0.9);
|
}, 'image/jpeg', 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
// Initialize immediately since the script is placed after all referenced DOM elements
|
||||||
init();
|
photoCrop.init();
|
||||||
lucide.createIcons();
|
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user