3c26f1310e
- 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>
395 lines
15 KiB
HTML
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 %}
|