a3546b4c01
- Move all credentials from hardcoded to .env with python-dotenv (add .env.example template and .gitignore) - Add CSRF token generation and validation on all state-changing requests - Replace random.choices with secrets.choice for all secure code generation - Add session cookie security headers (HttpOnly, Secure, SameSite) - Add presenter management: assign/remove presenters to breakout sessions, presenter QR scanning - Add email communications system: templates per event, custom email sending, sent email tracking - Add staff/organizer profile pages with staff code display - New DB tables: event_email_templates, sent_emails, user_communications - New DB columns: staff.reminder_count/reminder_dates, breakout_session_organizers.presenter_code - Expand English translation strings across all new features Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
330 lines
12 KiB
HTML
330 lines
12 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="current-photo" id="photo-container">
|
|
{% if attendee.profile_picture %}
|
|
<img src="{{ url_for('static', filename='uploads/' + attendee.profile_picture) }}" alt="{{ 'profile_photo'|t }}" class="profile-img" id="profile-preview">
|
|
{% else %}
|
|
<img src="" alt="{{ 'profile_preview'|t }}" class="profile-img d-none" id="profile-preview">
|
|
<div class="no-photo" id="no-photo"><i data-lucide="user"></i></div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<canvas id="crop-canvas" class="d-none"></canvas>
|
|
|
|
<div class="zoom-controls">
|
|
<button type="button" class="btn btn-sm btn-outline" onclick="adjustZoom(-0.25)"><i data-lucide="minus"></i></button>
|
|
<span id="zoom-level">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 class="form-group">
|
|
<label for="photo">{{ 'upload_photo'|t }}</label>
|
|
<input type="file" id="photo" name="photo" accept="image/*">
|
|
</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-row">
|
|
<div class="form-group">
|
|
<label>{{ 'attendee_code'|t }}</label>
|
|
<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 class="form-actions">
|
|
<button type="submit" class="btn btn-primary">{{ 'update_profile'|t }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
var zoomLevel = 1.0;
|
|
var translateX = 0;
|
|
var translateY = 0;
|
|
var isDragging = false;
|
|
var dragStartX = 0;
|
|
var dragStartY = 0;
|
|
var originalImage = null;
|
|
var containerSize = 150;
|
|
|
|
var container, preview, photoInput;
|
|
|
|
function init() {
|
|
container = document.getElementById('photo-container');
|
|
preview = document.getElementById('profile-preview');
|
|
photoInput = document.getElementById('photo');
|
|
|
|
if (photoInput) {
|
|
photoInput.addEventListener('change', handleFileSelect);
|
|
}
|
|
|
|
if (container && preview) {
|
|
container.addEventListener('mousedown', startDrag);
|
|
document.addEventListener('mousemove', doDrag);
|
|
document.addEventListener('mouseup', endDrag);
|
|
container.addEventListener('wheel', handleWheel);
|
|
}
|
|
}
|
|
|
|
function handleFileSelect(e) {
|
|
var file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
var reader = new FileReader();
|
|
reader.onload = function(event) {
|
|
var img = new Image();
|
|
img.onload = function() {
|
|
originalImage = img;
|
|
zoomLevel = 1.0;
|
|
translateX = 0;
|
|
translateY = 0;
|
|
preview.src = event.target.result;
|
|
preview.style.display = 'block';
|
|
var noPhoto = document.getElementById('no-photo');
|
|
if (noPhoto) noPhoto.style.display = 'none';
|
|
updateDisplay();
|
|
};
|
|
img.src = event.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
function adjustZoom(delta) {
|
|
var oldZoom = zoomLevel;
|
|
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 factor = zoomLevel / oldZoom;
|
|
translateX *= factor;
|
|
translateY *= factor;
|
|
|
|
updateDisplay();
|
|
}
|
|
|
|
function updateDisplay() {
|
|
if (!preview || !originalImage) return;
|
|
|
|
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;
|
|
}
|
|
|
|
// Explicitly set layout dimensions so transform origin centers correctly
|
|
preview.style.width = dispW + 'px';
|
|
preview.style.height = dispH + 'px';
|
|
|
|
preview.style.transform = 'translate(' + (translateX + offsetX) + 'px, ' + (translateY + offsetY) + 'px) scale(' + zoomLevel + ')';
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
var canvas = document.getElementById('crop-canvas');
|
|
var ctx = canvas.getContext('2d');
|
|
canvas.width = 300;
|
|
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
|
|
// Display: translate(translateX + offsetX, translateY + offsetY) scale(zoomLevel) with transform-origin: center
|
|
// Container pixel (cx, cy) maps to element point: (cx - tx - ox) / zoom + (ox + dispW/2)
|
|
// 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
|
|
srcX = Math.max(0, Math.min(srcX, imgW - srcW));
|
|
srcY = Math.max(0, Math.min(srcY, imgH - srcH));
|
|
srcW = Math.min(srcW, imgW - srcX);
|
|
srcH = Math.min(srcH, imgH - srcY);
|
|
|
|
ctx.clearRect(0, 0, 300, 300);
|
|
// Draw exactly the visible area scaled up to the 300x300 canvas
|
|
ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, 300, 300);
|
|
|
|
canvas.toBlob(function(blob) {
|
|
var formData = new FormData();
|
|
formData.append('photo', blob, 'profile.jpg');
|
|
|
|
fetch('{{ url_for("upload_photo") }}', {
|
|
method: 'POST',
|
|
body: formData
|
|
}).then(function(response) {
|
|
return response.json();
|
|
}).then(function(data) {
|
|
if (data.success) {
|
|
showToast('Profile photo updated!', 'success');
|
|
setTimeout(function() { location.reload(); }, 1000);
|
|
} else {
|
|
showToast(data.error || 'Upload failed', 'error');
|
|
}
|
|
}).catch(function(error) {
|
|
showToast('Error uploading photo', 'error');
|
|
});
|
|
}, 'image/jpeg', 0.9);
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
init();
|
|
lucide.createIcons();
|
|
});
|
|
</script>
|
|
{% endblock %} |