first commit

This commit is contained in:
Fabio 2026-03-05 17:07:30 +01:00
commit d0a5e42899
175 changed files with 6688 additions and 0 deletions

7
.env Normal file
View file

@ -0,0 +1,7 @@
BASE_URL=https://prova.patachina.it
SERVER_PORT=4000
EMAIL=fabio@gmail.com
PASSWORD=master66
JWT_SECRET=123456789
JWT_EXPIRES=1h
PATH_FULL=true

3
.gitignore vendored Normal file
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 S6J
## Installazione
clonare questa repo e installare tutte le dipendenze con `npm ci`
## Start/Stop servers
| Description | Script |
| ------------------------- | -------------------- |
| Start server senza auth | `npm start-no-auth` |
| Start server con auth | `npm run start` |
## Tools
| Description | Script |
| ------------------------------ | ------------------- |
| Generate user hashed passwords | `npm run hash` |
[json-server api reference](https://github.com/typicode/json-server)
## Come usarlo
clonare e poi installare con
```
npm ci
```
nel file .env ci sono tutti i dati da modificare
poi inserire in user.json user e password utilizzati per fare il login
la password da inserire è criptata e viene generata con npm run hash
il nome viene utilizzato come cartella da scansionare, si trova dentro photos
es:
```
name: Fabio
public/photos
└── Fabio
└── original
└── 2017Irlanda19-29ago
├── IMG_0092.JPG
├── IMG_0099.JPG
├── IMG_0100.JPG
```
poi dentro Fabio genererà thumbs con tutti i thumbs
- npm run start
- su IP:4000 ci sarà la galleria e andando su impostazioni si potrà fare lo scan di tutte le foto
dopo aver fatto lo scan è possibile richiedere il json al server con tutte le informazioni anche senza autorizzazione
basta farlo partire con npm run start-no-auth e le info si possono vedere con
ip:4000/photos
- npm start
---
Inspired in this [post](https://www.techiediaries.com/fake-api-jwt-json-server/) by [Techiediaries](https://www.techiediaries.com/)

17
api_v1/config.js Normal file
View file

@ -0,0 +1,17 @@
require('dotenv').config();
const path = require('path');
module.exports = {
BASE_URL: process.env.BASE_URL,
EMAIL: process.env.EMAIL,
PASSWORD: process.env.PASSWORD,
SEND_PHOTOS: (process.env.SEND_PHOTOS || 'true').toLowerCase() === 'true',
WRITE_INDEX: (process.env.WRITE_INDEX || 'true').toLowerCase() === 'true',
WEB_ROOT: process.env.WEB_ROOT || 'public',
PATH_FULL: (process.env.PATH_FULL || 'false').toLowerCase() === 'true',
INDEX_PATH: process.env.INDEX_PATH || path.posix.join('photos', 'index.json'),
SUPPORTED_EXTS: new Set([
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
'.mp4', '.mov', '.m4v'
])
};

96
api_v1/geo.js Normal file
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":[]}

69
api_v1/scanner/gps.js Normal file
View file

@ -0,0 +1,69 @@
const { exec } = require('child_process');
// -----------------------------------------------------------------------------
// FOTO: GPS da ExifReader
// -----------------------------------------------------------------------------
function extractGpsFromExif(tags) {
if (!tags?.gps) return null;
const lat = tags.gps.Latitude;
const lng = tags.gps.Longitude;
const alt = tags.gps.Altitude;
if (lat == null || lng == null) return null;
return {
lat: Number(lat),
lng: Number(lng),
alt: alt != null ? Number(alt) : null
};
}
// -----------------------------------------------------------------------------
// VIDEO: GPS via exiftool (VERSIONE ORIGINALE CHE FUNZIONA)
// -----------------------------------------------------------------------------
function extractGpsWithExiftool(videoPath) {
//console.log(videoPath);
return new Promise((resolve) => {
const cmd = `exiftool -n -G1 -a -gps:all -quicktime:all -user:all "${videoPath}"`;
exec(cmd, (err, stdout) => {
if (err || !stdout) return resolve(null);
// 1) GPS Coordinates : <lat> <lng>
const userData = stdout.match(/GPS Coordinates\s*:\s*([0-9.\-]+)\s+([0-9.\-]+)/i);
if (userData) {
return resolve({
lat: Number(userData[1]),
lng: Number(userData[2]),
alt: null
});
}
// 2) GPSLatitude / GPSLongitude
const lat1 = stdout.match(/GPSLatitude\s*:\s*([0-9.\-]+)/i);
const lng1 = stdout.match(/GPSLongitude\s*:\s*([0-9.\-]+)/i);
if (lat1 && lng1) {
return resolve({
lat: Number(lat1[1]),
lng: Number(lng1[1]),
alt: null
});
}
// 3) GPSCoordinates : <lat> <lng>
const coords = stdout.match(/GPSCoordinates\s*:\s*([0-9.\-]+)\s+([0-9.\-]+)/i);
if (coords) {
return resolve({
lat: Number(coords[1]),
lng: Number(coords[2]),
alt: null
});
}
resolve(null);
});
});
}
module.exports = { extractGpsFromExif, extractGpsWithExiftool };

View file

@ -0,0 +1,47 @@
// scanner/indexStore.js
const path = require('path');
const fsp = require('fs/promises');
const { WEB_ROOT, INDEX_PATH } = require('../config');
// NOTE: siamo in .../api_v1/scanner → per arrivare alla root progetto servono due '..'
const absIndexPath = path.resolve(__dirname, '..', '..', WEB_ROOT, INDEX_PATH);
const absIndexTmp = absIndexPath + '.tmp';
const PRETTY = process.env.INDEX_PRETTY === 'true';
/**
* Carica l'indice. Formato canonico: mappa { [id]: meta }.
* Retro-compat: se il file è un array, convertilo in mappa.
*/
async function loadPreviousIndex() {
try {
const raw = await fsp.readFile(absIndexPath, 'utf8');
const json = JSON.parse(raw);
if (Array.isArray(json)) {
const map = {};
for (const p of json) {
if (p && p.id) map[p.id] = p;
}
return map;
}
return json && typeof json === 'object' ? json : {};
} catch {
return {};
}
}
/**
* Salva l'indice (mappa) in modo atomico: scrive su .tmp e poi rename.
*/
async function saveIndex(indexMap) {
const dir = path.dirname(absIndexPath);
await fsp.mkdir(dir, { recursive: true });
const data = PRETTY ? JSON.stringify(indexMap, null, 2) : JSON.stringify(indexMap);
await fsp.writeFile(absIndexTmp, data, 'utf8');
await fsp.rename(absIndexTmp, absIndexPath);
}
module.exports = { loadPreviousIndex, saveIndex, absIndexPath };

View file

@ -0,0 +1,60 @@
const axios = require('axios');
const { BASE_URL, EMAIL, PASSWORD, SEND_PHOTOS } = require('../config');
let cachedToken = null;
async function getToken(force = false) {
if (!SEND_PHOTOS) return null;
if (cachedToken && !force) return cachedToken;
try {
const res = await axios.post(`${BASE_URL}/auth/login`, {
email: EMAIL,
password: PASSWORD
});
cachedToken = res.data.token;
return cachedToken;
} catch (err) {
console.error('ERRORE LOGIN:', err.message);
return null;
}
}
async function postWithAuth(url, payload) {
if (!SEND_PHOTOS) return;
let token = await getToken();
if (!token) throw new Error('Token assente');
try {
await axios.post(url, payload, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
timeout: 20000,
});
} catch (err) {
if (err.response && err.response.status === 401) {
token = await getToken(true);
if (!token) throw err;
await axios.post(url, payload, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
timeout: 20000,
});
} else {
throw err;
}
}
}
module.exports = postWithAuth;

View file

@ -0,0 +1,117 @@
const path = require('path');
const fsp = require('fs/promises');
const ExifReader = require('exifreader');
const sharp = require('sharp');
const { sha256, inferMimeFromExt, parseExifDateUtc } = require('./utils');
const { extractGpsFromExif, extractGpsWithExiftool } = require('./gps');
const { createVideoThumbnail, createThumbnails } = require('./thumbs');
const { probeVideo } = require('./video');
const loc = require('../geo.js');
const { WEB_ROOT, PATH_FULL } = require('../config');
async function processFile(userName, cartella, fileRelPath, absPath, ext, st) {
const isVideo = ['.mp4', '.mov', '.m4v'].includes(ext);
console.log(
`PROCESSING → user=${userName} | cartella=${cartella} | file=${fileRelPath}`
);
const thumbBase = path.join(
WEB_ROOT,
'photos',
userName,
'thumbs',
cartella,
path.dirname(fileRelPath)
);
await fsp.mkdir(thumbBase, { recursive: true });
const baseName = path.parse(fileRelPath).name;
const absThumbMin = path.join(thumbBase, `${baseName}_min.jpg`);
const absThumbAvg = path.join(thumbBase, `${baseName}_avg.jpg`);
if (isVideo) {
await createVideoThumbnail(absPath, absThumbMin, absThumbAvg);
} else {
await createThumbnails(absPath, absThumbMin, absThumbAvg);
}
let tags = {};
try { tags = await ExifReader.load(absPath, { expanded: true }); } catch {}
const timeRaw = tags?.exif?.DateTimeOriginal?.value?.[0] || null;
const takenAtIso = parseExifDateUtc(timeRaw);
let gps = isVideo
? await extractGpsWithExiftool(absPath)
: extractGpsFromExif(tags);
let width = null, height = null, duration = null;
if (isVideo) {
const info = await probeVideo(absPath);
const stream = info.streams?.find(s => s.width && s.height);
if (stream) {
width = stream.width;
height = stream.height;
}
duration = info.format?.duration || null;
} else {
try {
const meta = await sharp(absPath).metadata();
width = meta.width || null;
height = meta.height || null;
} catch {}
}
const mime_type = inferMimeFromExt(ext);
const id = sha256(`${userName}/${cartella}/${fileRelPath}`);
const location = gps ? await loc(gps.lng, gps.lat) : null;
//
// --- GESTIONE PATH FULL / RELATIVI ---
//
// relativi (comportamento attuale)
const relPath = fileRelPath;
const relThub1 = fileRelPath.replace(/\.[^.]+$/, '_min.jpg');
const relThub2 = fileRelPath.replace(/\.[^.]+$/, '_avg.jpg');
// completi (solo se PATH_FULL = true)
const fullPath = PATH_FULL
? path.posix.join('/photos', userName, cartella, fileRelPath)
: relPath;
const fullThub1 = PATH_FULL
? path.posix.join('/photos', userName, 'thumbs', cartella, relThub1)
: relThub1;
const fullThub2 = PATH_FULL
? path.posix.join('/photos', userName, 'thumbs', cartella, relThub2)
: relThub2;
return {
id,
user: userName,
cartella,
path: fullPath,
thub1: fullThub1,
thub2: fullThub2,
gps,
data: timeRaw,
taken_at: takenAtIso,
mime_type,
width,
height,
size_bytes: st.size,
mtimeMs: st.mtimeMs,
duration: isVideo ? duration : null,
location
};
}
module.exports = processFile;

