419 lines
12 KiB
JavaScript
419 lines
12 KiB
JavaScript
// public/js/galleryRenderer.js
|
|
// Virtualizzazione della timeline: foto e attività.
|
|
|
|
(() => {
|
|
"use strict";
|
|
|
|
const OVERSCAN_ROWS = 4;
|
|
|
|
let currentSections = [];
|
|
let framePending = false;
|
|
|
|
function createPhotoTile(item, sectionItems, itemIndex, globalIndex) {
|
|
const tile = document.createElement("div");
|
|
tile.className = "thumb";
|
|
tile.id = `photo_${item.id}`;
|
|
|
|
if (item.deleted_at) tile.classList.add("soft-deleted");
|
|
|
|
const gridThumb = window.toAbsoluteUrl(
|
|
item.thub1 || item.thub2 || item.path,
|
|
item.name,
|
|
"thumbs",
|
|
item.cartella
|
|
);
|
|
|
|
const modalPreview = window.toAbsoluteUrl(
|
|
item.thub2 || item.thub1 || item.path,
|
|
item.name,
|
|
"thumbs",
|
|
item.cartella
|
|
);
|
|
|
|
const original = window.toAbsoluteUrl(
|
|
item.path,
|
|
item.name,
|
|
"original",
|
|
item.cartella
|
|
);
|
|
|
|
const image = document.createElement("img");
|
|
image.src = gridThumb;
|
|
image.alt = item.name || "";
|
|
image.loading = globalIndex < 20 ? "eager" : "lazy";
|
|
image.decoding = "async";
|
|
|
|
if (globalIndex < 8) image.fetchPriority = "high";
|
|
|
|
tile.appendChild(image);
|
|
|
|
// Nome completo visibile solo quando sono abilitate le soft delete.
|
|
if (localStorage.getItem("showDeleted") === "1" && item.name) {
|
|
const filename = document.createElement("div");
|
|
filename.className = "thumb-filename";
|
|
filename.textContent = item.name;
|
|
filename.title = item.name;
|
|
tile.appendChild(filename);
|
|
}
|
|
|
|
if (item.mime_type?.startsWith("video/")) {
|
|
const play = document.createElement("div");
|
|
play.className = "play-icon";
|
|
play.textContent = "▶";
|
|
tile.appendChild(play);
|
|
}
|
|
|
|
window.GallerySelection?.bindTile(
|
|
tile, "photos", item, () => {
|
|
window.closeBottomSheet?.();
|
|
|
|
if (typeof window.openModalFromList === "function") {
|
|
window.openModalFromList(sectionItems, itemIndex);
|
|
} else {
|
|
window.openModal?.(original, modalPreview, item);
|
|
}
|
|
}
|
|
);
|
|
|
|
return tile;
|
|
}
|
|
|
|
function createActivityTile(item, sectionItems, itemIndex) {
|
|
const tile = document.createElement("div");
|
|
tile.className = "activity-tile";
|
|
tile.id = `activity_${String(item.id || "").replaceAll("/", "_")}`;
|
|
|
|
if (item.deleted_at) tile.classList.add("soft-deleted");
|
|
|
|
const routeBox = document.createElement("div");
|
|
routeBox.className = "activity-route-thumb loading";
|
|
routeBox.innerHTML =
|
|
'<div class="activity-route-placeholder"><span>Caricamento percorso...</span></div>';
|
|
|
|
tile.appendChild(routeBox);
|
|
window.ActivityThumbs?.renderRouteThumb(routeBox, item);
|
|
|
|
const label = document.createElement("div");
|
|
label.className = "activity-label";
|
|
label.textContent =
|
|
item.display_name ||
|
|
item.route_name ||
|
|
item.title ||
|
|
item.name ||
|
|
"(senza nome)";
|
|
|
|
tile.appendChild(label);
|
|
|
|
// Nome file completo, estensione compresa, quando le soft delete sono visibili.
|
|
if (localStorage.getItem("showDeleted") === "1" && item.name) {
|
|
const filename = document.createElement("div");
|
|
filename.className = "thumb-filename";
|
|
filename.textContent = item.name;
|
|
filename.title = item.name;
|
|
tile.appendChild(filename);
|
|
}
|
|
|
|
const metaText = window.GalleryUtils?.formatActivityMeta(item) || "";
|
|
|
|
if (metaText) {
|
|
const meta = document.createElement("div");
|
|
meta.className = "activity-meta";
|
|
meta.textContent = metaText;
|
|
tile.appendChild(meta);
|
|
}
|
|
|
|
window.GallerySelection?.bindTile(
|
|
tile, "activities", item, async () => {
|
|
window.closeBottomSheet?.();
|
|
|
|
if (typeof window.openActivityModal !== "function") {
|
|
console.error(
|
|
"[galleryRenderer] openActivityModal non disponibile"
|
|
);
|
|
return;
|
|
}
|
|
|
|
await window.openActivityModal(item, sectionItems, itemIndex);
|
|
}
|
|
);
|
|
|
|
return tile;
|
|
}
|
|
|
|
function getLayout(container) {
|
|
const activities = window.state?.mode === "activities";
|
|
const width = Math.max(1, container.clientWidth - 8);
|
|
|
|
const baseMinTile = activities
|
|
? (window.innerWidth <= 420
|
|
? 110
|
|
: window.innerWidth >= 700
|
|
? 140
|
|
: 120)
|
|
: (window.innerWidth <= 420 ? 96 : 100);
|
|
|
|
const minTile = window.GalleryZoom
|
|
? window.GalleryZoom.getMinTile(activities, baseMinTile)
|
|
: baseMinTile;
|
|
|
|
const gap = activities ? 6 : 4;
|
|
const columns = Math.max(
|
|
1,
|
|
Math.floor((width + gap) / (minTile + gap))
|
|
);
|
|
|
|
const tileWidth = (width - gap * (columns - 1)) / columns;
|
|
|
|
return {
|
|
activities,
|
|
columns,
|
|
gap,
|
|
rowHeight: tileWidth + gap,
|
|
width
|
|
};
|
|
}
|
|
|
|
function updateSection(entry, viewportHeight) {
|
|
const { container, section, windowGrid } = entry;
|
|
const count = section.items.length;
|
|
const layout = getLayout(container);
|
|
|
|
const totalRows = Math.ceil(count / layout.columns);
|
|
const totalHeight = totalRows
|
|
? totalRows * layout.rowHeight - layout.gap
|
|
: 0;
|
|
|
|
container.style.height = `${Math.max(0, totalHeight)}px`;
|
|
|
|
const rect = container.getBoundingClientRect();
|
|
|
|
let firstRow = Math.floor(
|
|
-rect.top / layout.rowHeight - OVERSCAN_ROWS
|
|
);
|
|
|
|
let lastRow = Math.ceil(
|
|
(viewportHeight - rect.top) / layout.rowHeight + OVERSCAN_ROWS
|
|
);
|
|
|
|
firstRow = Math.max(0, Math.min(totalRows, firstRow));
|
|
lastRow = Math.max(firstRow, Math.min(totalRows, lastRow));
|
|
|
|
const signature = [
|
|
layout.activities ? "activities" : "photos",
|
|
layout.columns,
|
|
layout.rowHeight.toFixed(2),
|
|
layout.width.toFixed(2),
|
|
firstRow,
|
|
lastRow
|
|
].join(":");
|
|
|
|
if (entry.signature === signature) return;
|
|
entry.signature = signature;
|
|
|
|
const firstIndex = firstRow * layout.columns;
|
|
const lastIndex = Math.min(count, lastRow * layout.columns);
|
|
|
|
windowGrid.style.top = `${firstRow * layout.rowHeight}px`;
|
|
windowGrid.style.gridTemplateColumns =
|
|
`repeat(${layout.columns}, minmax(0, 1fr))`;
|
|
windowGrid.style.gap = `${layout.gap}px`;
|
|
|
|
const wanted = new Set();
|
|
|
|
for (let i = firstIndex; i < lastIndex; i++) {
|
|
wanted.add(i);
|
|
}
|
|
|
|
// Elimina dalla cache locale solo gli elementi fuori dalla finestra.
|
|
for (const [index, node] of entry.rendered) {
|
|
if (!wanted.has(index)) {
|
|
node.remove();
|
|
entry.rendered.delete(index);
|
|
}
|
|
}
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
for (let i = firstIndex; i < lastIndex; i++) {
|
|
let tile = entry.rendered.get(i);
|
|
|
|
if (!tile) {
|
|
const item = section.items[i];
|
|
|
|
tile = layout.activities
|
|
? createActivityTile(item, section.items, i)
|
|
: createPhotoTile(
|
|
item,
|
|
section.items,
|
|
i,
|
|
section.globalStart + i
|
|
);
|
|
|
|
entry.rendered.set(i, tile);
|
|
}
|
|
|
|
fragment.appendChild(tile);
|
|
}
|
|
|
|
windowGrid.replaceChildren(fragment);
|
|
}
|
|
|
|
function updateVisibleSections() {
|
|
framePending = false;
|
|
|
|
const viewportHeight = window.innerHeight;
|
|
|
|
for (const entry of currentSections) {
|
|
updateSection(entry, viewportHeight);
|
|
}
|
|
}
|
|
|
|
function refreshLayout() {
|
|
// Invalida le geometrie memorizzate: la galleria potrebbe
|
|
// essere stata nascosta mentre la mappa era aperta.
|
|
for (const entry of currentSections) {
|
|
entry.signature = null;
|
|
}
|
|
|
|
// Attende che il browser abbia ripristinato il layout visibile.
|
|
window.requestAnimationFrame(() => {
|
|
window.requestAnimationFrame(() => {
|
|
updateVisibleSections();
|
|
});
|
|
});
|
|
}
|
|
|
|
function scheduleUpdate() {
|
|
if (framePending) return;
|
|
|
|
framePending = true;
|
|
window.requestAnimationFrame(updateVisibleSections);
|
|
}
|
|
|
|
function renderGallery(sections, append = false) {
|
|
const gallery = document.getElementById("gallery");
|
|
if (!gallery) return;
|
|
|
|
const incoming = Array.isArray(sections) ? sections : [];
|
|
const activities = window.state?.mode === "activities";
|
|
|
|
function createEntry(section, globalStart, fragment) {
|
|
const items = section.items || [];
|
|
|
|
const heading = document.createElement("h2");
|
|
heading.className = "gallery-section-title";
|
|
heading.textContent = section.label;
|
|
fragment.appendChild(heading);
|
|
|
|
const container = document.createElement("div");
|
|
container.className = "gallery-section gallery-virtualized";
|
|
|
|
const windowGrid = document.createElement("div");
|
|
windowGrid.className = "gallery-virtual-window";
|
|
|
|
container.appendChild(windowGrid);
|
|
fragment.appendChild(container);
|
|
|
|
return {
|
|
section: {
|
|
label: section.label,
|
|
items,
|
|
globalStart
|
|
},
|
|
heading,
|
|
container,
|
|
windowGrid,
|
|
rendered: new Map(),
|
|
signature: null
|
|
};
|
|
}
|
|
|
|
// Riutilizza il DOM esistente quando arriva una pagina successiva,
|
|
// sia per le foto sia per le attività, se le sezioni restano in ordine.
|
|
if (append && currentSections.length > 0) {
|
|
const isPrefix =
|
|
currentSections.length <= incoming.length &&
|
|
currentSections.every(
|
|
(entry, index) =>
|
|
entry.section.label === incoming[index].label
|
|
);
|
|
|
|
if (isPrefix) {
|
|
const fragment = document.createDocumentFragment();
|
|
let globalPhotoIndex = 0;
|
|
|
|
for (let i = 0; i < incoming.length; i++) {
|
|
const section = incoming[i];
|
|
const newItems = section.items || [];
|
|
let entry = currentSections[i];
|
|
|
|
if (entry) {
|
|
// Mantiene lo stesso array: le miniature già esistenti
|
|
// conservano i riferimenti usati per aprire il visualizzatore.
|
|
const oldItems = entry.section.items;
|
|
const oldLength = oldItems.length;
|
|
const common = Math.min(oldLength, newItems.length);
|
|
|
|
// Aggiorna gli elementi esistenti e copia davvero quelli nuovi.
|
|
for (let j = 0; j < common; j++) {
|
|
oldItems[j] = newItems[j];
|
|
}
|
|
|
|
for (let j = oldLength; j < newItems.length; j++) {
|
|
oldItems.push(newItems[j]);
|
|
}
|
|
|
|
if (newItems.length < oldLength) {
|
|
oldItems.length = newItems.length;
|
|
}
|
|
|
|
// Forza l'aggiornamento della finestra virtualizzata
|
|
// quando cambia il numero di elementi della sezione.
|
|
entry.signature = null;
|
|
entry.section.label = section.label;
|
|
entry.heading.textContent = section.label;
|
|
entry.section.globalStart = globalPhotoIndex;
|
|
|
|
globalPhotoIndex += oldItems.length;
|
|
} else {
|
|
entry = createEntry(section, globalPhotoIndex, fragment);
|
|
currentSections.push(entry);
|
|
globalPhotoIndex += entry.section.items.length;
|
|
}
|
|
}
|
|
|
|
// Aggiunge solo le nuove sezioni, senza sostituire quelle vecchie.
|
|
if (fragment.childNodes.length) {
|
|
gallery.appendChild(fragment);
|
|
}
|
|
|
|
updateVisibleSections();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Rendering completo per prima pagina, filtri e modalità attività.
|
|
const fragment = document.createDocumentFragment();
|
|
const entries = [];
|
|
let globalPhotoIndex = 0;
|
|
|
|
for (const section of incoming) {
|
|
const entry = createEntry(section, globalPhotoIndex, fragment);
|
|
entries.push(entry);
|
|
|
|
if (!activities) {
|
|
globalPhotoIndex += entry.section.items.length;
|
|
}
|
|
}
|
|
|
|
currentSections = entries;
|
|
gallery.replaceChildren(fragment);
|
|
updateVisibleSections();
|
|
}
|
|
|
|
window.addEventListener("scroll", scheduleUpdate, { passive: true });
|
|
window.addEventListener("resize", scheduleUpdate, { passive: true });
|
|
|
|
window.GalleryRenderer = Object.freeze({ renderGallery, refreshLayout });
|
|
window.renderGallery = renderGallery;
|
|
})();
|