loko/streetup/panoramax/templates/panoramax/upload.html
2026-07-22 14:48:40 +02:00

614 lines
29 KiB
HTML

{% extends "panoramax/base.html" %}
{% load i18n %}
{% load static %}
{% block head %}
{{ block.super }}
<style>
.upload-container {
max-width: 1200px;
margin: 1.5rem auto;
padding: 0 1rem;
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.glass-card {
background: rgba(15, 23, 42, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
color: #f8fafc;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
margin-bottom: 2rem;
overflow: hidden;
}
.card-header-gradient {
background: linear-gradient(135deg, #1e293b, #0f172a);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding: 1.25rem 1.5rem;
}
.card-body-content {
padding: 1.5rem;
}
.form-label {
font-weight: 500;
color: #94a3b8;
margin-bottom: 0.5rem;
}
.form-control, .form-select {
background-color: rgba(30, 41, 59, 0.7);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #f8fafc;
border-radius: 8px;
padding: 0.6rem 0.9rem;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
background-color: rgba(30, 41, 59, 0.9);
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25);
color: #f8fafc;
}
.form-control::placeholder {
color: #94a3b8;
opacity: 0.65;
}
.form-check-input {
background-color: rgba(30, 41, 59, 0.7);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.form-check-input:checked {
background-color: #3b82f6;
border-color: #3b82f6;
}
.btn-gradient {
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
border: none;
color: white;
font-weight: 600;
padding: 0.7rem 1.8rem;
border-radius: 8px;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(29, 78, 216, 0.3);
}
.btn-gradient:hover {
background: linear-gradient(135deg, #2563eb, #1e40af);
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(29, 78, 216, 0.4);
}
/* Upload Type Selection Cards */
.type-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.type-card {
background: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 1rem;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
}
.type-card:hover {
background: rgba(30, 41, 59, 0.8);
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.type-card.active {
background: rgba(59, 130, 246, 0.15);
border-color: #3b82f6;
box-shadow: 0 0 0 1px #3b82f6;
}
.type-card i {
font-size: 1.8rem;
margin-bottom: 0.5rem;
color: #94a3b8;
}
.type-card.active i {
color: #3b82f6;
}
.type-card .title {
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.2rem;
}
.type-card .desc {
font-size: 0.75rem;
color: #64748b;
}
/* Status Badges */
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
font-weight: 600;
padding: 4px 10px;
border-radius: 20px;
}
.status-pending {
background: rgba(234, 179, 8, 0.15);
color: #f59e0b;
border: 1px solid rgba(245, 158, 11, 0.25);
}
.status-processing {
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
border: 1px solid rgba(96, 165, 250, 0.25);
}
.status-completed {
background: rgba(16, 185, 129, 0.15);
color: #34d399;
border: 1px solid rgba(52, 211, 153, 0.25);
}
.status-failed {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
border: 1px solid rgba(248, 113, 113, 0.25);
}
/* Table styling */
.table-dark-custom {
width: 100%;
margin-bottom: 0;
color: #f8fafc;
border-collapse: separate;
border-spacing: 0;
}
.table-dark-custom th {
background-color: rgba(30, 41, 59, 0.6);
border-bottom: 2px solid rgba(255, 255, 255, 0.08);
color: #94a3b8;
font-weight: 600;
padding: 0.8rem 1rem;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.5px;
}
.table-dark-custom td {
padding: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
vertical-align: middle;
}
.table-dark-custom tr:last-child td {
border-bottom: none;
}
.progress {
height: 6px;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.progress-bar-animated {
animation: progress-bar-stripes 1s linear infinite;
}
</style>
{% endblock head %}
{% block main %}
<div id="main" class="d-flex">
<div id="content-wrapper" class="flex-grow-1" style="height: calc(100vh - 56px); overflow-y: auto; background: #0f172a; width: 100%;">
<!-- Barre de navigation Panoramax intégrée dans la page -->
<div class="panoramax-header border-bottom border-secondary" style="padding: 1.25rem 2rem; background: rgba(15, 23, 42, 0.95);">
<div class="d-flex justify-content-between align-items-center">
<h1 class="h4 font-weight-bold text-white mb-0" style="display: flex; align-items: center; gap: 8px;">
<i class="bi bi-images text-primary"></i>
{% translate "Photos 360" %}
</h1>
<div class="panoramax-nav-links" style="display: flex; gap: 0.5rem;">
<a href="{% url 'panoramax:index' %}" class="btn btn-sm btn-outline-light">
<i class="bi bi-map me-1"></i>{% translate "Carte" %}
</a>
{% if user.config.panoramax_can_upload %}
<a href="{% url 'panoramax:upload' %}" class="btn btn-sm btn-primary">
<i class="bi bi-cloud-upload me-1"></i>{% translate "Importer" %}
</a>
{% endif %}
</div>
</div>
</div>
<div class="upload-container">
{% if messages %}
<div class="mb-4">
{% for message in messages %}
<div class="alert alert-{% if message.tags == 'error' %}danger{% else %}{{ message.tags }}{% endif %} alert-dismissible fade show bg-dark text-white border-secondary" role="alert">
{{ message }}
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
<div class="row">
<!-- Form Section -->
<div class="col-lg-5">
<div class="glass-card">
<div class="card-header-gradient">
<h5 class="mb-0 fw-bold">{% translate "Nouvel Upload" %}</h5>
</div>
<div class="card-body-content">
<form method="POST" enctype="multipart/form-data" id="uploadForm">
{% csrf_token %}
<div class="mb-3">
<label for="id_title" class="form-label">{% translate "Description (facultatif)" %}</label>
<input type="text" id="id_title" name="title" class="form-control" placeholder="Ex: Rénovation Place Flagey">
</div>
<div class="mb-3">
<label class="form-label">{% translate "Format d'upload" %}</label>
<div class="type-grid">
<div class="type-card active" data-type="files" onclick="selectUploadType('files')">
<i class="bi bi-images"></i>
<div class="title">{% translate "Sélection" %}</div>
<div class="desc">{% translate "Images JPEG" %}</div>
</div>
<div class="type-card" data-type="folder" onclick="selectUploadType('folder')">
<i class="bi bi-folder-symlink"></i>
<div class="title">{% translate "Dossier" %}</div>
<div class="desc">{% translate "Répertoire entier" %}</div>
</div>
<div class="type-card" data-type="zip" onclick="selectUploadType('zip')">
<i class="bi bi-file-earmark-zip"></i>
<div class="title">{% translate "ZIP" %}</div>
<div class="desc">{% translate "Archive .zip" %}</div>
</div>
<div class="type-card" data-type="video" onclick="selectUploadType('video')">
<i class="bi bi-camera-video"></i>
<div class="title">{% translate "Vidéo" %}</div>
<div class="desc">{% translate "1 photo / 10m" %}</div>
</div>
</div>
<input type="hidden" id="id_upload_type" name="upload_type" value="files">
</div>
<div class="mb-3" id="mainFileInputContainer">
<label for="id_uploaded_file" class="form-label" id="fileInputLabel">{% translate "Fichiers images (JPEG)" %}</label>
<input type="file" id="id_uploaded_file" name="uploaded_file" class="form-control" accept="image/jpeg,image/jpg" multiple required>
<small class="text-muted d-block mt-1" id="fileHelpText">{% translate "Sélectionnez une ou plusieurs photos JPEG géolocalisées." %}</small>
</div>
<div class="mb-4">
<label for="id_gpx_file" class="form-label">{% translate "Fichier GPX (Facultatif)" %}</label>
<input type="file" id="id_gpx_file" name="gpx_file" class="form-control" accept=".gpx">
<small class="text-muted d-block mt-1" id="gpxHelpText">{% translate "Utilisé pour recaler le positionnement des photos ou vidéos." %}</small>
</div>
<div class="mb-4" id="cameraOffsetContainer">
<label for="id_camera_offset" class="form-label">{% translate "Orientation de la caméra (Offset)" %}</label>
<select id="id_camera_offset" name="camera_offset" class="form-select bg-dark text-white border-secondary">
<option value="0">{% translate "Droit devant (0°)" %}</option>
<option value="90">{% translate "Vers la droite (90°)" %}</option>
<option value="180">{% translate "Vers l'arrière (180°)" %}</option>
<option value="270">{% translate "Vers la gauche (270°)" %}</option>
</select>
<small class="text-muted d-block mt-1">{% translate "Spécifiez la direction de la caméra par rapport au sens de déplacement du véhicule." %}</small>
</div>
<button type="submit" class="btn btn-gradient w-100 py-2">
<i class="bi bi-cloud-arrow-up me-2"></i>{% translate "Lancer le traitement" %}
</button>
</form>
</div>
</div>
</div>
<!-- History/Dashboard Section -->
<div class="col-lg-7">
<div class="glass-card">
<div class="card-header-gradient">
<h5 class="mb-0 fw-bold">{% translate "Suivi des Traitements" %}</h5>
</div>
<div class="table-responsive">
<table class="table table-dark-custom" id="uploadsTable">
<thead>
<tr>
<th>{% translate "Tâche" %}</th>
<th>{% translate "Type" %}</th>
<th>{% translate "Date" %}</th>
<th>{% translate "Progression" %}</th>
<th>{% translate "Statut" %}</th>
</tr>
</thead>
<tbody>
{% for upload in uploads %}
<tr id="upload-row-{{ upload.id }}" data-upload-id="{{ upload.id }}" data-status="{{ upload.status }}">
<td>
<div class="font-weight-bold">{{ upload.title }}</div>
{% if upload.error_message %}
<div class="small text-danger error-msg" style="max-width: 250px;">{{ upload.error_message }}</div>
{% endif %}
</td>
<td>
<span class="badge bg-secondary">
{% if upload.upload_type == 'video' %}{% translate "Vidéo" %}
{% elif upload.upload_type == 'zip' %}{% translate "Archive ZIP" %}
{% elif upload.upload_type == 'folder' %}{% translate "Dossier" %}
{% else %}{% translate "Photos" %}{% endif %}
</span>
</td>
<td class="small text-muted">{{ upload.created_at|date:"d/m/Y H:i" }}</td>
<td style="width: 140px;">
<div class="progress-container">
<div class="d-flex justify-content-between small text-muted mb-1">
<span class="progress-percent">{{ upload.progress }}%</span>
</div>
<div class="progress">
<div class="progress-bar {% if upload.status == 'processing' %}progress-bar-striped progress-bar-animated bg-primary{% elif upload.status == 'completed' %}bg-success{% elif upload.status == 'failed' %}bg-danger{% else %}bg-warning{% endif %}"
role="progressbar"
style="width: {{ upload.progress }}%"
aria-valuenow="{{ upload.progress }}"
aria-valuemin="0"
aria-valuemax="100">
</div>
</div>
</div>
</td>
<td>
<span class="status-badge status-{{ upload.status }}">
{% if upload.status == 'processing' %}<div class="spinner-border spinner-border-sm text-primary" role="status" style="width: 10px; height: 10px;"></div>{% endif %}
<span class="status-text">{{ upload.get_status_display }}</span>
</span>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center py-4 text-muted">
<i class="bi bi-info-circle fs-4 d-block mb-2"></i>
{% translate "Aucun upload enregistré pour le moment." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
function selectUploadType(type) {
document.querySelectorAll('.type-card').forEach(card => card.classList.remove('active'));
document.querySelector(`.type-card[data-type="${type}"]`).classList.add('active');
document.getElementById('id_upload_type').value = type;
const fileInput = document.getElementById('id_uploaded_file');
const fileLabel = document.getElementById('fileInputLabel');
const helpText = document.getElementById('fileHelpText');
const gpxInput = document.getElementById('id_gpx_file');
// Reset attributes
fileInput.removeAttribute('webkitdirectory');
fileInput.removeAttribute('directory');
fileInput.removeAttribute('multiple');
fileInput.value = '';
if (type === 'video') {
fileLabel.textContent = "{% translate 'Fichier Vidéo (MP4, AVI, 360...)' %}";
helpText.textContent = "{% translate 'Téléversez un fichier vidéo de votre parcours. Si la vidéo ne contient pas de télémétrie GPS intégrée, un tracé GPX est requis.' %}";
fileInput.setAttribute('accept', 'video/*,.360');
gpxInput.removeAttribute('required');
} else if (type === 'zip') {
fileLabel.textContent = "{% translate 'Archive ZIP' %}";
helpText.textContent = "{% translate 'Téléversez un fichier ZIP contenant l\'ensemble de vos photos JPEG.' %}";
fileInput.setAttribute('accept', '.zip');
gpxInput.removeAttribute('required');
} else if (type === 'folder') {
fileLabel.textContent = "{% translate 'Dossier d\'images' %}";
helpText.textContent = "{% translate 'Sélectionnez un répertoire complet de photos JPEG.' %}";
fileInput.setAttribute('accept', 'image/jpeg,image/jpg');
fileInput.setAttribute('webkitdirectory', '');
fileInput.setAttribute('directory', '');
fileInput.setAttribute('multiple', '');
gpxInput.removeAttribute('required');
} else {
fileLabel.textContent = "{% translate 'Fichiers images (JPEG)' %}";
helpText.textContent = "{% translate 'Sélectionnez une ou plusieurs photos JPEG.' %}";
fileInput.setAttribute('accept', 'image/jpeg,image/jpg');
fileInput.setAttribute('multiple', '');
gpxInput.removeAttribute('required');
}
}
// Dynamic updates
function startStatusPolling() {
setInterval(() => {
const rows = document.querySelectorAll('tr[data-upload-id]');
rows.forEach(row => {
const status = row.getAttribute('data-status');
const isPanoReady = row.getAttribute('data-panoramax-ready') === 'true';
if (status === 'pending' || status === 'processing' || (status === 'completed' && !isPanoReady)) {
const id = row.getAttribute('data-upload-id');
fetch(`/panoramax/upload/${id}/status/`)
.then(response => response.json())
.then(data => {
row.setAttribute('data-status', data.status);
const progressBar = row.querySelector('.progress-bar');
const progressPercent = row.querySelector('.progress-percent');
const badge = row.querySelector('.status-badge');
const statusText = row.querySelector('.status-text');
// Check if Panoramax is processing
if (data.status === 'completed' && data.panoramax_status) {
const pStatus = data.panoramax_status;
if (pStatus.ready) {
// Fully ready and published on Panoramax!
row.setAttribute('data-panoramax-ready', 'true');
progressPercent.textContent = '100%';
progressBar.style.width = '100%';
progressBar.setAttribute('aria-valuenow', 100);
progressBar.className = 'progress-bar bg-success';
progressBar.style.backgroundColor = '';
badge.className = 'status-badge status-completed';
statusText.innerHTML = '{% translate "Publié sur Panoramax" %}';
} else {
// Panoramax is actively processing photos
progressPercent.textContent = pStatus.pct + '% (Panoramax)';
progressBar.style.width = pStatus.pct + '%';
progressBar.setAttribute('aria-valuenow', pStatus.pct);
progressBar.className = 'progress-bar progress-bar-striped progress-bar-animated';
progressBar.style.backgroundColor = '#0d9488'; // sleek teal color
badge.className = 'status-badge status-processing';
const subStatusText = pStatus.preparing > 0 ? '{% translate "Panoramax : Anonymisation" %}' : '{% translate "Panoramax : Traitement" %}';
statusText.innerHTML = `
<div class="spinner-border spinner-border-sm text-info me-1" role="status" style="width: 10px; height: 10px;"></div>
<span>${subStatusText} (${pStatus.prepared}/${pStatus.total})</span>
`;
// Display info if preparing (CPU blurring)
let infoDiv = row.querySelector('.pano-info-msg');
if (pStatus.preparing > 0) {
if (!infoDiv) {
infoDiv = document.createElement('div');
infoDiv.className = 'small text-info pano-info-msg mt-1';
infoDiv.style.maxWidth = '250px';
infoDiv.innerHTML = `<i class="bi bi-shield-shaded me-1"></i>{% translate "Anonymisation (visages/plaques) en cours (CPU)..." %}`;
row.querySelector('td').appendChild(infoDiv);
}
} else if (infoDiv) {
infoDiv.remove();
}
// Display warning if there are broken photos
let warnDiv = row.querySelector('.pano-warn-msg');
if ((pStatus.broken > 0 || pStatus.rejected > 0) && !warnDiv) {
warnDiv = document.createElement('div');
warnDiv.className = 'small text-warning pano-warn-msg mt-1';
warnDiv.style.maxWidth = '250px';
warnDiv.innerHTML = `<i class="bi bi-exclamation-triangle me-1"></i>${pStatus.broken} {% translate "photos illisibles" %}`;
row.querySelector('td').appendChild(warnDiv);
}
}
} else {
// Default behavior (local processing phase)
progressPercent.textContent = data.progress + '%';
progressBar.style.width = data.progress + '%';
progressBar.setAttribute('aria-valuenow', data.progress);
progressBar.style.backgroundColor = '';
progressBar.className = 'progress-bar';
if (data.status === 'processing') {
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated', 'bg-primary');
} else if (data.status === 'completed') {
progressBar.classList.add('bg-success');
row.setAttribute('data-panoramax-ready', 'true');
} else if (data.status === 'failed') {
progressBar.classList.add('bg-danger');
row.setAttribute('data-panoramax-ready', 'true');
} else {
progressBar.classList.add('bg-warning');
}
badge.className = `status-badge status-${data.status}`;
let loaderHtml = '';
if (data.status === 'processing') {
loaderHtml = '<div class="spinner-border spinner-border-sm text-primary me-1" role="status" style="width: 10px; height: 10px;"></div>';
}
statusText.innerHTML = loaderHtml + data.status_display;
}
// Update error messages if failed
let errorDiv = row.querySelector('.error-msg');
if (data.status === 'failed' && data.error_message) {
if (!errorDiv) {
errorDiv = document.createElement('div');
errorDiv.className = 'small text-danger error-msg mt-1';
errorDiv.style.maxWidth = '250px';
row.querySelector('td').appendChild(errorDiv);
}
errorDiv.textContent = data.error_message;
} else if (errorDiv && data.status !== 'failed') {
errorDiv.remove();
}
})
.catch(err => console.error('Error fetching status:', err));
}
});
}, 2000);
}
document.addEventListener('DOMContentLoaded', () => {
startStatusPolling();
const form = document.getElementById('uploadForm');
if (form) {
form.addEventListener('submit', (e) => {
if (form.dataset.submitting === 'true') {
e.preventDefault();
return;
}
form.dataset.submitting = 'true';
const submitBtn = form.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>{% translate "Téléversement en cours..." %}';
setTimeout(() => {
submitBtn.disabled = true;
}, 50);
}
let loadingText = document.getElementById('uploadLoadingText');
if (!loadingText) {
loadingText = document.createElement('div');
loadingText.id = 'uploadLoadingText';
loadingText.className = 'alert alert-info mt-3 small d-flex align-items-center gap-2';
loadingText.style.backgroundColor = 'rgba(13, 148, 136, 0.15)';
loadingText.style.borderColor = 'rgba(13, 148, 136, 0.3)';
loadingText.style.color = '#2dd4bf';
loadingText.innerHTML = `
<div class="spinner-grow spinner-grow-sm" role="status" style="color: #2dd4bf; flex-shrink: 0;"></div>
<div><strong>{% translate "Téléversement des fichiers volumineux en cours..." %}</strong><br>{% translate "Veuillez ne pas quitter cette page ni fermer le navigateur pendant le chargement." %}</div>
`;
form.appendChild(loadingText);
}
});
}
});
</script>
</div>
</div>
{% endblock main %}