225 lines
8.2 KiB
HTML
225 lines
8.2 KiB
HTML
{% extends 'base.html' %}
|
||
{% block content %}
|
||
<section class="card upload-card">
|
||
<h1>{{ t('upload') }}</h1>
|
||
<p class="upload-intro">{{ t('upload_intro') }}</p>
|
||
<form id="upload-form" method="post" enctype="multipart/form-data" class="form-grid">
|
||
<label class="upload-picker" for="photo-input">
|
||
<span class="upload-picker-title">{{ t('file') }}</span>
|
||
<span class="upload-picker-subtitle">{{ t('upload_picker_hint') }}</span>
|
||
<input id="photo-input" class="sr-only" type="file" name="photo" accept="image/jpeg,image/png,image/jpg,image/gif,image/heic,image/heif,.gif,.heic,.heif" multiple />
|
||
</label>
|
||
<p id="upload-selected-count" class="upload-count"></p>
|
||
<p id="upload-ready-hint" class="upload-ready">{{ t('upload_ready') }}</p>
|
||
<ul id="upload-file-list" class="upload-file-list"></ul>
|
||
<div id="upload-progress" class="upload-progress" hidden>
|
||
<div
|
||
id="upload-progress-track"
|
||
class="upload-progress-track"
|
||
role="progressbar"
|
||
aria-valuemin="0"
|
||
aria-valuemax="100"
|
||
aria-valuenow="0"
|
||
>
|
||
<span id="upload-progress-bar"></span>
|
||
</div>
|
||
<p id="upload-progress-count" class="upload-progress-count" aria-live="polite"></p>
|
||
<p id="upload-progress-status" class="upload-progress-status">{{ t('upload_in_progress') }}</p>
|
||
</div>
|
||
<button id="upload-submit-btn" class="btn" type="submit" disabled>{{ t('upload_submit') }}</button>
|
||
<button id="upload-retry-btn" class="btn upload-retry-btn" type="button" hidden>{{ t('upload_retry') }}</button>
|
||
</form>
|
||
</section>
|
||
|
||
<script>
|
||
(() => {
|
||
const form = document.getElementById("upload-form");
|
||
const fileInput = document.getElementById("photo-input");
|
||
const countEl = document.getElementById("upload-selected-count");
|
||
const readyEl = document.getElementById("upload-ready-hint");
|
||
const listEl = document.getElementById("upload-file-list");
|
||
const submitBtn = document.getElementById("upload-submit-btn");
|
||
const retryBtn = document.getElementById("upload-retry-btn");
|
||
const progressEl = document.getElementById("upload-progress");
|
||
const progressTrack = document.getElementById("upload-progress-track");
|
||
const progressBar = document.getElementById("upload-progress-bar");
|
||
const progressCount = document.getElementById("upload-progress-count");
|
||
const progressStatus = document.getElementById("upload-progress-status");
|
||
const countTpl = {{ t('upload_selected_count')|tojson }};
|
||
const progressTpl = {{ t('upload_progress')|tojson }};
|
||
const inProgressText = {{ t('upload_in_progress')|tojson }};
|
||
const batchFailedText = {{ t('upload_batch_failed')|tojson }};
|
||
const galleryUrl = {{ url_for('gallery')|tojson }};
|
||
const maxBatchFiles = 10;
|
||
const maxBatchBytes = 40 * 1024 * 1024;
|
||
const selectedFiles = [];
|
||
let remainingFiles = [];
|
||
let totalFiles = 0;
|
||
let uploadedFiles = 0;
|
||
let isUploading = false;
|
||
|
||
const fileKey = (file) => `${file.name}__${file.size}__${file.lastModified}`;
|
||
|
||
const renderSelection = () => {
|
||
if (!selectedFiles.length) {
|
||
countEl.textContent = "";
|
||
listEl.innerHTML = "";
|
||
if (readyEl) {
|
||
readyEl.classList.remove("is-visible");
|
||
}
|
||
if (submitBtn) {
|
||
submitBtn.disabled = true;
|
||
}
|
||
return;
|
||
}
|
||
|
||
countEl.textContent = countTpl.replace("{count}", String(selectedFiles.length));
|
||
listEl.innerHTML = "";
|
||
if (readyEl) {
|
||
readyEl.classList.add("is-visible");
|
||
}
|
||
if (submitBtn) {
|
||
submitBtn.disabled = isUploading;
|
||
}
|
||
|
||
selectedFiles.slice(0, 20).forEach((file, index) => {
|
||
const item = document.createElement("li");
|
||
const label = document.createElement("span");
|
||
label.textContent = file.name;
|
||
|
||
const removeBtn = document.createElement("button");
|
||
removeBtn.type = "button";
|
||
removeBtn.className = "upload-file-remove";
|
||
removeBtn.setAttribute("aria-label", "remove file");
|
||
removeBtn.textContent = "×";
|
||
removeBtn.addEventListener("click", () => {
|
||
selectedFiles.splice(index, 1);
|
||
renderSelection();
|
||
});
|
||
|
||
item.appendChild(label);
|
||
item.appendChild(removeBtn);
|
||
listEl.appendChild(item);
|
||
});
|
||
if (selectedFiles.length > 20) {
|
||
const more = document.createElement("li");
|
||
more.textContent = `+ ${selectedFiles.length - 20} weitere`;
|
||
listEl.appendChild(more);
|
||
}
|
||
};
|
||
|
||
fileInput.addEventListener("change", () => {
|
||
const existingKeys = new Set(selectedFiles.map(fileKey));
|
||
Array.from(fileInput.files || []).forEach((file) => {
|
||
const key = fileKey(file);
|
||
if (!existingKeys.has(key)) {
|
||
selectedFiles.push(file);
|
||
existingKeys.add(key);
|
||
}
|
||
});
|
||
fileInput.value = "";
|
||
renderSelection();
|
||
});
|
||
|
||
const updateProgress = () => {
|
||
const percent = totalFiles ? Math.round((uploadedFiles / totalFiles) * 100) : 0;
|
||
progressBar.style.width = `${percent}%`;
|
||
progressTrack.setAttribute("aria-valuenow", String(percent));
|
||
progressCount.textContent = progressTpl
|
||
.replace("{uploaded}", String(uploadedFiles))
|
||
.replace("{total}", String(totalFiles));
|
||
};
|
||
|
||
const nextBatch = () => {
|
||
let batchSize = 0;
|
||
let batchLength = 0;
|
||
while (batchLength < remainingFiles.length && batchLength < maxBatchFiles) {
|
||
const nextSize = remainingFiles[batchLength].size;
|
||
if (batchLength > 0 && batchSize + nextSize > maxBatchBytes) {
|
||
break;
|
||
}
|
||
batchSize += nextSize;
|
||
batchLength += 1;
|
||
}
|
||
return remainingFiles.slice(0, batchLength);
|
||
};
|
||
|
||
const setUploadLocked = (locked) => {
|
||
fileInput.disabled = locked;
|
||
submitBtn.disabled = locked;
|
||
document.querySelectorAll(".upload-file-remove").forEach((button) => {
|
||
button.disabled = locked;
|
||
});
|
||
form.classList.toggle("is-uploading", locked);
|
||
};
|
||
|
||
const uploadRemainingFiles = async () => {
|
||
if (isUploading || !remainingFiles.length) return;
|
||
isUploading = true;
|
||
setUploadLocked(true);
|
||
retryBtn.hidden = true;
|
||
progressEl.hidden = false;
|
||
progressStatus.textContent = inProgressText;
|
||
progressStatus.classList.remove("is-error");
|
||
updateProgress();
|
||
|
||
try {
|
||
while (remainingFiles.length) {
|
||
const batch = nextBatch();
|
||
const body = new FormData();
|
||
batch.forEach((file) => body.append("photo", file, file.name));
|
||
const response = await fetch(form.action || window.location.href, {
|
||
method: "POST",
|
||
body,
|
||
credentials: "same-origin",
|
||
headers: { "X-Upload-Batch": "1" }
|
||
});
|
||
const contentType = response.headers.get("content-type") || "";
|
||
const payload = contentType.includes("application/json") ? await response.json() : null;
|
||
if (!response.ok || !payload || !payload.ok) {
|
||
throw new Error(payload?.error || batchFailedText);
|
||
}
|
||
remainingFiles.splice(0, batch.length);
|
||
uploadedFiles += batch.length;
|
||
updateProgress();
|
||
}
|
||
window.location.assign(galleryUrl);
|
||
} catch (error) {
|
||
progressStatus.textContent = `${batchFailedText} ${error.message || ""}`.trim();
|
||
progressStatus.classList.add("is-error");
|
||
retryBtn.hidden = false;
|
||
} finally {
|
||
isUploading = false;
|
||
form.classList.remove("is-uploading");
|
||
if (remainingFiles.length) {
|
||
fileInput.disabled = true;
|
||
submitBtn.disabled = true;
|
||
}
|
||
}
|
||
};
|
||
|
||
form.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
if (!selectedFiles.length || isUploading) {
|
||
renderSelection();
|
||
return;
|
||
}
|
||
totalFiles = selectedFiles.length;
|
||
uploadedFiles = 0;
|
||
remainingFiles = [...selectedFiles];
|
||
uploadRemainingFiles();
|
||
});
|
||
|
||
retryBtn.addEventListener("click", uploadRemainingFiles);
|
||
|
||
window.addEventListener("beforeunload", (event) => {
|
||
if (!isUploading) return;
|
||
event.preventDefault();
|
||
event.returnValue = "";
|
||
});
|
||
|
||
renderSelection();
|
||
})();
|
||
</script>
|
||
{% endblock %}
|