View file

@ -0,0 +1,58 @@
// scanner/scanCartella.js
const path = require('path');
const fsp = require('fs/promises');
const processFile = require('./processFile');
const { sha256 } = require('./utils');
const { SUPPORTED_EXTS } = require('../config');
/**
* Scansiona ricorsivamente una cartella e ritorna SOLO i cambiamenti
* (nuovi/modificati) rispetto a previousIndex (mappa { id: meta }).
*/
async function scanCartella(userName, cartella, absCartella, previousIndex) {
const changes = [];
async function walk(currentAbs, relPath = '') {
const entries = await fsp.readdir(currentAbs, { withFileTypes: true });
for (const e of entries) {
const absPath = path.join(currentAbs, e.name);
if (e.isDirectory()) {
await walk(absPath, path.join(relPath, e.name));
continue;
}
const ext = path.extname(e.name).toLowerCase();
if (!SUPPORTED_EXTS.has(ext)) continue;
const fileRelPath = relPath ? `${relPath}/${e.name}` : e.name;
const id = sha256(`${userName}/${cartella}/${fileRelPath}`);
const st = await fsp.stat(absPath);
const prev = previousIndex[id];
const unchanged =
prev &&
prev.size_bytes === st.size &&
prev.mtimeMs === st.mtimeMs;
if (unchanged) continue; // NOTE: skip "unchanged"
const meta = await processFile(
userName,
cartella,
fileRelPath,
absPath,
ext,
st
);
meta.id = id; // id sempre presente
changes.push(meta);
}
}
await walk(absCartella);
return changes;
}
module.exports = scanCartella;

134
api_v1/scanner/scanPhoto.js Normal file
View file

@ -0,0 +1,134 @@
// scanner/scanPhoto.js
const path = require('path');
const fsp = require('fs/promises');
const { loadPreviousIndex, saveIndex } = require('./indexStore');
const scanUserRoot = require('./scanUser');
const postWithAuth = require('./postWithAuth');
const {
WEB_ROOT,
SEND_PHOTOS,
BASE_URL,
WRITE_INDEX,
} = require('../config');
const COMMON = 'Common'; // Nome canonico e case-sensitive per la shared folder
/**
* Restituisce la prima directory esistente tra le candidate
* @param {string[]} candidates
* @returns {Promise<string|null>}
*/
async function firstExistingDir(candidates) {
for (const dir of candidates) {
try {
const st = await fsp.stat(dir);
if (st.isDirectory()) return dir;
} catch { /* ignore */ }
}
return null;
}
/**
* Scansione foto con indice separato:
* - Carica indice precedente (mappa { id: meta })
* - Produce SOLO i cambiamenti (nuovi/modificati)
* - Merge e salvataggio atomico dell'indice
* - POST verso /photos solo del delta (se abilitato)
* - Admin: include la cartella "Common"
*
* Nota path: usiamo un path ASSOLUTO per <root>/public/photos
* partendo da .../api_v1/scanner -> '..', '..' per salire alla root del progetto.
*/
async function scanPhoto(dir, userName) {
try {
const previousIndexMap = await loadPreviousIndex();
const nextIndexMap = { ...previousIndexMap };
// Path assoluto alla radice photos (es: <root>/public/photos)
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
let changes = [];
if (userName === 'Admin') {
// 1) Scansiona TUTTI gli utenti tranne la/e cartella/e common
let entries = [];
try {
entries = await fsp.readdir(photosRoot, { withFileTypes: true });
} catch (e) {
console.error(`[SCAN] photosRoot non accessibile: ${photosRoot}`, e.message);
entries = [];
}
for (const e of entries) {
if (!e.isDirectory()) continue;
// salta qualunque "common" (qualunque casing) per non doppiare
if (e.name.toLowerCase() === COMMON.toLowerCase()) continue;
const userDir = path.join(photosRoot, e.name, 'original');
try {
const st = await fsp.stat(userDir);
if (!st.isDirectory()) continue;
} catch {
continue;
}
const userChanges = await scanUserRoot(e.name, userDir, previousIndexMap);
for (const m of userChanges) nextIndexMap[m.id] = m;
changes.push(...userChanges);
}
// 2) Scansiona la cartella COMMON (case strict), con fallback legacy (solo lettura) a 'common'
const commonPreferred = path.join(photosRoot, COMMON, 'original'); // .../photos/Common/original
const commonLegacy = path.join(photosRoot, 'common', 'original'); // .../photos/common/original (legacy)
const commonDir = await firstExistingDir([commonPreferred, commonLegacy]);
if (commonDir) {
// Forziamo SEMPRE userName = 'Common' per ID/thumbnails coerenti in 'Common'
const commonChanges = await scanUserRoot(COMMON, commonDir, previousIndexMap);
for (const m of commonChanges) nextIndexMap[m.id] = m;
changes.push(...commonChanges);
console.log(`[SCAN] Common indicizzati da ${commonDir}: +${commonChanges.length}`);
} else {
console.log(`[SCAN] Nessuna cartella "${COMMON}" trovata sotto ${photosRoot} (atteso: ${commonPreferred})`);
}
} else {
// Non-Admin (per completezza; in server.js /scan è Admin-only)
const userDir = path.join(photosRoot, userName, 'original');
try {
const st = await fsp.stat(userDir);
if (st.isDirectory()) {
const userChanges = await scanUserRoot(userName, userDir, previousIndexMap);
for (const m of userChanges) nextIndexMap[m.id] = m;
changes.push(...userChanges);
}
} catch {
// utente senza dir 'original' -> nessuna modifica
}
}
// Salva indice (mappa) in modo atomico
if (WRITE_INDEX) {
await saveIndex(nextIndexMap);
}
// POST solo dei cambiamenti (delta)
if (SEND_PHOTOS && BASE_URL && changes.length) {
for (const p of changes) {
try {
await postWithAuth(`${BASE_URL}/photos`, p);
} catch (err) {
console.error('Errore invio:', err.message);
}
}
}
console.log(`SCAN COMPLETATA per utente ${userName}: ${changes.length} file aggiornati`);
return changes;
} catch (e) {
console.error('Errore generale scanPhoto:', e);
throw e;
}
}
module.exports = scanPhoto;

View file

@ -0,0 +1,64 @@
// scanner/scanUser.js
const path = require('path');
const fsp = require('fs/promises');
const scanCartella = require('./scanCartella');
const processFile = require('./processFile');
const { sha256 } = require('./utils');
const { SUPPORTED_EXTS } = require('../config');
/**
* Scansiona la root dell'utente (p.es. .../<user>/original) e:
* - indicizza i file direttamente al root (cartella virtuale "_root")
* - per ogni sottocartella, chiama scanCartella
*/
async function scanUserRoot(userName, userDir, previousIndex) {
const results = [];
const entries = await fsp.readdir(userDir, { withFileTypes: true });
// 1) File direttamente al root (virtual folder "_root")
for (const e of entries) {
if (e.isDirectory()) continue;
const ext = path.extname(e.name).toLowerCase();
if (!SUPPORTED_EXTS.has(ext)) continue;
const absPath = path.join(userDir, e.name);
const st = await fsp.stat(absPath);
const fileRelPath = e.name;
const cartella = '_root';
const id = sha256(`${userName}/${cartella}/${fileRelPath}`);
const prev = previousIndex[id];
const unchanged =
prev &&
prev.size_bytes === st.size &&
prev.mtimeMs === st.mtimeMs;
if (unchanged) continue;
const meta = await processFile(
userName,
cartella,
fileRelPath,
absPath,
ext,
st
);
meta.id = id;
results.push(meta);
}
// 2) Sottocartelle (comportamento classico)
for (const e of entries) {
if (!e.isDirectory()) continue;
const cartella = e.name;
const absCartella = path.join(userDir, cartella);
const files = await scanCartella(userName, cartella, absCartella, previousIndex);
results.push(...files);
}
return results;
}
module.exports = scanUserRoot;

31
api_v1/scanner/thumbs.js Normal file
View file

@ -0,0 +1,31 @@
const sharp = require('sharp');
const { exec } = require('child_process');
function createVideoThumbnail(videoPath, thumbMinPath, thumbAvgPath) {
return new Promise((resolve) => {
const cmd = `
ffmpeg -y -i "${videoPath}" -ss 00:00:01.000 -vframes 1 "${thumbAvgPath}" &&
ffmpeg -y -i "${thumbAvgPath}" -vf "scale=100:-1" "${thumbMinPath}"
`;
exec(cmd, () => resolve());
});
}
async function createThumbnails(filePath, thumbMinPath, thumbAvgPath) {
try {
await sharp(filePath)
.resize({ width: 100, height: 100, fit: 'inside', withoutEnlargement: true })
.withMetadata()
.toFile(thumbMinPath);
await sharp(filePath)
.resize({ width: 400, withoutEnlargement: true })
.withMetadata()
.toFile(thumbAvgPath);
} catch (err) {
console.error('Errore creazione thumbnails:', err.message, filePath);
}
}
module.exports = { createVideoThumbnail, createThumbnails };

