Files
conference/templates/attendee/profile.html
T
paul 3c26f1310e 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>
2026-05-27 07:10:52 +00:00

395 lines
15 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ 'profile'|t }} - NetEvents{% endblock %}
{% block content %}
<div class="profile-page">
<h1>{{ 'my_profile'|t }}</h1>
<div class="profile-container">
<div class="profile-photo-box">
<h3>{{ 'profile_photo'|t }}</h3>
<div class="profile-photo-section">
<div class="photo-crop-container" id="crop-container">
{% if attendee.profile_picture %}
<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 %}
<img src="" alt="{{ 'profile_photo'|t }}" class="crop-image d-none" id="crop-image">
<div class="no-photo-circle" id="no-photo-circle"><i data-lucide="user"></i></div>
{% endif %}
<!-- <div class="crop-ring"></div> -->
</div>
<div class="crop-slider-group">
<i data-lucide="minus" class="crop-slider-icon"></i>
<input type="range" id="zoom-slider" min="1" max="3" step="0.01" value="1" class="crop-slider">
<i data-lucide="plus" class="crop-slider-icon"></i>
<span id="zoom-label">100%</span>
</div>
<div class="photo-actions">
<label for="photo-input" class="btn btn-outline btn-sm">
<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>
<form method="POST" action="{{ url_for('attendee_profile') }}" class="profile-form">
<div class="form-row">
<div class="form-group">
<label for="first_name">{{ 'first_name'|t }}</label>
<input type="text" id="first_name" name="first_name" value="{{ attendee.first_name }}" required>
</div>
<div class="form-group">
<label for="last_name">{{ 'last_name'|t }}</label>
<input type="text" id="last_name" name="last_name" value="{{ attendee.last_name }}" required>
</div>
</div>
<div class="form-group">
<label for="email">{{ 'email'|t }}</label>
<input type="email" id="email" name="email" value="{{ attendee.email }}" required>
</div>
<div class="form-group">
<label for="organisation">{{ 'organisation'|t }}</label>
<input type="text" id="organisation" name="organisation" value="{{ attendee.organisation or '' }}">
</div>
<div class="form-group">
<label for="role">{{ 'role_profession'|t }}</label>
<input type="text" id="role" name="role" value="{{ attendee.role or '' }}">
</div>
<div class="form-row">
<div class="form-group">
<label for="phone">{{ 'phone'|t }}</label>
<input type="tel" id="phone" name="phone" value="{{ attendee.phone or '' }}">
</div>
<div class="form-group">
<label for="linkedin">{{ 'linkedin'|t }}</label>
<input type="url" id="linkedin" name="linkedin" value="{{ attendee.linkedin or '' }}">
</div>
</div>
<div class="form-group">
<label for="introduction">{{ 'short_introduction'|t }}</label>
<textarea id="introduction" name="introduction" rows="4">{{ attendee.introduction or '' }}</textarea>
</div>
<div class="form-group">
<label for="preferred_language">{{ 'language'|t }}</label>
<select id="preferred_language" name="preferred_language">
{% for lang in ['en', 'nl', 'de', 'fr', 'es', 'it', 'pl'] %}
<option value="{{ lang }}" {% if attendee.preferred_language == lang %}selected{% endif %}>{{ lang }}</option>
{% endfor %}
</select>
</div>
<div class="form-group switch-group">
<label for="visible_to_others">{{ 'visible_to_attendees'|t }}</label>
<label class="toggle">
<input type="checkbox" id="visible_to_others" name="visible_to_others" value="1" {% if attendee.visible_to_others %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</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 class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'update_profile'|t }}</button>
</div>
</form>
</div>
</div>
<script>
var photoCrop = {
containerSize: 280,
outputSize: 400,
zoomLevel: 1.0,
panX: 0,
panY: 0,
isDragging: false,
dragStartX: 0,
dragStartY: 0,
panStartX: 0,
panStartY: 0,
originalImage: null,
container: null,
image: null,
noPhoto: null,
zoomSlider: null,
zoomLabel: null,
saveBtn: null,
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');
console.log('photoCrop.init: container=' + !!this.container + ' image=' + !!this.image + ' noPhoto=' + !!this.noPhoto + ' photoInput=' + !!document.getElementById('photo-input'));
var self = this;
// File input change handler
var photoInput = document.getElementById('photo-input');
if (photoInput) {
photoInput.addEventListener('change', function(e) {
self.handleFileSelect(e);
});
}
// Zoom slider
if (this.zoomSlider) {
this.zoomSlider.addEventListener('input', function() {
self.zoomLevel = parseFloat(this.value);
self.zoomLabel.textContent = Math.round(self.zoomLevel * 100) + '%';
self.updateDisplay();
});
}
// Drag and wheel on container
if (this.container) {
this.container.addEventListener('mousedown', function(e) { self.startDrag(e); });
this.container.addEventListener('touchstart', function(e) { self.startDrag(e); }, { passive: false });
document.addEventListener('mousemove', function(e) { self.doDrag(e); });
document.addEventListener('touchmove', function(e) { self.doDrag(e); }, { passive: false });
document.addEventListener('mouseup', function(e) { self.endDrag(); });
document.addEventListener('touchend', function(e) { self.endDrag(); });
this.container.addEventListener('wheel', function(e) { self.handleWheel(e); }, { passive: false });
}
},
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;
};
reader.readAsDataURL(file);
},
updateDisplay: function() {
if (!this.originalImage) return;
var imgW = this.originalImage.naturalWidth;
var imgH = this.originalImage.naturalHeight;
var c = this.containerSize;
// 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;
}
// Apply zoom
dispW *= this.zoomLevel;
dispH *= this.zoomLevel;
// Center the image initially; panX/panY offset from centered position
var left = (c - dispW) / 2 + this.panX;
var top = (c - dispH) / 2 + this.panY;
this.image.style.width = dispW + 'px';
this.image.style.height = dispH + 'px';
this.image.style.left = left + 'px';
this.image.style.top = top + 'px';
},
startDrag: function(e) {
if (!this.originalImage) return;
this.isDragging = true;
var clientX = e.touches ? e.touches[0].clientX : e.clientX;
var clientY = e.touches ? e.touches[0].clientY : e.clientY;
this.dragStartX = clientX;
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();
}
};
// Called from the save button onclick
function savePhoto() {
var self = photoCrop;
if (!self.originalImage) {
if (typeof showToast !== 'undefined') showToast('Please select an image first', 'warning');
return;
}
if (self.saveBtn) {
self.saveBtn.classList.add('btn-loading');
self.saveBtn.disabled = true;
}
var imgW = self.originalImage.naturalWidth;
var imgH = self.originalImage.naturalHeight;
var c = self.containerSize;
// Recompute fit dimensions (same as updateDisplay)
var fitScale, dispW, dispH;
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);
srcH = Math.min(srcH, imgH - srcY);
// Draw to canvas
var canvas = document.createElement('canvas');
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) {
var formData = new FormData();
formData.append('photo', blob, 'profile.jpg');
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
fetch('/attendee/photo', {
method: 'POST',
body: formData,
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {}
}).then(function(response) {
return response.json();
}).then(function(data) {
if (self.saveBtn) {
self.saveBtn.classList.remove('btn-loading');
self.saveBtn.disabled = false;
}
if (data.success) {
// 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);
}
// Initialize immediately since the script is placed after all referenced DOM elements
photoCrop.init();
if (typeof lucide !== 'undefined') lucide.createIcons();
</script>
{% endblock %}