first commit
6
.env
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
BASE_URL=https://prova.patachina.it
|
||||
SERVER_PORT=4000
|
||||
EMAIL=fabio@gmail.com
|
||||
PASSWORD=master66
|
||||
JWT_SECRET=123456789
|
||||
JWT_EXPIRES=1h
|
||||
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
|
||||
|
||||
## 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/)
|
||||
|
||||
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":[]}
|
||||
381
api_v1/scanphoto.js
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
/* eslint-disable no-console */
|
||||
require('dotenv').config();
|
||||
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const ExifReader = require('exifreader');
|
||||
const sharp = require('sharp');
|
||||
const axios = require('axios');
|
||||
const { exec } = require('child_process');
|
||||
|
||||
// IMPORT GEO.JS
|
||||
const loc = require('./geo.js');
|
||||
|
||||
const BASE_URL = process.env.BASE_URL;
|
||||
const EMAIL = process.env.EMAIL;
|
||||
const PASSWORD = process.env.PASSWORD;
|
||||
|
||||
const SEND_PHOTOS = (process.env.SEND_PHOTOS || 'true').toLowerCase() === 'true';
|
||||
const WRITE_INDEX = (process.env.WRITE_INDEX || 'true').toLowerCase() === 'true';
|
||||
const WEB_ROOT = process.env.WEB_ROOT || 'public';
|
||||
const INDEX_PATH = process.env.INDEX_PATH || path.posix.join('photos', 'index.json');
|
||||
|
||||
const SUPPORTED_EXTS = new Set([
|
||||
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
|
||||
'.mp4', '.mov', '.m4v'
|
||||
]);
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// UTILS
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// GPS — FOTO
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// GPS — VIDEO (exiftool)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function extractGpsWithExiftool(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);
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// AUTH / POST
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// VIDEO: ffmpeg thumbnail
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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());
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// VIDEO: ffprobe metadata
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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({});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// THUMBNAILS IMMAGINI
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// SCANSIONE RICORSIVA
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
async function scanDir(dirAbs, userName, results = []) {
|
||||
const dirEntries = await fsp.readdir(dirAbs, { withFileTypes: true });
|
||||
|
||||
for (const dirent of dirEntries) {
|
||||
const absPath = path.join(dirAbs, dirent.name);
|
||||
|
||||
if (dirent.isDirectory()) {
|
||||
await scanDir(absPath, userName, results);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ext = path.extname(dirent.name).toLowerCase();
|
||||
if (!SUPPORTED_EXTS.has(ext)) continue;
|
||||
console.log("Elaboro:", absPath);
|
||||
const isVideo = ['.mp4', '.mov', '.m4v'].includes(ext);
|
||||
|
||||
const relFile = toPosix(path.relative(WEB_ROOT, absPath));
|
||||
const relDir = toPosix(path.posix.dirname(relFile));
|
||||
|
||||
const relThumbDir = relDir.replace(/original/i, 'thumbs');
|
||||
const absThumbDir = path.join(WEB_ROOT, relThumbDir);
|
||||
await fsp.mkdir(absThumbDir, { recursive: true });
|
||||
|
||||
const baseName = path.parse(dirent.name).name;
|
||||
|
||||
const absThumbMin = path.join(absThumbDir, `${baseName}_min.jpg`);
|
||||
const absThumbAvg = path.join(absThumbDir, `${baseName}_avg.jpg`);
|
||||
|
||||
if (isVideo) {
|
||||
await createVideoThumbnail(absPath, absThumbMin, absThumbAvg);
|
||||
} else {
|
||||
await createThumbnails(absPath, absThumbMin, absThumbAvg);
|
||||
}
|
||||
|
||||
const relThumbMin = toPosix(path.relative(WEB_ROOT, absThumbMin));
|
||||
const relThumbAvg = toPosix(path.relative(WEB_ROOT, 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 = null;
|
||||
|
||||
if (isVideo) {
|
||||
gps = await extractGpsWithExiftool(absPath);
|
||||
} else {
|
||||
gps = extractGpsFromExif(tags);
|
||||
}
|
||||
|
||||
let width = null, height = null, size_bytes = null, duration = null;
|
||||
|
||||
const st = await fsp.stat(absPath);
|
||||
size_bytes = st.size;
|
||||
|
||||
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(relFile);
|
||||
|
||||
const location = gps ? await loc(gps.lng, gps.lat) : null;
|
||||
|
||||
results.push({
|
||||
id,
|
||||
name: dirent.name,
|
||||
path: relFile,
|
||||
thub1: relThumbMin,
|
||||
thub2: relThumbAvg,
|
||||
gps,
|
||||
data: timeRaw,
|
||||
taken_at: takenAtIso,
|
||||
mime_type,
|
||||
width,
|
||||
height,
|
||||
size_bytes,
|
||||
duration: isVideo ? duration : null,
|
||||
location,
|
||||
user: userName
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// MAIN
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
async function scanPhoto(dir, userName) {
|
||||
try {
|
||||
const absDir = path.isAbsolute(dir) ? dir : path.join(process.cwd(), dir);
|
||||
const photos = await scanDir(absDir, userName);
|
||||
|
||||
if (SEND_PHOTOS && BASE_URL) {
|
||||
for (const p of photos) {
|
||||
try {
|
||||
await postWithAuth(`${BASE_URL}/photos`, p);
|
||||
} catch (err) {
|
||||
console.error('Errore invio:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
if (WRITE_INDEX) {
|
||||
const absIndexPath = path.join(WEB_ROOT, INDEX_PATH);
|
||||
await fsp.mkdir(path.dirname(absIndexPath), { recursive: true });
|
||||
await fsp.writeFile(absIndexPath, JSON.stringify(photos, null, 2), 'utf8');
|
||||
}
|
||||
*/
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
return photos;
|
||||
|
||||
} catch (e) {
|
||||
console.error('Errore generale scanPhoto:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = scanPhoto;
|
||||
255
api_v1/server.js
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
require('dotenv').config();
|
||||
|
||||
const fs = require('fs');
|
||||
//const bodyParser = require('body-parser');
|
||||
const jsonServer = require('json-server');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcrypt');
|
||||
const path = require('path');
|
||||
const scanPhoto = require('./scanphoto.js');
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// 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(bodyParser.urlencoded({ extended: true }));
|
||||
//server.use(bodyParser.json());
|
||||
server.use(jsonServer.bodyParser);
|
||||
|
||||
// -----------------------------------------------------
|
||||
// JWT HELPERS
|
||||
// -----------------------------------------------------
|
||||
function createToken(payload) {
|
||||
return jwt.sign(payload, SECRET_KEY, { expiresIn: EXPIRES_IN });
|
||||
}
|
||||
|
||||
// Denylist per token revocati fino a scadenza
|
||||
// token(string) -> exp(secondi epoch)
|
||||
const denylist = new Map();
|
||||
|
||||
function addToDenylist(token) {
|
||||
try {
|
||||
const decoded = jwt.decode(token);
|
||||
const exp = decoded?.exp || Math.floor(Date.now() / 1000) + 60; // fallback 60s
|
||||
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;
|
||||
// pulizia automatica entry scadute
|
||||
if (exp * 1000 < Date.now()) {
|
||||
denylist.delete(token);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Versione SINCRONA: lancia errore se il token non è valido
|
||||
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
|
||||
// -----------------------------------------------------
|
||||
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 (PUBBLICO - usa Authorization se presente)
|
||||
// -----------------------------------------------------
|
||||
server.post('/auth/logout', (req, res) => {
|
||||
const auth = req.headers.authorization || '';
|
||||
const [scheme, token] = auth.split(' ');
|
||||
|
||||
if (scheme === 'Bearer' && token) {
|
||||
// Revoca il token fino alla sua scadenza
|
||||
addToDenylist(token);
|
||||
}
|
||||
// 204: nessun contenuto, logout idempotente
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// JWT MIDDLEWARE (TUTTO IL RESTO È PROTETTO)
|
||||
// -----------------------------------------------------
|
||||
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' });
|
||||
}
|
||||
|
||||
// Blocca token revocati (logout)
|
||||
if (isRevoked(token)) {
|
||||
return res.status(401).json({ status: 401, message: 'Token revoked' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = verifyToken(token);
|
||||
req.user = decoded; // { id, email, name, iat, exp }
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ status: 401, message: 'Error: access_token is not valid' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// FILTRO AUTOMATICO PER USER
|
||||
// -----------------------------------------------------
|
||||
server.use((req, res, next) => {
|
||||
if (req.method === 'GET' && req.user) {
|
||||
req.query.user = req.user.name;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// SCAN FOTO (PER-UTENTE)
|
||||
// -----------------------------------------------------
|
||||
server.get('/scan', async (req, res) => {
|
||||
try {
|
||||
resetDB();
|
||||
|
||||
const userFolder = `./public/photos/${req.user.name}/original`;
|
||||
|
||||
await scanPhoto(userFolder, req.user.name);
|
||||
|
||||
res.send({
|
||||
status: 'Ricaricato',
|
||||
user: req.user.name,
|
||||
folder: userFolder
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Errore scan:', err);
|
||||
res.status(500).json({ error: 'Errore durante lo scan', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// FILE STATICI (hardening path traversal)
|
||||
// -----------------------------------------------------
|
||||
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
|
||||
// -----------------------------------------------------
|
||||
server.get('/initDB', (req, res) => {
|
||||
resetDB();
|
||||
res.send({ status: 'DB resettato' });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// ROUTER JSON-SERVER
|
||||
// -----------------------------------------------------
|
||||
server.use(router);
|
||||
|
||||
// -----------------------------------------------------
|
||||
// START SERVER
|
||||
// -----------------------------------------------------
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Auth API server running on port ${PORT} ...`);
|
||||
});
|
||||
|
||||
// (Opzionale) pulizia periodica denylist per contenere la memoria
|
||||
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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
65
public/admin.html
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<!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>
|
||||
|
||||
<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: 9998; /* 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(150px, 1fr)); /* leggermente più piccole */
|
||||
gap: 6px; /* SPACING RIDOTTO */
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 8px; /* 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: 6px;
|
||||
right: 6px;
|
||||
background: rgba(0,0,0,0.55);
|
||||
color: white;
|
||||
padding: 3px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
84
public/css/header.css
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
header {
|
||||
padding: 10px 15px;
|
||||
background: #333;
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: sticky; /* o fixed se preferisci header sempre fisso */
|
||||
top: 0;
|
||||
z-index: 100; /* > mappa (che sta a 50) */
|
||||
|
||||
}
|
||||
|
||||
.top-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 22px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 22px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
color: white; /* 🔥 questo mancava */
|
||||
}
|
||||
|
||||
/* Testo solo per screen reader */
|
||||
.sr-only {
|
||||
position: absolute !important;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0,0,1px,1px);
|
||||
white-space: nowrap; border: 0;
|
||||
}
|
||||
|
||||
/* Bottone */
|
||||
.icon-btn.logout-btn {
|
||||
--size: 36px;
|
||||
--bg-hover: rgba(255,255,255,0.12); /* hover chiaro su sfondo nero */
|
||||
--ring: rgba(255,255,255,0.35); /* focus ring chiaro */
|
||||
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: background-color .15s ease, box-shadow .15s ease, transform .05s ease;
|
||||
}
|
||||
|
||||
.icon-btn.logout-btn:hover { background: var(--bg-hover); }
|
||||
.icon-btn.logout-btn:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--ring); }
|
||||
.icon-btn.logout-btn:active { transform: translateY(1px); }
|
||||
|
||||
/* PNG bianco: se l'originale è scuro/nero su trasparente */
|
||||
.logout-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: block;
|
||||
filter: brightness(0) invert(1); /* → bianco */
|
||||
/* facoltativo, per nitidezza */
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
|
||||
/* Se hai una versione retina 2x, usa srcset nell'HTML */
|
||||
44
public/css/infoPanel.css
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
.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;
|
||||
}
|
||||
|
||||
.info-panel.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.info-panel h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.info-row b {
|
||||
display: inline-block;
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.info-map {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
margin-top: 15px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.info-spacer {
|
||||
height: 16px; /* o 20px se vuoi più spazio */
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
88
public/css/map.css
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/* ===============================
|
||||
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);
|
||||
}
|
||||
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 |
177
public/index.html
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
<!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">
|
||||
<!-- Testo accessibile (solo per screen reader) -->
|
||||
<span class="sr-only">Logout</span>
|
||||
</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>
|
||||
121
public/js/bottomSheet.js
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// ===============================
|
||||
// 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 = (typeof toAbsoluteUrl === "function")
|
||||
? toAbsoluteUrl(p.thub2 || p.thub1 || p.path)
|
||||
: (p.thub2 || p.thub1 || p.path);
|
||||
|
||||
const originalUrl = (typeof toAbsoluteUrl === "function")
|
||||
? toAbsoluteUrl(p.path)
|
||||
: p.path;
|
||||
|
||||
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 = (typeof toAbsoluteUrl === "function")
|
||||
? toAbsoluteUrl(photo.thub2 || photo.thub1)
|
||||
: (photo.thub2 || photo.thub1);
|
||||
|
||||
const originalUrl = (typeof toAbsoluteUrl === "function")
|
||||
? toAbsoluteUrl(photo.path)
|
||||
: photo.path;
|
||||
|
||||
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;
|
||||
45
public/js/config.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// ===============================
|
||||
// CONFIG DINAMICA DAL SERVER
|
||||
// ===============================
|
||||
|
||||
window.BASE_URL = null;
|
||||
window.PHOTOS_URL = null;
|
||||
window.MEDIA_BASE_ORIGIN = null;
|
||||
window.configReady = false;
|
||||
|
||||
// Carica /config dal backend
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/config');
|
||||
const cfg = await res.json();
|
||||
|
||||
window.BASE_URL = cfg.baseUrl;
|
||||
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);
|
||||
|
||||
window.configReady = true;
|
||||
|
||||
} catch (err) {
|
||||
console.error("[config] Errore nel caricamento della config:", err);
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
|
||||
// ===============================
|
||||
// Utility: normalizza URL dei media
|
||||
// ===============================
|
||||
function toAbsoluteUrl(pathOrUrl) {
|
||||
if (!pathOrUrl) return '';
|
||||
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
|
||||
|
||||
const normalized = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`;
|
||||
return `${window.MEDIA_BASE_ORIGIN}${normalized}`;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
139
public/js/gallery.js
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// ===============================
|
||||
// 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;
|
||||
|
||||
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();
|
||||
}
|
||||
})();
|
||||
212
public/js/main.js
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
// ===============================
|
||||
// AVVIO
|
||||
// ===============================
|
||||
console.log("main.js avviato");
|
||||
|
||||
// ===============================
|
||||
// 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 {
|
||||
// Sostituisci con la tua variabile dell'istanza L.map, se diversa
|
||||
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);
|
||||
});
|
||||
})();
|
||||
|
||||
// ===============================
|
||||
// LOGIN AUTOMATICO SU INDEX
|
||||
// ===============================
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
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");
|
||||
document.getElementById("loginModal").style.display = "flex";
|
||||
return;
|
||||
}
|
||||
|
||||
// 4) Token valido → salva e carica gallery
|
||||
window.token = savedToken;
|
||||
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;
|
||||
|
||||
// ===============================
|
||||
// MENU ⋮
|
||||
// ===============================
|
||||
const optionsBtn = document.getElementById("optionsBtn");
|
||||
const optionsSheetEl = document.getElementById("optionsSheet");
|
||||
|
||||
optionsBtn?.addEventListener("click", () => {
|
||||
optionsSheetEl?.classList.add("open");
|
||||
});
|
||||
|
||||
// ===============================
|
||||
// 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;
|
||||
|
||||
optionsSheetEl?.classList.remove("open");
|
||||
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
|
||||
document.getElementById("loginModal").style.display = "none";
|
||||
|
||||
// Carica gallery
|
||||
loadPhotos();
|
||||
|
||||
} catch (err) {
|
||||
console.error("Errore login:", err);
|
||||
errorEl.textContent = "Errore di connessione al server";
|
||||
}
|
||||
});
|
||||
224
public/js/mapGlobal.js
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
// ===============================
|
||||
// MAPPA GLOBALE — stile Google Photos Web
|
||||
// - Cluster numerici che si spezzano con lo zoom
|
||||
// - Click su cluster → zoom progressivo e, quando "pochi", strip in basso
|
||||
// - Click su marker singolo → MODAL immediato (niente bottom)
|
||||
// - Raggruppamento dinamico basato su raggio in pixel → metri
|
||||
// ===============================
|
||||
|
||||
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);
|
||||
|
||||
// —— Parametri "alla GP" regolabili ————————————————————————————————
|
||||
const RADIUS_PX = 50; // raggio "visivo" sullo schermo per raggruppare vicini
|
||||
const DISABLE_CLUSTER_AT_ZOOM = 18; // oltre questo zoom i cluster si spaccano
|
||||
const OPEN_STRIP_CHILDREN_MAX = 20; // se un cluster ha <= N marker → apri strip invece di continuare a zoomare
|
||||
// ————————————————————————————————————————————————————————————————
|
||||
|
||||
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");
|
||||
|
||||
// Chiudi mappa
|
||||
if (isOpen) {
|
||||
mapDiv.classList.remove("open");
|
||||
gallery?.classList.remove("hidden");
|
||||
window.closeBottomSheet?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Apri mappa e nascondi galleria
|
||||
mapDiv.classList.add("open");
|
||||
gallery?.classList.add("hidden");
|
||||
|
||||
|
||||
// 👇 NUOVO: se la mappa è già stata creata in passato, riallinea l’alias
|
||||
if (window.globalMap) {
|
||||
window.leafletMapInstance = window.globalMap;
|
||||
}
|
||||
|
||||
|
||||
// Attendi dimensioni reali (evita init a height:0)
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
let tries = 0;
|
||||
while (mapDiv.getBoundingClientRect().height < 50 && tries < 10) {
|
||||
await new Promise(r => setTimeout(r, 30));
|
||||
tries++;
|
||||
}
|
||||
|
||||
// Inizializza solo la prima volta
|
||||
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 come Google Photos
|
||||
window.globalMarkers = L.markerClusterGroup({
|
||||
showCoverageOnHover: false,
|
||||
spiderfyOnMaxZoom: true,
|
||||
disableClusteringAtZoom: DISABLE_CLUSTER_AT_ZOOM
|
||||
});
|
||||
|
||||
// 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) {
|
||||
// Converti i child markers in lista foto e apri la strip
|
||||
const photos = childMarkers
|
||||
.map(m => m.__photo)
|
||||
.filter(Boolean);
|
||||
|
||||
if (photos.length > 1) {
|
||||
window.openBottomSheet?.(photos);
|
||||
} else if (photos.length === 1) {
|
||||
// Singola → modal diretto
|
||||
openPhotoModal(photos[0]);
|
||||
}
|
||||
} else {
|
||||
// Continua a zoomare sui bounds del cluster
|
||||
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();
|
||||
}
|
||||
|
||||
// Fix dimensioni dopo apertura
|
||||
setTimeout(() => window.globalMap?.invalidateSize?.(), 120);
|
||||
}
|
||||
|
||||
// —— Icona thumbnail per i marker ———————————————————————————————
|
||||
function createPhotoIcon(photo) {
|
||||
const thumb = (typeof toAbsoluteUrl === "function")
|
||||
? toAbsoluteUrl(photo?.thub2 || photo?.thub1)
|
||||
: (photo?.thub2 || photo?.thub1);
|
||||
|
||||
return L.icon({
|
||||
iconUrl: thumb || "",
|
||||
iconSize: [56, 56],
|
||||
iconAnchor: [28, 28],
|
||||
className: "photo-marker"
|
||||
});
|
||||
}
|
||||
|
||||
// —— Modal diretto per singola foto ————————————————————————————
|
||||
function openPhotoModal(photo) {
|
||||
const thumb = (typeof toAbsoluteUrl === "function")
|
||||
? toAbsoluteUrl(photo?.thub2 || photo?.thub1 || photo?.path)
|
||||
: (photo?.thub2 || photo?.thub1 || photo?.path);
|
||||
|
||||
const original = (typeof toAbsoluteUrl === "function")
|
||||
? toAbsoluteUrl(photo?.path)
|
||||
: photo?.path;
|
||||
|
||||
window.closeBottomSheet?.();
|
||||
window.openModal?.(original, thumb, photo);
|
||||
}
|
||||
|
||||
// —— Raggio in metri a partire da N pixel all’zoom attuale ——————————
|
||||
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);
|
||||
}
|
||||
|
||||
// —— Distanza (metri) ————————————————————————————————————————
|
||||
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));
|
||||
}
|
||||
|
||||
// —— Raggruppa foto entro raggio (metri) ——————————————————————
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
// —— Disegno/refresh marker ————————————————————————————————
|
||||
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 || ""
|
||||
});
|
||||
|
||||
// Alleghiamo la foto al marker per recuperarla in eventi cluster
|
||||
marker.__photo = photo;
|
||||
|
||||
// Click su marker: calcola raggio dinamico e apri strip o modal
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// Se la gallery si aggiorna (loadPhotos), ridisegna marker
|
||||
const originalRefresh = window.refreshGallery;
|
||||
window.refreshGallery = function wrappedRefreshGallery(...args) {
|
||||
try { originalRefresh?.apply(this, args); } catch (_) {}
|
||||
if (window.globalMap && window.globalMarkers) {
|
||||
redrawPhotoMarkers();
|
||||
}
|
||||
};
|
||||
});
|
||||
350
public/js/modal.js
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
// ===============================
|
||||
// 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) {
|
||||
return (typeof toAbsoluteUrl === 'function') ? toAbsoluteUrl(path) : path;
|
||||
}
|
||||
|
||||
function mediaUrlsFromPhoto(photo) {
|
||||
const original = absUrl(photo?.path);
|
||||
const preview = absUrl(photo?.thub2 || photo?.thub1 || photo?.path);
|
||||
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/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 |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0165.JPG
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0166.JPG
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0167.JPG
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0170.JPG
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0171.JPG
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0172.JPG
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0174.JPG
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0175.JPG
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0176.JPG
Normal file
|
After Width: | Height: | Size: 951 KiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0177.JPG
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0178.JPG
Normal file
|
After Width: | Height: | Size: 4.4 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0179.JPG
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0180.JPG
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0182.JPG
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0183.JPG
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0185.JPG
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0188.JPG
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0190.JPG
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0193.JPG
Normal file
|
After Width: | Height: | Size: 1.8 MiB |