41
api_v1/scanner/utils.js Normal file
View file

@ -0,0 +1,41 @@
const path = require('path');
const crypto = require('crypto');
function toPosix(p) {
return p.split(path.sep).join('/');
}
function sha256(s) {
return crypto.createHash('sha256').update(s).digest('hex');
}
function inferMimeFromExt(ext) {
switch (ext.toLowerCase()) {
case '.jpg':
case '.jpeg': return 'image/jpeg';
case '.png': return 'image/png';
case '.webp': return 'image/webp';
case '.heic':
case '.heif': return 'image/heic';
case '.mp4': return 'video/mp4';
case '.mov': return 'video/quicktime';
case '.m4v': return 'video/x-m4v';
default: return 'application/octet-stream';
}
}
function parseExifDateUtc(s) {
if (!s) return null;
const re = /^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$/;
const m = re.exec(s);
if (!m) return null;
const dt = new Date(Date.UTC(+m[1], +m[2]-1, +m[3], +m[4], +m[5], +m[6]));
return dt.toISOString();
}
module.exports = {
toPosix,
sha256,
inferMimeFromExt,
parseExifDateUtc
};

17
api_v1/scanner/video.js Normal file
View file

@ -0,0 +1,17 @@
const { exec } = require('child_process');
function probeVideo(videoPath) {
return new Promise((resolve) => {
const cmd = `ffprobe -v quiet -print_format json -show_format -show_streams "${videoPath}"`;
exec(cmd, (err, stdout) => {
if (err) return resolve({});
try {
resolve(JSON.parse(stdout));
} catch {
resolve({});
}
});
});
}
module.exports = { probeVideo };

306
api_v1/server.js Normal file
View file

@ -0,0 +1,306 @@
// server.js
require('dotenv').config();
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const jsonServer = require('json-server');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const scanPhoto = require('./scanner/scanPhoto.js');
const { WEB_ROOT, INDEX_PATH } = require('./config');
const SECRET_KEY = process.env.JWT_SECRET || '123456789';
const EXPIRES_IN = process.env.JWT_EXPIRES || '1h';
const PORT = process.env.SERVER_PORT || 4000;
const server = jsonServer.create();
// -----------------------------------------------------
// STATIC FILES
// -----------------------------------------------------
server.use(
jsonServer.defaults({
static: path.join(__dirname, '../public'),
})
);
// -----------------------------------------------------
// CONFIG ENDPOINT (PUBBLICO)
// -----------------------------------------------------
server.get('/config', (req, res) => {
res.json({
baseUrl: process.env.BASE_URL,
pathFull: process.env.PATH_FULL,
});
});
// -----------------------------------------------------
// ROUTER DB
// -----------------------------------------------------
let router;
if (fs.existsSync('./api_v1/db.json')) {
router = jsonServer.router('./api_v1/db.json');
} else {
const initialData = fs.readFileSync('api_v1/initialDB.json', 'utf8');
fs.writeFileSync('api_v1/db.json', initialData);
router = jsonServer.router('./api_v1/db.json');
}
// -----------------------------------------------------
// USERS DB
// -----------------------------------------------------
const userdb = JSON.parse(fs.readFileSync('./api_v1/users.json', 'utf-8'));
server.use(jsonServer.bodyParser);
// -----------------------------------------------------
// JWT HELPERS
// -----------------------------------------------------
function createToken(payload) {
return jwt.sign(payload, SECRET_KEY, { expiresIn: EXPIRES_IN });
}
const denylist = new Map();
function addToDenylist(token) {
try {
const decoded = jwt.decode(token);
const exp = decoded?.exp || Math.floor(Date.now() / 1000) + 60;
denylist.set(token, exp);
} catch {
denylist.set(token, Math.floor(Date.now() / 1000) + 60);
}
}
function isRevoked(token) {
const exp = denylist.get(token);
if (!exp) return false;
if (exp * 1000 < Date.now()) {
denylist.delete(token);
return false;
}
return true;
}
function verifyToken(token) {
return jwt.verify(token, SECRET_KEY);
}
function isAuthenticated({ email, password }) {
return (
userdb.users.findIndex(
(user) => user.email === email && bcrypt.compareSync(password, user.password)
) !== -1
);
}
// -----------------------------------------------------
// RESET DB (utility interna usata da /initDB)
// -----------------------------------------------------
function resetDB() {
const initialData = fs.readFileSync('api_v1/initialDB.json', 'utf8');
fs.writeFileSync('api_v1/db.json', initialData);
router.db.setState(JSON.parse(initialData));
console.log('DB resettato');
}
// -----------------------------------------------------
// HOME
// -----------------------------------------------------
server.get('/', (req, res) => {
res.sendFile(path.resolve('public/index.html'));
});
// -----------------------------------------------------
// LOGIN (PUBBLICO)
// -----------------------------------------------------
server.post('/auth/login', (req, res) => {
const { email, password } = req.body;
const user = userdb.users.find((u) => u.email === email);
if (!user || !bcrypt.compareSync(password, user.password)) {
return res.status(401).json({ status: 401, message: 'Incorrect email or password' });
}
const token = createToken({
id: user.id,
email: user.email,
name: user.name,
});
res.status(200).json({
token,
name: user.name,
});
});
// -----------------------------------------------------
// LOGOUT
// -----------------------------------------------------
server.post('/auth/logout', (req, res) => {
const auth = req.headers.authorization || '';
const [scheme, token] = auth.split(' ');
if (scheme === 'Bearer' && token) {
addToDenylist(token);
}
return res.status(204).end();
});
// -----------------------------------------------------
// JWT MIDDLEWARE (tutte le rotte tranne /auth/*)
// -----------------------------------------------------
server.use(/^(?!\/auth).*$/, (req, res, next) => {
const auth = req.headers.authorization || '';
const [scheme, token] = auth.split(' ');
if (scheme !== 'Bearer' || !token) {
return res.status(401).json({ status: 401, message: 'Bad authorization header' });
}
if (isRevoked(token)) {
return res.status(401).json({ status: 401, message: 'Token revoked' });
}
try {
const decoded = verifyToken(token);
req.user = decoded;
next();
} catch (err) {
return res
.status(401)
.json({ status: 401, message: 'Error: access_token is not valid' });
}
});
// -----------------------------------------------------
// FILTRO AUTOMATICO PER USER (GET)
// - Non-Admin: forzo user=[<nome>, 'Common'] così vedono anche la Common
// - Admin: vede tutto senza forzature
// -----------------------------------------------------
server.use((req, res, next) => {
if (req.method === 'GET' && req.user && req.user.name !== 'Admin') {
const u = req.user.name;
const q = req.query.user;
const base = q ? (Array.isArray(q) ? q : [q]) : [];
req.query.user = Array.from(new Set([...base, u, 'Common']));
}
next();
});
// -----------------------------------------------------
// SCAN FOTO
// - Admin: scansiona tutti gli utenti + Common
// - Non-Admin: scansiona solo la propria area (NO Common)
// -----------------------------------------------------
server.get('/scan', async (req, res) => {
try {
if (req.user && req.user.name === 'Admin') {
await scanPhoto(undefined, 'Admin');
return res.send({
status: 'Scansione completata',
user: 'Admin',
scope: 'tutti gli utenti + Common',
});
}
// Non-Admin → solo la sua area (niente Common)
await scanPhoto(undefined, req.user.name);
res.send({
status: 'Scansione completata',
user: req.user.name,
scope: 'utente corrente',
});
} catch (err) {
console.error('Errore scan:', err);
res.status(500).json({ error: 'Errore durante lo scan', details: err.message });
}
});
// -----------------------------------------------------
// FILE STATICI
// -----------------------------------------------------
server.get('/files', (req, res) => {
const requested = req.query.file || '';
const publicDir = path.resolve(path.join(__dirname, '../public'));
const resolved = path.resolve(publicDir, requested);
if (!resolved.startsWith(publicDir)) {
return res.status(400).json({ error: 'Invalid path' });
}
if (!fs.existsSync(resolved) || fs.statSync(resolved).isDirectory()) {
return res.status(404).json({ error: 'Not found' });
}
res.sendFile(resolved);
});
// -----------------------------------------------------
// RESET DB MANUALE + rimozione index.json
// -----------------------------------------------------
server.get('/initDB', async (req, res) => {
try {
resetDB();
// <root>/public/photos/index.json (coerente con indexStore.js)
const absIndexPath = path.resolve(__dirname, '..', WEB_ROOT, INDEX_PATH);
try {
await fsp.unlink(absIndexPath);
console.log('initDB: index.json rimosso ->', absIndexPath);
} catch (err) {
if (err && err.code === 'ENOENT') {
console.log('initDB: index.json non trovato, niente da cancellare:', absIndexPath);
} else {
console.error('initDB: errore cancellando index.json:', err);
}
}
res.json({ status: 'DB resettato', indexRemoved: true, indexPath: absIndexPath });
} catch (err) {
console.error('initDB: errore generale:', err);
res.status(500).json({ status: 'errore', message: err.message });
}
});
// -----------------------------------------------------
// UPSERT anti-duplicato per /photos (prima del router)
// Se id esiste -> aggiorna; altrimenti crea
// -----------------------------------------------------
server.post('/photos', (req, res, next) => {
try {
const id = req.body && req.body.id;
if (!id) return next();
const db = router.db; // lowdb instance
const col = db.get('photos');
const existing = col.find({ id }).value();
if (existing) {
col.find({ id }).assign(req.body).write();
return res.status(200).json(req.body);
}
return next(); // non esiste: crea con il router
} catch (e) {
console.error('UPSERT /photos error:', e);
return res.status(500).json({ error: 'upsert failed' });
}
});
// -----------------------------------------------------
// ROUTER JSON-SERVER
// -----------------------------------------------------
server.use(router);
// -----------------------------------------------------
// START SERVER
// -----------------------------------------------------
server.listen(PORT, () => {
console.log(`Auth API server running on port ${PORT} ...`);
});
// Pulizia denylist
setInterval(() => {
const nowSec = Math.floor(Date.now() / 1000);
for (const [tok, exp] of denylist.entries()) {
if (exp < nowSec) denylist.delete(tok);
}
}, 60 * 1000);

