first commit
7
.env
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
BASE_URL=https://prova.patachina.it
|
||||
SERVER_PORT=4000
|
||||
EMAIL=fabio@gmail.com
|
||||
PASSWORD=master66
|
||||
JWT_SECRET=123456789
|
||||
JWT_EXPIRES=1h
|
||||
PATH_FULL=true
|
||||
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
thumbs/
|
||||
db.json
|
||||
69
README.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Galleria con json-server e protetto con JWT S6J
|
||||
|
||||
## Installazione
|
||||
|
||||
clonare questa repo e installare tutte le dipendenze con `npm ci`
|
||||
|
||||
|
||||
## Start/Stop servers
|
||||
|
||||
| Description | Script |
|
||||
| ------------------------- | -------------------- |
|
||||
| Start server senza auth | `npm start-no-auth` |
|
||||
| Start server con auth | `npm run start` |
|
||||
|
||||
## Tools
|
||||
|
||||
| Description | Script |
|
||||
| ------------------------------ | ------------------- |
|
||||
| Generate user hashed passwords | `npm run hash` |
|
||||
|
||||
|
||||
[json-server api reference](https://github.com/typicode/json-server)
|
||||
|
||||
## Come usarlo
|
||||
|
||||
clonare e poi installare con
|
||||
|
||||
```
|
||||
npm ci
|
||||
```
|
||||
|
||||
nel file .env ci sono tutti i dati da modificare
|
||||
|
||||
poi inserire in user.json user e password utilizzati per fare il login
|
||||
|
||||
la password da inserire è criptata e viene generata con npm run hash
|
||||
|
||||
il nome viene utilizzato come cartella da scansionare, si trova dentro photos
|
||||
|
||||
es:
|
||||
```
|
||||
name: Fabio
|
||||
|
||||
public/photos
|
||||
└── Fabio
|
||||
└── original
|
||||
└── 2017Irlanda19-29ago
|
||||
├── IMG_0092.JPG
|
||||
├── IMG_0099.JPG
|
||||
├── IMG_0100.JPG
|
||||
```
|
||||
poi dentro Fabio genererà thumbs con tutti i thumbs
|
||||
|
||||
- npm run start
|
||||
- su IP:4000 ci sarà la galleria e andando su impostazioni si potrà fare lo scan di tutte le foto
|
||||
|
||||
dopo aver fatto lo scan è possibile richiedere il json al server con tutte le informazioni anche senza autorizzazione
|
||||
|
||||
basta farlo partire con npm run start-no-auth e le info si possono vedere con
|
||||
|
||||
ip:4000/photos
|
||||
|
||||
- npm start
|
||||
|
||||
|
||||
---
|
||||
|
||||
Inspired in this [post](https://www.techiediaries.com/fake-api-jwt-json-server/) by [Techiediaries](https://www.techiediaries.com/)
|
||||
|
||||
17
api_v1/config.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
require('dotenv').config();
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
BASE_URL: process.env.BASE_URL,
|
||||
EMAIL: process.env.EMAIL,
|
||||
PASSWORD: process.env.PASSWORD,
|
||||
SEND_PHOTOS: (process.env.SEND_PHOTOS || 'true').toLowerCase() === 'true',
|
||||
WRITE_INDEX: (process.env.WRITE_INDEX || 'true').toLowerCase() === 'true',
|
||||
WEB_ROOT: process.env.WEB_ROOT || 'public',
|
||||
PATH_FULL: (process.env.PATH_FULL || 'false').toLowerCase() === 'true',
|
||||
INDEX_PATH: process.env.INDEX_PATH || path.posix.join('photos', 'index.json'),
|
||||
SUPPORTED_EXTS: new Set([
|
||||
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
|
||||
'.mp4', '.mov', '.m4v'
|
||||
])
|
||||
};
|
||||
96
api_v1/geo.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
const axios = require("axios");
|
||||
|
||||
// Funzione principale
|
||||
async function loc(lng, lat) {
|
||||
const primary = await place(lng, lat); // Geoapify
|
||||
const fallback = await placePhoton(lng, lat); // Photon
|
||||
|
||||
// Se Geoapify fallisce → usa Photon
|
||||
if (!primary) return fallback;
|
||||
|
||||
// Se Geoapify manca city → prendi da Photon
|
||||
if (!primary.city && fallback?.city) {
|
||||
primary.city = fallback.city;
|
||||
}
|
||||
|
||||
// Se Geoapify manca postcode → prendi da Photon
|
||||
if (!primary.postcode && fallback?.postcode) {
|
||||
primary.postcode = fallback.postcode;
|
||||
}
|
||||
|
||||
// Se Geoapify manca address → prendi da Photon
|
||||
if (!primary.address && fallback?.address) {
|
||||
primary.address = fallback.address;
|
||||
}
|
||||
|
||||
// Se Geoapify manca region → prendi da Photon
|
||||
if (!primary.region && fallback?.region) {
|
||||
primary.region = fallback.region;
|
||||
}
|
||||
|
||||
// Se Geoapify manca county_code → Photon NON lo fornisce
|
||||
// quindi non possiamo riempirlo
|
||||
|
||||
return primary;
|
||||
}
|
||||
|
||||
// Geoapify (sorgente principale)
|
||||
async function place(lng, lat) {
|
||||
const apiKey = "6dc7fb95a3b246cfa0f3bcef5ce9ed9a";
|
||||
const url = `https://api.geoapify.com/v1/geocode/reverse?lat=${lat}&lon=${lng}&apiKey=${apiKey}`;
|
||||
|
||||
try {
|
||||
const r = await axios.get(url);
|
||||
|
||||
if (r.status !== 200) return undefined;
|
||||
if (!r.data.features || r.data.features.length === 0) return undefined;
|
||||
|
||||
const k = r.data.features[0].properties;
|
||||
|
||||
return {
|
||||
continent: k?.timezone?.name?.split("/")?.[0] || undefined,
|
||||
country: k?.country || undefined,
|
||||
region: k?.state || undefined,
|
||||
postcode: k?.postcode || undefined,
|
||||
city: k?.city || undefined,
|
||||
county_code: k?.county_code || undefined,
|
||||
address: k?.address_line1 || undefined,
|
||||
timezone: k?.timezone?.name || undefined,
|
||||
time: k?.timezone?.offset_STD || undefined
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Photon (fallback)
|
||||
async function placePhoton(lng, lat) {
|
||||
try {
|
||||
const url = `https://photon.patachina.it/reverse?lon=${lng}&lat=${lat}`;
|
||||
const r = await axios.get(url);
|
||||
|
||||
if (!r.data || !r.data.features || r.data.features.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const p = r.data.features[0].properties;
|
||||
|
||||
return {
|
||||
continent: undefined, // Photon non lo fornisce
|
||||
country: p.country || undefined,
|
||||
region: p.state || undefined,
|
||||
postcode: p.postcode || undefined,
|
||||
city: p.city || p.town || p.village || undefined,
|
||||
county_code: undefined, // Photon non fornisce codici ISO
|
||||
address: p.street ? `${p.street} ${p.housenumber || ""}`.trim() : undefined,
|
||||
timezone: undefined,
|
||||
time: undefined
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = loc;
|
||||
1
api_v1/initialDB.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"photos":[]}
|
||||
69
api_v1/scanner/gps.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
const { exec } = require('child_process');
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// FOTO: GPS da ExifReader
|
||||
// -----------------------------------------------------------------------------
|
||||
function extractGpsFromExif(tags) {
|
||||
if (!tags?.gps) return null;
|
||||
|
||||
const lat = tags.gps.Latitude;
|
||||
const lng = tags.gps.Longitude;
|
||||
const alt = tags.gps.Altitude;
|
||||
|
||||
if (lat == null || lng == null) return null;
|
||||
|
||||
return {
|
||||
lat: Number(lat),
|
||||
lng: Number(lng),
|
||||
alt: alt != null ? Number(alt) : null
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// VIDEO: GPS via exiftool (VERSIONE ORIGINALE CHE FUNZIONA)
|
||||
// -----------------------------------------------------------------------------
|
||||
function extractGpsWithExiftool(videoPath) {
|
||||
//console.log(videoPath);
|
||||
return new Promise((resolve) => {
|
||||
const cmd = `exiftool -n -G1 -a -gps:all -quicktime:all -user:all "${videoPath}"`;
|
||||
|
||||
exec(cmd, (err, stdout) => {
|
||||
if (err || !stdout) return resolve(null);
|
||||
|
||||
// 1) GPS Coordinates : <lat> <lng>
|
||||
const userData = stdout.match(/GPS Coordinates\s*:\s*([0-9.\-]+)\s+([0-9.\-]+)/i);
|
||||
if (userData) {
|
||||
return resolve({
|
||||
lat: Number(userData[1]),
|
||||
lng: Number(userData[2]),
|
||||
alt: null
|
||||
});
|
||||
}
|
||||
|
||||
// 2) GPSLatitude / GPSLongitude
|
||||
const lat1 = stdout.match(/GPSLatitude\s*:\s*([0-9.\-]+)/i);
|
||||
const lng1 = stdout.match(/GPSLongitude\s*:\s*([0-9.\-]+)/i);
|
||||
if (lat1 && lng1) {
|
||||
return resolve({
|
||||
lat: Number(lat1[1]),
|
||||
lng: Number(lng1[1]),
|
||||
alt: null
|
||||
});
|
||||
}
|
||||
|
||||
// 3) GPSCoordinates : <lat> <lng>
|
||||
const coords = stdout.match(/GPSCoordinates\s*:\s*([0-9.\-]+)\s+([0-9.\-]+)/i);
|
||||
if (coords) {
|
||||
return resolve({
|
||||
lat: Number(coords[1]),
|
||||
lng: Number(coords[2]),
|
||||
alt: null
|
||||
});
|
||||
}
|
||||
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { extractGpsFromExif, extractGpsWithExiftool };
|
||||
47
api_v1/scanner/indexStore.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// scanner/indexStore.js
|
||||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
const { WEB_ROOT, INDEX_PATH } = require('../config');
|
||||
|
||||
// NOTE: siamo in .../api_v1/scanner → per arrivare alla root progetto servono due '..'
|
||||
const absIndexPath = path.resolve(__dirname, '..', '..', WEB_ROOT, INDEX_PATH);
|
||||
const absIndexTmp = absIndexPath + '.tmp';
|
||||
|
||||
const PRETTY = process.env.INDEX_PRETTY === 'true';
|
||||
|
||||
/**
|
||||
* Carica l'indice. Formato canonico: mappa { [id]: meta }.
|
||||
* Retro-compat: se il file è un array, convertilo in mappa.
|
||||
*/
|
||||
async function loadPreviousIndex() {
|
||||
try {
|
||||
const raw = await fsp.readFile(absIndexPath, 'utf8');
|
||||
const json = JSON.parse(raw);
|
||||
|
||||
if (Array.isArray(json)) {
|
||||
const map = {};
|
||||
for (const p of json) {
|
||||
if (p && p.id) map[p.id] = p;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
return json && typeof json === 'object' ? json : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Salva l'indice (mappa) in modo atomico: scrive su .tmp e poi rename.
|
||||
*/
|
||||
async function saveIndex(indexMap) {
|
||||
const dir = path.dirname(absIndexPath);
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
const data = PRETTY ? JSON.stringify(indexMap, null, 2) : JSON.stringify(indexMap);
|
||||
|
||||
await fsp.writeFile(absIndexTmp, data, 'utf8');
|
||||
await fsp.rename(absIndexTmp, absIndexPath);
|
||||
}
|
||||
|
||||
module.exports = { loadPreviousIndex, saveIndex, absIndexPath };
|
||||
60
api_v1/scanner/postWithAuth.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
const axios = require('axios');
|
||||
const { BASE_URL, EMAIL, PASSWORD, SEND_PHOTOS } = require('../config');
|
||||
|
||||
let cachedToken = null;
|
||||
|
||||
async function getToken(force = false) {
|
||||
if (!SEND_PHOTOS) return null;
|
||||
if (cachedToken && !force) return cachedToken;
|
||||
|
||||
try {
|
||||
const res = await axios.post(`${BASE_URL}/auth/login`, {
|
||||
email: EMAIL,
|
||||
password: PASSWORD
|
||||
});
|
||||
|
||||
cachedToken = res.data.token;
|
||||
return cachedToken;
|
||||
|
||||
} catch (err) {
|
||||
console.error('ERRORE LOGIN:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function postWithAuth(url, payload) {
|
||||
if (!SEND_PHOTOS) return;
|
||||
|
||||
let token = await getToken();
|
||||
if (!token) throw new Error('Token assente');
|
||||
|
||||
try {
|
||||
await axios.post(url, payload, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
if (err.response && err.response.status === 401) {
|
||||
token = await getToken(true);
|
||||
if (!token) throw err;
|
||||
|
||||
await axios.post(url, payload, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = postWithAuth;
|
||||
|
||||
117
api_v1/scanner/processFile.js
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
const ExifReader = require('exifreader');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const { sha256, inferMimeFromExt, parseExifDateUtc } = require('./utils');
|
||||
const { extractGpsFromExif, extractGpsWithExiftool } = require('./gps');
|
||||
const { createVideoThumbnail, createThumbnails } = require('./thumbs');
|
||||
const { probeVideo } = require('./video');
|
||||
const loc = require('../geo.js');
|
||||
const { WEB_ROOT, PATH_FULL } = require('../config');
|
||||
|
||||
async function processFile(userName, cartella, fileRelPath, absPath, ext, st) {
|
||||
const isVideo = ['.mp4', '.mov', '.m4v'].includes(ext);
|
||||
|
||||
console.log(
|
||||
`PROCESSING → user=${userName} | cartella=${cartella} | file=${fileRelPath}`
|
||||
);
|
||||
|
||||
const thumbBase = path.join(
|
||||
WEB_ROOT,
|
||||
'photos',
|
||||
userName,
|
||||
'thumbs',
|
||||
cartella,
|
||||
path.dirname(fileRelPath)
|
||||
);
|
||||
|
||||
await fsp.mkdir(thumbBase, { recursive: true });
|
||||
|
||||
const baseName = path.parse(fileRelPath).name;
|
||||
|
||||
const absThumbMin = path.join(thumbBase, `${baseName}_min.jpg`);
|
||||
const absThumbAvg = path.join(thumbBase, `${baseName}_avg.jpg`);
|
||||
|
||||
if (isVideo) {
|
||||
await createVideoThumbnail(absPath, absThumbMin, absThumbAvg);
|
||||
} else {
|
||||
await createThumbnails(absPath, absThumbMin, absThumbAvg);
|
||||
}
|
||||
|
||||
let tags = {};
|
||||
try { tags = await ExifReader.load(absPath, { expanded: true }); } catch {}
|
||||
|
||||
const timeRaw = tags?.exif?.DateTimeOriginal?.value?.[0] || null;
|
||||
const takenAtIso = parseExifDateUtc(timeRaw);
|
||||
|
||||
let gps = isVideo
|
||||
? await extractGpsWithExiftool(absPath)
|
||||
: extractGpsFromExif(tags);
|
||||
|
||||
let width = null, height = null, duration = null;
|
||||
|
||||
if (isVideo) {
|
||||
const info = await probeVideo(absPath);
|
||||
const stream = info.streams?.find(s => s.width && s.height);
|
||||
if (stream) {
|
||||
width = stream.width;
|
||||
height = stream.height;
|
||||
}
|
||||
duration = info.format?.duration || null;
|
||||
} else {
|
||||
try {
|
||||
const meta = await sharp(absPath).metadata();
|
||||
width = meta.width || null;
|
||||
height = meta.height || null;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const mime_type = inferMimeFromExt(ext);
|
||||
const id = sha256(`${userName}/${cartella}/${fileRelPath}`);
|
||||
|
||||
const location = gps ? await loc(gps.lng, gps.lat) : null;
|
||||
|
||||
//
|
||||
// --- GESTIONE PATH FULL / RELATIVI ---
|
||||
//
|
||||
|
||||
// relativi (comportamento attuale)
|
||||
const relPath = fileRelPath;
|
||||
const relThub1 = fileRelPath.replace(/\.[^.]+$/, '_min.jpg');
|
||||
const relThub2 = fileRelPath.replace(/\.[^.]+$/, '_avg.jpg');
|
||||
|
||||
// completi (solo se PATH_FULL = true)
|
||||
const fullPath = PATH_FULL
|
||||
? path.posix.join('/photos', userName, cartella, fileRelPath)
|
||||
: relPath;
|
||||
|
||||
const fullThub1 = PATH_FULL
|
||||
? path.posix.join('/photos', userName, 'thumbs', cartella, relThub1)
|
||||
: relThub1;
|
||||
|
||||
const fullThub2 = PATH_FULL
|
||||
? path.posix.join('/photos', userName, 'thumbs', cartella, relThub2)
|
||||
: relThub2;
|
||||
|
||||
return {
|
||||
id,
|
||||
user: userName,
|
||||
cartella,
|
||||
path: fullPath,
|
||||
thub1: fullThub1,
|
||||
thub2: fullThub2,
|
||||
gps,
|
||||
data: timeRaw,
|
||||
taken_at: takenAtIso,
|
||||
mime_type,
|
||||
width,
|
||||
height,
|
||||
size_bytes: st.size,
|
||||
mtimeMs: st.mtimeMs,
|
||||
duration: isVideo ? duration : null,
|
||||
location
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = processFile;
|
||||
58
api_v1/scanner/scanCartella.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// scanner/scanCartella.js
|
||||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
const processFile = require('./processFile');
|
||||
const { sha256 } = require('./utils');
|
||||
const { SUPPORTED_EXTS } = require('../config');
|
||||
|
||||
/**
|
||||
* Scansiona ricorsivamente una cartella e ritorna SOLO i cambiamenti
|
||||
* (nuovi/modificati) rispetto a previousIndex (mappa { id: meta }).
|
||||
*/
|
||||
async function scanCartella(userName, cartella, absCartella, previousIndex) {
|
||||
const changes = [];
|
||||
|
||||
async function walk(currentAbs, relPath = '') {
|
||||
const entries = await fsp.readdir(currentAbs, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
const absPath = path.join(currentAbs, e.name);
|
||||
if (e.isDirectory()) {
|
||||
await walk(absPath, path.join(relPath, e.name));
|
||||
continue;
|
||||
}
|
||||
|
||||
const ext = path.extname(e.name).toLowerCase();
|
||||
if (!SUPPORTED_EXTS.has(ext)) continue;
|
||||
|
||||
const fileRelPath = relPath ? `${relPath}/${e.name}` : e.name;
|
||||
const id = sha256(`${userName}/${cartella}/${fileRelPath}`);
|
||||
|
||||
const st = await fsp.stat(absPath);
|
||||
const prev = previousIndex[id];
|
||||
|
||||
const unchanged =
|
||||
prev &&
|
||||
prev.size_bytes === st.size &&
|
||||
prev.mtimeMs === st.mtimeMs;
|
||||
|
||||
if (unchanged) continue; // NOTE: skip "unchanged"
|
||||
|
||||
const meta = await processFile(
|
||||
userName,
|
||||
cartella,
|
||||
fileRelPath,
|
||||
absPath,
|
||||
ext,
|
||||
st
|
||||
);
|
||||
|
||||
meta.id = id; // id sempre presente
|
||||
changes.push(meta);
|
||||
}
|
||||
}
|
||||
|
||||
await walk(absCartella);
|
||||
return changes;
|
||||
}
|
||||
|
||||
module.exports = scanCartella;
|
||||
134
api_v1/scanner/scanPhoto.js
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// scanner/scanPhoto.js
|
||||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
const { loadPreviousIndex, saveIndex } = require('./indexStore');
|
||||
const scanUserRoot = require('./scanUser');
|
||||
const postWithAuth = require('./postWithAuth');
|
||||
const {
|
||||
WEB_ROOT,
|
||||
SEND_PHOTOS,
|
||||
BASE_URL,
|
||||
WRITE_INDEX,
|
||||
} = require('../config');
|
||||
|
||||
const COMMON = 'Common'; // Nome canonico e case-sensitive per la shared folder
|
||||
|
||||
/**
|
||||
* Restituisce la prima directory esistente tra le candidate
|
||||
* @param {string[]} candidates
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function firstExistingDir(candidates) {
|
||||
for (const dir of candidates) {
|
||||
try {
|
||||
const st = await fsp.stat(dir);
|
||||
if (st.isDirectory()) return dir;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scansione foto con indice separato:
|
||||
* - Carica indice precedente (mappa { id: meta })
|
||||
* - Produce SOLO i cambiamenti (nuovi/modificati)
|
||||
* - Merge e salvataggio atomico dell'indice
|
||||
* - POST verso /photos solo del delta (se abilitato)
|
||||
* - Admin: include la cartella "Common"
|
||||
*
|
||||
* Nota path: usiamo un path ASSOLUTO per <root>/public/photos
|
||||
* partendo da .../api_v1/scanner -> '..', '..' per salire alla root del progetto.
|
||||
*/
|
||||
async function scanPhoto(dir, userName) {
|
||||
try {
|
||||
const previousIndexMap = await loadPreviousIndex();
|
||||
const nextIndexMap = { ...previousIndexMap };
|
||||
|
||||
// Path assoluto alla radice photos (es: <root>/public/photos)
|
||||
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
|
||||
|
||||
let changes = [];
|
||||
|
||||
if (userName === 'Admin') {
|
||||
// 1) Scansiona TUTTI gli utenti tranne la/e cartella/e common
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fsp.readdir(photosRoot, { withFileTypes: true });
|
||||
} catch (e) {
|
||||
console.error(`[SCAN] photosRoot non accessibile: ${photosRoot}`, e.message);
|
||||
entries = [];
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory()) continue;
|
||||
// salta qualunque "common" (qualunque casing) per non doppiare
|
||||
if (e.name.toLowerCase() === COMMON.toLowerCase()) continue;
|
||||
|
||||
const userDir = path.join(photosRoot, e.name, 'original');
|
||||
try {
|
||||
const st = await fsp.stat(userDir);
|
||||
if (!st.isDirectory()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const userChanges = await scanUserRoot(e.name, userDir, previousIndexMap);
|
||||
for (const m of userChanges) nextIndexMap[m.id] = m;
|
||||
changes.push(...userChanges);
|
||||
}
|
||||
|
||||
// 2) Scansiona la cartella COMMON (case strict), con fallback legacy (solo lettura) a 'common'
|
||||
const commonPreferred = path.join(photosRoot, COMMON, 'original'); // .../photos/Common/original
|
||||
const commonLegacy = path.join(photosRoot, 'common', 'original'); // .../photos/common/original (legacy)
|
||||
|
||||
const commonDir = await firstExistingDir([commonPreferred, commonLegacy]);
|
||||
if (commonDir) {
|
||||
// Forziamo SEMPRE userName = 'Common' per ID/thumbnails coerenti in 'Common'
|
||||
const commonChanges = await scanUserRoot(COMMON, commonDir, previousIndexMap);
|
||||
for (const m of commonChanges) nextIndexMap[m.id] = m;
|
||||
changes.push(...commonChanges);
|
||||
console.log(`[SCAN] Common indicizzati da ${commonDir}: +${commonChanges.length}`);
|
||||
} else {
|
||||
console.log(`[SCAN] Nessuna cartella "${COMMON}" trovata sotto ${photosRoot} (atteso: ${commonPreferred})`);
|
||||
}
|
||||
|
||||
} else {
|
||||
// Non-Admin (per completezza; in server.js /scan è Admin-only)
|
||||
const userDir = path.join(photosRoot, userName, 'original');
|
||||
try {
|
||||
const st = await fsp.stat(userDir);
|
||||
if (st.isDirectory()) {
|
||||
const userChanges = await scanUserRoot(userName, userDir, previousIndexMap);
|
||||
for (const m of userChanges) nextIndexMap[m.id] = m;
|
||||
changes.push(...userChanges);
|
||||
}
|
||||
} catch {
|
||||
// utente senza dir 'original' -> nessuna modifica
|
||||
}
|
||||
}
|
||||
|
||||
// Salva indice (mappa) in modo atomico
|
||||
if (WRITE_INDEX) {
|
||||
await saveIndex(nextIndexMap);
|
||||
}
|
||||
|
||||
// POST solo dei cambiamenti (delta)
|
||||
if (SEND_PHOTOS && BASE_URL && changes.length) {
|
||||
for (const p of changes) {
|
||||
try {
|
||||
await postWithAuth(`${BASE_URL}/photos`, p);
|
||||
} catch (err) {
|
||||
console.error('Errore invio:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`SCAN COMPLETATA per utente ${userName}: ${changes.length} file aggiornati`);
|
||||
return changes;
|
||||
} catch (e) {
|
||||
console.error('Errore generale scanPhoto:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = scanPhoto;
|
||||
64
api_v1/scanner/scanUser.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// scanner/scanUser.js
|
||||
const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
const scanCartella = require('./scanCartella');
|
||||
const processFile = require('./processFile');
|
||||
const { sha256 } = require('./utils');
|
||||
const { SUPPORTED_EXTS } = require('../config');
|
||||
|
||||
/**
|
||||
* Scansiona la root dell'utente (p.es. .../<user>/original) e:
|
||||
* - indicizza i file direttamente al root (cartella virtuale "_root")
|
||||
* - per ogni sottocartella, chiama scanCartella
|
||||
*/
|
||||
async function scanUserRoot(userName, userDir, previousIndex) {
|
||||
const results = [];
|
||||
const entries = await fsp.readdir(userDir, { withFileTypes: true });
|
||||
|
||||
// 1) File direttamente al root (virtual folder "_root")
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory()) continue;
|
||||
|
||||
const ext = path.extname(e.name).toLowerCase();
|
||||
if (!SUPPORTED_EXTS.has(ext)) continue;
|
||||
|
||||
const absPath = path.join(userDir, e.name);
|
||||
const st = await fsp.stat(absPath);
|
||||
|
||||
const fileRelPath = e.name;
|
||||
const cartella = '_root';
|
||||
const id = sha256(`${userName}/${cartella}/${fileRelPath}`);
|
||||
|
||||
const prev = previousIndex[id];
|
||||
const unchanged =
|
||||
prev &&
|
||||
prev.size_bytes === st.size &&
|
||||
prev.mtimeMs === st.mtimeMs;
|
||||
|
||||
if (unchanged) continue;
|
||||
|
||||
const meta = await processFile(
|
||||
userName,
|
||||
cartella,
|
||||
fileRelPath,
|
||||
absPath,
|
||||
ext,
|
||||
st
|
||||
);
|
||||
meta.id = id;
|
||||
results.push(meta);
|
||||
}
|
||||
|
||||
// 2) Sottocartelle (comportamento classico)
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory()) continue;
|
||||
const cartella = e.name;
|
||||
const absCartella = path.join(userDir, cartella);
|
||||
const files = await scanCartella(userName, cartella, absCartella, previousIndex);
|
||||
results.push(...files);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
module.exports = scanUserRoot;
|
||||
31
api_v1/scanner/thumbs.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
const sharp = require('sharp');
|
||||
const { exec } = require('child_process');
|
||||
|
||||
function createVideoThumbnail(videoPath, thumbMinPath, thumbAvgPath) {
|
||||
return new Promise((resolve) => {
|
||||
const cmd = `
|
||||
ffmpeg -y -i "${videoPath}" -ss 00:00:01.000 -vframes 1 "${thumbAvgPath}" &&
|
||||
ffmpeg -y -i "${thumbAvgPath}" -vf "scale=100:-1" "${thumbMinPath}"
|
||||
`;
|
||||
exec(cmd, () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function createThumbnails(filePath, thumbMinPath, thumbAvgPath) {
|
||||
try {
|
||||
await sharp(filePath)
|
||||
.resize({ width: 100, height: 100, fit: 'inside', withoutEnlargement: true })
|
||||
.withMetadata()
|
||||
.toFile(thumbMinPath);
|
||||
|
||||
await sharp(filePath)
|
||||
.resize({ width: 400, withoutEnlargement: true })
|
||||
.withMetadata()
|
||||
.toFile(thumbAvgPath);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Errore creazione thumbnails:', err.message, filePath);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createVideoThumbnail, createThumbnails };
|
||||
41
api_v1/scanner/utils.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
function toPosix(p) {
|
||||
return p.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function sha256(s) {
|
||||
return crypto.createHash('sha256').update(s).digest('hex');
|
||||
}
|
||||
|
||||
function inferMimeFromExt(ext) {
|
||||
switch (ext.toLowerCase()) {
|
||||
case '.jpg':
|
||||
case '.jpeg': return 'image/jpeg';
|
||||
case '.png': return 'image/png';
|
||||
case '.webp': return 'image/webp';
|
||||
case '.heic':
|
||||
case '.heif': return 'image/heic';
|
||||
case '.mp4': return 'video/mp4';
|
||||
case '.mov': return 'video/quicktime';
|
||||
case '.m4v': return 'video/x-m4v';
|
||||
default: return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
function parseExifDateUtc(s) {
|
||||
if (!s) return null;
|
||||
const re = /^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$/;
|
||||
const m = re.exec(s);
|
||||
if (!m) return null;
|
||||
const dt = new Date(Date.UTC(+m[1], +m[2]-1, +m[3], +m[4], +m[5], +m[6]));
|
||||
return dt.toISOString();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
toPosix,
|
||||
sha256,
|
||||
inferMimeFromExt,
|
||||
parseExifDateUtc
|
||||
};
|
||||
17
api_v1/scanner/video.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
const { exec } = require('child_process');
|
||||
|
||||
function probeVideo(videoPath) {
|
||||
return new Promise((resolve) => {
|
||||
const cmd = `ffprobe -v quiet -print_format json -show_format -show_streams "${videoPath}"`;
|
||||
exec(cmd, (err, stdout) => {
|
||||
if (err) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(stdout));
|
||||
} catch {
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { probeVideo };
|
||||
306
api_v1/server.js
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
// server.js
|
||||
require('dotenv').config();
|
||||
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
const jsonServer = require('json-server');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const scanPhoto = require('./scanner/scanPhoto.js');
|
||||
const { WEB_ROOT, INDEX_PATH } = require('./config');
|
||||
|
||||
const SECRET_KEY = process.env.JWT_SECRET || '123456789';
|
||||
const EXPIRES_IN = process.env.JWT_EXPIRES || '1h';
|
||||
const PORT = process.env.SERVER_PORT || 4000;
|
||||
|
||||
const server = jsonServer.create();
|
||||
|
||||
// -----------------------------------------------------
|
||||
// STATIC FILES
|
||||
// -----------------------------------------------------
|
||||
server.use(
|
||||
jsonServer.defaults({
|
||||
static: path.join(__dirname, '../public'),
|
||||
})
|
||||
);
|
||||
|
||||
// -----------------------------------------------------
|
||||
// CONFIG ENDPOINT (PUBBLICO)
|
||||
// -----------------------------------------------------
|
||||
server.get('/config', (req, res) => {
|
||||
res.json({
|
||||
baseUrl: process.env.BASE_URL,
|
||||
pathFull: process.env.PATH_FULL,
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// ROUTER DB
|
||||
// -----------------------------------------------------
|
||||
let router;
|
||||
if (fs.existsSync('./api_v1/db.json')) {
|
||||
router = jsonServer.router('./api_v1/db.json');
|
||||
} else {
|
||||
const initialData = fs.readFileSync('api_v1/initialDB.json', 'utf8');
|
||||
fs.writeFileSync('api_v1/db.json', initialData);
|
||||
router = jsonServer.router('./api_v1/db.json');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
// USERS DB
|
||||
// -----------------------------------------------------
|
||||
const userdb = JSON.parse(fs.readFileSync('./api_v1/users.json', 'utf-8'));
|
||||
server.use(jsonServer.bodyParser);
|
||||
|
||||
// -----------------------------------------------------
|
||||
// JWT HELPERS
|
||||
// -----------------------------------------------------
|
||||
function createToken(payload) {
|
||||
return jwt.sign(payload, SECRET_KEY, { expiresIn: EXPIRES_IN });
|
||||
}
|
||||
|
||||
const denylist = new Map();
|
||||
|
||||
function addToDenylist(token) {
|
||||
try {
|
||||
const decoded = jwt.decode(token);
|
||||
const exp = decoded?.exp || Math.floor(Date.now() / 1000) + 60;
|
||||
denylist.set(token, exp);
|
||||
} catch {
|
||||
denylist.set(token, Math.floor(Date.now() / 1000) + 60);
|
||||
}
|
||||
}
|
||||
|
||||
function isRevoked(token) {
|
||||
const exp = denylist.get(token);
|
||||
if (!exp) return false;
|
||||
if (exp * 1000 < Date.now()) {
|
||||
denylist.delete(token);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function verifyToken(token) {
|
||||
return jwt.verify(token, SECRET_KEY);
|
||||
}
|
||||
|
||||
function isAuthenticated({ email, password }) {
|
||||
return (
|
||||
userdb.users.findIndex(
|
||||
(user) => user.email === email && bcrypt.compareSync(password, user.password)
|
||||
) !== -1
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
// RESET DB (utility interna usata da /initDB)
|
||||
// -----------------------------------------------------
|
||||
function resetDB() {
|
||||
const initialData = fs.readFileSync('api_v1/initialDB.json', 'utf8');
|
||||
fs.writeFileSync('api_v1/db.json', initialData);
|
||||
router.db.setState(JSON.parse(initialData));
|
||||
console.log('DB resettato');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
// HOME
|
||||
// -----------------------------------------------------
|
||||
server.get('/', (req, res) => {
|
||||
res.sendFile(path.resolve('public/index.html'));
|
||||
});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// LOGIN (PUBBLICO)
|
||||
// -----------------------------------------------------
|
||||
server.post('/auth/login', (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
const user = userdb.users.find((u) => u.email === email);
|
||||
|
||||
if (!user || !bcrypt.compareSync(password, user.password)) {
|
||||
return res.status(401).json({ status: 401, message: 'Incorrect email or password' });
|
||||
}
|
||||
|
||||
const token = createToken({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
token,
|
||||
name: user.name,
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// LOGOUT
|
||||
// -----------------------------------------------------
|
||||
server.post('/auth/logout', (req, res) => {
|
||||
const auth = req.headers.authorization || '';
|
||||
const [scheme, token] = auth.split(' ');
|
||||
if (scheme === 'Bearer' && token) {
|
||||
addToDenylist(token);
|
||||
}
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// JWT MIDDLEWARE (tutte le rotte tranne /auth/*)
|
||||
// -----------------------------------------------------
|
||||
server.use(/^(?!\/auth).*$/, (req, res, next) => {
|
||||
const auth = req.headers.authorization || '';
|
||||
const [scheme, token] = auth.split(' ');
|
||||
|
||||
if (scheme !== 'Bearer' || !token) {
|
||||
return res.status(401).json({ status: 401, message: 'Bad authorization header' });
|
||||
}
|
||||
if (isRevoked(token)) {
|
||||
return res.status(401).json({ status: 401, message: 'Token revoked' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = verifyToken(token);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (err) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ status: 401, message: 'Error: access_token is not valid' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// FILTRO AUTOMATICO PER USER (GET)
|
||||
// - Non-Admin: forzo user=[<nome>, 'Common'] così vedono anche la Common
|
||||
// - Admin: vede tutto senza forzature
|
||||
// -----------------------------------------------------
|
||||
server.use((req, res, next) => {
|
||||
if (req.method === 'GET' && req.user && req.user.name !== 'Admin') {
|
||||
const u = req.user.name;
|
||||
const q = req.query.user;
|
||||
const base = q ? (Array.isArray(q) ? q : [q]) : [];
|
||||
req.query.user = Array.from(new Set([...base, u, 'Common']));
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// SCAN FOTO
|
||||
// - Admin: scansiona tutti gli utenti + Common
|
||||
// - Non-Admin: scansiona solo la propria area (NO Common)
|
||||
// -----------------------------------------------------
|
||||
server.get('/scan', async (req, res) => {
|
||||
try {
|
||||
if (req.user && req.user.name === 'Admin') {
|
||||
await scanPhoto(undefined, 'Admin');
|
||||
return res.send({
|
||||
status: 'Scansione completata',
|
||||
user: 'Admin',
|
||||
scope: 'tutti gli utenti + Common',
|
||||
});
|
||||
}
|
||||
|
||||
// Non-Admin → solo la sua area (niente Common)
|
||||
await scanPhoto(undefined, req.user.name);
|
||||
res.send({
|
||||
status: 'Scansione completata',
|
||||
user: req.user.name,
|
||||
scope: 'utente corrente',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Errore scan:', err);
|
||||
res.status(500).json({ error: 'Errore durante lo scan', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// FILE STATICI
|
||||
// -----------------------------------------------------
|
||||
server.get('/files', (req, res) => {
|
||||
const requested = req.query.file || '';
|
||||
const publicDir = path.resolve(path.join(__dirname, '../public'));
|
||||
const resolved = path.resolve(publicDir, requested);
|
||||
|
||||
if (!resolved.startsWith(publicDir)) {
|
||||
return res.status(400).json({ error: 'Invalid path' });
|
||||
}
|
||||
if (!fs.existsSync(resolved) || fs.statSync(resolved).isDirectory()) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
res.sendFile(resolved);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// RESET DB MANUALE + rimozione index.json
|
||||
// -----------------------------------------------------
|
||||
server.get('/initDB', async (req, res) => {
|
||||
try {
|
||||
resetDB();
|
||||
|
||||
// <root>/public/photos/index.json (coerente con indexStore.js)
|
||||
const absIndexPath = path.resolve(__dirname, '..', WEB_ROOT, INDEX_PATH);
|
||||
|
||||
try {
|
||||
await fsp.unlink(absIndexPath);
|
||||
console.log('initDB: index.json rimosso ->', absIndexPath);
|
||||
} catch (err) {
|
||||
if (err && err.code === 'ENOENT') {
|
||||
console.log('initDB: index.json non trovato, niente da cancellare:', absIndexPath);
|
||||
} else {
|
||||
console.error('initDB: errore cancellando index.json:', err);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ status: 'DB resettato', indexRemoved: true, indexPath: absIndexPath });
|
||||
} catch (err) {
|
||||
console.error('initDB: errore generale:', err);
|
||||
res.status(500).json({ status: 'errore', message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// UPSERT anti-duplicato per /photos (prima del router)
|
||||
// Se id esiste -> aggiorna; altrimenti crea
|
||||
// -----------------------------------------------------
|
||||
server.post('/photos', (req, res, next) => {
|
||||
try {
|
||||
const id = req.body && req.body.id;
|
||||
if (!id) return next();
|
||||
|
||||
const db = router.db; // lowdb instance
|
||||
const col = db.get('photos');
|
||||
const existing = col.find({ id }).value();
|
||||
|
||||
if (existing) {
|
||||
col.find({ id }).assign(req.body).write();
|
||||
return res.status(200).json(req.body);
|
||||
}
|
||||
return next(); // non esiste: crea con il router
|
||||
} catch (e) {
|
||||
console.error('UPSERT /photos error:', e);
|
||||
return res.status(500).json({ error: 'upsert failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// ROUTER JSON-SERVER
|
||||
// -----------------------------------------------------
|
||||
server.use(router);
|
||||
|
||||
// -----------------------------------------------------
|
||||
// START SERVER
|
||||
// -----------------------------------------------------
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Auth API server running on port ${PORT} ...`);
|
||||
});
|
||||
|
||||
// Pulizia denylist
|
||||
setInterval(() => {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
for (const [tok, exp] of denylist.entries()) {
|
||||
if (exp < nowSec) denylist.delete(tok);
|
||||
}
|
||||
}, 60 * 1000);
|
||||
51
api_v1/tools.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Required libraries
|
||||
*/
|
||||
const bcrypt = require('bcrypt')
|
||||
const readLine = require('readline')
|
||||
const async = require('async')
|
||||
|
||||
// Password hash method
|
||||
const hashPassword = plain => bcrypt.hashSync(plain, 8)
|
||||
|
||||
// Ask user password method
|
||||
function askPassword(question) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const rl = readLine.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
})
|
||||
|
||||
rl.question(question, answer => {
|
||||
rl.close()
|
||||
resolve(answer)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Generate hash password method
|
||||
async function generateHash() {
|
||||
try {
|
||||
console.log('**********************************')
|
||||
console.log('** Password hash script **')
|
||||
console.log('**********************************')
|
||||
|
||||
const passwordAnswer = await askPassword(
|
||||
'Please give me a password to hash: '
|
||||
)
|
||||
|
||||
if (passwordAnswer != '') {
|
||||
const hashedPassword = hashPassword(passwordAnswer)
|
||||
const compare = bcrypt.compareSync(passwordAnswer, hashedPassword)
|
||||
await console.log('Hashed password:', hashedPassword)
|
||||
await console.log('Valdiation:', compare)
|
||||
} else {
|
||||
console.log('You need write something. Script aborted!')
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
return process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
generateHash()
|
||||
22
api_v1/users.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"users": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Admin",
|
||||
"email": "admin@gmail.com",
|
||||
"password": "$2b$08$g0UWN6RnN7e.8rX3fuXSSOSJDTvucu./0FAU.yXp0wx4SJXyeaU3."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Fabio",
|
||||
"email": "fabio@gmail.com",
|
||||
"password": "$2b$08$g0UWN6RnN7e.8rX3fuXSSOSJDTvucu./0FAU.yXp0wx4SJXyeaU3."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Jessica",
|
||||
"email": "jessie@libero.it",
|
||||
"password": "$2b$08$g0UWN6RnN7e.8rX3fuXSSOSJDTvucu./0FAU.yXp0wx4SJXyeaU3."
|
||||
}
|
||||
]
|
||||
}
|
||||
2570
package-lock.json
generated
Normal file
28
package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "gallery-jwt-json-server",
|
||||
"version": "0.0.1",
|
||||
"description": "Gallery and JWT Protected REST API with json-server",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start-no-auth": "json-server --watch ./api_v1/db.json -s ./public --host 0.0.0.0 --port 4000",
|
||||
"start": "node ./api_v1/server.js -s ./public",
|
||||
"hash": "node ./api_v1/tools.js"
|
||||
},
|
||||
"keywords": [
|
||||
"api"
|
||||
],
|
||||
"author": "Fabio",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"async": "^3.2.6",
|
||||
"axios": "^1.13.5",
|
||||
"bcrypt": "^6.0.0",
|
||||
"body-parser": "^2.2.2",
|
||||
"dotenv": "^17.3.1",
|
||||
"exifreader": "^4.36.2",
|
||||
"json-server": "^0.17.4",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"path": "^0.12.7",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
}
|
||||
70
public/admin.html
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Photo Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app" style="padding:20px;">
|
||||
<h2>Gestione Foto</h2>
|
||||
|
||||
<button onclick="scan()">Scansiona Foto</button>
|
||||
<button onclick="resetDB()">Reset DB</button>
|
||||
<button onclick="readDB()">Leggi DB</button>
|
||||
<button onclick="window.location.href='index.html'">Torna alla galleria</button>
|
||||
|
||||
<pre id="out"></pre>
|
||||
</div>
|
||||
<!-- Eruda Debug Console -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
|
||||
<script>
|
||||
eruda.init();
|
||||
console.log("Eruda inizializzato");
|
||||
</script>
|
||||
<script>
|
||||
let BASE_URL = null;
|
||||
let token = localStorage.getItem("token");
|
||||
let db = [];
|
||||
|
||||
// Se non c'è token → torna alla galleria (login avviene lì)
|
||||
if (!token) {
|
||||
window.location.href = "index.html";
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
const res = await fetch('/config');
|
||||
const cfg = await res.json();
|
||||
BASE_URL = cfg.baseUrl;
|
||||
}
|
||||
|
||||
async function readDB() {
|
||||
const res = await fetch(`${BASE_URL}/photos`, {
|
||||
headers: { "Authorization": "Bearer " + token }
|
||||
});
|
||||
|
||||
db = await res.json();
|
||||
document.getElementById("out").textContent = JSON.stringify(db, null, 2);
|
||||
}
|
||||
|
||||
async function scan() {
|
||||
await fetch(`${BASE_URL}/scan`, {
|
||||
headers: { "Authorization": "Bearer " + token }
|
||||
});
|
||||
await readDB();
|
||||
}
|
||||
|
||||
async function resetDB() {
|
||||
await fetch(`${BASE_URL}/initDB`, {
|
||||
headers: { "Authorization": "Bearer " + token }
|
||||
});
|
||||
await readDB();
|
||||
}
|
||||
|
||||
window.onload = async () => {
|
||||
await loadConfig();
|
||||
};
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
27
public/css/base.css
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
:root { --header-h: 60px; }
|
||||
|
||||
/* Safe-area iOS */
|
||||
@supports (top: env(safe-area-inset-top)) {
|
||||
:root { --safe-top: env(safe-area-inset-top); }
|
||||
}
|
||||
@supports not (top: env(safe-area-inset-top)) {
|
||||
:root { --safe-top: 0px; }
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
200
public/css/bottomSheet.css
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/* =========================================
|
||||
Variabili globali
|
||||
========================================= */
|
||||
:root {
|
||||
--header-height: 60px; /* cambia se il tuo header è più alto/basso */
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
MAPPA GLOBALE (contenitore sotto l’header)
|
||||
========================================= */
|
||||
.global-map {
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
width: 100%;
|
||||
display: none; /* visibile solo con .open */
|
||||
z-index: 10; /* sotto a bottom-sheet (9999) e modal (10000) */
|
||||
background: #000; /* evita flash bianco durante init */
|
||||
}
|
||||
|
||||
.global-map.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Leaflet riempie il contenitore */
|
||||
.global-map .leaflet-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Marker immagine (miniatura) */
|
||||
.leaflet-marker-icon.photo-marker {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
|
||||
border: 2px solid rgba(255,255,255,0.9);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Nascondi la gallery quando la mappa è aperta */
|
||||
.gallery.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
BOTTOM SHEET — struttura base comune
|
||||
========================================= */
|
||||
.bottom-sheet {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
|
||||
background: rgba(255,255,255,0.95);
|
||||
backdrop-filter: blur(6px);
|
||||
border-top: 1px solid #ddd;
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.15);
|
||||
|
||||
display: none; /* diventa flex con .open */
|
||||
flex-direction: column;
|
||||
z-index: 9999; /* molto alto: il modal starà sopra (10000) */
|
||||
}
|
||||
|
||||
.bottom-sheet.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Maniglia superiore */
|
||||
.sheet-header {
|
||||
height: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sheet-header::before {
|
||||
content: "";
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
background: #bbb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
BOTTOM SHEET FOTO (strip bassa come nel vecchio)
|
||||
========================================= */
|
||||
.photo-strip {
|
||||
height: 140px; /* altezza originale della strip */
|
||||
overflow-y: hidden; /* niente scroll verticale */
|
||||
overflow-x: auto; /* scroll orizzontale per le foto */
|
||||
}
|
||||
|
||||
/* Contenitore elementi della strip — compatibile con id e class */
|
||||
#sheetGallery,
|
||||
.sheet-gallery {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
/* Singolo elemento della strip */
|
||||
.sheet-item {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
|
||||
background: #eee;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
/* Miniatura della foto nella strip */
|
||||
.sheet-thumb,
|
||||
.sheet-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 8px; /* alias; la .sheet-item ha già 10px */
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
BOTTOM SHEET OPZIONI (⋮) — menu grande
|
||||
========================================= */
|
||||
.options-sheet {
|
||||
height: auto;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sheet-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.sheet-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
text-align: left;
|
||||
background: #f5f5f5;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sheet-btn:hover {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
#optionsSheet h3 {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
OVERLAY per chiusura sheet/option
|
||||
========================================= */
|
||||
.sheet-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.0); /* invisibile ma cliccabile */
|
||||
display: none;
|
||||
z-index: 80; /* appena sotto il bottom sheet */
|
||||
}
|
||||
|
||||
.sheet-overlay.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
MODAL sopra allo sheet
|
||||
========================================= */
|
||||
.modal.open {
|
||||
z-index: 10000 !important; /* sopra al bottom sheet (9999) */
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
Piccoli affinamenti facoltativi
|
||||
========================================= */
|
||||
/* scrollbar sottile solo per la strip (opzionale) */
|
||||
#sheetGallery::-webkit-scrollbar,
|
||||
.sheet-gallery::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
#sheetGallery::-webkit-scrollbar-thumb,
|
||||
.sheet-gallery::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.25);
|
||||
border-radius: 4px;
|
||||
}
|
||||
46
public/css/gallery.css
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
.gallery {
|
||||
display: block;
|
||||
padding: 6px; /* più stretto */
|
||||
}
|
||||
|
||||
.gallery-section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 18px 6px 6px; /* più compatto */
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.gallery-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); /* leggermente più piccole */
|
||||
gap: 4px; /* SPACING RIDOTTO */
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 6px; /* più compatto */
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.12); /* più leggero */
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
background: rgba(0,0,0,0.55);
|
||||
color: white;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
85
public/css/header.css
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/* ===============================
|
||||
Header compatto
|
||||
=============================== */
|
||||
header {
|
||||
padding: 4px 10px; /* era 10px 15px */
|
||||
background: #333;
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Titolo più piccolo e senza margini extra */
|
||||
header h1 {
|
||||
font-size: 18px; /* ridotto */
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Contenitore bottoni in alto a destra */
|
||||
.top-buttons {
|
||||
display: flex;
|
||||
gap: 6px; /* era 10px */
|
||||
}
|
||||
|
||||
/* Bottoni icona più compatti */
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px; /* era 22px */
|
||||
padding: 3px 6px; /* era 6px 10px */
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
line-height: 1;
|
||||
min-height: 32px; /* tap target minimo desktop */
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
/* Logout rotondo: riduciamo la “bolla” */
|
||||
.icon-btn.logout-btn {
|
||||
--size: 28px; /* era 36px */
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* PNG del logout in scala con l’header */
|
||||
.logout-icon {
|
||||
width: 18px; /* era 22px */
|
||||
height: 18px;
|
||||
display: block;
|
||||
filter: brightness(0) invert(1);
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
Visibilità Logout robusta
|
||||
=============================== */
|
||||
|
||||
/* Base: nascosto (prima del login o se non autenticato) */
|
||||
#logoutBtn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Quando autenticato, mostra il bottone coerente con gli altri icon-btn */
|
||||
body.authenticated #logoutBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Se esistono regole più forti altrove che lo nascondono,
|
||||
puoi temporaneamente forzare:
|
||||
body.authenticated #logoutBtn { display: inline-flex !important; } */
|
||||
88
public/css/infoPanel.css
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/* ===============================
|
||||
Variabili (scala tipografica pannello info)
|
||||
Modifica qui per regolare tutto il pannello
|
||||
=============================== */
|
||||
:root {
|
||||
--info-font: 14px; /* base testo pannello (prima ~16px) */
|
||||
--info-line: 1.4; /* interlinea per migliorare leggibilità */
|
||||
|
||||
--info-heading: 15px; /* dimensione titoli h3 nel pannello */
|
||||
--info-h3-mt: 6px; /* margin-top h3 */
|
||||
--info-h3-mb: 10px; /* margin-bottom h3 */
|
||||
|
||||
--info-row-gap: 8px; /* spazio verticale tra righe (era 10px) */
|
||||
--info-label-w: 100px; /* larghezza colonna etichette (era 110px) */
|
||||
|
||||
--info-map-h: 220px; /* altezza mappa (era 250px) */
|
||||
--info-map-mt: 15px; /* spazio sopra la mappa */
|
||||
|
||||
--info-spacer-h: 16px; /* altezza degli spacer */
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
PANNELLO INFO
|
||||
=============================== */
|
||||
.info-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 320px;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
box-shadow: -2px 0 6px rgba(0,0,0,0.25);
|
||||
overflow-y: auto;
|
||||
z-index: 10000;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
/* Scala tipografica via variabili */
|
||||
font-size: var(--info-font);
|
||||
line-height: var(--info-line);
|
||||
}
|
||||
|
||||
.info-panel.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Titoli un filo più compatti */
|
||||
.info-panel h3 {
|
||||
font-size: var(--info-heading);
|
||||
margin: var(--info-h3-mt) 0 var(--info-h3-mb);
|
||||
}
|
||||
|
||||
/* Righe e label */
|
||||
.info-row {
|
||||
margin-bottom: var(--info-row-gap);
|
||||
}
|
||||
|
||||
.info-row b {
|
||||
display: inline-block;
|
||||
width: var(--info-label-w);
|
||||
}
|
||||
|
||||
/* Mappa nel pannello */
|
||||
.info-map {
|
||||
width: 100%;
|
||||
height: var(--info-map-h);
|
||||
margin-top: var(--info-map-mt);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
/* Spacer verticali */
|
||||
.info-spacer {
|
||||
height: var(--info-spacer-h);
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
(Opzionale) Mobile: un filo più grande < 480px
|
||||
Decommenta se vuoi mantenere leggibilità maggiore su schermi piccoli
|
||||
=============================== */
|
||||
/*
|
||||
@media (max-width: 480px) {
|
||||
.info-panel { font-size: 15px; }
|
||||
.info-panel h3 { font-size: 16px; }
|
||||
}
|
||||
*/
|
||||
27
public/css/login.css
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
|
||||
.login-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 20000;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
color: red;
|
||||
font-size: 14px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
117
public/css/map.css
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/* ===============================
|
||||
MAPPA GLOBALE
|
||||
=============================== */
|
||||
|
||||
/* La mappa occupa tutto lo schermo SOTTO l’header */
|
||||
.global-map {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(var(--header-h, 60px) + var(--safe-top, 0px)); /* niente hard-code */
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
|
||||
display: none; /* chiusa di default */
|
||||
}
|
||||
|
||||
/* Quando è aperta, visibile */
|
||||
.global-map.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* La Leaflet container deve riempire il contenitore */
|
||||
.global-map,
|
||||
.global-map .leaflet-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Nasconde la gallery quando la mappa è aperta */
|
||||
.gallery.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
MARKER FOTO
|
||||
=============================== */
|
||||
|
||||
.photo-marker {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.photo-marker img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
CLUSTER
|
||||
=============================== */
|
||||
|
||||
.photo-cluster {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.cluster-back {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
opacity: 0.5;
|
||||
filter: blur(1px);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.cluster-front {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ===============================
|
||||
MARKER CLUSTER
|
||||
=============================== */
|
||||
|
||||
|
||||
.marker-cluster-wrapper { background: transparent; border: 0; }
|
||||
.gp-cluster {
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||
border: 3px solid rgba(255,255,255,0.85);
|
||||
transition: width .12s, height .12s, font-size .12s;
|
||||
}
|
||||
.gp-cluster .cluster-collage { position:absolute; inset:0; display:grid; grid-template-columns: repeat(2,1fr); grid-template-rows: repeat(2,1fr); }
|
||||
.gp-cluster .cluster-collage div img { width:100%; height:100%; object-fit:cover; display:block; }
|
||||
.gp-cluster .gp-count {
|
||||
position:absolute; right:6px; bottom:6px;
|
||||
background: rgba(0,0,0,0.55); padding:4px 7px; border-radius:12px;
|
||||
color:#fff; font-weight:700; font-size:12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.gp-cluster.cluster-sm .gp-count { font-size:11px; }
|
||||
.gp-cluster.cluster-md .gp-count { font-size:13px; }
|
||||
.gp-cluster.cluster-lg .gp-count { font-size:15px; }
|
||||
.gp-cluster.cluster-xl .gp-count { font-size:17px; }
|
||||
299
public/css/modal.css
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
/* ===============================
|
||||
MODAL OVERLAY
|
||||
=============================== */
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0; /* top:0 right:0 bottom:0 left:0 */
|
||||
background: rgba(0,0,0,0.8);
|
||||
display: none; /* chiuso di default */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999; /* sopra a qualunque overlay/sheet */
|
||||
overflow: hidden; /* evita scroll sullo sfondo */
|
||||
/* Animazione di fade */
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease-out;
|
||||
}
|
||||
|
||||
.modal.open {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* effetto vetro opzionale dove supportato */
|
||||
@supports (backdrop-filter: blur(4px)) {
|
||||
.modal {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
CONTENITORE CONTENUTI
|
||||
=============================== */
|
||||
|
||||
.modal-content {
|
||||
width: 90vw;
|
||||
height: 90vh;
|
||||
max-width: 1200px;
|
||||
max-height: 90vh;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
/* Animazione di scale-in */
|
||||
transform: scale(0.98);
|
||||
transition: transform 160ms ease-out;
|
||||
}
|
||||
|
||||
.modal.open .modal-content {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Ridimensionamento su mobile */
|
||||
@media (max-width: 768px) {
|
||||
.modal-content {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Contenitore del media */
|
||||
#modalMediaContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
/* Evita che clic sul media “passino” al layer sotto */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Immagini e video si adattano all’area */
|
||||
#modalMediaContainer img,
|
||||
#modalMediaContainer video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
background: #000; /* evita flash bianco */
|
||||
position: relative; /* crea contesto */
|
||||
z-index: 1; /* sotto ai pulsanti */
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
PULSANTE CHIUSURA (X)
|
||||
=============================== */
|
||||
|
||||
/* FISSO sopra al video, con safe-area per iPhone */
|
||||
.modal-close {
|
||||
position: fixed; /* <-- chiave: resta sopra al video anche con stacking strani */
|
||||
top: calc(8px + env(safe-area-inset-top));
|
||||
right: calc(12px + env(safe-area-inset-right));
|
||||
z-index: 10001; /* il modal è 9999 */
|
||||
|
||||
background: rgba(0,0,0,0.35);
|
||||
color: #fff;
|
||||
border-radius: 22px;
|
||||
min-width: 44px; /* target minimo consigliato */
|
||||
height: 44px;
|
||||
padding: 0 10px;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
border: 1px solid rgba(255,255,255,0.25);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
user-select: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* area di hit più ampia senza cambiare il look */
|
||||
.modal-close::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -8px; /* allarga di 8px tutt’intorno */
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.modal-close:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.modal-close:focus-visible {
|
||||
outline: 2px solid #4c9ffe;
|
||||
outline-offset: 2px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
PULSANTE INFO (ℹ️)
|
||||
=============================== */
|
||||
|
||||
.modal-info-btn {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 16px;
|
||||
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
cursor: pointer;
|
||||
border: 1px solid #d0d0d0;
|
||||
font-size: 20px;
|
||||
z-index: 10000; /* sopra al media, sotto alla X */
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
|
||||
/* 🔒 Disattiva selezione e popup dizionario */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.modal-info-btn:hover {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.modal-info-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.modal-info-btn:focus-visible {
|
||||
outline: 2px solid #4c9ffe;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
/* ℹ️ evidenziato quando il pannello info è aperto */
|
||||
.modal-info-btn.active {
|
||||
background: #f7f7f7;
|
||||
border-color: #cfcfcf;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
(OPZIONALE) LINK "APRI ORIGINALE ↗"
|
||||
=============================== */
|
||||
|
||||
.modal-open-original {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 56px; /* lascia spazio alla X */
|
||||
background: rgba(255,255,255,0.95);
|
||||
color: #000;
|
||||
border-radius: 16px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #d0d0d0;
|
||||
font-size: 13px;
|
||||
z-index: 10000; /* sopra al media */
|
||||
text-decoration: none;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.modal-open-original:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.modal-open-original:focus-visible {
|
||||
outline: 2px solid #4c9ffe;
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
MODAL STATE UTILI
|
||||
=============================== */
|
||||
|
||||
body.no-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* High contrast / accessibility (opzionale) */
|
||||
@media (prefers-contrast: more) {
|
||||
.modal {
|
||||
background: rgba(0,0,0,0.9);
|
||||
}
|
||||
.modal-close,
|
||||
.modal-info-btn,
|
||||
.modal-open-original {
|
||||
border-color: #000;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Riduci animazioni se l’utente lo preferisce */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.modal,
|
||||
.modal-content {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ===============================
|
||||
FRECCE DI NAVIGAZIONE < >
|
||||
=============================== */
|
||||
|
||||
.modal-nav-btn {
|
||||
position: fixed; /* fisso: resta sopra a video/immagine */
|
||||
top: calc(50% + env(safe-area-inset-top));
|
||||
transform: translateY(-50%);
|
||||
z-index: 10000; /* sopra al media, sotto alla X (10001) */
|
||||
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255,255,255,0.25);
|
||||
background: rgba(0,0,0,0.35);
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
|
||||
transition: background-color .15s ease, transform .05s ease;
|
||||
}
|
||||
|
||||
.modal-nav-btn:hover { background: rgba(0,0,0,0.5); }
|
||||
.modal-nav-btn:active { transform: translateY(-50%) translateY(1px); }
|
||||
.modal-nav-btn:focus-visible {
|
||||
outline: 2px solid #4c9ffe;
|
||||
outline-offset: 2px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.modal-nav-btn.prev { left: calc(12px + env(safe-area-inset-left)); }
|
||||
.modal-nav-btn.next { right: calc(12px + env(safe-area-inset-right)); }
|
||||
|
||||
/* Nascondi automaticamente se c'è un solo elemento */
|
||||
.modal-nav-btn.hidden { display: none !important; }
|
||||
26
public/css/optionsSheet.css
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#optionsSheet .sheet-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#optionsSheet h3 {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.sheet-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
text-align: left;
|
||||
background: #f5f5f5;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sheet-btn:hover {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
24
public/css/utils.css
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.shadow-sm {
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.shadow-md {
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
BIN
public/img/switch.png
Normal file
|
After Width: | Height: | Size: 451 B |
175
public/index.html
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Galleria Foto</title>
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<link rel="stylesheet" href="css/header.css">
|
||||
<link rel="stylesheet" href="css/gallery.css">
|
||||
<link rel="stylesheet" href="css/modal.css">
|
||||
<link rel="stylesheet" href="css/infoPanel.css">
|
||||
<link rel="stylesheet" href="css/bottomSheet.css">
|
||||
<link rel="stylesheet" href="css/optionsSheet.css">
|
||||
<link rel="stylesheet" href="css/map.css">
|
||||
<link rel="stylesheet" href="css/utils.css">
|
||||
<link rel="stylesheet" href="css/login.css">
|
||||
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
|
||||
|
||||
<!-- MarkerCluster CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster/dist/MarkerCluster.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster/dist/MarkerCluster.Default.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- =============================== -->
|
||||
<!-- BARRA SUPERIORE -->
|
||||
<!-- =============================== -->
|
||||
<header>
|
||||
<h1>Galleria Foto</h1>
|
||||
|
||||
<div class="top-buttons">
|
||||
<button id="openMapBtn" class="icon-btn">🗺️</button>
|
||||
<button id="optionsBtn" class="icon-btn">⋮</button>
|
||||
<button id="settingsBtn" class="icon-btn">⚙️</button>
|
||||
<button
|
||||
id="logoutBtn"
|
||||
class="icon-btn logout-btn"
|
||||
type="button"
|
||||
data-logout
|
||||
data-redirect="/"
|
||||
title="Logout"
|
||||
aria-label="Logout">
|
||||
|
||||
<!-- Icona PNG -->
|
||||
<img
|
||||
class="logout-icon"
|
||||
src="img/switch.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width="22" height="22">
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div id="gallery" class="gallery"></div>
|
||||
|
||||
<!-- Mappa globale -->
|
||||
<div id="globalMap" class="global-map"></div>
|
||||
</main>
|
||||
|
||||
<!-- =============================== -->
|
||||
<!-- MODAL FOTO/VIDEO -->
|
||||
<!-- =============================== -->
|
||||
<div id="modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-close" id="modalClose">×</div>
|
||||
|
||||
<!-- Frecce navigazione -->
|
||||
<button class="modal-nav-btn prev" id="modalPrev" type="button" aria-label="Precedente"><</button>
|
||||
<button class="modal-nav-btn next" id="modalNext" type="button" aria-label="Successiva">></button>
|
||||
|
||||
<div id="modalMediaContainer"></div>
|
||||
|
||||
<div class="modal-info-btn" id="modalInfoBtn">ℹ️</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- =============================== -->
|
||||
<!-- PANNELLO INFO -->
|
||||
<!-- =============================== -->
|
||||
<div id="infoPanel" class="info-panel"></div>
|
||||
|
||||
<!-- =============================== -->
|
||||
<!-- PANNELLO OVERLAY -->
|
||||
<!-- =============================== -->
|
||||
<div id="sheetOverlay" class="sheet-overlay"></div>
|
||||
|
||||
<!-- =============================== -->
|
||||
<!-- BOTTOM SHEET FOTO (MAPPA) -->
|
||||
<!-- =============================== -->
|
||||
<div id="bottomSheet" class="bottom-sheet photo-strip">
|
||||
<div class="sheet-header"></div>
|
||||
<div class="sheet-gallery" id="sheetGallery"></div>
|
||||
</div>
|
||||
|
||||
<!-- =============================== -->
|
||||
<!-- BOTTOM SHEET OPZIONI (⋮) -->
|
||||
<!-- =============================== -->
|
||||
<div id="optionsSheet" class="bottom-sheet options-sheet">
|
||||
<div class="sheet-header"></div>
|
||||
|
||||
<div class="sheet-content">
|
||||
|
||||
<h3>Ordinamento</h3>
|
||||
<button class="sheet-btn" data-sort="desc">Più recenti prima</button>
|
||||
<button class="sheet-btn" data-sort="asc">Più vecchie prima</button>
|
||||
|
||||
<h3>Raggruppamento</h3>
|
||||
<button class="sheet-btn" data-group="auto">Automatico (Oggi, Ieri…)</button>
|
||||
<button class="sheet-btn" data-group="day">Giorno</button>
|
||||
<button class="sheet-btn" data-group="month">Mese</button>
|
||||
<button class="sheet-btn" data-group="year">Anno</button>
|
||||
|
||||
<h3>Filtri</h3>
|
||||
<button class="sheet-btn" data-filter="folder">Per cartella</button>
|
||||
<button class="sheet-btn" data-filter="location">Per luogo</button>
|
||||
<button class="sheet-btn" data-filter="type">Per tipo</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LOGIN MODAL -->
|
||||
<div id="loginModal" class="login-modal">
|
||||
<div class="login-box">
|
||||
<h2>Login</h2>
|
||||
|
||||
<input id="loginEmail" type="text" placeholder="Email">
|
||||
<input id="loginPassword" type="password" placeholder="Password">
|
||||
|
||||
<div id="loginError" class="login-error"></div>
|
||||
|
||||
<button id="loginSubmit">Accedi</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Leaflet JS -->
|
||||
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
|
||||
|
||||
<!-- MarkerCluster JS -->
|
||||
<script src="https://unpkg.com/leaflet.markercluster/dist/leaflet.markercluster.js"></script>
|
||||
|
||||
<!-- Eruda Debug Console -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
|
||||
<script>
|
||||
eruda.init();
|
||||
console.log("Eruda inizializzato");
|
||||
</script>
|
||||
|
||||
<!-- Debug immediato -->
|
||||
<script>
|
||||
console.log("Caricamento pagina OK");
|
||||
</script>
|
||||
|
||||
<!-- App -->
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/data.js"></script>
|
||||
<script src="js/gallery.js"></script>
|
||||
<script src="js/modal.js"></script>
|
||||
<script src="js/infoPanel.js"></script>
|
||||
|
||||
<!-- DEVE ESSERE PRIMA DI mapGlobal.js -->
|
||||
<script src="js/bottomSheet.js"></script>
|
||||
|
||||
<script src="js/mapGlobal.js"></script>
|
||||
<script src="js/logout.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
133
public/js/bottomSheet.js
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// ===============================
|
||||
// BOTTOM SHEET — strip multi-foto (2+); singola → modal diretto
|
||||
// Regola: aprendo qualcosa di nuovo, chiudo il precedente
|
||||
// ===============================
|
||||
|
||||
const bottomSheet = document.getElementById("bottomSheet");
|
||||
const sheetGallery = document.getElementById("sheetGallery");
|
||||
let optionsSheetRef = document.getElementById("optionsSheet");
|
||||
|
||||
// Overlay (creazione difensiva)
|
||||
let sheetOverlay = document.getElementById("sheetOverlay");
|
||||
if (!sheetOverlay) {
|
||||
sheetOverlay = document.createElement("div");
|
||||
sheetOverlay.id = "sheetOverlay";
|
||||
sheetOverlay.className = "sheet-overlay";
|
||||
document.body.appendChild(sheetOverlay);
|
||||
}
|
||||
|
||||
function openBottomSheet(photoList) {
|
||||
const list = Array.isArray(photoList) ? photoList : [];
|
||||
|
||||
// 0 o 1 foto → MODAL diretto, nessuna bottom-zone
|
||||
if (list.length <= 1) {
|
||||
const p = list[0];
|
||||
if (p) {
|
||||
const thumbUrl = absUrl(
|
||||
p.thub2 || p.thub1 || p.path,
|
||||
p.user,
|
||||
"thumbs",
|
||||
p.cartella
|
||||
);
|
||||
|
||||
const originalUrl = absUrl(
|
||||
p.path,
|
||||
p.user,
|
||||
"original",
|
||||
p.cartella
|
||||
);
|
||||
|
||||
closeBottomSheet();
|
||||
|
||||
if (typeof window.openModalFromList === "function") {
|
||||
window.openModalFromList([p], 0);
|
||||
} else {
|
||||
window.openModal?.(originalUrl, thumbUrl, p);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2+ foto → strip in basso
|
||||
sheetGallery.innerHTML = "";
|
||||
|
||||
list.forEach((photo, index) => {
|
||||
const thumbUrl = absUrl(
|
||||
photo.thub2 || photo.thub1,
|
||||
photo.user,
|
||||
"thumbs",
|
||||
photo.cartella
|
||||
);
|
||||
|
||||
const originalUrl = absUrl(
|
||||
photo.path,
|
||||
photo.user,
|
||||
"original",
|
||||
photo.cartella
|
||||
);
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.className = "sheet-item";
|
||||
div.tabIndex = 0;
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.className = "sheet-thumb";
|
||||
img.src = thumbUrl;
|
||||
img.alt = photo?.name || "";
|
||||
img.loading = "lazy";
|
||||
|
||||
const openFromIndex = () => {
|
||||
closeBottomSheet();
|
||||
if (typeof window.openModalFromList === "function") {
|
||||
window.openModalFromList(list, index);
|
||||
} else {
|
||||
window.openModal?.(originalUrl, thumbUrl, photo);
|
||||
}
|
||||
};
|
||||
|
||||
div.addEventListener("click", openFromIndex);
|
||||
div.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openFromIndex(); }
|
||||
});
|
||||
|
||||
div.appendChild(img);
|
||||
sheetGallery.appendChild(div);
|
||||
});
|
||||
|
||||
bottomSheet.classList.add("open");
|
||||
sheetOverlay.classList.add("open");
|
||||
}
|
||||
|
||||
function closeBottomSheet() {
|
||||
bottomSheet.classList.remove("open");
|
||||
sheetOverlay.classList.remove("open");
|
||||
}
|
||||
|
||||
function openOptionsSheet() {
|
||||
optionsSheetRef?.classList.add("open");
|
||||
sheetOverlay.classList.add("open");
|
||||
}
|
||||
function closeOptionsSheet() {
|
||||
optionsSheetRef?.classList.remove("open");
|
||||
sheetOverlay.classList.remove("open");
|
||||
}
|
||||
|
||||
// Chiusura cliccando fuori
|
||||
sheetOverlay.addEventListener("click", () => {
|
||||
closeBottomSheet();
|
||||
closeOptionsSheet();
|
||||
});
|
||||
|
||||
// Chiusura toccando la maniglia
|
||||
document.querySelectorAll(".sheet-header").forEach(header => {
|
||||
header.addEventListener("click", () => {
|
||||
closeBottomSheet();
|
||||
closeOptionsSheet();
|
||||
});
|
||||
});
|
||||
|
||||
// Export
|
||||
window.openBottomSheet = openBottomSheet;
|
||||
window.closeBottomSheet = closeBottomSheet;
|
||||
window.openOptionsSheet = openOptionsSheet;
|
||||
window.closeOptionsSheet = closeOptionsSheet;
|
||||
63
public/js/config.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// ===============================
|
||||
// CONFIG DINAMICA DAL SERVER
|
||||
// ===============================
|
||||
|
||||
window.BASE_URL = null;
|
||||
window.PHOTOS_URL = null;
|
||||
window.MEDIA_BASE_ORIGIN = null;
|
||||
window.configReady = false;
|
||||
window.PATH_FULL = false;
|
||||
|
||||
// Carica /config dal backend
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/config');
|
||||
const cfg = await res.json();
|
||||
|
||||
window.BASE_URL = cfg.baseUrl;
|
||||
window.PATH_FULL = cfg.pathFull;
|
||||
window.PHOTOS_URL = `${window.BASE_URL}/photos`;
|
||||
window.MEDIA_BASE_ORIGIN = new URL(window.PHOTOS_URL).origin;
|
||||
|
||||
console.log("[config] BASE_URL:", window.BASE_URL);
|
||||
console.log("[config] PHOTOS_URL:", window.PHOTOS_URL);
|
||||
console.log("[config] MEDIA_BASE_ORIGIN:", window.MEDIA_BASE_ORIGIN);
|
||||
console.log("[config] PATH_FULL:", window.PATH_FULL);
|
||||
|
||||
window.configReady = true;
|
||||
|
||||
} catch (err) {
|
||||
console.error("[config] Errore nel caricamento della config:", err);
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
|
||||
// ===============================
|
||||
// Utility: normalizza URL dei media
|
||||
// ===============================
|
||||
function toAbsoluteUrl1(pathOrUrl) {
|
||||
if (!pathOrUrl) return '';
|
||||
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
|
||||
|
||||
const normalized = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`;
|
||||
return `${window.MEDIA_BASE_ORIGIN}${normalized}`;
|
||||
}
|
||||
|
||||
function toAbsoluteUrl(pathOrUrl, name, type, cartella) {
|
||||
const BASE_URL = 'https://prova.patachina.it';
|
||||
|
||||
if (!pathOrUrl) return '';
|
||||
if (!name || !type || !cartella) {
|
||||
throw new Error('name, type e cartella sono obbligatori');
|
||||
}
|
||||
|
||||
// Normalizza il path rimuovendo slash iniziali/finali
|
||||
const cleanedPath = String(pathOrUrl).replace(/^\/+|\/+$/g, '');
|
||||
|
||||
// Costruzione dell'URL finale
|
||||
return `${BASE_URL}/photos/${encodeURIComponent(name)}/${encodeURIComponent(type)}/${encodeURIComponent(cartella)}/${cleanedPath}`;
|
||||
}
|
||||
|
||||
window.toAbsoluteUrl = toAbsoluteUrl;
|
||||
84
public/js/data.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// ===============================
|
||||
// FETCH DELLE FOTO
|
||||
// ===============================
|
||||
async function loadPhotos() {
|
||||
console.log("Inizio fetch:", window.PHOTOS_URL);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(window.PHOTOS_URL, {
|
||||
headers: {
|
||||
"Authorization": "Bearer " + window.token
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Errore fetch:", e);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`HTTP ${res.status} ${res.statusText} – body:`, text.slice(0, 200));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
console.error("La risposta non è un array:", parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
window.photosData = parsed;
|
||||
console.log("JSON parse OK, numero foto:", parsed.length);
|
||||
|
||||
} catch (e) {
|
||||
console.error("Errore nel parse JSON:", e);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshGallery();
|
||||
}
|
||||
|
||||
async function loadPhotos1() {
|
||||
console.log("Inizio fetch:", window.BASE_URL + "/photos");
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${window.BASE_URL}/photos`, {
|
||||
headers: {
|
||||
"Authorization": "Bearer " + window.token
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Errore fetch:", e);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`HTTP ${res.status} ${res.statusText} – body:`, text.slice(0, 200));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
console.error("La risposta non è un array:", parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
window.photosData = parsed;
|
||||
console.log("JSON parse OK, numero foto:", parsed.length);
|
||||
|
||||
} catch (e) {
|
||||
console.error("Errore nel parse JSON:", e);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshGallery();
|
||||
}
|
||||
165
public/js/gallery.js
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// ===============================
|
||||
// GALLERY — completa, stile Google Photos
|
||||
// - Ordinamento
|
||||
// - Filtri
|
||||
// - Raggruppamento (auto/giorno/mese/anno)
|
||||
// - Render a sezioni
|
||||
// - Click: openModalFromList(sezione, indice) se disponibile (fallback openModal)
|
||||
// ===============================
|
||||
|
||||
// ORDINAMENTO
|
||||
function sortByDate(photos, direction = "desc") {
|
||||
return photos.slice().sort((a, b) => {
|
||||
const da = a?.taken_at ? new Date(a.taken_at) : 0;
|
||||
const db = b?.taken_at ? new Date(b.taken_at) : 0;
|
||||
return direction === "asc" ? (da - db) : (db - da);
|
||||
});
|
||||
}
|
||||
|
||||
// FILTRI
|
||||
function applyFilters(photos) {
|
||||
if (!window.currentFilter) return photos;
|
||||
|
||||
switch (window.currentFilter) {
|
||||
case "folder":
|
||||
return photos.filter(p => p.folder || (p.path && p.path.includes('/photos/')));
|
||||
case "location":
|
||||
return photos.filter(p => p?.gps && p.gps.lat);
|
||||
case "type":
|
||||
return photos.filter(p => p?.mime_type && p.mime_type.startsWith("image/"));
|
||||
default:
|
||||
return photos;
|
||||
}
|
||||
}
|
||||
|
||||
// RAGGRUPPAMENTO STILE GOOGLE PHOTOS
|
||||
function groupByDate(photos, mode = "auto") {
|
||||
const sections = [];
|
||||
const now = new Date();
|
||||
|
||||
function getLabel(photo) {
|
||||
const date = photo?.taken_at ? new Date(photo.taken_at) : null;
|
||||
if (!date || isNaN(+date)) return "Senza data";
|
||||
|
||||
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
|
||||
if (mode === "day") return formatDay(date);
|
||||
if (mode === "month") return formatMonth(date);
|
||||
if (mode === "year") return date.getFullYear().toString();
|
||||
|
||||
if (diffDays === 0) return "Oggi";
|
||||
if (diffDays === 1) return "Ieri";
|
||||
if (diffDays <= 7) return "Questa settimana";
|
||||
if (diffDays <= 14) return "La settimana scorsa";
|
||||
if (diffDays <= 30) return "Questo mese";
|
||||
if (diffDays <= 60) return "Mese scorso";
|
||||
|
||||
if (date.getFullYear() === now.getFullYear()) return formatMonth(date);
|
||||
return date.getFullYear().toString();
|
||||
}
|
||||
|
||||
photos.forEach(photo => {
|
||||
const label = getLabel(photo);
|
||||
let section = sections.find(s => s.label === label);
|
||||
if (!section) {
|
||||
section = { label, photos: [] };
|
||||
sections.push(section);
|
||||
}
|
||||
section.photos.push(photo);
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// FORMATTATORI
|
||||
function formatDay(date) {
|
||||
return date.toLocaleDateString("it-IT", { weekday: "long", day: "numeric", month: "long" });
|
||||
}
|
||||
function formatMonth(date) {
|
||||
return date.toLocaleDateString("it-IT", { month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
// RENDER
|
||||
function renderGallery(sections) {
|
||||
const gallery = document.getElementById("gallery");
|
||||
if (!gallery) return;
|
||||
gallery.innerHTML = "";
|
||||
|
||||
sections.forEach(section => {
|
||||
const h = document.createElement("h2");
|
||||
h.className = "gallery-section-title";
|
||||
h.textContent = section.label;
|
||||
gallery.appendChild(h);
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "gallery-section";
|
||||
|
||||
section.photos.forEach((photo, idx) => {
|
||||
const thumbDiv = document.createElement("div");
|
||||
thumbDiv.className = "thumb";
|
||||
|
||||
// const th1 = (typeof toAbsoluteUrl === 'function') ? toAbsoluteUrl(photo?.thub1) : photo?.thub1;
|
||||
// const th2 = (typeof toAbsoluteUrl === 'function') ? toAbsoluteUrl(photo?.thub2 || photo?.thub1) : (photo?.thub2 || photo?.thub1);
|
||||
// const original = (typeof toAbsoluteUrl === 'function') ? toAbsoluteUrl(photo?.path) : photo?.path;
|
||||
|
||||
let th1, th2, original;
|
||||
|
||||
if (window.PATH_FULL) {
|
||||
// Uso direttamente i path completi generati dal backend
|
||||
th1 = photo?.thub1;
|
||||
th2 = photo?.thub2 || photo?.thub1;
|
||||
original = photo?.path;
|
||||
} else {
|
||||
// Comportamento attuale: costruisco URL con toAbsoluteUrl()
|
||||
th1 = (typeof toAbsoluteUrl === 'function')
|
||||
? toAbsoluteUrl(photo?.thub1, photo?.user, "thumbs", photo?.cartella)
|
||||
: photo?.thub1;
|
||||
|
||||
th2 = (typeof toAbsoluteUrl === 'function')
|
||||
? toAbsoluteUrl(photo?.thub2 || photo?.thub1, photo?.user, "thumbs", photo?.cartella)
|
||||
: (photo?.thub2 || photo?.thub1);
|
||||
|
||||
original = (typeof toAbsoluteUrl === 'function')
|
||||
? toAbsoluteUrl(photo?.path, photo?.user, "original", photo?.cartella)
|
||||
: photo?.path;
|
||||
}
|
||||
|
||||
|
||||
//console.log(photo?.user);
|
||||
console.log(th1);
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = th1 || th2 || original || "";
|
||||
img.alt = photo?.name || "";
|
||||
img.loading = "lazy";
|
||||
thumbDiv.appendChild(img);
|
||||
|
||||
if (photo?.mime_type && photo.mime_type.startsWith("video/")) {
|
||||
const play = document.createElement("div");
|
||||
play.className = "play-icon";
|
||||
play.textContent = "▶";
|
||||
thumbDiv.appendChild(play);
|
||||
}
|
||||
|
||||
thumbDiv.addEventListener("click", () => {
|
||||
// Chiudi sempre la strip prima di aprire una nuova foto
|
||||
window.closeBottomSheet?.();
|
||||
|
||||
if (typeof window.openModalFromList === "function") {
|
||||
window.openModalFromList(section.photos, idx);
|
||||
} else {
|
||||
window.openModal?.(original, th2, photo);
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(thumbDiv);
|
||||
});
|
||||
|
||||
gallery.appendChild(container);
|
||||
});
|
||||
}
|
||||
|
||||
// Esporti su window
|
||||
window.sortByDate = sortByDate;
|
||||
window.applyFilters = applyFilters;
|
||||
window.groupByDate = groupByDate;
|
||||
window.renderGallery = renderGallery;
|
||||
167
public/js/infoPanel.js
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
// ===============================
|
||||
// PANNELLO INFO + MAPPA (toggle affidabile + auto-refresh su cambio foto)
|
||||
// ===============================
|
||||
|
||||
const infoPanel = document.getElementById('infoPanel');
|
||||
let infoMapInstance = null; // tieni traccia della mappa per pulizia
|
||||
|
||||
// -------------------------------
|
||||
// Helpers UI / stato
|
||||
// -------------------------------
|
||||
function isPanelOpen() {
|
||||
return infoPanel.classList.contains('open') ||
|
||||
infoPanel.getAttribute('aria-hidden') === 'false' ||
|
||||
infoPanel.getAttribute('data-open') === '1' ||
|
||||
infoPanel.style.display === 'block';
|
||||
}
|
||||
|
||||
function markButtonActive(active) {
|
||||
const btn = document.getElementById('modalInfoBtn');
|
||||
if (btn) btn.classList.toggle('active', !!active);
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
// Render contenuti + (ri)creazione mappa
|
||||
// -------------------------------
|
||||
function renderInfo(photo) {
|
||||
if (!photo) return;
|
||||
|
||||
const gps = photo.gps || { lat: '-', lng: '-', alt: '-' };
|
||||
const folder = photo.path?.split('/').slice(2, -1).join('/') || '-';
|
||||
const loc = photo.location || {};
|
||||
|
||||
// Inietta contenuti
|
||||
infoPanel.innerHTML = `
|
||||
<h3>Informazioni</h3>
|
||||
|
||||
<div class="info-row"><b>Nome:</b> ${photo.name ?? '-'}</div>
|
||||
<div class="info-row"><b>Data:</b> ${photo.taken_at ?? '-'}</div>
|
||||
|
||||
<div class="info-row"><b>Latitudine:</b> ${gps.lat ?? '-'}</div>
|
||||
<div class="info-row"><b>Longitudine:</b> ${gps.lng ?? '-'}</div>
|
||||
<div class="info-row"><b>Altitudine:</b> ${gps.alt ?? '-'} m</div>
|
||||
|
||||
<div class="info-row"><b>Dimensioni:</b> ${photo.width ?? '-'} × ${photo.height ?? '-'}</div>
|
||||
<div class="info-row"><b>Peso:</b> ${photo.size_bytes ? (photo.size_bytes / 1024 / 1024).toFixed(2) + ' MB' : '-'}</div>
|
||||
<div class="info-row"><b>Tipo:</b> ${photo.mime_type ?? '-'}</div>
|
||||
|
||||
<div class="info-row"><b>Cartella:</b> ${folder}</div>
|
||||
|
||||
<div class="info-spacer"></div>
|
||||
|
||||
<h3>Mappa</h3>
|
||||
${gps.lat !== '-' && gps.lng !== '-' ? '<div id="infoMap" class="info-map"></div>' : ''}
|
||||
|
||||
<div class="info-spacer"></div>
|
||||
|
||||
<h3>Location</h3>
|
||||
${loc.continent ? `<div class="info-row"><b>Continente:</b> ${loc.continent}</div>` : ''}
|
||||
${loc.country ? `<div class="info-row"><b>Nazione:</b> ${loc.country}</div>` : ''}
|
||||
${loc.region ? `<div class="info-row"><b>Regione:</b> ${loc.region}</div>` : ''}
|
||||
${loc.city ? `<div class="info-row"><b>Città:</b> ${loc.city}</div>` : ''}
|
||||
${loc.address ? `<div class="info-row"><b>Indirizzo:</b> ${loc.address}</div>` : ''}
|
||||
${loc.postcode ? `<div class="info-row"><b>CAP:</b> ${loc.postcode}</div>` : ''}
|
||||
${loc.county_code ? `<div class="info-row"><b>Provincia:</b> ${loc.county_code}</div>` : ''}
|
||||
${loc.timezone ? `<div class="info-row"><b>Timezone:</b> ${loc.timezone}</div>` : ''}
|
||||
${loc.time ? `<div class="info-row"><b>Offset:</b> ${loc.time}</div>` : ''}
|
||||
`;
|
||||
|
||||
// (Ri)crea la mappa se ci sono coordinate
|
||||
// 1) Pulisci istanza precedente (evita "Map container is already initialized")
|
||||
try { infoMapInstance?.remove(); } catch {}
|
||||
infoMapInstance = null;
|
||||
|
||||
if (gps.lat !== '-' && gps.lng !== '-') {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
infoMapInstance = L.map('infoMap', {
|
||||
zoomControl: false,
|
||||
attributionControl: false
|
||||
}).setView([gps.lat, gps.lng], 13);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19
|
||||
}).addTo(infoMapInstance);
|
||||
|
||||
L.marker([gps.lat, gps.lng]).addTo(infoMapInstance);
|
||||
} catch (err) {
|
||||
console.warn('Errore creazione mappa info:', err);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
// API pubbliche: apri / chiudi / toggle
|
||||
// -------------------------------
|
||||
window.openInfoPanel = function openInfoPanel(photo) {
|
||||
renderInfo(photo || window.currentPhoto);
|
||||
infoPanel.classList.add('open');
|
||||
infoPanel.setAttribute('aria-hidden', 'false');
|
||||
infoPanel.setAttribute('data-open', '1');
|
||||
markButtonActive(true);
|
||||
};
|
||||
|
||||
window.closeInfoPanel = function closeInfoPanel() {
|
||||
infoPanel.classList.remove('open');
|
||||
infoPanel.setAttribute('aria-hidden', 'true');
|
||||
infoPanel.setAttribute('data-open', '0');
|
||||
markButtonActive(false);
|
||||
|
||||
// pulizia mappa
|
||||
try { infoMapInstance?.remove(); } catch {}
|
||||
infoMapInstance = null;
|
||||
};
|
||||
|
||||
window.toggleInfoPanel = function toggleInfoPanel(photo) {
|
||||
if (isPanelOpen()) window.closeInfoPanel();
|
||||
else window.openInfoPanel(photo || window.currentPhoto);
|
||||
};
|
||||
|
||||
// -------------------------------
|
||||
// Delegation: click su ℹ️ = TOGGLE vero
|
||||
// -------------------------------
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.id !== 'modalInfoBtn') return;
|
||||
e.stopPropagation(); // evita side-effects (es. navigazione ai bordi)
|
||||
window.toggleInfoPanel(window.currentPhoto);
|
||||
});
|
||||
|
||||
// Chiudi pannello cliccando FUORI (non su ℹ️)
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!isPanelOpen()) return;
|
||||
const inside = infoPanel.contains(e.target);
|
||||
const isBtn = e.target.id === 'modalInfoBtn';
|
||||
if (!inside && !isBtn) window.closeInfoPanel();
|
||||
});
|
||||
|
||||
// -------------------------------
|
||||
/* Auto-refresh: se cambia il media nel modal e l'info è aperto, aggiorna */
|
||||
(() => {
|
||||
const mediaContainer = document.getElementById('modalMediaContainer');
|
||||
if (!mediaContainer) return;
|
||||
|
||||
const refreshIfOpen = () => {
|
||||
if (!isPanelOpen()) return;
|
||||
const photo = window.currentPhoto;
|
||||
if (photo) renderInfo(photo);
|
||||
};
|
||||
|
||||
// 1) Osserva la sostituzione del media (immagine/video) nel modal
|
||||
const mo = new MutationObserver(() => {
|
||||
// Debounce minimo per evitare doppi render durante il replace
|
||||
setTimeout(refreshIfOpen, 0);
|
||||
});
|
||||
mo.observe(mediaContainer, { childList: true });
|
||||
|
||||
// 2) (Extra robustezza) ascolta le frecce se esistono
|
||||
document.getElementById('modalPrev')?.addEventListener('click', () => setTimeout(refreshIfOpen, 0));
|
||||
document.getElementById('modalNext')?.addEventListener('click', () => setTimeout(refreshIfOpen, 0));
|
||||
|
||||
// 3) (Extra) tastiera ←/→
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
setTimeout(refreshIfOpen, 0);
|
||||
}
|
||||
});
|
||||
})();
|
||||
153
public/js/logout.js
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// public/js/logout.js
|
||||
// Gestione logout: chiama /auth/logout, pulisce i token, redirige e programma l'auto-logout a scadenza JWT.
|
||||
// Espone: window.AppAuth.logout({redirect: true|false}) e window.AppAuth.isLoggedIn().
|
||||
|
||||
(() => {
|
||||
const AUTH_LOGOUT_ENDPOINT = '/auth/logout';
|
||||
const KEYS = ['access_token', 'token', 'refresh_token']; // intercetta sia 'token' che 'access_token'
|
||||
|
||||
// --- Helpers token --------------------------------------------------------
|
||||
function getAccessToken() {
|
||||
try {
|
||||
return (
|
||||
localStorage.getItem('access_token') ||
|
||||
localStorage.getItem('token') ||
|
||||
''
|
||||
);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function clearTokens() {
|
||||
try {
|
||||
KEYS.forEach(k => localStorage.removeItem(k));
|
||||
// Se usi sessionStorage per altro, rimuovi solo chiavi auth (non fare clear totale)
|
||||
// sessionStorage.removeItem('my_auth_state'); // esempio
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// --- Chiamata server ------------------------------------------------------
|
||||
async function serverLogout(token) {
|
||||
try {
|
||||
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||
await fetch(AUTH_LOGOUT_ENDPOINT, { method: 'POST', headers });
|
||||
} catch (err) {
|
||||
// In caso di errore rete, lato client puliamo comunque.
|
||||
console.warn('Logout server fallito (ignoro):', err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Redirect -------------------------------------------------------------
|
||||
function getRedirectFromBtn() {
|
||||
const btn = document.querySelector('[data-logout]');
|
||||
return btn?.getAttribute('data-redirect') || '/';
|
||||
}
|
||||
|
||||
function redirectAfterLogout(redirectUrl) {
|
||||
const target = redirectUrl || '/';
|
||||
window.location.assign(target);
|
||||
}
|
||||
|
||||
// --- Auto-logout alla scadenza JWT ---------------------------------------
|
||||
function decodeJwt(token) {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
if (!base64Url) return null;
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64).split('').map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('')
|
||||
);
|
||||
return JSON.parse(jsonPayload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let autoLogoutTimer = null;
|
||||
function scheduleAutoLogout() {
|
||||
clearTimeout(autoLogoutTimer);
|
||||
const token = getAccessToken();
|
||||
if (!token) return;
|
||||
|
||||
const payload = decodeJwt(token);
|
||||
const expSec = payload?.exp;
|
||||
if (!expSec) return;
|
||||
|
||||
const msToExp = expSec * 1000 - Date.now();
|
||||
if (msToExp <= 0) {
|
||||
// già scaduto → logout immediato
|
||||
doLogout({ redirect: true });
|
||||
return;
|
||||
}
|
||||
|
||||
autoLogoutTimer = setTimeout(() => {
|
||||
doLogout({ redirect: true });
|
||||
}, msToExp);
|
||||
}
|
||||
|
||||
// --- UI helpers -----------------------------------------------------------
|
||||
function setButtonsDisabled(disabled) {
|
||||
document.querySelectorAll('[data-logout]').forEach(btn => {
|
||||
btn.disabled = disabled;
|
||||
btn.setAttribute('aria-busy', disabled ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
// --- Azione principale ----------------------------------------------------
|
||||
async function doLogout({ redirect = true } = {}) {
|
||||
const token = getAccessToken();
|
||||
setButtonsDisabled(true);
|
||||
|
||||
// 1) Revoca lato server (denylist) se possibile
|
||||
await serverLogout(token);
|
||||
|
||||
// 2) Pulizia lato client
|
||||
clearTokens();
|
||||
|
||||
// 3) Chiudi eventuali media globali
|
||||
try { window.player?.pause?.(); } catch {}
|
||||
|
||||
// 4) Notifica globale (se vuoi ascoltarla altrove)
|
||||
try { window.dispatchEvent(new CustomEvent('logout:success')); } catch {}
|
||||
|
||||
// 5) Redirect
|
||||
if (redirect) redirectAfterLogout(getRedirectFromBtn());
|
||||
|
||||
setButtonsDisabled(false);
|
||||
}
|
||||
|
||||
// --- Click handler --------------------------------------------------------
|
||||
function bindLogoutButtons() {
|
||||
document.querySelectorAll('[data-logout]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
if (btn.disabled) return; // evita doppi click
|
||||
await doLogout({ redirect: true });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Public API -----------------------------------------------------------
|
||||
window.AppAuth = Object.freeze({
|
||||
logout: (opts) => doLogout(opts),
|
||||
isLoggedIn: () => !!getAccessToken()
|
||||
});
|
||||
|
||||
// --- Init -----------------------------------------------------------------
|
||||
function init() {
|
||||
bindLogoutButtons();
|
||||
scheduleAutoLogout();
|
||||
|
||||
// Opzionale: nascondi il bottone se non loggato
|
||||
document.querySelectorAll('[data-logout]').forEach(btn => {
|
||||
if (!window.AppAuth.isLoggedIn()) btn.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
293
public/js/main.js
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
// ===============================
|
||||
// AVVIO
|
||||
// ===============================
|
||||
console.log("main.js avviato");
|
||||
|
||||
// ===============================
|
||||
// UTILS AUTH + SYNC HEADER UI
|
||||
// ===============================
|
||||
function isAuthenticated() {
|
||||
// Fonte verità: presenza del token in localStorage
|
||||
return !!localStorage.getItem("token");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincronizza l’UI dell’header in base allo stato auth:
|
||||
* - Aggiunge una classe sul body (CSS-friendly)
|
||||
* - Mostra/Nasconde il bottone logout (hotfix inline, se vuoi puoi affidarti solo al CSS)
|
||||
*/
|
||||
function syncHeaderAuthUI() {
|
||||
const authed = isAuthenticated();
|
||||
document.body.classList.toggle('authenticated', authed);
|
||||
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
if (logoutBtn) {
|
||||
// Hotfix immediato: forza la visibilità anche via inline style
|
||||
// (puoi rimuovere queste due righe se preferisci usare solo la regola CSS body.authenticated #logoutBtn)
|
||||
logoutBtn.style.display = authed ? 'inline-flex' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// PATCH: misura l'altezza reale dell'header e aggiorna --header-h
|
||||
// (serve per far partire la mappa subito sotto l’header, anche su mobile)
|
||||
// ===============================
|
||||
(function () {
|
||||
const root = document.documentElement;
|
||||
const header = document.querySelector('header');
|
||||
|
||||
function setHeaderHeight() {
|
||||
const h = header ? Math.round(header.getBoundingClientRect().height) : 60;
|
||||
root.style.setProperty('--header-h', h + 'px');
|
||||
}
|
||||
|
||||
setHeaderHeight();
|
||||
|
||||
if (window.ResizeObserver && header) {
|
||||
const ro = new ResizeObserver(setHeaderHeight);
|
||||
ro.observe(header);
|
||||
} else {
|
||||
window.addEventListener('resize', setHeaderHeight);
|
||||
window.addEventListener('orientationchange', setHeaderHeight);
|
||||
}
|
||||
|
||||
window.addEventListener('load', setHeaderHeight);
|
||||
})();
|
||||
|
||||
// ===============================
|
||||
// PATCH: quando si apre la mappa (#globalMap.open) invalida le dimensioni Leaflet
|
||||
// (utile perché prima era display:none; invalidateSize evita “tagli” o tile sfasati)
|
||||
// ===============================
|
||||
(function () {
|
||||
const mapEl = document.getElementById('globalMap');
|
||||
if (!mapEl) return;
|
||||
|
||||
function invalidateWhenOpen() {
|
||||
if (!mapEl.classList.contains('open')) return;
|
||||
// Aspetta un tick così il layout è aggiornato
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// In mapGlobal.js imposta: window.leafletMapInstance = window.globalMap;
|
||||
window.leafletMapInstance?.invalidateSize();
|
||||
} catch (e) {
|
||||
console.warn('invalidateSize non eseguito:', e);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// 1) Osserva il cambio classe (quando aggiungi .open)
|
||||
const mo = new MutationObserver((mutations) => {
|
||||
if (mutations.some(m => m.attributeName === 'class')) {
|
||||
invalidateWhenOpen();
|
||||
}
|
||||
});
|
||||
mo.observe(mapEl, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
// 2) Fallback: se usi il bottone #openMapBtn per aprire/chiudere
|
||||
document.getElementById('openMapBtn')?.addEventListener('click', () => {
|
||||
setTimeout(invalidateWhenOpen, 0);
|
||||
});
|
||||
})();
|
||||
|
||||
// ===============================
|
||||
// MENU ⋮ (toggle apri/chiudi con lo stesso bottone, senza global conflicts)
|
||||
// ===============================
|
||||
(() => {
|
||||
const optBtn = document.getElementById("optionsBtn");
|
||||
const optSheet = document.getElementById("optionsSheet");
|
||||
const overlayEl= document.getElementById("sheetOverlay");
|
||||
|
||||
if (!optBtn || !optSheet) return;
|
||||
|
||||
function openOptionsSheet() {
|
||||
try { window.closeBottomSheet?.(); } catch {}
|
||||
optSheet.classList.add("open");
|
||||
overlayEl?.classList.add("open");
|
||||
// ARIA (facoltativo)
|
||||
optBtn.setAttribute("aria-expanded", "true");
|
||||
optSheet.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeOptionsSheet() {
|
||||
optSheet.classList.remove("open");
|
||||
overlayEl?.classList.remove("open");
|
||||
// ARIA (facoltativo)
|
||||
optBtn.setAttribute("aria-expanded", "false");
|
||||
optSheet.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
function toggleOptionsSheet(e) {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
if (optSheet.classList.contains("open")) closeOptionsSheet();
|
||||
else openOptionsSheet();
|
||||
}
|
||||
|
||||
// Click sul bottone: toggle (fase di cattura per battere eventuali altri handler)
|
||||
optBtn.addEventListener("click", toggleOptionsSheet, { capture: true });
|
||||
|
||||
// Chiudi clic overlay
|
||||
overlayEl?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
closeOptionsSheet();
|
||||
});
|
||||
|
||||
// Chiudi con ESC
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && optSheet.classList.contains("open")) {
|
||||
closeOptionsSheet();
|
||||
}
|
||||
});
|
||||
|
||||
// Evita chiusure involontarie per click interni
|
||||
optSheet.addEventListener("click", (e) => e.stopPropagation());
|
||||
|
||||
// Espone una close per usarla altrove (es. dopo la scelta)
|
||||
window.closeOptionsSheet = closeOptionsSheet;
|
||||
})();
|
||||
|
||||
// ===============================
|
||||
// LOGIN AUTOMATICO SU INDEX
|
||||
// ===============================
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
// Allinea subito l’UI in base al token eventualmente già presente
|
||||
syncHeaderAuthUI();
|
||||
|
||||
try {
|
||||
// 1) Carica config
|
||||
const cfgRes = await fetch('/config');
|
||||
const cfg = await cfgRes.json();
|
||||
window.BASE_URL = cfg.baseUrl;
|
||||
|
||||
// 2) Recupera token salvato
|
||||
const savedToken = localStorage.getItem("token");
|
||||
|
||||
// Se non c'è token → mostra login
|
||||
if (!savedToken) {
|
||||
document.getElementById("loginModal").style.display = "flex";
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Verifica token
|
||||
const ping = await fetch(`${window.BASE_URL}/photos`, {
|
||||
headers: { "Authorization": "Bearer " + savedToken }
|
||||
});
|
||||
|
||||
if (!ping.ok) {
|
||||
// Token invalido → cancella e mostra login
|
||||
localStorage.removeItem("token");
|
||||
syncHeaderAuthUI(); // riallinea header subito
|
||||
document.getElementById("loginModal").style.display = "flex";
|
||||
return;
|
||||
}
|
||||
|
||||
// 4) Token valido → salva e carica gallery
|
||||
window.token = savedToken;
|
||||
syncHeaderAuthUI(); // <— mostra il logout senza refresh
|
||||
loadPhotos();
|
||||
|
||||
} catch (err) {
|
||||
console.error("Errore autenticazione:", err);
|
||||
document.getElementById("loginModal").style.display = "flex";
|
||||
}
|
||||
});
|
||||
|
||||
// ===============================
|
||||
// VARIABILI GLOBALI
|
||||
// ===============================
|
||||
let currentSort = "desc";
|
||||
let currentGroup = "auto";
|
||||
let currentFilter = null;
|
||||
|
||||
window.currentSort = currentSort;
|
||||
window.currentGroup = currentGroup;
|
||||
window.currentFilter = currentFilter;
|
||||
|
||||
// ===============================
|
||||
// BOTTONI OPZIONI
|
||||
// ===============================
|
||||
document.querySelectorAll("#optionsSheet .sheet-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
if (btn.dataset.sort) window.currentSort = currentSort = btn.dataset.sort;
|
||||
if (btn.dataset.group) window.currentGroup = currentGroup = btn.dataset.group;
|
||||
if (btn.dataset.filter) window.currentFilter = currentFilter = btn.dataset.filter;
|
||||
|
||||
// Chiudi sheet e overlay dopo la scelta (usa l’API esposta sopra)
|
||||
window.closeOptionsSheet?.();
|
||||
|
||||
refreshGallery();
|
||||
});
|
||||
});
|
||||
|
||||
// ===============================
|
||||
// REFRESH GALLERY
|
||||
// ===============================
|
||||
function refreshGallery() {
|
||||
console.log("Aggiornamento galleria...");
|
||||
|
||||
const data = Array.isArray(window.photosData) ? window.photosData : [];
|
||||
let photos = [...data];
|
||||
|
||||
if (typeof applyFilters === 'function') photos = applyFilters(photos);
|
||||
if (typeof sortByDate === 'function') photos = sortByDate(photos, currentSort);
|
||||
|
||||
let sections = [{ label: 'Tutte', photos }];
|
||||
if (typeof groupByDate === 'function') sections = groupByDate(photos, currentGroup);
|
||||
|
||||
if (typeof renderGallery === 'function') {
|
||||
renderGallery(sections);
|
||||
}
|
||||
}
|
||||
|
||||
window.refreshGallery = refreshGallery;
|
||||
|
||||
// ===============================
|
||||
// SETTINGS (⚙️) — apre admin.html
|
||||
// ===============================
|
||||
const settingsBtn = document.getElementById('settingsBtn');
|
||||
settingsBtn?.addEventListener('click', () => {
|
||||
window.location.href = "admin.html";
|
||||
});
|
||||
|
||||
// ===============================
|
||||
// LOGIN SUBMIT
|
||||
// ===============================
|
||||
document.getElementById("loginSubmit").addEventListener("click", async () => {
|
||||
const email = document.getElementById("loginEmail").value;
|
||||
const password = document.getElementById("loginPassword").value;
|
||||
const errorEl = document.getElementById("loginError");
|
||||
|
||||
errorEl.textContent = "";
|
||||
|
||||
try {
|
||||
const res = await fetch(`${window.BASE_URL}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
errorEl.textContent = "Utente o password errati";
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const token = data.token;
|
||||
|
||||
// Salva token
|
||||
localStorage.setItem("token", token);
|
||||
window.token = token;
|
||||
|
||||
// Chiudi login
|
||||
const loginModalEl = document.getElementById("loginModal");
|
||||
if (loginModalEl) loginModalEl.style.display = "none";
|
||||
|
||||
// Riallinea UI header subito (mostra logout) e carica gallery
|
||||
syncHeaderAuthUI();
|
||||
loadPhotos();
|
||||
|
||||
} catch (err) {
|
||||
console.error("Errore login:", err);
|
||||
errorEl.textContent = "Errore di connessione al server";
|
||||
}
|
||||
});
|
||||
250
public/js/mapGlobal.js
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
// ===============================
|
||||
// MAPPA GLOBALE — stile Google Photos Web
|
||||
// ===============================
|
||||
|
||||
window.globalMap = null;
|
||||
window.globalMarkers = null; // qui sarà un MarkerClusterGroup
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const openBtn = document.getElementById("openMapBtn");
|
||||
if (!openBtn) {
|
||||
console.error("openMapBtn non trovato nel DOM");
|
||||
return;
|
||||
}
|
||||
openBtn.addEventListener("click", openGlobalMap);
|
||||
|
||||
const RADIUS_PX = 50;
|
||||
const DISABLE_CLUSTER_AT_ZOOM = 18;
|
||||
const OPEN_STRIP_CHILDREN_MAX = 20;
|
||||
|
||||
async function openGlobalMap() {
|
||||
const mapDiv = document.getElementById("globalMap");
|
||||
const gallery = document.getElementById("gallery");
|
||||
if (!mapDiv) {
|
||||
console.error("globalMap DIV non trovato");
|
||||
return;
|
||||
}
|
||||
|
||||
const isOpen = mapDiv.classList.contains("open");
|
||||
|
||||
if (isOpen) {
|
||||
mapDiv.classList.remove("open");
|
||||
gallery?.classList.remove("hidden");
|
||||
window.closeBottomSheet?.();
|
||||
return;
|
||||
}
|
||||
|
||||
mapDiv.classList.add("open");
|
||||
gallery?.classList.add("hidden");
|
||||
|
||||
if (window.globalMap) {
|
||||
window.leafletMapInstance = window.globalMap;
|
||||
}
|
||||
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
let tries = 0;
|
||||
while (mapDiv.getBoundingClientRect().height < 50 && tries < 10) {
|
||||
await new Promise(r => setTimeout(r, 30));
|
||||
tries++;
|
||||
}
|
||||
|
||||
if (window.globalMap === null) {
|
||||
console.log("Inizializzo mappa Leaflet + MarkerCluster…");
|
||||
|
||||
window.globalMap = L.map("globalMap", {
|
||||
zoomControl: true,
|
||||
attributionControl: true
|
||||
}).setView([42.5, 12.5], 6);
|
||||
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19
|
||||
}).addTo(window.globalMap);
|
||||
|
||||
// CLUSTER con iconCreateFunction in stile Google Photos
|
||||
window.globalMarkers = L.markerClusterGroup({
|
||||
showCoverageOnHover: false,
|
||||
spiderfyOnMaxZoom: true,
|
||||
disableClusteringAtZoom: DISABLE_CLUSTER_AT_ZOOM,
|
||||
iconCreateFunction: function(cluster) {
|
||||
const count = cluster.getChildCount();
|
||||
|
||||
// dimensione scalata: base + sqrt(count) * fattore, con cap massimo
|
||||
const size = Math.min(92, Math.round(28 + Math.sqrt(count) * 6));
|
||||
|
||||
// classi per stili (piccolo/medio/grande)
|
||||
const cls = count > 200 ? 'cluster-xl' : (count > 50 ? 'cluster-lg' : (count > 10 ? 'cluster-md' : 'cluster-sm'));
|
||||
|
||||
// prendi fino a 4 thumbnails dai child markers (se disponibili)
|
||||
const children = cluster.getAllChildMarkers().slice(0, 4);
|
||||
const thumbs = children
|
||||
.map(m => m.__photo?.thub2 || m.__photo?.thub1)
|
||||
.filter(Boolean);
|
||||
|
||||
// se esiste toAbsoluteUrl, trasformiamo le thumb in URL assoluti
|
||||
const resolvedThumbs = (typeof toAbsoluteUrl === "function")
|
||||
? thumbs.map(t => absUrl(t, children[0]?.__photo?.user, "thumbs", children[0]?.__photo?.cartella))
|
||||
: thumbs;
|
||||
|
||||
// crea collage HTML: fino a 4 immagini in griglia 2x2
|
||||
const imgsHtml = resolvedThumbs.length
|
||||
? `<div class="cluster-collage">${resolvedThumbs.map((t,i)=>`<div class="c${i}"><img src="${t}" alt=""></div>`).join('')}</div>`
|
||||
: '';
|
||||
|
||||
const html = `
|
||||
<div class="gp-cluster ${cls}" style="width:${size}px;height:${size}px;">
|
||||
${imgsHtml}
|
||||
<div class="gp-count"><span>${count}</span></div>
|
||||
</div>`;
|
||||
|
||||
return L.divIcon({
|
||||
html,
|
||||
className: 'marker-cluster-wrapper',
|
||||
iconSize: L.point(size, size)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Listener "clusterclick": zooma oppure, quando pochi, apri strip
|
||||
window.globalMarkers.on("clusterclick", (a) => {
|
||||
const childMarkers = a.layer.getAllChildMarkers();
|
||||
const count = childMarkers.length;
|
||||
|
||||
if (count <= OPEN_STRIP_CHILDREN_MAX || window.globalMap.getZoom() >= DISABLE_CLUSTER_AT_ZOOM - 1) {
|
||||
const photos = childMarkers
|
||||
.map(m => m.__photo)
|
||||
.filter(Boolean);
|
||||
|
||||
if (photos.length > 1) {
|
||||
window.openBottomSheet?.(photos);
|
||||
} else if (photos.length === 1) {
|
||||
openPhotoModal(photos[0]);
|
||||
}
|
||||
} else {
|
||||
window.globalMap.fitBounds(a.layer.getBounds(), { padding: [60, 60], maxZoom: DISABLE_CLUSTER_AT_ZOOM, animate: true });
|
||||
}
|
||||
});
|
||||
|
||||
window.globalMap.addLayer(window.globalMarkers);
|
||||
|
||||
// Disegna i marker
|
||||
redrawPhotoMarkers();
|
||||
}
|
||||
|
||||
setTimeout(() => window.globalMap?.invalidateSize?.(), 120);
|
||||
}
|
||||
|
||||
function createPhotoIcon(photo) {
|
||||
const thumb = (typeof toAbsoluteUrl === "function")
|
||||
? absUrl(
|
||||
photo?.thub2 || photo?.thub1,
|
||||
photo?.user,
|
||||
"thumbs",
|
||||
photo?.cartella
|
||||
)
|
||||
: (photo?.thub2 || photo?.thub1);
|
||||
|
||||
return L.icon({
|
||||
iconUrl: thumb || "",
|
||||
iconSize: [56, 56],
|
||||
iconAnchor: [28, 28],
|
||||
className: "photo-marker"
|
||||
});
|
||||
}
|
||||
|
||||
function openPhotoModal(photo) {
|
||||
const thumb = (typeof toAbsoluteUrl === "function")
|
||||
? absUrl(
|
||||
photo?.thub2 || photo?.thub1 || photo?.path,
|
||||
photo?.user,
|
||||
"thumbs",
|
||||
photo?.cartella
|
||||
)
|
||||
: (photo?.thub2 || photo?.thub1 || photo?.path);
|
||||
|
||||
const original = (typeof toAbsoluteUrl === "function")
|
||||
? absUrl(
|
||||
photo?.path,
|
||||
photo?.user,
|
||||
"original",
|
||||
photo?.cartella
|
||||
)
|
||||
: photo?.path;
|
||||
|
||||
window.closeBottomSheet?.();
|
||||
window.openModal?.(original, thumb, photo);
|
||||
}
|
||||
|
||||
function radiusMetersAtZoom(latlng, px) {
|
||||
if (!window.globalMap) return 0;
|
||||
const p = window.globalMap.latLngToContainerPoint(latlng);
|
||||
const p2 = L.point(p.x + px, p.y);
|
||||
const ll2 = window.globalMap.containerPointToLatLng(p2);
|
||||
return window.globalMap.distance(latlng, ll2);
|
||||
}
|
||||
|
||||
function distanceMeters(lat1, lng1, lat2, lng2) {
|
||||
const toRad = d => d * Math.PI / 180;
|
||||
const R = 6371000;
|
||||
const φ1 = toRad(lat1), φ2 = toRad(lat2);
|
||||
const dφ = toRad(lat2 - lat1);
|
||||
const dλ = toRad(lng2 - lng1);
|
||||
const a = Math.sin(dφ/2) ** 2 +
|
||||
Math.cos(φ1) * Math.cos(φ2) *
|
||||
Math.sin(dλ/2) ** 2;
|
||||
return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
function buildGroupByRadius(lat, lng, data, radiusM) {
|
||||
return data.filter(p => {
|
||||
const plat = +p?.gps?.lat;
|
||||
const plng = +p?.gps?.lng;
|
||||
if (!plat || !plng) return false;
|
||||
return distanceMeters(lat, lng, plat, plng) <= radiusM;
|
||||
});
|
||||
}
|
||||
|
||||
function redrawPhotoMarkers() {
|
||||
if (!window.globalMarkers || !window.globalMap) return;
|
||||
|
||||
window.globalMarkers.clearLayers();
|
||||
const data = Array.isArray(window.photosData) ? window.photosData : [];
|
||||
|
||||
data.forEach(photo => {
|
||||
const lat = +photo?.gps?.lat;
|
||||
const lng = +photo?.gps?.lng;
|
||||
if (!lat || !lng) return;
|
||||
|
||||
const marker = L.marker([lat, lng], {
|
||||
icon: createPhotoIcon(photo),
|
||||
title: photo?.name || ""
|
||||
});
|
||||
|
||||
marker.__photo = photo;
|
||||
|
||||
marker.on("click", () => {
|
||||
const here = L.latLng(lat, lng);
|
||||
const radiusM = radiusMetersAtZoom(here, RADIUS_PX);
|
||||
const dataAll = Array.isArray(window.photosData) ? window.photosData : [];
|
||||
const group = buildGroupByRadius(lat, lng, dataAll, radiusM);
|
||||
|
||||
if (group.length > 1) {
|
||||
window.openBottomSheet?.(group);
|
||||
} else if (group.length === 1) {
|
||||
openPhotoModal(group[0]);
|
||||
} else {
|
||||
openPhotoModal(photo);
|
||||
}
|
||||
});
|
||||
|
||||
window.globalMarkers.addLayer(marker);
|
||||
});
|
||||
}
|
||||
|
||||
const originalRefresh = window.refreshGallery;
|
||||
window.refreshGallery = function wrappedRefreshGallery(...args) {
|
||||
try { originalRefresh?.apply(this, args); } catch (_) {}
|
||||
if (window.globalMap && window.globalMarkers) {
|
||||
redrawPhotoMarkers();
|
||||
}
|
||||
};
|
||||
});
|
||||
378
public/js/modal.js
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
// ===============================
|
||||
// MODALE (FOTO + VIDEO) — avanzato con navigazione e preload
|
||||
// - Sostituisce il contenuto, non accumula
|
||||
// - Chiude la bottom-zone quando si apre
|
||||
// - Prev/Next (←/→ e click ai bordi), preload 3+3
|
||||
// - Pulsante INFO (ℹ️) riportato dentro il modal con toggle affidabile
|
||||
// ===============================
|
||||
|
||||
const modal = document.getElementById('modal');
|
||||
const modalClose = document.getElementById('modalClose');
|
||||
|
||||
window.currentPhoto = null; // usato anche da infoPanel
|
||||
window.modalList = []; // lista corrente per navigazione
|
||||
window.modalIndex = 0; // indice corrente nella lista
|
||||
|
||||
// Frecce visibili
|
||||
const modalPrev = document.getElementById('modalPrev');
|
||||
const modalNext = document.getElementById('modalNext');
|
||||
|
||||
// ===============================
|
||||
// Stato/Helper Info Panel (toggle affidabile)
|
||||
// ===============================
|
||||
let infoOpen = false; // stato interno affidabile
|
||||
|
||||
function getInfoPanel() {
|
||||
return document.getElementById('infoPanel');
|
||||
}
|
||||
|
||||
function isInfoOpen() {
|
||||
return infoOpen;
|
||||
}
|
||||
|
||||
function openInfo(photo) {
|
||||
// Prova API esplicita, altrimenti fallback a toggle
|
||||
try {
|
||||
if (typeof window.openInfoPanel === 'function') {
|
||||
window.openInfoPanel(photo);
|
||||
} else if (typeof window.toggleInfoPanel === 'function') {
|
||||
window.toggleInfoPanel(photo);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
infoOpen = true;
|
||||
const panel = getInfoPanel();
|
||||
panel?.classList.add('open');
|
||||
panel?.setAttribute('aria-hidden', 'false');
|
||||
panel?.setAttribute('data-open', '1');
|
||||
document.getElementById('modalInfoBtn')?.classList.add('active');
|
||||
}
|
||||
|
||||
function closeInfo() {
|
||||
// Prova API esplicita, altrimenti fallback a toggle (senza argomento)
|
||||
try {
|
||||
if (typeof window.closeInfoPanel === 'function') {
|
||||
window.closeInfoPanel();
|
||||
} else if (typeof window.toggleInfoPanel === 'function') {
|
||||
window.toggleInfoPanel();
|
||||
}
|
||||
} catch {}
|
||||
|
||||
infoOpen = false;
|
||||
const panel = getInfoPanel();
|
||||
panel?.classList.remove('open');
|
||||
panel?.setAttribute('aria-hidden', 'true');
|
||||
panel?.setAttribute('data-open', '0');
|
||||
document.getElementById('modalInfoBtn')?.classList.remove('active');
|
||||
}
|
||||
|
||||
function toggleInfo(photo) {
|
||||
if (isInfoOpen()) closeInfo();
|
||||
else openInfo(photo);
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// Utility MIME / media
|
||||
// ===============================
|
||||
function isProbablyVideo(photo, srcOriginal) {
|
||||
const mime = String(photo?.mime_type || '').toLowerCase();
|
||||
if (mime.startsWith('video/')) return true;
|
||||
return /\.(mp4|m4v|webm|mov|qt|avi|mkv)$/i.test(String(srcOriginal || ''));
|
||||
}
|
||||
|
||||
function guessVideoMime(photo, srcOriginal) {
|
||||
let t = String(photo?.mime_type || '').toLowerCase();
|
||||
if (t && t !== 'application/octet-stream') return t;
|
||||
const src = String(srcOriginal || '');
|
||||
if (/\.(mp4|m4v)$/i.test(src)) return 'video/mp4';
|
||||
if (/\.(webm)$/i.test(src)) return 'video/webm';
|
||||
if (/\.(mov|qt)$/i.test(src)) return 'video/quicktime';
|
||||
if (/\.(avi)$/i.test(src)) return 'video/x-msvideo';
|
||||
if (/\.(mkv)$/i.test(src)) return 'video/x-matroska';
|
||||
return '';
|
||||
}
|
||||
|
||||
function createVideoElement(srcOriginal, srcPreview, photo) {
|
||||
const video = document.createElement('video');
|
||||
video.controls = true;
|
||||
video.playsInline = true; // iOS: evita fullscreen nativo
|
||||
video.setAttribute('webkit-playsinline', ''); // compat iOS storici
|
||||
video.preload = 'metadata';
|
||||
video.poster = srcPreview || '';
|
||||
video.style.maxWidth = '100%';
|
||||
video.style.maxHeight = '100%';
|
||||
video.style.objectFit = 'contain';
|
||||
|
||||
const source = document.createElement('source');
|
||||
source.src = srcOriginal;
|
||||
const type = guessVideoMime(photo, srcOriginal);
|
||||
if (type) source.type = type;
|
||||
video.appendChild(source);
|
||||
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
try { video.currentTime = 0.001; } catch (_) {}
|
||||
console.log('[video] loadedmetadata', { w: video.videoWidth, h: video.videoHeight, dur: video.duration });
|
||||
});
|
||||
|
||||
video.addEventListener('error', () => {
|
||||
const code = video.error && video.error.code;
|
||||
console.warn('[video] error code:', code, 'type:', type, 'src:', srcOriginal);
|
||||
const msg = document.createElement('div');
|
||||
msg.style.padding = '12px';
|
||||
msg.style.color = '#fff';
|
||||
msg.style.background = 'rgba(0,0,0,0.6)';
|
||||
msg.style.borderRadius = '8px';
|
||||
msg.innerHTML = `
|
||||
<strong>Impossibile riprodurre questo video nel browser.</strong>
|
||||
${code === 4 ? 'Formato/codec non supportato (es. HEVC/H.265 su Chrome/Edge).' : 'Errore durante il caricamento.'}
|
||||
<br><br>
|
||||
Suggerimenti:
|
||||
<ul style="margin:6px 0 0 18px">
|
||||
<li><a href="${srcOriginal}" target="_blank" rel="noopener" style="color:#fff;text-decoration:underline">Apri il file in una nuova scheda</a></li>
|
||||
<li>Prova Safari (supporta HEVC) oppure converti in MP4 (H.264 + AAC)</li>
|
||||
</ul>
|
||||
`;
|
||||
const container = document.getElementById('modalMediaContainer');
|
||||
container && container.appendChild(msg);
|
||||
});
|
||||
|
||||
// Evita di far scattare la navigazione "ai bordi"
|
||||
video.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
return video;
|
||||
}
|
||||
|
||||
function createImageElement(srcOriginal, srcPreview) {
|
||||
const img = document.createElement('img');
|
||||
img.src = srcPreview || srcOriginal || '';
|
||||
img.style.maxWidth = '100%';
|
||||
img.style.maxHeight = '100%';
|
||||
img.style.objectFit = 'contain';
|
||||
|
||||
// Progressive loading: preview → fullres
|
||||
if (srcPreview && srcOriginal && srcPreview !== srcOriginal) {
|
||||
const full = new Image();
|
||||
full.src = srcOriginal;
|
||||
full.onload = () => { img.src = srcOriginal; };
|
||||
}
|
||||
return img;
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// Helpers per URL assoluti
|
||||
// ===============================
|
||||
|
||||
function absUrl(path, name, type, cartella) {
|
||||
// Se PATH_FULL è attivo, il backend ha già fornito un URL completo
|
||||
if (window.PATH_FULL) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Comportamento normale
|
||||
return (typeof toAbsoluteUrl === 'function')
|
||||
? toAbsoluteUrl(path, name, type, cartella)
|
||||
: path;
|
||||
}
|
||||
|
||||
|
||||
function mediaUrlsFromPhoto(photo) {
|
||||
let original, preview;
|
||||
|
||||
if (window.PATH_FULL) {
|
||||
// Percorsi completi già pronti dal backend
|
||||
original = photo?.path;
|
||||
preview = photo?.thub2 || photo?.thub1 || photo?.path;
|
||||
} else {
|
||||
// Costruzione URL come prima
|
||||
original = absUrl(photo?.path, photo?.user, "original", photo?.cartella);
|
||||
|
||||
preview = absUrl(
|
||||
photo?.thub2 || photo?.thub1 || photo?.path,
|
||||
photo?.user,
|
||||
"thumbs",
|
||||
photo?.cartella
|
||||
);
|
||||
}
|
||||
|
||||
return { original, preview };
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ===============================
|
||||
// PRELOAD ±N (solo immagini; per i video: poster/preview)
|
||||
// ===============================
|
||||
function preloadNeighbors(N = 3) {
|
||||
const list = window.modalList || [];
|
||||
const idx = window.modalIndex || 0;
|
||||
|
||||
for (let offset = 1; offset <= N; offset++) {
|
||||
const iPrev = idx - offset;
|
||||
const iNext = idx + offset;
|
||||
[iPrev, iNext].forEach(i => {
|
||||
const p = list[i];
|
||||
if (!p) return;
|
||||
const { original, preview } = mediaUrlsFromPhoto(p);
|
||||
const isVideo = String(p?.mime_type || '').toLowerCase().startsWith('video/');
|
||||
const src = isVideo ? (preview || original) : original;
|
||||
if (!src) return;
|
||||
const img = new Image();
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// Core: imposta contenuto modal
|
||||
// ===============================
|
||||
function setModalContent(photo, srcOriginal, srcPreview) {
|
||||
const container = document.getElementById('modalMediaContainer');
|
||||
container.innerHTML = '';
|
||||
window.currentPhoto = photo;
|
||||
|
||||
const isVideo = isProbablyVideo(photo, srcOriginal);
|
||||
console.log('[openModal]', { isVideo, mime: photo?.mime_type, srcOriginal, srcPreview });
|
||||
|
||||
if (isVideo) {
|
||||
const video = createVideoElement(srcOriginal, srcPreview, photo);
|
||||
container.appendChild(video);
|
||||
} else {
|
||||
const img = createImageElement(srcOriginal, srcPreview);
|
||||
container.appendChild(img);
|
||||
}
|
||||
|
||||
// Pulsante INFO (ℹ️) dentro il modal — toggle vero
|
||||
const infoBtn = document.createElement('button');
|
||||
infoBtn.id = 'modalInfoBtn';
|
||||
infoBtn.className = 'modal-info-btn';
|
||||
infoBtn.type = 'button';
|
||||
infoBtn.setAttribute('aria-label', 'Dettagli');
|
||||
infoBtn.textContent = 'ℹ️';
|
||||
container.appendChild(infoBtn);
|
||||
|
||||
infoBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // non far scattare navigazione
|
||||
toggleInfo(window.currentPhoto);
|
||||
});
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// API base: open/close modal (mantiene sostituzione contenuto)
|
||||
// ===============================
|
||||
function openModal(srcOriginal, srcPreview, photo) {
|
||||
// Chiudi sempre la strip prima di aprire
|
||||
window.closeBottomSheet?.();
|
||||
|
||||
setModalContent(photo, srcOriginal, srcPreview);
|
||||
modal.classList.add('open');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
// Chiudi anche l'info se aperto
|
||||
if (isInfoOpen()) closeInfo();
|
||||
|
||||
const v = document.querySelector('#modal video');
|
||||
if (v) {
|
||||
try { v.pause(); } catch (_) {}
|
||||
v.removeAttribute('src');
|
||||
while (v.firstChild) v.removeChild(v.firstChild);
|
||||
try { v.load(); } catch (_) {}
|
||||
}
|
||||
const container = document.getElementById('modalMediaContainer');
|
||||
if (container) container.innerHTML = '';
|
||||
modal.classList.remove('open');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
document.body.style.overflow = '';
|
||||
|
||||
// Nascondi frecce alla chiusura (così non "rimangono" visibili)
|
||||
try {
|
||||
modalPrev?.classList.add('hidden');
|
||||
modalNext?.classList.add('hidden');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// X: stopPropagation + chiudi
|
||||
modalClose?.addEventListener('click', (e) => { e.stopPropagation(); closeModal(); });
|
||||
|
||||
// Backdrop: chiudi cliccando fuori
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
|
||||
|
||||
// ===============================
|
||||
// Navigazione: lista + indice + prev/next + click ai bordi + tastiera
|
||||
// ===============================
|
||||
function openAt(i) {
|
||||
const list = window.modalList || [];
|
||||
if (!list[i]) return;
|
||||
window.modalIndex = i;
|
||||
const photo = list[i];
|
||||
const { original, preview } = mediaUrlsFromPhoto(photo);
|
||||
|
||||
// Se l'info è aperto, aggiorna i contenuti per la nuova foto
|
||||
if (isInfoOpen()) {
|
||||
openInfo(photo);
|
||||
}
|
||||
|
||||
openModal(original, preview, photo); // sostituisce contenuto
|
||||
preloadNeighbors(3);
|
||||
updateArrows();
|
||||
}
|
||||
|
||||
window.openModalFromList = function(list, index) {
|
||||
window.modalList = Array.isArray(list) ? list : [];
|
||||
window.modalIndex = Math.max(0, Math.min(index || 0, window.modalList.length - 1));
|
||||
openAt(window.modalIndex);
|
||||
};
|
||||
|
||||
function showPrev() { if (window.modalIndex > 0) openAt(window.modalIndex - 1); }
|
||||
function showNext() { if (window.modalIndex < (window.modalList.length - 1)) openAt(window.modalIndex + 1); }
|
||||
|
||||
// Tastiera
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!modal.classList.contains('open')) return;
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); showPrev(); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); showNext(); }
|
||||
});
|
||||
|
||||
// Click ai bordi del modal: sinistra=prev, destra=next (ignora controlli)
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (!modal.classList.contains('open')) return;
|
||||
|
||||
// Ignora click sui controlli
|
||||
if (e.target.closest('.modal-info-btn, .modal-close, .modal-nav-btn')) return;
|
||||
|
||||
if (e.target === modal) return; // già gestito per chiusura
|
||||
|
||||
const rect = modal.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const side = x / rect.width;
|
||||
if (side < 0.25) showPrev();
|
||||
else if (side > 0.75) showNext();
|
||||
});
|
||||
|
||||
// Esporta API base (per compatibilità con codice esistente)
|
||||
window.openModal = openModal;
|
||||
window.closeModal = closeModal;
|
||||
|
||||
// ===============================
|
||||
// FRECCE DI NAVIGAZIONE < >
|
||||
// ===============================
|
||||
function updateArrows() {
|
||||
if (!modalPrev || !modalNext) return;
|
||||
const len = (window.modalList || []).length;
|
||||
const i = window.modalIndex || 0;
|
||||
|
||||
// Mostra frecce solo se ci sono almeno 2 elementi
|
||||
const show = len > 1;
|
||||
modalPrev.classList.toggle('hidden', !show);
|
||||
modalNext.classList.toggle('hidden', !show);
|
||||
|
||||
// Disabilita ai bordi (no wrap)
|
||||
modalPrev.classList.toggle('disabled', i <= 0);
|
||||
modalNext.classList.toggle('disabled', i >= len - 1);
|
||||
}
|
||||
|
||||
// Click sulle frecce: non propagare (evita conflitti col click sui bordi)
|
||||
modalPrev?.addEventListener('click', (e) => { e.stopPropagation(); showPrev(); updateArrows(); });
|
||||
modalNext?.addEventListener('click', (e) => { e.stopPropagation(); showNext(); updateArrows(); });
|
||||
BIN
public/photos/Common1/original/IMG_20220619_135541.jpg
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
public/photos/Common1/original/IMG_20220619_135542.jpg
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
public/photos/Common1/original/IMG_20220619_135543.jpg
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
public/photos/Common1/original/IMG_20220619_135636.jpg
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
public/photos/Common1/original/IMG_20220619_135641.jpg
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
public/photos/Common1/original/IMG_20220619_135643.jpg
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
public/photos/Common1/original/IMG_20220619_135741.jpg
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
BIN
public/photos/Common1/original/IMG_20220619_135744.jpg
Normal file
|
After Width: | Height: | Size: 4.6 MiB |
BIN
public/photos/Common1/original/IMG_20220619_135745.jpg
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0092.JPG
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0099.JPG
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0100.JPG
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0102.JPG
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0103.JPG
Normal file
|
After Width: | Height: | Size: 3.8 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0104.JPG
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0106.JPG
Normal file
|
After Width: | Height: | Size: 4.1 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0107.JPG
Normal file
|
After Width: | Height: | Size: 3 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0108.JPG
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0109.JPG
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0110.JPG
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0112.JPG
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0113.JPG
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0114.JPG
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0116.JPG
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0119.JPG
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0120.JPG
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0122.JPG
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0123.JPG
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0124.JPG
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0125.JPG
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0126.JPG
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0133.JPG
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0134.JPG
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0135.JPG
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0136.JPG
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0137.JPG
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0138.JPG
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0139.JPG
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0140.JPG
Normal file
|
After Width: | Height: | Size: 4.4 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0141.JPG
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0143.JPG
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0145.JPG
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0146.JPG
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0147.JPG
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0148.JPG
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0149.JPG
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0150.JPG
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0152.JPG
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0153.JPG
Normal file
|
After Width: | Height: | Size: 4.6 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0154.JPG
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0155.JPG
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0156.JPG
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0157.JPG
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0160.JPG
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0162.JPG
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0163.JPG
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0164.JPG
Normal file
|
After Width: | Height: | Size: 3 MiB |