first commit

This commit is contained in:
Fabio 2026-02-26 11:48:04 +01:00
commit 179d206910
154 changed files with 6084 additions and 0 deletions

6
.env Normal file
View 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
View file

@ -0,0 +1,3 @@
node_modules/
thumbs/
db.json

69
README.md Normal file
View 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
View 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
View file

@ -0,0 +1 @@
{"photos":[]}

381
api_v1/scanphoto.js Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

28
package.json Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,200 @@
/* =========================================
Variabili globali
========================================= */
:root {
--header-height: 60px; /* cambia se il tuo header è più alto/basso */
}
/* =========================================
MAPPA GLOBALE (contenitore sotto lheader)
========================================= */
.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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,88 @@
/* ===============================
MAPPA GLOBALE
=============================== */
/* La mappa occupa tutto lo schermo SOTTO lheader */
.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
View 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 allarea */
#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 tuttintorno */
}
.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 lutente 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; }

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 B

177
public/index.html Normal file
View 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">&lt;</button>
<button class="modal-nav-btn next" id="modalNext" type="button" aria-label="Successiva">&gt;</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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 lheader, 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
View 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 lalias
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 allzoom 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 = toRad(lat2 - lat1);
const = toRad(lng2 - lng1);
const a = Math.sin(/2) ** 2 +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(/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
View 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(); });

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Some files were not shown because too many files have changed in this diff Show more