51
api_v1/tools.js Normal file
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 --host 0.0.0.0 --port 4000",
"start": "node ./api_v1/server.js -s ./public",
"hash": "node ./api_v1/tools.js"
},
"keywords": [
"api"
],
"author": "Fabio",
"license": "MIT",
"dependencies": {
"async": "^3.2.6",
"axios": "^1.13.5",
"bcrypt": "^6.0.0",
"body-parser": "^2.2.2",
"dotenv": "^17.3.1",
"exifreader": "^4.36.2",
"json-server": "^0.17.4",
"jsonwebtoken": "^9.0.3",
"path": "^0.12.7",
"sharp": "^0.34.5"
}
}

70
public/admin.html Normal file
View file

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Photo Manager</title>
</head>
<body>
<div id="app" style="padding:20px;">
<h2>Gestione Foto</h2>
<button onclick="scan()">Scansiona Foto</button>
<button onclick="resetDB()">Reset DB</button>
<button onclick="readDB()">Leggi DB</button>
<button onclick="window.location.href='index.html'">Torna alla galleria</button>
<pre id="out"></pre>
</div>
<!-- Eruda Debug Console -->
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script>
eruda.init();
console.log("Eruda inizializzato");
</script>
<script>
let BASE_URL = null;
let token = localStorage.getItem("token");
let db = [];
// Se non c'è token → torna alla galleria (login avviene lì)
if (!token) {
window.location.href = "index.html";
}
async function loadConfig() {
const res = await fetch('/config');
const cfg = await res.json();
BASE_URL = cfg.baseUrl;
}
async function readDB() {
const res = await fetch(`${BASE_URL}/photos`, {
headers: { "Authorization": "Bearer " + token }
});
db = await res.json();
document.getElementById("out").textContent = JSON.stringify(db, null, 2);
}
async function scan() {
await fetch(`${BASE_URL}/scan`, {
headers: { "Authorization": "Bearer " + token }
});
await readDB();
}
async function resetDB() {
await fetch(`${BASE_URL}/initDB`, {
headers: { "Authorization": "Bearer " + token }
});
await readDB();
}
window.onload = async () => {
await loadConfig();
};
</script>
</body>
</html>

27
public/css/base.css Normal file
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: 80; /* appena sotto il bottom sheet */
}
.sheet-overlay.open {
display: block;
}
/* =========================================
MODAL sopra allo sheet
========================================= */
.modal.open {
z-index: 10000 !important; /* sopra al bottom sheet (9999) */
}
/* =========================================
Piccoli affinamenti facoltativi
========================================= */
/* scrollbar sottile solo per la strip (opzionale) */
#sheetGallery::-webkit-scrollbar,
.sheet-gallery::-webkit-scrollbar {
height: 8px;
}
#sheetGallery::-webkit-scrollbar-thumb,
.sheet-gallery::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.25);
border-radius: 4px;
}

46
public/css/gallery.css Normal file
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(100px, 1fr)); /* leggermente più piccole */
gap: 4px; /* SPACING RIDOTTO */
padding: 0 4px;
}
.thumb {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 6px; /* più compatto */
overflow: hidden;
background: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.12); /* più leggero */
position: relative;
cursor: pointer;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.play-icon {
position: absolute;
bottom: 4px;
right: 4px;
background: rgba(0,0,0,0.55);
color: white;
padding: 2px 4px;
border-radius: 4px;
font-size: 11px;
}

85
public/css/header.css Normal file
View file

@ -0,0 +1,85 @@
/* ===============================
Header compatto
=============================== */
header {
padding: 4px 10px; /* era 10px 15px */
background: #333;
color: white;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
}
/* Titolo più piccolo e senza margini extra */
header h1 {
font-size: 18px; /* ridotto */
line-height: 1.1;
margin: 0;
}
/* Contenitore bottoni in alto a destra */
.top-buttons {
display: flex;
gap: 6px; /* era 10px */
}
/* Bottoni icona più compatti */
.icon-btn {
background: none;
border: none;
font-size: 18px; /* era 22px */
padding: 3px 6px; /* era 6px 10px */
cursor: pointer;
border-radius: 6px;
color: white;
line-height: 1;
min-height: 32px; /* tap target minimo desktop */
min-width: 32px;
}
.icon-btn:hover {
background: rgba(255,255,255,0.15);
}
/* Logout rotondo: riduciamo la “bolla” */
.icon-btn.logout-btn {
--size: 28px; /* era 36px */
width: var(--size);
height: var(--size);
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* PNG del logout in scala con lheader */
.logout-icon {
width: 18px; /* era 22px */
height: 18px;
display: block;
filter: brightness(0) invert(1);
image-rendering: -webkit-optimize-contrast;
}
/* ===============================
Visibilità Logout robusta
=============================== */
/* Base: nascosto (prima del login o se non autenticato) */
#logoutBtn {
display: none;
}
/* Quando autenticato, mostra il bottone coerente con gli altri icon-btn */
body.authenticated #logoutBtn {
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Se esistono regole più forti altrove che lo nascondono,
puoi temporaneamente forzare:
body.authenticated #logoutBtn { display: inline-flex !important; } */

88
public/css/infoPanel.css Normal file
View file

@ -0,0 +1,88 @@
/* ===============================
Variabili (scala tipografica pannello info)
Modifica qui per regolare tutto il pannello
=============================== */
:root {
--info-font: 14px; /* base testo pannello (prima ~16px) */
--info-line: 1.4; /* interlinea per migliorare leggibilità */
--info-heading: 15px; /* dimensione titoli h3 nel pannello */
--info-h3-mt: 6px; /* margin-top h3 */
--info-h3-mb: 10px; /* margin-bottom h3 */
--info-row-gap: 8px; /* spazio verticale tra righe (era 10px) */
--info-label-w: 100px; /* larghezza colonna etichette (era 110px) */
--info-map-h: 220px; /* altezza mappa (era 250px) */
--info-map-mt: 15px; /* spazio sopra la mappa */
--info-spacer-h: 16px; /* altezza degli spacer */
}
/* ===============================
PANNELLO INFO
=============================== */
.info-panel {
position: fixed;
top: 0;
right: 0;
width: 320px;
height: 100%;
background: #fff;
padding: 16px;
box-shadow: -2px 0 6px rgba(0,0,0,0.25);
overflow-y: auto;
z-index: 10000;
transform: translateX(100%);
transition: transform 0.3s ease;
/* Scala tipografica via variabili */
font-size: var(--info-font);
line-height: var(--info-line);
}
.info-panel.open {
transform: translateX(0);
}
/* Titoli un filo più compatti */
.info-panel h3 {
font-size: var(--info-heading);
margin: var(--info-h3-mt) 0 var(--info-h3-mb);
}
/* Righe e label */
.info-row {
margin-bottom: var(--info-row-gap);
}
.info-row b {
display: inline-block;
width: var(--info-label-w);
}
/* Mappa nel pannello */
.info-map {
width: 100%;
height: var(--info-map-h);
margin-top: var(--info-map-mt);
border-radius: 6px;
overflow: hidden;
border: 1px solid #ccc;
}
/* Spacer verticali */
.info-spacer {
height: var(--info-spacer-h);
}
/* ===============================
(Opzionale) Mobile: un filo più grande < 480px
Decommenta se vuoi mantenere leggibilità maggiore su schermi piccoli
=============================== */
/*
@media (max-width: 480px) {
.info-panel { font-size: 15px; }
.info-panel h3 { font-size: 16px; }
}
*/

27
public/css/login.css Normal file
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;
}

117
public/css/map.css Normal file
View file

