366 lines
15 KiB
HTML
366 lines
15 KiB
HTML
{% extends 'base.html' %}
|
||
{% block content %}
|
||
<section class="card">
|
||
<h1>{{ t('gallery') }}</h1>
|
||
{% if images %}
|
||
<div class="gallery-filter-controls">
|
||
<div class="gallery-uploader-filter" id="gallery-uploader-filter">
|
||
<button
|
||
class="gallery-uploader-trigger"
|
||
id="gallery-uploader-trigger"
|
||
type="button"
|
||
aria-expanded="false"
|
||
aria-controls="gallery-uploader-menu"
|
||
aria-label="{{ t('gallery_filter_label') }}"
|
||
>
|
||
<svg class="gallery-uploader-search" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||
<path d="m21 20-4.35-4.35a8 8 0 1 0-1.41 1.41L19.59 21 21 20ZM5 11a6 6 0 1 1 12 0 6 6 0 0 1-12 0Z" />
|
||
</svg>
|
||
<span>{{ t('gallery_filter_trigger') }}</span>
|
||
<strong id="gallery-uploader-summary">{{ t('gallery_uploader_all') }}</strong>
|
||
<svg class="gallery-uploader-chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||
<path d="m7 9.5 5 5 5-5 1.4 1.4-6.4 6.4-6.4-6.4L7 9.5Z" />
|
||
</svg>
|
||
</button>
|
||
<div class="gallery-uploader-menu" id="gallery-uploader-menu" aria-label="{{ t('gallery_uploader_filter_label') }}" hidden>
|
||
<label class="gallery-uploader-option gallery-uploader-option-all">
|
||
<input id="gallery-uploader-all" type="checkbox" checked />
|
||
<span>{{ t('gallery_uploader_all') }}</span>
|
||
</label>
|
||
<div class="gallery-uploader-options">
|
||
{% for uploader in uploaders %}
|
||
<label class="gallery-uploader-option">
|
||
<input type="checkbox" value="{{ uploader }}" data-uploader-option checked />
|
||
<span>{{ uploader }}</span>
|
||
</label>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="gallery-grid">
|
||
{% for image in images %}
|
||
<figure class="gallery-item gallery-card" data-uploader="{{ image['uploaded_by_name'] }}">
|
||
<div class="gallery-media">
|
||
<a
|
||
href="{{ url_for('serve_upload', filename=image['filename']) }}"
|
||
class="gallery-open"
|
||
data-image-index="{{ loop.index0 }}"
|
||
>
|
||
<img src="{{ url_for('serve_thumbnail', filename=image['filename']) }}" alt="{{ t('gallery_image_alt').format(name=image['uploaded_by_name']) }}" loading="lazy" />
|
||
</a>
|
||
{% if is_host or guest_id == image['uploaded_by'] %}
|
||
<form
|
||
class="gallery-delete-form"
|
||
method="post"
|
||
action="{{ url_for('delete_image', image_id=image['id']) }}"
|
||
>
|
||
<button class="gallery-delete-btn" type="submit" aria-label="{{ t('delete') }}" title="{{ t('delete') }}">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||
<path d="M9 3h6l1 2h4v2H4V5h4l1-2zm1 6h2v8h-2V9zm4 0h2v8h-2V9zM7 9h2v8H7V9z" />
|
||
</svg>
|
||
</button>
|
||
</form>
|
||
{% endif %}
|
||
</div>
|
||
<figcaption>
|
||
<span>{{ t('gallery_uploaded_by_prefix') }}</span>
|
||
<strong>{{ image['uploaded_by_name'] }}</strong>
|
||
</figcaption>
|
||
</figure>
|
||
{% endfor %}
|
||
</div>
|
||
<p class="gallery-filter-empty" id="gallery-filter-empty" hidden>{{ t('gallery_filter_no_results') }}</p>
|
||
|
||
<div class="lightbox" id="gallery-lightbox" aria-hidden="true">
|
||
<button class="lightbox-close" type="button" aria-label="Close">×</button>
|
||
<div class="lightbox-counter" id="lightbox-counter">1 / 1</div>
|
||
<a class="lightbox-download" id="lightbox-download" href="#" aria-label="{{ t('download') }}" title="{{ t('download') }}">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||
<path d="M12 3a1 1 0 0 1 1 1v8.59l2.3-2.29a1 1 0 1 1 1.4 1.41l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 1 1 1.4-1.41L11 12.59V4a1 1 0 0 1 1-1zm-7 14a1 1 0 0 1 1 1v1h12v-1a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1z"/>
|
||
</svg>
|
||
</a>
|
||
<button class="lightbox-nav lightbox-prev" type="button" aria-label="Previous image">‹</button>
|
||
<img class="lightbox-image" id="lightbox-image" alt="" />
|
||
<button class="lightbox-nav lightbox-next" type="button" aria-label="Next image">›</button>
|
||
<div class="lightbox-save-hint" id="lightbox-save-hint" role="status">{{ t('gallery_save_hint') }}</div>
|
||
</div>
|
||
|
||
<dialog class="gallery-delete-dialog" id="gallery-delete-dialog" aria-labelledby="gallery-delete-title" aria-describedby="gallery-delete-message">
|
||
<h2 id="gallery-delete-title">{{ t('gallery_delete_title') }}</h2>
|
||
<p id="gallery-delete-message">{{ t('gallery_delete_confirm') }}</p>
|
||
<div class="gallery-delete-dialog-actions">
|
||
<button class="btn btn-ghost" id="gallery-delete-cancel" type="button">{{ t('gallery_delete_cancel') }}</button>
|
||
<button class="btn btn-danger" id="gallery-delete-confirm" type="button">{{ t('delete') }}</button>
|
||
</div>
|
||
</dialog>
|
||
|
||
<script>
|
||
(() => {
|
||
const deleteDialog = document.getElementById("gallery-delete-dialog");
|
||
const deleteCancel = document.getElementById("gallery-delete-cancel");
|
||
const deleteConfirm = document.getElementById("gallery-delete-confirm");
|
||
let pendingDeleteForm = null;
|
||
|
||
document.querySelectorAll(".gallery-delete-form").forEach((form) => {
|
||
form.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
pendingDeleteForm = form;
|
||
deleteDialog.showModal();
|
||
});
|
||
});
|
||
|
||
deleteCancel.addEventListener("click", () => deleteDialog.close());
|
||
deleteConfirm.addEventListener("click", () => {
|
||
if (pendingDeleteForm) {
|
||
HTMLFormElement.prototype.submit.call(pendingDeleteForm);
|
||
}
|
||
});
|
||
deleteDialog.addEventListener("close", () => {
|
||
pendingDeleteForm = null;
|
||
});
|
||
deleteDialog.addEventListener("click", (event) => {
|
||
if (event.target === deleteDialog) {
|
||
deleteDialog.close();
|
||
}
|
||
});
|
||
|
||
const lightbox = document.getElementById("gallery-lightbox");
|
||
const lightboxImage = document.getElementById("lightbox-image");
|
||
const lightboxDownload = document.getElementById("lightbox-download");
|
||
const lightboxCounter = document.getElementById("lightbox-counter");
|
||
const saveHint = document.getElementById("lightbox-save-hint");
|
||
const closeBtn = lightbox.querySelector(".lightbox-close");
|
||
const prevBtn = lightbox.querySelector(".lightbox-prev");
|
||
const nextBtn = lightbox.querySelector(".lightbox-next");
|
||
const openButtons = Array.from(document.querySelectorAll(".gallery-open"));
|
||
const galleryItems = Array.from(document.querySelectorAll(".gallery-item"));
|
||
const filterEmpty = document.getElementById("gallery-filter-empty");
|
||
const uploaderFilter = document.getElementById("gallery-uploader-filter");
|
||
const uploaderTrigger = document.getElementById("gallery-uploader-trigger");
|
||
const uploaderMenu = document.getElementById("gallery-uploader-menu");
|
||
const uploaderSummary = document.getElementById("gallery-uploader-summary");
|
||
const uploaderAll = document.getElementById("gallery-uploader-all");
|
||
const uploaderOptions = Array.from(document.querySelectorAll("[data-uploader-option]"));
|
||
const uploaderLabelAll = {{ t('gallery_uploader_all')|tojson }};
|
||
const uploaderLabelNone = {{ t('gallery_uploader_none')|tojson }};
|
||
const uploaderLabelSelected = {{ t('gallery_uploader_selected')|tojson }};
|
||
const images = [
|
||
{% for image in images %}
|
||
{
|
||
index: {{ loop.index0 }},
|
||
uploader: {{ image['uploaded_by_name']|tojson }},
|
||
src: {{ url_for('serve_upload', filename=image['filename'])|tojson }},
|
||
download: {{ url_for('serve_upload', filename=image['filename'], download=1)|tojson }},
|
||
alt: {{ t('gallery_image_alt').format(name=image['uploaded_by_name'])|tojson }}
|
||
}{% if not loop.last %},{% endif %}
|
||
{% endfor %}
|
||
];
|
||
|
||
let visibleImages = [...images];
|
||
let currentIndex = 0;
|
||
let touchStartX = 0;
|
||
let touchStartY = 0;
|
||
let didSwipe = false;
|
||
let controlsTimer = null;
|
||
let hintTimer = null;
|
||
|
||
const scheduleHideControls = () => {
|
||
clearTimeout(controlsTimer);
|
||
controlsTimer = window.setTimeout(() => {
|
||
lightbox.classList.add("lightbox-controls-hidden");
|
||
}, 3000);
|
||
};
|
||
|
||
const showControlsTemporarily = () => {
|
||
lightbox.classList.remove("lightbox-controls-hidden");
|
||
scheduleHideControls();
|
||
};
|
||
|
||
const setImage = (index, options = {}) => {
|
||
const { revealControls = true } = options;
|
||
if (!visibleImages.length) {
|
||
return;
|
||
}
|
||
currentIndex = (index + visibleImages.length) % visibleImages.length;
|
||
const item = visibleImages[currentIndex];
|
||
lightboxImage.classList.remove("is-fading");
|
||
void lightboxImage.offsetWidth;
|
||
lightboxImage.classList.add("is-fading");
|
||
lightboxImage.src = item.src;
|
||
lightboxImage.alt = item.alt;
|
||
lightboxDownload.href = item.download;
|
||
lightboxCounter.textContent = `${currentIndex + 1} / ${visibleImages.length}`;
|
||
if (revealControls) {
|
||
showControlsTemporarily();
|
||
}
|
||
};
|
||
|
||
const openLightbox = (index) => {
|
||
setImage(index);
|
||
lightbox.classList.add("is-open");
|
||
lightbox.setAttribute("aria-hidden", "false");
|
||
document.body.classList.add("no-scroll");
|
||
showControlsTemporarily();
|
||
saveHint.classList.add("is-visible");
|
||
clearTimeout(hintTimer);
|
||
hintTimer = window.setTimeout(() => saveHint.classList.remove("is-visible"), 5000);
|
||
};
|
||
|
||
const closeLightbox = () => {
|
||
lightbox.classList.remove("is-open");
|
||
lightbox.setAttribute("aria-hidden", "true");
|
||
document.body.classList.remove("no-scroll");
|
||
lightbox.classList.remove("lightbox-controls-hidden");
|
||
clearTimeout(controlsTimer);
|
||
clearTimeout(hintTimer);
|
||
saveHint.classList.remove("is-visible");
|
||
};
|
||
|
||
openButtons.forEach((link) => {
|
||
link.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
const imageIndex = Number(link.dataset.imageIndex || 0);
|
||
const visibleIndex = visibleImages.findIndex((item) => item.index === imageIndex);
|
||
if (visibleIndex >= 0) {
|
||
openLightbox(visibleIndex);
|
||
}
|
||
});
|
||
});
|
||
|
||
const applyFilters = () => {
|
||
const selectedUploaders = new Set(
|
||
uploaderOptions.filter((option) => option.checked).map((option) => option.value)
|
||
);
|
||
galleryItems.forEach((item) => {
|
||
const matchesUploader = selectedUploaders.has(item.dataset.uploader || "");
|
||
item.classList.toggle("is-filtered-out", !matchesUploader);
|
||
});
|
||
visibleImages = images.filter((item) => selectedUploaders.has(item.uploader));
|
||
filterEmpty.hidden = visibleImages.length > 0;
|
||
};
|
||
|
||
const syncUploaderSummary = () => {
|
||
const selectedCount = uploaderOptions.filter((option) => option.checked).length;
|
||
uploaderAll.checked = selectedCount === uploaderOptions.length;
|
||
uploaderAll.indeterminate = selectedCount > 0 && selectedCount < uploaderOptions.length;
|
||
if (selectedCount === uploaderOptions.length) {
|
||
uploaderSummary.textContent = uploaderLabelAll;
|
||
} else if (selectedCount === 0) {
|
||
uploaderSummary.textContent = uploaderLabelNone;
|
||
} else {
|
||
uploaderSummary.textContent = uploaderLabelSelected.replace("{count}", String(selectedCount));
|
||
}
|
||
};
|
||
|
||
const closeUploaderMenu = () => {
|
||
uploaderMenu.hidden = true;
|
||
uploaderTrigger.setAttribute("aria-expanded", "false");
|
||
};
|
||
|
||
uploaderTrigger.addEventListener("click", () => {
|
||
const shouldOpen = uploaderMenu.hidden;
|
||
uploaderMenu.hidden = !shouldOpen;
|
||
uploaderTrigger.setAttribute("aria-expanded", String(shouldOpen));
|
||
});
|
||
|
||
uploaderAll.addEventListener("change", () => {
|
||
uploaderOptions.forEach((option) => {
|
||
option.checked = uploaderAll.checked;
|
||
});
|
||
syncUploaderSummary();
|
||
applyFilters();
|
||
});
|
||
|
||
uploaderOptions.forEach((option) => {
|
||
option.addEventListener("change", () => {
|
||
syncUploaderSummary();
|
||
applyFilters();
|
||
});
|
||
});
|
||
|
||
document.addEventListener("click", (event) => {
|
||
if (!uploaderFilter.contains(event.target)) {
|
||
closeUploaderMenu();
|
||
}
|
||
});
|
||
|
||
prevBtn.addEventListener("click", () => setImage(currentIndex - 1, { revealControls: true }));
|
||
nextBtn.addEventListener("click", () => setImage(currentIndex + 1, { revealControls: true }));
|
||
closeBtn.addEventListener("click", closeLightbox);
|
||
|
||
lightbox.addEventListener("mousemove", () => {
|
||
if (lightbox.classList.contains("is-open")) {
|
||
showControlsTemporarily();
|
||
}
|
||
});
|
||
|
||
lightbox.addEventListener("click", (event) => {
|
||
if (event.target === lightbox) {
|
||
closeLightbox();
|
||
} else if (
|
||
lightbox.classList.contains("is-open") &&
|
||
event.target.closest(".lightbox-close, .lightbox-download, .lightbox-nav")
|
||
) {
|
||
showControlsTemporarily();
|
||
}
|
||
});
|
||
|
||
lightboxImage.addEventListener("touchstart", (event) => {
|
||
const point = event.changedTouches[0];
|
||
touchStartX = point.clientX;
|
||
touchStartY = point.clientY;
|
||
didSwipe = false;
|
||
}, { passive: true });
|
||
|
||
lightboxImage.addEventListener("touchend", (event) => {
|
||
const point = event.changedTouches[0];
|
||
const deltaX = point.clientX - touchStartX;
|
||
const deltaY = point.clientY - touchStartY;
|
||
const absX = Math.abs(deltaX);
|
||
const absY = Math.abs(deltaY);
|
||
if (absX < 40 || absX <= absY) {
|
||
return;
|
||
}
|
||
didSwipe = true;
|
||
if (deltaX < 0) {
|
||
setImage(currentIndex + 1, { revealControls: false });
|
||
} else {
|
||
setImage(currentIndex - 1, { revealControls: false });
|
||
}
|
||
}, { passive: true });
|
||
|
||
lightboxImage.addEventListener("click", (event) => {
|
||
if (didSwipe) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
didSwipe = false;
|
||
}
|
||
});
|
||
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape" && !uploaderMenu.hidden) {
|
||
closeUploaderMenu();
|
||
uploaderTrigger.focus();
|
||
return;
|
||
}
|
||
if (!lightbox.classList.contains("is-open")) {
|
||
return;
|
||
}
|
||
showControlsTemporarily();
|
||
if (event.key === "Escape") {
|
||
closeLightbox();
|
||
} else if (event.key === "ArrowLeft") {
|
||
setImage(currentIndex - 1, { revealControls: true });
|
||
} else if (event.key === "ArrowRight") {
|
||
setImage(currentIndex + 1, { revealControls: true });
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
{% else %}
|
||
<p>{{ t('gallery_empty') }}</p>
|
||
{% endif %}
|
||
</section>
|
||
{% endblock %}
|