@ -0,0 +1,117 @@
/* ===============================
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);
}
/* ===============================
MARKER CLUSTER
=============================== */
.marker-cluster-wrapper { background: transparent; border: 0; }
.gp-cluster {
position: relative;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
border: 3px solid rgba(255,255,255,0.85);
transition: width .12s, height .12s, font-size .12s;
}
.gp-cluster .cluster-collage { position:absolute; inset:0; display:grid; grid-template-columns: repeat(2,1fr); grid-template-rows: repeat(2,1fr); }
.gp-cluster .cluster-collage div img { width:100%; height:100%; object-fit:cover; display:block; }
.gp-cluster .gp-count {
position:absolute; right:6px; bottom:6px;
background: rgba(0,0,0,0.55); padding:4px 7px; border-radius:12px;
color:#fff; font-weight:700; font-size:12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.gp-cluster.cluster-sm .gp-count { font-size:11px; }
.gp-cluster.cluster-md .gp-count { font-size:13px; }
.gp-cluster.cluster-lg .gp-count { font-size:15px; }
.gp-cluster.cluster-xl .gp-count { font-size:17px; }

299
public/css/modal.css Normal file
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

175
public/index.html Normal file
View file

@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<title>Galleria Foto</title>
<!-- CSS -->
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/header.css">
<link rel="stylesheet" href="css/gallery.css">
<link rel="stylesheet" href="css/modal.css">
<link rel="stylesheet" href="css/infoPanel.css">
<link rel="stylesheet" href="css/bottomSheet.css">
<link rel="stylesheet" href="css/optionsSheet.css">
<link rel="stylesheet" href="css/map.css">
<link rel="stylesheet" href="css/utils.css">
<link rel="stylesheet" href="css/login.css">
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
<!-- MarkerCluster CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster/dist/MarkerCluster.Default.css" />
</head>
<body>
<!-- =============================== -->
<!-- BARRA SUPERIORE -->
<!-- =============================== -->
<header>
<h1>Galleria Foto</h1>
<div class="top-buttons">
<button id="openMapBtn" class="icon-btn">🗺️</button>
<button id="optionsBtn" class="icon-btn"></button>
<button id="settingsBtn" class="icon-btn">⚙️</button>
<button
id="logoutBtn"
class="icon-btn logout-btn"
type="button"
data-logout
data-redirect="/"
title="Logout"
aria-label="Logout">
<!-- Icona PNG -->
<img
class="logout-icon"
src="img/switch.png"
alt=""
aria-hidden="true"
width="22" height="22">
</button>
</div>
</header>
<main>
<div id="gallery" class="gallery"></div>
<!-- Mappa globale -->
<div id="globalMap" class="global-map"></div>
</main>
<!-- =============================== -->
<!-- MODAL FOTO/VIDEO -->
<!-- =============================== -->
<div id="modal" class="modal">
<div class="modal-content">
<div class="modal-close" id="modalClose">×</div>
<!-- Frecce navigazione -->
<button class="modal-nav-btn prev" id="modalPrev" type="button" aria-label="Precedente">&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>

133
public/js/bottomSheet.js Normal file
View file

@ -0,0 +1,133 @@
// ===============================
// BOTTOM SHEET — strip multi-foto (2+); singola → modal diretto
// Regola: aprendo qualcosa di nuovo, chiudo il precedente
// ===============================
const bottomSheet = document.getElementById("bottomSheet");
const sheetGallery = document.getElementById("sheetGallery");
let optionsSheetRef = document.getElementById("optionsSheet");
// Overlay (creazione difensiva)
let sheetOverlay = document.getElementById("sheetOverlay");
if (!sheetOverlay) {
sheetOverlay = document.createElement("div");
sheetOverlay.id = "sheetOverlay";
sheetOverlay.className = "sheet-overlay";
document.body.appendChild(sheetOverlay);
}
function openBottomSheet(photoList) {
const list = Array.isArray(photoList) ? photoList : [];
// 0 o 1 foto → MODAL diretto, nessuna bottom-zone
if (list.length <= 1) {
const p = list[0];
if (p) {
const thumbUrl = absUrl(
p.thub2 || p.thub1 || p.path,
p.user,
"thumbs",
p.cartella
);
const originalUrl = absUrl(
p.path,
p.user,
"original",
p.cartella
);
closeBottomSheet();
if (typeof window.openModalFromList === "function") {
window.openModalFromList([p], 0);
} else {
window.openModal?.(originalUrl, thumbUrl, p);
}
}
return;
}
// 2+ foto → strip in basso
sheetGallery.innerHTML = "";
list.forEach((photo, index) => {
const thumbUrl = absUrl(
photo.thub2 || photo.thub1,
photo.user,
"thumbs",
photo.cartella
);
const originalUrl = absUrl(
photo.path,
photo.user,
"original",
photo.cartella
);
const div = document.createElement("div");
div.className = "sheet-item";
div.tabIndex = 0;
const img = document.createElement("img");
img.className = "sheet-thumb";
img.src = thumbUrl;
img.alt = photo?.name || "";
img.loading = "lazy";
const openFromIndex = () => {
closeBottomSheet();
if (typeof window.openModalFromList === "function") {
window.openModalFromList(list, index);
} else {
window.openModal?.(originalUrl, thumbUrl, photo);
}
};
div.addEventListener("click", openFromIndex);
div.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openFromIndex(); }
});
div.appendChild(img);
sheetGallery.appendChild(div);
});
bottomSheet.classList.add("open");
sheetOverlay.classList.add("open");
}
function closeBottomSheet() {
bottomSheet.classList.remove("open");
sheetOverlay.classList.remove("open");
}
function openOptionsSheet() {
optionsSheetRef?.classList.add("open");
sheetOverlay.classList.add("open");
}
function closeOptionsSheet() {
optionsSheetRef?.classList.remove("open");
sheetOverlay.classList.remove("open");
}
// Chiusura cliccando fuori
sheetOverlay.addEventListener("click", () => {
closeBottomSheet();
closeOptionsSheet();
});
// Chiusura toccando la maniglia
document.querySelectorAll(".sheet-header").forEach(header => {
header.addEventListener("click", () => {
closeBottomSheet();
closeOptionsSheet();
});
});
// Export
window.openBottomSheet = openBottomSheet;
window.closeBottomSheet = closeBottomSheet;
window.openOptionsSheet = openOptionsSheet;
window.closeOptionsSheet = closeOptionsSheet;

63
public/js/config.js Normal file
View file

@ -0,0 +1,63 @@
// ===============================
// CONFIG DINAMICA DAL SERVER
// ===============================
window.BASE_URL = null;
window.PHOTOS_URL = null;
window.MEDIA_BASE_ORIGIN = null;
window.configReady = false;
window.PATH_FULL = false;
// Carica /config dal backend
(async () => {
try {
const res = await fetch('/config');
const cfg = await res.json();
window.BASE_URL = cfg.baseUrl;
window.PATH_FULL = cfg.pathFull;
window.PHOTOS_URL = `${window.BASE_URL}/photos`;
window.MEDIA_BASE_ORIGIN = new URL(window.PHOTOS_URL).origin;
console.log("[config] BASE_URL:", window.BASE_URL);
console.log("[config] PHOTOS_URL:", window.PHOTOS_URL);
console.log("[config] MEDIA_BASE_ORIGIN:", window.MEDIA_BASE_ORIGIN);
console.log("[config] PATH_FULL:", window.PATH_FULL);
window.configReady = true;
} catch (err) {
console.error("[config] Errore nel caricamento della config:", err);
}
})();
// ===============================
// Utility: normalizza URL dei media
// ===============================
function toAbsoluteUrl1(pathOrUrl) {
if (!pathOrUrl) return '';
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
const normalized = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`;
return `${window.MEDIA_BASE_ORIGIN}${normalized}`;
}
function toAbsoluteUrl(pathOrUrl, name, type, cartella) {
const BASE_URL = 'https://prova.patachina.it';
if (!pathOrUrl) return '';
if (!name || !type || !cartella) {
throw new Error('name, type e cartella sono obbligatori');
}
// Normalizza il path rimuovendo slash iniziali/finali
const cleanedPath = String(pathOrUrl).replace(/^\/+|\/+$/g, '');
// Costruzione dell'URL finale
return `${BASE_URL}/photos/${encodeURIComponent(name)}/${encodeURIComponent(type)}/${encodeURIComponent(cartella)}/${cleanedPath}`;
}
window.toAbsoluteUrl = toAbsoluteUrl;

84
public/js/data.js Normal file
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();
}

165
public/js/gallery.js Normal file
View file

@ -0,0 +1,165 @@
// ===============================
// GALLERY — completa, stile Google Photos
// - Ordinamento
// - Filtri
// - Raggruppamento (auto/giorno/mese/anno)
// - Render a sezioni
// - Click: openModalFromList(sezione, indice) se disponibile (fallback openModal)
// ===============================
// ORDINAMENTO
function sortByDate(photos, direction = "desc") {
return photos.slice().sort((a, b) => {
const da = a?.taken_at ? new Date(a.taken_at) : 0;
const db = b?.taken_at ? new Date(b.taken_at) : 0;
return direction === "asc" ? (da - db) : (db - da);
});
}
// FILTRI
function applyFilters(photos) {
if (!window.currentFilter) return photos;
switch (window.currentFilter) {
case "folder":
return photos.filter(p => p.folder || (p.path && p.path.includes('/photos/')));
case "location":
return photos.filter(p => p?.gps && p.gps.lat);
case "type":
return photos.filter(p => p?.mime_type && p.mime_type.startsWith("image/"));
default:
return photos;
}
}
// RAGGRUPPAMENTO STILE GOOGLE PHOTOS
function groupByDate(photos, mode = "auto") {
const sections = [];
const now = new Date();
function getLabel(photo) {
const date = photo?.taken_at ? new Date(photo.taken_at) : null;
if (!date || isNaN(+date)) return "Senza data";
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
if (mode === "day") return formatDay(date);
if (mode === "month") return formatMonth(date);
if (mode === "year") return date.getFullYear().toString();
if (diffDays === 0) return "Oggi";
if (diffDays === 1) return "Ieri";
if (diffDays <= 7) return "Questa settimana";
if (diffDays <= 14) return "La settimana scorsa";
if (diffDays <= 30) return "Questo mese";
if (diffDays <= 60) return "Mese scorso";
if (date.getFullYear() === now.getFullYear()) return formatMonth(date);
return date.getFullYear().toString();
}
photos.forEach(photo => {
const label = getLabel(photo);
let section = sections.find(s => s.label === label);
if (!section) {
section = { label, photos: [] };
sections.push(section);
}
section.photos.push(photo);
});
return sections;
}
// FORMATTATORI
function formatDay(date) {
return date.toLocaleDateString("it-IT", { weekday: "long", day: "numeric", month: "long" });
}
function formatMonth(date) {
return date.toLocaleDateString("it-IT", { month: "long", year: "numeric" });
}
// RENDER
function renderGallery(sections) {
const gallery = document.getElementById("gallery");
if (!gallery) return;
gallery.innerHTML = "";
sections.forEach(section => {
const h = document.createElement("h2");
h.className = "gallery-section-title";
h.textContent = section.label;
gallery.appendChild(h);
const container = document.createElement("div");
container.className = "gallery-section";
section.photos.forEach((photo, idx) => {
const thumbDiv = document.createElement("div");
thumbDiv.className = "thumb";
// const th1 = (typeof toAbsoluteUrl === 'function') ? toAbsoluteUrl(photo?.thub1) : photo?.thub1;
// const th2 = (typeof toAbsoluteUrl === 'function') ? toAbsoluteUrl(photo?.thub2 || photo?.thub1) : (photo?.thub2 || photo?.thub1);
// const original = (typeof toAbsoluteUrl === 'function') ? toAbsoluteUrl(photo?.path) : photo?.path;
let th1, th2, original;
if (window.PATH_FULL) {
// Uso direttamente i path completi generati dal backend
th1 = photo?.thub1;
th2 = photo?.thub2 || photo?.thub1;
original = photo?.path;
} else {
// Comportamento attuale: costruisco URL con toAbsoluteUrl()
th1 = (typeof toAbsoluteUrl === 'function')
? toAbsoluteUrl(photo?.thub1, photo?.user, "thumbs", photo?.cartella)
: photo?.thub1;
th2 = (typeof toAbsoluteUrl === 'function')
? toAbsoluteUrl(photo?.thub2 || photo?.thub1, photo?.user, "thumbs", photo?.cartella)
: (photo?.thub2 || photo?.thub1);
original = (typeof toAbsoluteUrl === 'function')
? toAbsoluteUrl(photo?.path, photo?.user, "original", photo?.cartella)
: photo?.path;
}
//console.log(photo?.user);
console.log(th1);
const img = document.createElement("img");
img.src = th1 || th2 || original || "";
img.alt = photo?.name || "";
img.loading = "lazy";
thumbDiv.appendChild(img);
if (photo?.mime_type && photo.mime_type.startsWith("video/")) {
const play = document.createElement("div");
play.className = "play-icon";
play.textContent = "▶";
thumbDiv.appendChild(play);
}
thumbDiv.addEventListener("click", () => {
// Chiudi sempre la strip prima di aprire una nuova foto
window.closeBottomSheet?.();
if (typeof window.openModalFromList === "function") {
window.openModalFromList(section.photos, idx);
} else {
window.openModal?.(original, th2, photo);
}
});
container.appendChild(thumbDiv);
});
gallery.appendChild(container);
});
}
// Esporti su window
window.sortByDate = sortByDate;
window.applyFilters = applyFilters;
window.groupByDate = groupByDate;
window.renderGallery = renderGallery;

167
public/js/infoPanel.js Normal file
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();
}
})();

293
public/js/main.js Normal file
View file

@ -0,0 +1,293 @@
// ===============================
// AVVIO
// ===============================
console.log("main.js avviato");
// ===============================
// UTILS AUTH + SYNC HEADER UI
// ===============================
function isAuthenticated() {
// Fonte verità: presenza del token in localStorage
return !!localStorage.getItem("token");
}
/**
* Sincronizza lUI dellheader in base allo stato auth:
* - Aggiunge una classe sul body (CSS-friendly)
* - Mostra/Nasconde il bottone logout (hotfix inline, se vuoi puoi affidarti solo al CSS)
*/
function syncHeaderAuthUI() {
const authed = isAuthenticated();
document.body.classList.toggle('authenticated', authed);
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
// Hotfix immediato: forza la visibilità anche via inline style
// (puoi rimuovere queste due righe se preferisci usare solo la regola CSS body.authenticated #logoutBtn)
logoutBtn.style.display = authed ? 'inline-flex' : 'none';
}
}
// ===============================
// PATCH: misura l'altezza reale dell'header e aggiorna --header-h
// (serve per far partire la mappa subito sotto 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 {
// In mapGlobal.js imposta: window.leafletMapInstance = window.globalMap;
window.leafletMapInstance?.invalidateSize();
} catch (e) {
console.warn('invalidateSize non eseguito:', e);
}
}, 0);
}
// 1) Osserva il cambio classe (quando aggiungi .open)
const mo = new MutationObserver((mutations) => {
if (mutations.some(m => m.attributeName === 'class')) {
invalidateWhenOpen();
}
});
mo.observe(mapEl, { attributes: true, attributeFilter: ['class'] });
// 2) Fallback: se usi il bottone #openMapBtn per aprire/chiudere
document.getElementById('openMapBtn')?.addEventListener('click', () => {
setTimeout(invalidateWhenOpen, 0);
});
})();
// ===============================
// MENU ⋮ (toggle apri/chiudi con lo stesso bottone, senza global conflicts)
// ===============================
(() => {
const optBtn = document.getElementById("optionsBtn");
const optSheet = document.getElementById("optionsSheet");
const overlayEl= document.getElementById("sheetOverlay");
if (!optBtn || !optSheet) return;
function openOptionsSheet() {
try { window.closeBottomSheet?.(); } catch {}
optSheet.classList.add("open");
overlayEl?.classList.add("open");
// ARIA (facoltativo)
optBtn.setAttribute("aria-expanded", "true");
optSheet.setAttribute("aria-hidden", "false");
}
function closeOptionsSheet() {
optSheet.classList.remove("open");
overlayEl?.classList.remove("open");
// ARIA (facoltativo)
optBtn.setAttribute("aria-expanded", "false");
optSheet.setAttribute("aria-hidden", "true");
}
function toggleOptionsSheet(e) {
e?.preventDefault();
e?.stopPropagation();
if (optSheet.classList.contains("open")) closeOptionsSheet();
else openOptionsSheet();
}
// Click sul bottone: toggle (fase di cattura per battere eventuali altri handler)
optBtn.addEventListener("click", toggleOptionsSheet, { capture: true });
// Chiudi clic overlay
overlayEl?.addEventListener("click", (e) => {
e.stopPropagation();
closeOptionsSheet();
});
// Chiudi con ESC
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && optSheet.classList.contains("open")) {
closeOptionsSheet();
}
});
// Evita chiusure involontarie per click interni
optSheet.addEventListener("click", (e) => e.stopPropagation());
// Espone una close per usarla altrove (es. dopo la scelta)
window.closeOptionsSheet = closeOptionsSheet;
})();
// ===============================
// LOGIN AUTOMATICO SU INDEX
// ===============================
document.addEventListener("DOMContentLoaded", async () => {
// Allinea subito lUI in base al token eventualmente già presente
syncHeaderAuthUI();
try {
// 1) Carica config
const cfgRes = await fetch('/config');
const cfg = await cfgRes.json();
window.BASE_URL = cfg.baseUrl;
// 2) Recupera token salvato
const savedToken = localStorage.getItem("token");
// Se non c'è token → mostra login
if (!savedToken) {
document.getElementById("loginModal").style.display = "flex";
return;
}
// 3) Verifica token
const ping = await fetch(`${window.BASE_URL}/photos`, {
headers: { "Authorization": "Bearer " + savedToken }
});
if (!ping.ok) {
// Token invalido → cancella e mostra login
localStorage.removeItem("token");
syncHeaderAuthUI(); // riallinea header subito
document.getElementById("loginModal").style.display = "flex";
return;
}
// 4) Token valido → salva e carica gallery
window.token = savedToken;
syncHeaderAuthUI(); // <— mostra il logout senza refresh
loadPhotos();
} catch (err) {
console.error("Errore autenticazione:", err);
document.getElementById("loginModal").style.display = "flex";
}
});
// ===============================
// VARIABILI GLOBALI
// ===============================
let currentSort = "desc";
let currentGroup = "auto";
let currentFilter = null;
window.currentSort = currentSort;
window.currentGroup = currentGroup;
window.currentFilter = currentFilter;
// ===============================
// BOTTONI OPZIONI
// ===============================
document.querySelectorAll("#optionsSheet .sheet-btn").forEach(btn => {
btn.addEventListener("click", () => {
if (btn.dataset.sort) window.currentSort = currentSort = btn.dataset.sort;
if (btn.dataset.group) window.currentGroup = currentGroup = btn.dataset.group;
if (btn.dataset.filter) window.currentFilter = currentFilter = btn.dataset.filter;
// Chiudi sheet e overlay dopo la scelta (usa lAPI esposta sopra)
window.closeOptionsSheet?.();
refreshGallery();
});
});
// ===============================
// REFRESH GALLERY
// ===============================
function refreshGallery() {
console.log("Aggiornamento galleria...");
const data = Array.isArray(window.photosData) ? window.photosData : [];
let photos = [...data];
if (typeof applyFilters === 'function') photos = applyFilters(photos);
if (typeof sortByDate === 'function') photos = sortByDate(photos, currentSort);
let sections = [{ label: 'Tutte', photos }];
if (typeof groupByDate === 'function') sections = groupByDate(photos, currentGroup);
if (typeof renderGallery === 'function') {
renderGallery(sections);
}
}
window.refreshGallery = refreshGallery;
// ===============================
// SETTINGS (⚙️) — apre admin.html
// ===============================
const settingsBtn = document.getElementById('settingsBtn');
settingsBtn?.addEventListener('click', () => {
window.location.href = "admin.html";
});
// ===============================
// LOGIN SUBMIT
// ===============================
document.getElementById("loginSubmit").addEventListener("click", async () => {
const email = document.getElementById("loginEmail").value;
const password = document.getElementById("loginPassword").value;
const errorEl = document.getElementById("loginError");
errorEl.textContent = "";
try {
const res = await fetch(`${window.BASE_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password })
});
if (!res.ok) {
errorEl.textContent = "Utente o password errati";
return;
}
const data = await res.json();
const token = data.token;
// Salva token
localStorage.setItem("token", token);
window.token = token;
// Chiudi login
const loginModalEl = document.getElementById("loginModal");
if (loginModalEl) loginModalEl.style.display = "none";
// Riallinea UI header subito (mostra logout) e carica gallery
syncHeaderAuthUI();
loadPhotos();
} catch (err) {
console.error("Errore login:", err);
errorEl.textContent = "Errore di connessione al server";
}
});

250
public/js/mapGlobal.js Normal file
View file

@ -0,0 +1,250 @@
// ===============================
// MAPPA GLOBALE — stile Google Photos Web
// ===============================
window.globalMap = null;
window.globalMarkers = null; // qui sarà un MarkerClusterGroup
document.addEventListener("DOMContentLoaded", () => {
const openBtn = document.getElementById("openMapBtn");
if (!openBtn) {
console.error("openMapBtn non trovato nel DOM");
return;
}
openBtn.addEventListener("click", openGlobalMap);
const RADIUS_PX = 50;
const DISABLE_CLUSTER_AT_ZOOM = 18;
const OPEN_STRIP_CHILDREN_MAX = 20;
async function openGlobalMap() {
const mapDiv = document.getElementById("globalMap");
const gallery = document.getElementById("gallery");
if (!mapDiv) {
console.error("globalMap DIV non trovato");
return;
}
const isOpen = mapDiv.classList.contains("open");
if (isOpen) {
mapDiv.classList.remove("open");
gallery?.classList.remove("hidden");
window.closeBottomSheet?.();
return;
}
mapDiv.classList.add("open");
gallery?.classList.add("hidden");
if (window.globalMap) {
window.leafletMapInstance = window.globalMap;
}
await new Promise(r => requestAnimationFrame(r));
let tries = 0;
while (mapDiv.getBoundingClientRect().height < 50 && tries < 10) {
await new Promise(r => setTimeout(r, 30));
tries++;
}
if (window.globalMap === null) {
console.log("Inizializzo mappa Leaflet + MarkerCluster…");
window.globalMap = L.map("globalMap", {
zoomControl: true,
attributionControl: true
}).setView([42.5, 12.5], 6);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19
}).addTo(window.globalMap);
// CLUSTER con iconCreateFunction in stile Google Photos
window.globalMarkers = L.markerClusterGroup({
showCoverageOnHover: false,
spiderfyOnMaxZoom: true,
disableClusteringAtZoom: DISABLE_CLUSTER_AT_ZOOM,
iconCreateFunction: function(cluster) {
const count = cluster.getChildCount();
// dimensione scalata: base + sqrt(count) * fattore, con cap massimo
const size = Math.min(92, Math.round(28 + Math.sqrt(count) * 6));
// classi per stili (piccolo/medio/grande)
const cls = count > 200 ? 'cluster-xl' : (count > 50 ? 'cluster-lg' : (count > 10 ? 'cluster-md' : 'cluster-sm'));
// prendi fino a 4 thumbnails dai child markers (se disponibili)
const children = cluster.getAllChildMarkers().slice(0, 4);
const thumbs = children
.map(m => m.__photo?.thub2 || m.__photo?.thub1)
.filter(Boolean);
// se esiste toAbsoluteUrl, trasformiamo le thumb in URL assoluti
const resolvedThumbs = (typeof toAbsoluteUrl === "function")
? thumbs.map(t => absUrl(t, children[0]?.__photo?.user, "thumbs", children[0]?.__photo?.cartella))
: thumbs;
// crea collage HTML: fino a 4 immagini in griglia 2x2
const imgsHtml = resolvedThumbs.length
? `<div class="cluster-collage">${resolvedThumbs.map((t,i)=>`<div class="c${i}"><img src="${t}" alt=""></div>`).join('')}</div>`
: '';
const html = `
<div class="gp-cluster ${cls}" style="width:${size}px;height:${size}px;">
${imgsHtml}
<div class="gp-count"><span>${count}</span></div>
</div>`;
return L.divIcon({
html,
className: 'marker-cluster-wrapper',
iconSize: L.point(size, size)
});
}
});
// Listener "clusterclick": zooma oppure, quando pochi, apri strip
window.globalMarkers.on("clusterclick", (a) => {
const childMarkers = a.layer.getAllChildMarkers();
const count = childMarkers.length;
if (count <= OPEN_STRIP_CHILDREN_MAX || window.globalMap.getZoom() >= DISABLE_CLUSTER_AT_ZOOM - 1) {
const photos = childMarkers
.map(m => m.__photo)
.filter(Boolean);
if (photos.length > 1) {
window.openBottomSheet?.(photos);
} else if (photos.length === 1) {
openPhotoModal(photos[0]);
}
} else {
window.globalMap.fitBounds(a.layer.getBounds(), { padding: [60, 60], maxZoom: DISABLE_CLUSTER_AT_ZOOM, animate: true });
}
});
window.globalMap.addLayer(window.globalMarkers);
// Disegna i marker
redrawPhotoMarkers();
}
setTimeout(() => window.globalMap?.invalidateSize?.(), 120);
}
function createPhotoIcon(photo) {
const thumb = (typeof toAbsoluteUrl === "function")
? absUrl(
photo?.thub2 || photo?.thub1,
photo?.user,
"thumbs",
photo?.cartella
)
: (photo?.thub2 || photo?.thub1);
return L.icon({
iconUrl: thumb || "",
iconSize: [56, 56],
iconAnchor: [28, 28],
className: "photo-marker"
});
}
function openPhotoModal(photo) {
const thumb = (typeof toAbsoluteUrl === "function")
? absUrl(
photo?.thub2 || photo?.thub1 || photo?.path,
photo?.user,
"thumbs",
photo?.cartella
)
: (photo?.thub2 || photo?.thub1 || photo?.path);
const original = (typeof toAbsoluteUrl === "function")
? absUrl(
photo?.path,
photo?.user,
"original",
photo?.cartella
)
: photo?.path;
window.closeBottomSheet?.();
window.openModal?.(original, thumb, photo);
}
function radiusMetersAtZoom(latlng, px) {
if (!window.globalMap) return 0;
const p = window.globalMap.latLngToContainerPoint(latlng);
const p2 = L.point(p.x + px, p.y);
const ll2 = window.globalMap.containerPointToLatLng(p2);
return window.globalMap.distance(latlng, ll2);
}
function distanceMeters(lat1, lng1, lat2, lng2) {
const toRad = d => d * Math.PI / 180;
const R = 6371000;
const φ1 = toRad(lat1), φ2 = toRad(lat2);
const = 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));
}
function buildGroupByRadius(lat, lng, data, radiusM) {
return data.filter(p => {
const plat = +p?.gps?.lat;
const plng = +p?.gps?.lng;
if (!plat || !plng) return false;
return distanceMeters(lat, lng, plat, plng) <= radiusM;
});
}
function redrawPhotoMarkers() {
if (!window.globalMarkers || !window.globalMap) return;
window.globalMarkers.clearLayers();
const data = Array.isArray(window.photosData) ? window.photosData : [];
data.forEach(photo => {
const lat = +photo?.gps?.lat;
const lng = +photo?.gps?.lng;
if (!lat || !lng) return;
const marker = L.marker([lat, lng], {
icon: createPhotoIcon(photo),
title: photo?.name || ""
});
marker.__photo = photo;
marker.on("click", () => {
const here = L.latLng(lat, lng);
const radiusM = radiusMetersAtZoom(here, RADIUS_PX);
const dataAll = Array.isArray(window.photosData) ? window.photosData : [];
const group = buildGroupByRadius(lat, lng, dataAll, radiusM);
if (group.length > 1) {
window.openBottomSheet?.(group);
} else if (group.length === 1) {
openPhotoModal(group[0]);
} else {
openPhotoModal(photo);
}
});
window.globalMarkers.addLayer(marker);
});
}
const originalRefresh = window.refreshGallery;
window.refreshGallery = function wrappedRefreshGallery(...args) {
try { originalRefresh?.apply(this, args); } catch (_) {}
if (window.globalMap && window.globalMarkers) {
redrawPhotoMarkers();
}
};
});

378
public/js/modal.js Normal file
View file

@ -0,0 +1,378 @@
// ===============================
// MODALE (FOTO + VIDEO) — avanzato con navigazione e preload
// - Sostituisce il contenuto, non accumula
// - Chiude la bottom-zone quando si apre
// - Prev/Next (←/→ e click ai bordi), preload 3+3
// - Pulsante INFO () riportato dentro il modal con toggle affidabile
// ===============================
const modal = document.getElementById('modal');
const modalClose = document.getElementById('modalClose');
window.currentPhoto = null; // usato anche da infoPanel
window.modalList = []; // lista corrente per navigazione
window.modalIndex = 0; // indice corrente nella lista
// Frecce visibili
const modalPrev = document.getElementById('modalPrev');
const modalNext = document.getElementById('modalNext');
// ===============================
// Stato/Helper Info Panel (toggle affidabile)
// ===============================
let infoOpen = false; // stato interno affidabile
function getInfoPanel() {
return document.getElementById('infoPanel');
}
function isInfoOpen() {
return infoOpen;
}
function openInfo(photo) {
// Prova API esplicita, altrimenti fallback a toggle
try {
if (typeof window.openInfoPanel === 'function') {
window.openInfoPanel(photo);
} else if (typeof window.toggleInfoPanel === 'function') {
window.toggleInfoPanel(photo);
}
} catch {}
infoOpen = true;
const panel = getInfoPanel();
panel?.classList.add('open');
panel?.setAttribute('aria-hidden', 'false');
panel?.setAttribute('data-open', '1');
document.getElementById('modalInfoBtn')?.classList.add('active');
}
function closeInfo() {
// Prova API esplicita, altrimenti fallback a toggle (senza argomento)
try {
if (typeof window.closeInfoPanel === 'function') {
window.closeInfoPanel();
} else if (typeof window.toggleInfoPanel === 'function') {
window.toggleInfoPanel();
}
} catch {}
infoOpen = false;
const panel = getInfoPanel();
panel?.classList.remove('open');
panel?.setAttribute('aria-hidden', 'true');
panel?.setAttribute('data-open', '0');
document.getElementById('modalInfoBtn')?.classList.remove('active');
}
function toggleInfo(photo) {
if (isInfoOpen()) closeInfo();
else openInfo(photo);
}
// ===============================
// Utility MIME / media
// ===============================
function isProbablyVideo(photo, srcOriginal) {
const mime = String(photo?.mime_type || '').toLowerCase();
if (mime.startsWith('video/')) return true;
return /\.(mp4|m4v|webm|mov|qt|avi|mkv)$/i.test(String(srcOriginal || ''));
}
function guessVideoMime(photo, srcOriginal) {
let t = String(photo?.mime_type || '').toLowerCase();
if (t && t !== 'application/octet-stream') return t;
const src = String(srcOriginal || '');
if (/\.(mp4|m4v)$/i.test(src)) return 'video/mp4';
if (/\.(webm)$/i.test(src)) return 'video/webm';
if (/\.(mov|qt)$/i.test(src)) return 'video/quicktime';
if (/\.(avi)$/i.test(src)) return 'video/x-msvideo';
if (/\.(mkv)$/i.test(src)) return 'video/x-matroska';
return '';
}
function createVideoElement(srcOriginal, srcPreview, photo) {
const video = document.createElement('video');
video.controls = true;
video.playsInline = true; // iOS: evita fullscreen nativo
video.setAttribute('webkit-playsinline', ''); // compat iOS storici
video.preload = 'metadata';
video.poster = srcPreview || '';
video.style.maxWidth = '100%';
video.style.maxHeight = '100%';
video.style.objectFit = 'contain';
const source = document.createElement('source');
source.src = srcOriginal;
const type = guessVideoMime(photo, srcOriginal);
if (type) source.type = type;
video.appendChild(source);
video.addEventListener('loadedmetadata', () => {
try { video.currentTime = 0.001; } catch (_) {}
console.log('[video] loadedmetadata', { w: video.videoWidth, h: video.videoHeight, dur: video.duration });
});
video.addEventListener('error', () => {
const code = video.error && video.error.code;
console.warn('[video] error code:', code, 'type:', type, 'src:', srcOriginal);
const msg = document.createElement('div');
msg.style.padding = '12px';
msg.style.color = '#fff';
msg.style.background = 'rgba(0,0,0,0.6)';
msg.style.borderRadius = '8px';
msg.innerHTML = `
<strong>Impossibile riprodurre questo video nel browser.</strong>
${code === 4 ? 'Formato/codec non supportato (es. HEVC/H.265 su Chrome/Edge).' : 'Errore durante il caricamento.'}
<br><br>
Suggerimenti:
<ul style="margin:6px 0 0 18px">
<li><a href="${srcOriginal}" target="_blank" rel="noopener" style="color:#fff;text-decoration:underline">Apri il file in una nuova scheda</a></li>
<li>Prova Safari (supporta HEVC) oppure converti in MP4 (H.264 + AAC)</li>
</ul>
`;
const container = document.getElementById('modalMediaContainer');
container && container.appendChild(msg);
});
// Evita di far scattare la navigazione "ai bordi"
video.addEventListener('click', (e) => e.stopPropagation());
return video;
}
function createImageElement(srcOriginal, srcPreview) {
const img = document.createElement('img');
img.src = srcPreview || srcOriginal || '';
img.style.maxWidth = '100%';
img.style.maxHeight = '100%';
img.style.objectFit = 'contain';
// Progressive loading: preview → fullres
if (srcPreview && srcOriginal && srcPreview !== srcOriginal) {
const full = new Image();
full.src = srcOriginal;
full.onload = () => { img.src = srcOriginal; };
}
return img;
}
// ===============================
// Helpers per URL assoluti
// ===============================
function absUrl(path, name, type, cartella) {
// Se PATH_FULL è attivo, il backend ha già fornito un URL completo
if (window.PATH_FULL) {
return path;
}
// Comportamento normale
return (typeof toAbsoluteUrl === 'function')
? toAbsoluteUrl(path, name, type, cartella)
: path;
}
function mediaUrlsFromPhoto(photo) {
let original, preview;
if (window.PATH_FULL) {
// Percorsi completi già pronti dal backend
original = photo?.path;
preview = photo?.thub2 || photo?.thub1 || photo?.path;
} else {
// Costruzione URL come prima
original = absUrl(photo?.path, photo?.user, "original", photo?.cartella);
preview = absUrl(
photo?.thub2 || photo?.thub1 || photo?.path,
photo?.user,
"thumbs",
photo?.cartella
);
}
return { original, preview };
}
// ===============================
// PRELOAD ±N (solo immagini; per i video: poster/preview)
// ===============================
function preloadNeighbors(N = 3) {
const list = window.modalList || [];
const idx = window.modalIndex || 0;
for (let offset = 1; offset <= N; offset++) {
const iPrev = idx - offset;
const iNext = idx + offset;
[iPrev, iNext].forEach(i => {
const p = list[i];
if (!p) return;
const { original, preview } = mediaUrlsFromPhoto(p);
const isVideo = String(p?.mime_type || '').toLowerCase().startsWith('video/');
const src = isVideo ? (preview || original) : original;
if (!src) return;
const img = new Image();
img.src = src;
});
}
}
// ===============================
// Core: imposta contenuto modal
// ===============================
function setModalContent(photo, srcOriginal, srcPreview) {
const container = document.getElementById('modalMediaContainer');
container.innerHTML = '';
window.currentPhoto = photo;
const isVideo = isProbablyVideo(photo, srcOriginal);
console.log('[openModal]', { isVideo, mime: photo?.mime_type, srcOriginal, srcPreview });
if (isVideo) {
const video = createVideoElement(srcOriginal, srcPreview, photo);
container.appendChild(video);
} else {
const img = createImageElement(srcOriginal, srcPreview);
container.appendChild(img);
}
// Pulsante INFO () dentro il modal — toggle vero
const infoBtn = document.createElement('button');
infoBtn.id = 'modalInfoBtn';
infoBtn.className = 'modal-info-btn';
infoBtn.type = 'button';
infoBtn.setAttribute('aria-label', 'Dettagli');
infoBtn.textContent = '';
container.appendChild(infoBtn);
infoBtn.addEventListener('click', (e) => {
e.stopPropagation(); // non far scattare navigazione
toggleInfo(window.currentPhoto);
});
}
// ===============================
// API base: open/close modal (mantiene sostituzione contenuto)
// ===============================
function openModal(srcOriginal, srcPreview, photo) {
// Chiudi sempre la strip prima di aprire
window.closeBottomSheet?.();
setModalContent(photo, srcOriginal, srcPreview);
modal.classList.add('open');
modal.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
}
function closeModal() {
// Chiudi anche l'info se aperto
if (isInfoOpen()) closeInfo();
const v = document.querySelector('#modal video');
if (v) {
try { v.pause(); } catch (_) {}
v.removeAttribute('src');
while (v.firstChild) v.removeChild(v.firstChild);
try { v.load(); } catch (_) {}
}
const container = document.getElementById('modalMediaContainer');
if (container) container.innerHTML = '';
modal.classList.remove('open');
modal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
// Nascondi frecce alla chiusura (così non "rimangono" visibili)
try {
modalPrev?.classList.add('hidden');
modalNext?.classList.add('hidden');
} catch {}
}
// X: stopPropagation + chiudi
modalClose?.addEventListener('click', (e) => { e.stopPropagation(); closeModal(); });
// Backdrop: chiudi cliccando fuori
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
// ===============================
// Navigazione: lista + indice + prev/next + click ai bordi + tastiera
// ===============================
function openAt(i) {
const list = window.modalList || [];
if (!list[i]) return;
window.modalIndex = i;
const photo = list[i];
const { original, preview } = mediaUrlsFromPhoto(photo);
// Se l'info è aperto, aggiorna i contenuti per la nuova foto
if (isInfoOpen()) {
openInfo(photo);
}
openModal(original, preview, photo); // sostituisce contenuto
preloadNeighbors(3);
updateArrows();
}
window.openModalFromList = function(list, index) {
window.modalList = Array.isArray(list) ? list : [];
window.modalIndex = Math.max(0, Math.min(index || 0, window.modalList.length - 1));
openAt(window.modalIndex);
};
function showPrev() { if (window.modalIndex > 0) openAt(window.modalIndex - 1); }
function showNext() { if (window.modalIndex < (window.modalList.length - 1)) openAt(window.modalIndex + 1); }
// Tastiera
document.addEventListener('keydown', (e) => {
if (!modal.classList.contains('open')) return;
if (e.key === 'ArrowLeft') { e.preventDefault(); showPrev(); }
if (e.key === 'ArrowRight') { e.preventDefault(); showNext(); }
});
// Click ai bordi del modal: sinistra=prev, destra=next (ignora controlli)
modal.addEventListener('click', (e) => {
if (!modal.classList.contains('open')) return;
// Ignora click sui controlli
if (e.target.closest('.modal-info-btn, .modal-close, .modal-nav-btn')) return;
if (e.target === modal) return; // già gestito per chiusura
const rect = modal.getBoundingClientRect();
const x = e.clientX - rect.left;
const side = x / rect.width;
if (side < 0.25) showPrev();
else if (side > 0.75) showNext();
});
// Esporta API base (per compatibilità con codice esistente)
window.openModal = openModal;
window.closeModal = closeModal;
// ===============================
// FRECCE DI NAVIGAZIONE < >
// ===============================
function updateArrows() {
if (!modalPrev || !modalNext) return;
const len = (window.modalList || []).length;
const i = window.modalIndex || 0;
// Mostra frecce solo se ci sono almeno 2 elementi
const show = len > 1;
modalPrev.classList.toggle('hidden', !show);
modalNext.classList.toggle('hidden', !show);
// Disabilita ai bordi (no wrap)
modalPrev.classList.toggle('disabled', i <= 0);
modalNext.classList.toggle('disabled', i >= len - 1);
}
// Click sulle frecce: non propagare (evita conflitti col click sui bordi)
modalPrev?.addEventListener('click', (e) => { e.stopPropagation(); showPrev(); updateArrows(); });
modalNext?.addEventListener('click', (e) => { e.stopPropagation(); showNext(); updateArrows(); });

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

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

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