first commit

This commit is contained in:
Fabio 2026-04-18 20:14:42 +02:00
commit dda72af2fa
262 changed files with 77568 additions and 0 deletions

1
.Fabio.last_event Normal file
View file

@ -0,0 +1 @@
1774133507738

41
.env Normal file
View file

@ -0,0 +1,41 @@
BASE_URL=https://prova.patachina.it
SERVER_PORT=4000
EMAIL=fabio@gmail.com
PASSWORD=master66
JWT_SECRET=123456789
JWT_EXPIRES=1h
# Dove si trova la cartella public (relativa alla root del progetto)
WEB_ROOT=public
# Percorso relativo di index.json dentro public/
INDEX_PATH=photos/index.json
# true = restituisce path assoluti nei record
PATH_FULL=true
# Logging
LOG_MODE=both # console | file | both
LOG_FILE=scan.log
LOG_DIR=public
LOG_VERBOSE=true
# POLLING_TIME in secondi
# Funzionamento:
# - POLLING_TIME = 0 → polling disattivato (solo WebSocket)
# - POLLING_TIME > 0 → esegue incrementalSync() ogni N secondi
# Perché esiste?
# - Serve come "rete di sicurezza" se il WebSocket cade,
# se la tab viene sospesa, o se qualche evento viene perso.
# - Google Photos, Dropbox e iCloud usano lo stesso approccio.
GALLERY_REFRESH_SECONDS=30
WS_PORT=4002
WS_HOST=0.0.0.0
PHOTO_RETENTION_DAYS=30

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/)

278
README_WS.md Normal file
View file

@ -0,0 +1,278 @@
Perfetto Fabio — ora ti preparo il diagramma del flusso completo, chiaro, leggibile e fedele al comportamento reale del tuo sistema.
È un diagramma architetturale, non un semplice schema: mostra tutto il ciclo di vita di una sessione WebSocket affidabile, con SQLite, retry, timeout, needfullsync e sync del client.
---
🟦 DIAGRAMMA DEL FLUSSO COMPLETO — WebSocket Affidabile + Sync
Lo divido in 6 sezioni:
1. Connessione iniziale
2. Autenticazione + session_id
3. Invio evento dal server
4. Ricezione evento dal client
5. Retry + Timeout + needfullsync
6. Riconnessione del client
---
🟩 1) CONNESSIONE INIZIALE
`
CLIENT (browser) SERVER (ws-server.js)
──────────────────────────────────────────────────────────────────────
startWebSocket()
Apre WebSocket ------------------------→ ws.on("connection")
crea ws object
ws.authenticated = false
`
---
🟩 2) AUTENTICAZIONE + SESSION_ID
`
CLIENT SERVER
──────────────────────────────────────────────────────────────────────
Invia:
{
type: "auth",
token: JWT,
session_id: <persistente>
}
↓ ws.on("message")
verifica JWT
cerca sessione in SQLite
se non esiste → createSession()
aggiorna last_ack
salva wsBySession[session_id] = ws
Riceve:
{
type: "auth_ok",
user: "...",
session_id: "...",
needfullsync: true/false
}
Se needfullsync = true → incrementalSync()
`
---
🟩 3) INVIO EVENTO DAL SERVER (broadcast affidabile)
`
SERVER
──────────────────────────────────────────────────────────────────────
broadcastToUserReliable(user, payload)
Genera event_id (UUID)
Per ogni sessione dellutente:
- Se ws attivo:
ws.send(payload + event_id)
addPendingEvent(eventid, sessionid)
- Se ws NON attivo:
setSessionNeedFullSync(session_id, true)
`
---
🟩 4) RICEZIONE EVENTO DAL CLIENT
`
CLIENT
──────────────────────────────────────────────────────────────────────
Riceve evento:
{
type: "added" | "removed" | ...,
id: "...",
event_id: "..."
}
Se event_id già in processedEvents:
- invia solo ACK
- ignora evento
- FINE
Se nuovo:
- applica evento (add/remove foto)
- aggiorna UI
- aggiunge event_id a processedEvents
- invia ACK:
{ type: "ack", event_id }
`
---
🟩 5) RETRY + TIMEOUT + NEEDFULLSYNC
`
SERVER (ogni 3 secondi)
──────────────────────────────────────────────────────────────────────
Legge da SQLite tutti gli eventi pendenti
con sent_at < now - 3000 ms
Per ogni evento:
Se retries < 40 (2 minuti):
- se ws attivo:
ws.send(payload)
updatePendingEventRetry()
- se ws NON attivo:
setSessionNeedFullSync(session_id, true)
deletePendingEvent(event_id)
Se retries >= 40:
- ws.terminate()
- wsBySession.delete(session_id)
- deleteSession(session_id) ← cancella queue
- (sessione verrà ricreata al prossimo auth)
`
---
🟩 6) RICONNESSIONE DEL CLIENT
`
CLIENT riapre la tab / si risveglia
──────────────────────────────────────────────────────────────────────
startWebSocket()
Invia auth con lo stesso session_id
SERVER:
- trova sessione in SQLite?
NO → la ricrea con needfullsync = true
SÌ → legge needfullsync
- invia authok con needfull_sync
CLIENT:
Se needfullsync = true:
incrementalSync()
Altrimenti:
continua con WS realtime
`
---
🟦 RISULTATO DEL FLUSSO
✔ Nessun evento perso
✔ Nessun evento duplicato
✔ Chrome Android non può più “morire in silenzio”
✔ Multidispositivo perfetto
✔ Queue pulita dopo 2 minuti
✔ Sync completo automatico quando serve
✔ WS leggero (ping solo ondemand)
✔ Stato persistente in SQLite
---
┌──────────────────────────┐ ┌──────────────────────────┐
│ CLIENT │ │ SERVER │
└─────────────┬────────────┘ └─────────────┬────────────┘
│ │
│ 1) Connessione WS │
├──────────────────────────────────────→│
│ │
│ 2) AUTH + session_id │
│ {type:auth, token, session_id} │
├──────────────────────────────────────→│
│ │
│ │ verifica JWT
│ │ cerca sessione in DB
│ │ crea/aggiorna sessione
│ │
│ 3) AUTH_OK │
│ ←──────────────────────────────────────┤
│ {need_full_sync:true/false} │
│ │
│ se need_full_sync → incrementalSync() │
│ │
──────────────┼────────────────────────────────────────┼──────────────
│ │
│ 4) Ricezione evento WS │
│ {type:added/removed, event_id} │
│ ←──────────────────────────────────────┤
│ │
│ se event_id già visto → ACK │
│ se nuovo → applica evento │
│ │
│ ACK │
├──────────────────────────────────────→│
│ │
──────────────┼────────────────────────────────────────┼──────────────
│ │
│ │ 5) Retry loop (ogni 3s)
│ │ cerca eventi pendenti
│ │
│ │ se retries < 40:
│ │ reinvia evento
│ │
│ │ se retries >= 40:
│ │ ws.terminate()
│ │ deleteSession()
│ │ (queue eliminata)
│ │
──────────────┼────────────────────────────────────────┼──────────────
│ │
│ 6) Riconnessione client │
├──────────────────────────────────────→│
│ {auth, token, session_id} │
│ │
│ │ server vede sessione mancante
│ │ → need_full_sync = true
│ │
│ AUTH_OK │
│ ←──────────────────────────────────────┤
│ {need_full_sync:true} │
│ │
│ incrementalSync() │
│ │
──────────────┴────────────────────────────────────────┴──────────────
CLIENT → WS CONNECT
CLIENT → AUTH(token, session_id)
SERVER → AUTH_OK(need_full_sync)
EVENTO:
SERVER → EVENT(event_id)
CLIENT:
se nuovo → applica + ACK
se già visto → solo ACK
RETRY LOOP:
se retries < 40 reinvia
se retries >= 40 → chiudi WS + cancella sessione
RICONNESSIONE:
CLIENT → AUTH(session_id)
SERVER → AUTH_OK(need_full_sync=true)
CLIENT → incrementalSync()
______
AUTH → EVENTI → ACK → (retry finché vivo)
se client sparisce → timeout → cancella sessione
se torna → need_full_sync → incrementalSync

1
a.jpg Normal file
View file

@ -0,0 +1 @@
prova

4
api_v1/admin_secret.json Normal file
View file

@ -0,0 +1,4 @@
{
"email": "admin@gmail.com",
"password": "master66"
}

25
api_v1/config.js Normal file
View file

@ -0,0 +1,25 @@
require('dotenv').config();
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',
// Cartella public (root dei file statici)
WEB_ROOT: process.env.WEB_ROOT || 'public',
// PATH_FULL ora è un boolean, non un path
PATH_FULL: (process.env.PATH_FULL || 'false').toLowerCase() === 'true',
// Estensioni supportate
SUPPORTED_EXTS: new Set([
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
'.mp4', '.mov', '.m4v'
]),
// 🔥 NUOVO: intervallo refresh gallery (in secondi)
GALLERY_REFRESH_SECONDS: Number(process.env.GALLERY_REFRESH_SECONDS || 30)
};

BIN
api_v1/database.sqlite Normal file

Binary file not shown.

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;

View file

@ -0,0 +1,64 @@
// api_v1/scanner/debugVideoDates.js
const path = require('path');
const { probeVideo } = require('./video');
async function debugVideo(absPath) {
console.log("🎥 File:", absPath);
const info = await probeVideo(absPath);
console.log("\n=== RAW probeVideo(info) ===\n");
console.dir(info, { depth: 6 });
// Estrazione campi data come nello scanner
const formatTags = info.format?.tags || {};
const stream0Tags = info.streams?.[0]?.tags || {};
const stream1Tags = info.streams?.[1]?.tags || {};
const creationFormat = formatTags.creation_time || null;
const creationStream0 = stream0Tags.creation_time || null;
const creationStream1 = stream1Tags.creation_time || null;
console.log("\n=== DATE CANDIDATE ===");
console.log("format.tags.creation_time :", creationFormat);
console.log("streams[0].tags.creation_time:", creationStream0);
console.log("streams[1].tags.creation_time:", creationStream1);
// Simulazione logica attuale dello scanner (ma esplicita)
let videoDate =
creationFormat ||
creationStream0 ||
creationStream1 ||
null;
console.log("\nScelta videoDate (prima di qualsiasi conversione):", videoDate);
if (videoDate) {
// Variante 1: SENZA conversione (quella che ti ho suggerito)
const takenAtRaw = videoDate;
// Variante 2: CON conversione (quella che ti sballa il giorno)
const takenAtIso = new Date(videoDate).toISOString();
console.log("\n=== CONFRONTO ===");
console.log("takenAtRaw (usato così com'è) :", takenAtRaw);
console.log("takenAtIso (new Date().toISOString):", takenAtIso);
} else {
console.log("\n⚠ Nessuna data trovata nei tag video.");
}
}
async function main() {
const arg = process.argv[2];
if (!arg) {
console.error("Uso: node debugVideoDates.js /percorso/al/video.mp4");
process.exit(1);
}
const absPath = path.resolve(arg);
await debugVideo(absPath);
}
main().catch(err => {
console.error("❌ Errore debugVideoDates:", err);
});

View file

@ -0,0 +1,73 @@
// api_v1/scanner/deleteFolder.js
const path = require('path');
const fsp = require('fs/promises');
const { WEB_ROOT } = require('../config');
module.exports = function createDeleteFolderFunctions(db, RETENTION_DAYS = 30) {
const createCleanupFunctions = require('./orphanCleanup');
const { deleteFromDB, deleteThumbsById } = createCleanupFunctions(db, RETENTION_DAYS);
/**
* Cancella una cartella per un utente:
* - soft delete immediato di tutte le foto
* - hard delete se retention scaduta
* - registra hard delete in deleted_hard
* - elimina thumbs solo per hard delete
*/
async function deleteFolderForUser(userName, folderName) {
// 1) Recupera tutti gli ID nel DB
const rows = await db('photos')
.where({ user: userName, cartella: folderName })
.select('id');
let softCount = 0;
let hardCount = 0;
// 2) Soft delete / Hard delete per ogni foto
for (const r of rows) {
const id = r.id;
const result = await deleteFromDB(id, userName);
if (result === true) {
// deleteFromDB decide se è soft o hard
// possiamo verificare se la foto esiste ancora nel DB
const stillExists = await db("photos")
.where({ id, user: userName })
.first();
if (stillExists) {
softCount++;
} else {
hardCount++;
}
}
}
// 3) Cancella la cartella thumbs residua (solo se hard delete totale)
const thumbsDir = path.join(
WEB_ROOT,
userName,
"thumbs",
folderName
);
try {
await fsp.rm(thumbsDir, { recursive: true, force: true });
console.log(`🧹 Rimossa cartella thumbs residua: ${thumbsDir}`);
} catch (err) {
console.log(`⚠️ Errore rimozione thumbs dir: ${thumbsDir}`, err);
}
return {
totalPhotos: rows.length,
softDeleted: softCount,
hardDeleted: hardCount,
removedThumbsDir: thumbsDir
};
}
return { deleteFolderForUser };
};

View file

@ -0,0 +1,19 @@
// scanner/deleteWithAuth.js
const { API_KEY } = require('../config');
module.exports = async function deleteWithAuth(url) {
// import dinamico compatibile con CommonJS
const fetch = (await import('node-fetch')).default;
const res = await fetch(url, {
method: 'DELETE',
headers: {
'x-api-key': API_KEY,
'Content-Type': 'application/json'
}
});
if (!res.ok) {
throw new Error(`DELETE failed: ${res.status}`);
}
};

View file

@ -0,0 +1,81 @@
// api_v1/scanner/elevation.js
// ---------------------------------------------------------
// Provider 1: OpenElevation
// ---------------------------------------------------------
async function tryOpenElevation(lat, lon) {
try {
const url = `https://api.open-elevation.com/api/v1/lookup?locations=${lat},${lon}`;
const res = await fetch(url, { timeout: 5000 });
if (!res.ok) {
console.log("OpenElevation status:", res.status);
return null;
}
const text = await res.text();
// Se non è JSON → errore HTML → fallback
if (!text.startsWith('{')) {
console.log("OpenElevation non-JSON:", text.slice(0, 60));
return null;
}
const data = JSON.parse(text);
return data?.results?.[0]?.elevation ?? null;
} catch (err) {
console.log("Errore OpenElevation:", err.message);
return null;
}
}
// ---------------------------------------------------------
// Provider 2: OpenTopoData (fallback)
// ---------------------------------------------------------
async function tryOpenTopoData(lat, lon) {
try {
const url = `https://api.opentopodata.org/v1/eudem25m?locations=${lat},${lon}`;
const res = await fetch(url, { timeout: 5000 });
if (!res.ok) {
console.log("OpenTopoData status:", res.status);
return null;
}
const text = await res.text();
if (!text.startsWith('{')) {
console.log("OpenTopoData non-JSON:", text.slice(0, 60));
return null;
}
const data = JSON.parse(text);
return data?.results?.[0]?.elevation ?? null;
} catch (err) {
console.log("Errore OpenTopoData:", err.message);
return null;
}
}
// ---------------------------------------------------------
// Funzione principale con fallback
// ---------------------------------------------------------
async function getElevation(lat, lon) {
// 1⃣ Prova OpenElevation
const elev1 = await tryOpenElevation(lat, lon);
if (elev1 !== null) return elev1;
console.log("⚠️ OpenElevation fallito → uso OpenTopoData");
// 2⃣ Prova OpenTopoData
const elev2 = await tryOpenTopoData(lat, lon);
if (elev2 !== null) return elev2;
console.log("❌ Nessun provider di elevazione disponibile");
return null;
}
module.exports = getElevation;

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 };

43
api_v1/scanner/logger.js Normal file
View file

@ -0,0 +1,43 @@
// scanner/logger.js
const fs = require('fs');
const path = require('path');
const LOG_MODE = process.env.LOG_MODE || "console"; // console | file | both
const LOG_DIR = process.env.LOG_DIR || null;
const LOG_FILE = process.env.LOG_FILE || "scan.log";
let stream = null;
function resolveLogPath() {
if (LOG_DIR) {
return path.resolve(__dirname, "..", "..", LOG_DIR, LOG_FILE);
}
return path.resolve(__dirname, "..", "..", LOG_FILE);
}
if (LOG_MODE === "file" || LOG_MODE === "both") {
const logPath = resolveLogPath();
// crea la directory se non esiste
fs.mkdirSync(path.dirname(logPath), { recursive: true });
stream = fs.createWriteStream(logPath, { flags: "a" });
}
function ts() {
return new Date().toISOString().replace("T", " ").split(".")[0];
}
function log(message) {
const line = `${ts()} ${message}\n`;
if (LOG_MODE === "console" || LOG_MODE === "both") {
process.stdout.write(line);
}
if (LOG_MODE === "file" || LOG_MODE === "both") {
stream.write(line);
}
}
module.exports = { log };

View file

@ -0,0 +1,108 @@
// api_v1/scanner/orphanCleanup.js
const fsp = require('fs/promises');
const path = require('path');
module.exports = function createCleanupFunctions(db, RETENTION_DAYS = 30) {
// 1) Recupera gli ID dal DB per una cartella specifica
async function buildIdsListForFolder(userName, cartella) {
try {
const rows = await db("photos")
.select("id")
.where({ user: userName, cartella });
return rows.map(r => r.id);
} catch (err) {
console.error("Errore buildIdsListForFolder:", err);
return [];
}
}
// 2) Rimuove un ID dalla lista (invariati)
function removeIdFromList(idsIndex, id) {
return idsIndex.filter(x => x !== id);
}
// 3) Cancella thumbs dal filesystem
async function deleteThumbsById(id) {
const rec = await db('photos').where({ id }).first();
if (!rec) return false;
const thumbs = [rec.thub1, rec.thub2].filter(Boolean);
let deleted = false;
for (const t of thumbs) {
const abs = path.resolve(__dirname, '..', '..', 'public', t);
try {
await fsp.rm(abs, { force: true });
console.log(` 🔴 Thumb eliminato: ${abs}`);
deleted = true;
} catch {}
}
return deleted;
}
// 4) Soft delete + Hard delete con retention
async function deleteFromDB(id, userName) {
const now = new Date();
const nowIso = now.toISOString();
// Recupera record attuale
const rec = await db("photos").where({ id, user: userName }).first();
if (!rec) {
console.log(`⚠️ deleteFromDB: foto ${id} non trovata`);
return false;
}
// Se NON era già soft-deleted → SOFT DELETE
if (!rec.deleted_at) {
await db("photos")
.where({ id, user: userName })
.update({
deleted_at: nowIso,
updated_at: nowIso
});
console.log(`🟡 Soft delete → id=${id}`);
return true;
}
// Se ERA già soft-deleted → controlla retention
const deletedAt = new Date(rec.deleted_at);
const retentionMs = RETENTION_DAYS * 24 * 60 * 60 * 1000;
const shouldHardDelete = now - deletedAt > retentionMs;
if (!shouldHardDelete) {
console.log(`🟡 Soft delete già presente → id=${id} (in retention)`);
return true;
}
// 🔥 HARD DELETE
console.log(`🔴 HARD DELETE → id=${id}`);
// 1) Elimina thumbs
await deleteThumbsById(id);
// 2) Elimina record dal DB
await db("photos").where({ id, user: userName }).del();
// 3) Registra hard delete per progressive sync
await db("deleted_hard").insert({
id,
user: userName,
deleted_at: nowIso
});
return true;
}
return {
buildIdsListForFolder,
removeIdFromList,
deleteThumbsById,
deleteFromDB
};
};

View file

@ -0,0 +1,24 @@
// scanner/postWithAuth.js
// Versione locale: niente HTTP, niente token, solo DB
module.exports = function createPostToDB(db) {
/**
* Inserisce o aggiorna un record nel DB SQLite
* (sostituisce completamente axios.post)
*/
async function postToDB(record) {
if (!record || !record.id) {
throw new Error("Record non valido");
}
await db('photos')
.insert(record)
.onConflict('id')
.merge();
return true;
}
return postToDB;
};

View file

@ -0,0 +1,206 @@
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');
//const { getElevation } = require('./elevation');
const getElevation = require('./elevation');
async function processFile(userName, cartella, fileRelPath, absPath, ext, st) {
const isVideo = ['.mp4', '.mov', '.m4v'].includes(ext);
const thumbBase = path.join(
WEB_ROOT,
'photos',
userName,
'thumbs',
cartella,
path.dirname(fileRelPath)
);
await fsp.mkdir(thumbBase, { recursive: true });
// --- Nome file + estensione (dal path ASSOLUTO, sempre corretto) ---
const parsed = path.parse(absPath);
const baseName = parsed.name; // IMG_0249
const extName = parsed.ext; // .JPG
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);
}
// --- EXIF ---
let tags = {};
try {
tags = await ExifReader.load(absPath, { expanded: true });
} catch {}
let timeRaw = tags?.exif?.DateTimeOriginal?.value?.[0] || null;
let takenAtIso = parseExifDateUtc(timeRaw);
// Fallback per i video
if (isVideo) {
const info = await probeVideo(absPath);
// Cerca la data nei punti standard
const creationFormat = info.format?.tags?.creation_time || null;
const creationStream0 = info.streams?.[0]?.tags?.creation_time || null;
const creationStream1 = info.streams?.[1]?.tags?.creation_time || null;
// Scegli la prima disponibile
const videoDate =
creationFormat ||
creationStream0 ||
creationStream1 ||
null;
if (videoDate) {
// NON convertire, NON usare new Date()
takenAtIso = videoDate;
timeRaw = videoDate;
} else {
// Fallback finale: mtime del file
const fallback = new Date(st.mtimeMs).toISOString();
takenAtIso = fallback;
timeRaw = fallback;
}
}
// --- GPS ---
let gps = null;
if (isVideo) {
// i video usano exiftool
gps = await extractGpsWithExiftool(absPath);
} else {
// le foto usano exifreader
gps = extractGpsFromExif(tags);
}
// --- ALTITUDINE DA SERVIZIO ESTERNO SE MANCANTE ---
if (gps && gps.lat && gps.lng) {
if (gps.alt == null) {
gps.alt = await getElevation(gps.lat, gps.lng);
}
}
// --- DIMENSIONI & ROTAZIONE ---
let width = null, height = null, duration = null, duration_ms = null, rotation = 0;
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;
rotation = 0;
if (stream?.tags?.rotate) {
rotation = Number(stream.tags.rotate);
}
const sdl = stream?.side_data_list;
if (sdl && Array.isArray(sdl)) {
const rotEntry = sdl.find(d => d.rotation !== undefined);
if (rotEntry) rotation = Number(rotEntry.rotation);
const matrixEntry = sdl.find(d => typeof d.displaymatrix === 'string');
if (matrixEntry) {
const match = matrixEntry.displaymatrix.match(/rotation of ([\-0-9]+) degrees/i);
if (match) rotation = Number(match[1]);
}
}
rotation = ((rotation % 360) + 360) % 360;
}
duration = info.format?.duration || null;
duration_ms = duration ? Math.round(duration * 1000) : null;
} else {
try {
const meta = await sharp(absPath).metadata();
width = meta.width || null;
height = meta.height || null;
} catch {}
try {
const raw =
tags?.exif?.Orientation?.value ??
tags?.image?.Orientation?.value ??
tags?.ifd0?.Orientation?.value ??
null;
const val = Array.isArray(raw) ? raw[0] : raw;
const map = { 1: 0, 3: 180, 6: 90, 8: 270 };
rotation = map[val] ?? 0;
} 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 ---
//
const relPath = fileRelPath;
const relThub1 = fileRelPath.replace(/\.[^.]+$/, '_min.jpg');
const relThub2 = fileRelPath.replace(/\.[^.]+$/, '_avg.jpg');
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,
name: baseName + extName, // 👈 SEMPRE CORRETTO
path: fullPath,
thub1: fullThub1,
thub2: fullThub2,
gps,
data: timeRaw,
taken_at: takenAtIso,
mime_type,
width,
height,
rotation,
size_bytes: st.size,
mtimeMs: st.mtimeMs,
duration_ms: isVideo ? duration_ms : null,
location
};
}
module.exports = processFile;

View file

@ -0,0 +1,97 @@
// api_v1/scanner/recordChange.js
const { log } = require('./logger');
const wss = require('../../ws-server'); // IMPORTANTE
async function recordChange(db, photo_id, user, change_type) {
const timestamp = new Date().toISOString();
// -----------------------------------------------------
// DEDUPLICA SOLO PER "removed"
// -----------------------------------------------------
if (change_type === 'removed') {
const already = await db('photo_changes')
.where({ photo_id, user, change_type: 'removed' })
.first();
if (already) {
log(`📘 [CHANGES] removed già registrato → ${photo_id}, ma invio comunque WS`);
// -----------------------------------------------------
// 🔥 INVIA WS ANCHE SE GIÀ REGISTRATO
// -----------------------------------------------------
console.log("WS → invio evento (USER) [removed già registrato]");
try {
wss.broadcastToUser(user, {
type: "removed",
id: photo_id
});
console.log("WS → OK USER (removed già registrato)");
} catch (e) {
console.error("WS → ERRORE USER (removed già registrato):", e);
}
console.log("WS → invio evento (ADMIN) [removed già registrato]");
try {
wss.broadcastToAdmins({
type: "removed",
id: photo_id,
user
});
console.log("WS → OK ADMIN (removed già registrato)");
} catch (e) {
console.error("WS → ERRORE ADMIN (removed già registrato):", e);
}
return;
}
}
// -----------------------------------------------------
// SALVA NEL DB (added o primo removed)
// -----------------------------------------------------
await db("photo_changes").insert({
photo_id,
user,
change_type,
timestamp
});
log(`📘 [CHANGES] ${change_type}${photo_id}`);
// -----------------------------------------------------
// LOG PRIMA DEL BROADCAST
// -----------------------------------------------------
console.log("WS → invio evento:", { user, change_type, photo_id });
// -----------------------------------------------------
// 🔥 BROADCAST A USER
// -----------------------------------------------------
console.log("WS → invio evento (USER)");
try {
wss.broadcastToUser(user, {
type: change_type,
id: photo_id
});
console.log("WS → OK USER");
} catch (e) {
console.error("WS → ERRORE USER:", e);
}
// -----------------------------------------------------
// 🔥 BROADCAST A ADMIN
// -----------------------------------------------------
console.log("WS → invio evento (ADMIN)");
try {
wss.broadcastToAdmins({
type: change_type,
id: photo_id,
user
});
console.log("WS → OK ADMIN");
} catch (e) {
console.error("WS → ERRORE ADMIN:", e);
}
}
module.exports = recordChange;

210
api_v1/scanner/scanAuto.js Normal file
View file

@ -0,0 +1,210 @@
const path = require('path');
const fsp = require('fs/promises');
const scanCartella = require('./scanCartella');
const processFile = require('./processFile');
const { sha256 } = require('./utils');
const createCleanupFunctions = require('./orphanCleanup');
const { WEB_ROOT } = require('../config');
const { log } = require('./logger');
// ---------------------------------------------------------
// NORMALIZZAZIONE PATH (toglie /app/...)
// ---------------------------------------------------------
function normalizeRelPath(user, dirPath, fileName) {
if (!dirPath) return "";
let rel = dirPath.replace(/\\/g, "/");
// Rimuove tutto fino a /photos/
rel = rel.replace(/^.*\/photos\//, "");
// Ora rel = "Fabio/original/2017Irlanda/.../"
const prefix = `${user}/original/`;
if (rel.startsWith(prefix)) {
rel = rel.slice(prefix.length);
}
if (rel.startsWith("/")) rel = rel.slice(1);
// Aggiunge il file
if (fileName) {
rel = `${rel}/${fileName}`.replace(/\/+/g, "/");
}
return rel; // es: "2017Irlanda19-29ago/IMG_0120.JPG"
}
// ---------------------------------------------------------
// CALCOLO ID IDENTICO AL VECCHIO SCAN
// ---------------------------------------------------------
function computeId(user, relPath) {
const parts = relPath.split('/').filter(Boolean);
const cartella = parts[0];
const fileRelPath = parts.slice(1).join('/');
const id = sha256(`${user}/${cartella}/${fileRelPath}`);
return { id, cartella, fileRelPath };
}
// ---------------------------------------------------------
// 1) ADD FILE
// ---------------------------------------------------------
async function handleAddFile(user, dirPath, fileName, db) {
const relPath = normalizeRelPath(user, dirPath, fileName);
log(`🟦 [ADD] user=${user} file=${relPath}`);
// 🔥 Ricostruzione identica al vecchio scanPhoto
const parts = relPath.split('/');
const cartella = parts[0]; // es: "2017Irlanda19-29ago"
const fileRelPath = parts.slice(1).join('/'); // es: "IMG_0116.JPG"
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
const absPath = path.join(photosRoot, user, 'original', cartella, fileRelPath);
const ext = path.extname(fileName).toLowerCase();
const st = await fsp.stat(absPath);
// 🔥 FIX: processFile deve ricevere fileRelPath completo di "original/"
const meta = await processFile(
user,
cartella,
path.posix.join('original', cartella, fileRelPath),
absPath,
ext,
st
);
// Inserimento identico al vecchio scanPhoto
const row = {
id: meta.id,
user,
cartella,
name: meta.name,
path: meta.path,
thub1: meta.thub1,
thub2: meta.thub2,
mime_type: meta.mime_type,
width: meta.width,
height: meta.height,
rotation: meta.rotation,
size_bytes: meta.size_bytes,
mtimeMs: meta.mtimeMs,
duration_ms: meta.duration_ms ?? null,
taken_at: meta.taken_at ?? null,
data: meta.data ?? null,
lat: meta.gps?.lat ?? null,
lon: meta.gps?.lng ?? null,
alt: meta.gps?.alt ?? null,
location: meta.location ? JSON.stringify(meta.location) : null
};
await db('photos')
.insert(row)
.onConflict('id')
.merge();
log(`🟢 [ADD] Inserito/aggiornato id=${meta.id}`);
return row;
}
// ---------------------------------------------------------
// 2) DELETE FILE
// ---------------------------------------------------------
async function handleDeleteFile(user, dirPath, fileName, db) {
const relPath = normalizeRelPath(user, dirPath, fileName);
log(`🟥 [DEL] user=${user} file=${relPath}`);
const { id } = computeId(user, relPath);
const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db);
await deleteThumbsById(id);
await deleteFromDB(id, user);
log(`🔴 [DEL] File eliminato id=${id}`);
return { deleted: id };
}
// ---------------------------------------------------------
// 3) ADD DIRECTORY
// ---------------------------------------------------------
async function handleAddDir(user, dir, db) {
log(`🟦 [ADD_DIR] user=${user} dir=${dir}`);
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
const absDir = path.join(photosRoot, user, 'original', dir);
log(`📁 [ADD_DIR] absDir=${absDir}`);
let results = [];
for await (const f of scanCartella(user, dir, absDir, db)) {
log(`📄 [ADD_DIR] File trovato: ${f.relPath}`);
const meta = await processFile(
user,
dir,
f.relPath,
f.absPath,
f.ext,
f.stat
);
await db('photos').insert(meta).onConflict('id').merge();
log(`🟢 [ADD_DIR] Inserito/aggiornato id=${meta.id} name=${meta.name}`);
results.push(meta);
}
log(`🟣 [ADD_DIR] Completato. Files=${results.length}`);
return results;
}
// ---------------------------------------------------------
// 4) DELETE DIRECTORY
// ---------------------------------------------------------
async function handleDeleteDir(user, dir, db) {
log(`🟥 [DEL_DIR] user=${user} dir=${dir}`);
const rows = await db('photos')
.where({ user, cartella: dir })
.select('id');
const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db);
log(`📁 [DEL_DIR] Trovati ${rows.length} file da eliminare`);
for (const r of rows) {
log(`🗑️ [DEL_DIR] Eliminazione id=${r.id}`);
await deleteThumbsById(r.id);
await deleteFromDB(r.id, user);
}
log(`🔴 [DEL_DIR] Cartella eliminata. Totale file=${rows.length}`);
return { deleted: rows.length };
}
module.exports = {
handleAddFile,
handleDeleteFile,
handleAddDir,
handleDeleteDir
};

View file

@ -0,0 +1,68 @@
// api_v1/scanner/scanCartella.js
const path = require('path');
const fsp = require('fs/promises');
const fs = require('fs');
const { sha256 } = require('./utils');
const { SUPPORTED_EXTS } = require('../config');
const { log } = require('./logger');
/**
* Scansiona ricorsivamente la cartella:
* /photos/<user>/original/<cartella>/
*
* Restituisce (yield) i metadati dei file trovati.
*/
async function* scanCartella(userName, cartella, absCartella, db) {
async function* walk(currentAbs, relPath = '') {
let entries = [];
try {
entries = await fsp.readdir(currentAbs, { withFileTypes: true });
} catch {
return;
}
console.log(entries);
for (const e of entries) {
const absPath = path.join(currentAbs, e.name);
if (e.isDirectory()) {
yield* 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;
// ID deterministico basato sul percorso
const id = sha256(`${userName}/${cartella}/${fileRelPath}`);
let st;
try {
st = await fsp.stat(absPath);
} catch {
continue;
}
log(`📂 scanCartella → user=${userName} cartella=${cartella} relPath=${fileRelPath} id=${id}`);
yield {
id,
user: userName,
cartella,
name: e.name,
relPath: fileRelPath,
absPath,
ext,
stat: st,
path: `/photos/${userName}/original/${cartella}/${fileRelPath}`
};
}
}
yield* walk(absCartella);
}
module.exports = scanCartella;

233
api_v1/scanner/scanFile.js Normal file
View file

@ -0,0 +1,233 @@
// api_v1/scanner/scanFile.js
const path = require('path');
const fsp = require('fs/promises');
const { sha256 } = require('./utils');
const { SUPPORTED_EXTS } = require('../config');
const { log } = require('./logger');
/**
* scanFile: genera informazioni su UN singolo file
* Restituisce lo stesso formato di scanCartella, ma senza recursion.
*
* Parametri:
* - userName
* - cartella
* - absFile (percorso assoluto del file)
* - db (opzionale)
*/
async function scanFileEntry(userName, cartella, absFile, db) {
const ext = path.extname(absFile).toLowerCase();
if (!SUPPORTED_EXTS.has(ext)) {
log(`⛔ Estensione non supportata: ${ext}`);
return null;
}
const name = path.basename(absFile);
const relPath = name; // singolo file → niente struttura ricorsiva
let st;
try {
st = await fsp.stat(absFile);
} catch {
log(`⛔ Impossibile leggere stat per ${absFile}`);
return null;
}
const id = sha256(`${userName}/${cartella}/${relPath}`);
log(`📄 scanFile → user=${userName} cartella=${cartella} file=${name} id=${id}`);
return {
id,
user: userName,
cartella,
name,
relPath,
absPath: absFile,
ext,
stat: st,
path: `/photos/${userName}/original/${cartella}/${relPath}`
};
}
async function scanFile(userName, cartella, absFile, db) {
const f = await scanFile(user, cart, absFile);
const fileName = f.name;
const id = f.id;
const st = f.stat;
let prev = await db("photos")
.select("id", "size_bytes", "mtimeMs", "_indexHash", "fast_hash", "path")
.where({ id })
.first();
if (prev && LOG_VERBOSE) {
log(`${prefix} ⚪ Invariato: ${fileName}`);
}
const fastHash = sha256(`${st.size}-${st.mtimeMs}`);
// ---------------------------------------------------------
// PATH CAMBIATO = NUOVO FILE
// ---------------------------------------------------------
if (prev && prev.path !== f.path) {
log(`${prefix} 🟢 Nuovo/Modificato: ${fileName}`);
prev = null;
}
// ---------------------------------------------------------
// NUOVO FILE
// ---------------------------------------------------------
if (!prev) {
log(`${prefix} 🟢 Nuovo/Modificato: ${fileName}`);
const meta = await processFile(
user,
cartella,
f.relPath,
f.absPath,
f.ext,
st
);
meta.id = id;
meta.path = f.path;
// INSERT CORRETTO (senza colonna gps)
await db("photos").insert({
id: meta.id,
user,
cartella,
name: meta.name,
path: meta.path,
thub1: meta.thub1,
thub2: meta.thub2,
mime_type: meta.mime_type,
width: meta.width,
height: meta.height,
rotation: meta.rotation,
size_bytes: meta.size_bytes,
mtimeMs: meta.mtimeMs,
duration_ms: meta.duration_ms,
taken_at: meta.taken_at,
data: meta.data,
lat: meta.gps?.lat ?? null,
lon: meta.gps?.lng ?? null,
alt: meta.gps?.alt ?? null,
location: meta.location ? JSON.stringify(meta.location) : null,
_indexHash: meta._indexHash,
fast_hash: fastHash
});
await db('photo_changes').insert({
photo_id: meta.id,
user,
change_type: 'added',
timestamp: new Date().toISOString()
});
newFiles.push(meta);
//log(`🟢 [PUSH newFiles] id=${meta.id} path=${meta.path}`);
continue;
}
// ---------------------------------------------------------
// FAST-SIZE-SKIP
// ---------------------------------------------------------
if (prev.size_bytes === st.size) {
//log(`🔵 [FAST-SIZE-SKIP] id=${id}`);
await db("photos")
.where({ id })
.update({
path: f.path,
size_bytes: st.size,
mtimeMs: st.mtimeMs,
fast_hash: fastHash
});
continue;
}
// ---------------------------------------------------------
// FAST-HASH-SKIP
// ---------------------------------------------------------
if (prev.fast_hash === fastHash) {
//log(`🔵 [FAST-HASH-SKIP] id=${id}`);
await db("photos")
.where({ id })
.update({
path: f.path,
size_bytes: st.size,
mtimeMs: st.mtimeMs
});
continue;
}
// ---------------------------------------------------------
// MODIFICATO
// ---------------------------------------------------------
//log(`🟠 [FULL-SCAN] id=${id}`);
log(`${prefix} 🟠 Nuovo/Modificato: ${fileName}`);
const meta = await processFile(
user,
cartella,
f.relPath,
f.absPath,
f.ext,
st
);
meta.id = id;
meta.path = f.path;
await db("photos")
.insert({
id: meta.id,
user,
cartella,
name: meta.name,
path: meta.path,
thub1: meta.thub1,
thub2: meta.thub2,
mime_type: meta.mime_type,
width: meta.width,
height: meta.height,
rotation: meta.rotation,
size_bytes: meta.size_bytes,
mtimeMs: meta.mtimeMs,
duration_ms: meta.duration_ms,
taken_at: meta.taken_at,
data: meta.data,
lat: meta.gps?.lat ?? null,
lon: meta.gps?.lng ?? null,
alt: meta.gps?.alt ?? null,
location: meta.location ? JSON.stringify(meta.location) : null,
_indexHash: meta._indexHash,
fast_hash: fastHash
})
.onConflict("id")
.merge();
await db('photo_changes').insert({
photo_id: meta.id,
user,
change_type: 'updated',
timestamp: new Date().toISOString()
});
newFiles.push(meta);
log(`${prefix} 🟠 Nuovo/Modificato al server ${fileName}`);
}
}
}
module.exports = scanFile;

View file

@ -0,0 +1,53 @@
// api_v1/scanner/scanFile.js
const path = require('path');
const fsp = require('fs/promises');
const { sha256 } = require('./utils');
const { SUPPORTED_EXTS } = require('../config');
const { log } = require('./logger');
/**
* scanFile: genera informazioni su UN singolo file
* Restituisce lo stesso formato di scanCartella, ma senza recursion.
*
* Parametri:
* - userName
* - cartella
* - absFile (percorso assoluto del file)
* - db (opzionale)
*/
async function scanFile(userName, cartella, absFile, db) {
const ext = path.extname(absFile).toLowerCase();
if (!SUPPORTED_EXTS.has(ext)) {
log(`⛔ Estensione non supportata: ${ext}`);
return null;
}
const name = path.basename(absFile);
const relPath = name; // singolo file → niente struttura ricorsiva
let st;
try {
st = await fsp.stat(absFile);
} catch {
log(`⛔ Impossibile leggere stat per ${absFile}`);
return null;
}
const id = sha256(`${userName}/${cartella}/${relPath}`);
log(`📄 scanFile → user=${userName} cartella=${cartella} file=${name} id=${id}`);
return {
id,
user: userName,
cartella,
name,
relPath,
absPath: absFile,
ext,
stat: st,
path: `/photos/${userName}/original/${cartella}/${relPath}`
};
}
module.exports = scanFile;

View file

@ -0,0 +1,55 @@
// api_v1/scanner/scanFileEntry.js
const path = require('path');
const fsp = require('fs/promises');
const { sha256 } = require('./utils');
const { SUPPORTED_EXTS } = require('../config');
const { log } = require('./logger');
/**
* scanFile: genera informazioni su UN singolo file
* Restituisce lo stesso formato di scanCartella, ma senza recursion.
*
* Parametri:
* - userName
* - cartella
* - absFile (percorso assoluto del file)
* - db (opzionale)
*/
async function scanFile(userName, cartella, absFile, db) {
const ext = path.extname(absFile).toLowerCase();
if (!SUPPORTED_EXTS.has(ext)) {
log(`⛔ Estensione non supportata: ${ext}`);
return null;
}
const name = path.basename(absFile);
const relPath = name; // singolo file → niente struttura ricorsiva
// ID deterministico (funziona anche se il file non esiste)
const id = sha256(`${userName}/${cartella}/${relPath}`);
let st = null;
try {
st = await fsp.stat(absFile);
} catch {
// File NON esiste → caso DEL
log(`⛔ Impossibile leggere stat per ${absFile} (file mancante)`);
// Continuiamo comunque: l'ID è valido e serve per DEL
}
log(`📄 scanFile → user=${userName} cartella=${cartella} file=${name} id=${id}`);
return {
id,
user: userName,
cartella,
name,
relPath,
absPath: absFile,
ext,
stat: st, // può essere null
path: `/photos/${userName}/original/${cartella}/${relPath}`
};
}
module.exports = scanFile;

View file

@ -0,0 +1,76 @@
// api_v1/scanner/scanNewCartella.js
const path = require('path');
const fsp = require('fs/promises');
const { WEB_ROOT, SUPPORTED_EXTS } = require('../config');
const scanPhoto = require('./scanPhoto');
// ---------------------------------------------------------
// Conta ricorsivamente i file in /photos/<user>/original/<folder>/
// ---------------------------------------------------------
async function countFilesInUserFolder(userName, folderName) {
const rootDir = path.join(WEB_ROOT, userName, "original", folderName);
let count = 0;
async function walk(dir) {
let entries = [];
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const abs = path.join(dir, e.name);
if (e.isDirectory()) {
await walk(abs);
} else {
const ext = path.extname(e.name).toLowerCase();
if (SUPPORTED_EXTS.has(ext)) {
count++;
}
}
}
}
await walk(rootDir);
return count;
}
// ---------------------------------------------------------
// Crea thumbs/<folder> e scansiona la cartella come fa /scan
// ---------------------------------------------------------
async function scanNewCartella(userName, folderName, db) {
// 1) crea la cartella root in thumbs
const thumbsDir = path.join(WEB_ROOT, userName, "thumbs", folderName);
await fsp.mkdir(thumbsDir, { recursive: true });
// 2) conta i file ricorsivamente
const TOTAL_FILES = await countFilesInUserFolder(userName, folderName);
// 3) esegui lo scan della cartella
const CURRENT = { value: 0 };
const start = Date.now();
// ⭐ newFiles conterrà i meta delle foto nuove
const newFiles = await scanPhoto(
folderName,
userName,
db,
CURRENT,
TOTAL_FILES,
start
);
return {
ok: true,
folder: folderName,
totalFiles: TOTAL_FILES,
newFiles
};
}
module.exports = {
scanNewCartella,
countFilesInUserFolder
};

View file

@ -0,0 +1,75 @@
// api_v1/scanner/scanNewCartella.js
const path = require('path');
const fsp = require('fs/promises');
const { WEB_ROOT, SUPPORTED_EXTS } = require('../config');
const scanPhoto = require('./scanPhoto');
// ---------------------------------------------------------
// Conta ricorsivamente i file in /photos/<user>/original/<folder>/
// ---------------------------------------------------------
async function countFilesInUserFolder(userName, folderName) {
const rootDir = path.join(WEB_ROOT, userName, "original", folderName);
let count = 0;
async function walk(dir) {
let entries = [];
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const abs = path.join(dir, e.name);
if (e.isDirectory()) {
await walk(abs);
} else {
const ext = path.extname(e.name).toLowerCase();
if (SUPPORTED_EXTS.has(ext)) {
count++;
}
}
}
}
await walk(rootDir);
return count;
}
// ---------------------------------------------------------
// Crea thumbs/<folder> e scansiona la cartella come fa /scan
// ---------------------------------------------------------
async function scanNewCartella(userName, folderName, db) {
// 1) crea la cartella root in thumbs
const thumbsDir = path.join(WEB_ROOT, userName, "thumbs", folderName);
await fsp.mkdir(thumbsDir, { recursive: true });
// 2) conta i file ricorsivamente
const TOTAL_FILES = await countFilesInUserFolder(userName, folderName);
// 3) esegui lo scan della cartella
const CURRENT = { value: 0 };
const start = Date.now();
const newFiles = await scanPhoto(
folderName,
userName,
db,
CURRENT,
TOTAL_FILES,
start
);
return {
ok: true,
folder: folderName,
totalFiles: TOTAL_FILES,
newFiles
};
}
module.exports = {
scanNewCartella,
countFilesInUserFolder
};

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

@ -0,0 +1,101 @@
// api_v1/scanner/scanPhoto.js
const path = require('path');
const fsp = require('fs/promises');
const { log } = require('./logger');
const scanPhotoCartella = require('./scanPhotoCartella');
const postWithAuth = require('./postWithAuth');
const createCleanupFunctions = require('./orphanCleanup');
const {
WEB_ROOT,
SEND_PHOTOS,
BASE_URL
} = require('../config');
// ---------------------------------------------------------
// FORMAT TIME
// ---------------------------------------------------------
function formatTime(ms) {
const sec = Math.floor(ms / 1000);
const h = String(Math.floor(sec / 3600)).padStart(2, '0');
const m = String(Math.floor((sec % 3600) / 60)).padStart(2, '0');
const s = String(sec % 60).padStart(2, '0');
return `${h}:${m}:${s}`;
}
// ---------------------------------------------------------
// UPDATE STATUS FILE (legacy, può restare per compatibilità)
// ---------------------------------------------------------
async function updateStatusFile(CURRENT, TOTAL_FILES, start) {
const now = Date.now();
const elapsedMs = now - start;
const avg = elapsedMs / Math.max(CURRENT.value, 1);
const remainingMs = (TOTAL_FILES - CURRENT.value) * avg;
const status = {
current: CURRENT.value,
total: TOTAL_FILES,
percent: Number((CURRENT.value / TOTAL_FILES * 100).toFixed(2)),
eta: formatTime(remainingMs),
elapsed: formatTime(elapsedMs)
};
const statusPath = path.resolve(__dirname, "..", "..", "public/photos/scan_status.json");
await fsp.writeFile(statusPath, JSON.stringify(status));
}
// ---------------------------------------------------------
// SCAN UNA SOLA CARTELLA
// ---------------------------------------------------------
async function scanPhoto(dir, userName, db, CURRENT, TOTAL_FILES, start, onProgress) {
const newFiles = [];
const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db);
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
const userDir = path.join(photosRoot, userName, 'original');
const cartella = dir;
const absCartella = path.join(userDir, cartella);
log(`📁 [SCAN CARTELLA] ${cartella}`);
await scanPhotoCartella(
db,
userName,
cartella,
absCartella,
newFiles,
updateStatusFile, // può restare, ma non è più essenziale
CURRENT,
TOTAL_FILES,
start,
deleteThumbsById,
deleteFromDB,
onProgress // 🔥 PASSIAMO LA CALLBACK
);
// ---------------------------------------------------------
// INVIO AL SERVER REMOTO
// ---------------------------------------------------------
if (SEND_PHOTOS && BASE_URL && newFiles.length > 0) {
log(`📤 [SEND START] newFiles=${newFiles.length}`);
for (const p of newFiles) {
try {
p.user = userName;
await postWithAuth(`${BASE_URL}/photos`, p);
log(`📥 [SENT TO SERVER] ${p.name}`);
} catch (err) {
log(`❌ [SERVER SENT ERROR] ${p.name}${err.message}`);
}
}
} else {
log(`⚠️ [NO SEND] newFiles=${newFiles.length}`);
}
return newFiles;
}
module.exports = scanPhoto;

View file

@ -0,0 +1,101 @@
// api_v1/scanner/scanPhoto.js
const path = require('path');
const fsp = require('fs/promises');
const { log } = require('./logger');
const scanPhotoCartella = require('./scanPhotoCartella');
const postWithAuth = require('./postWithAuth');
const createCleanupFunctions = require('./orphanCleanup');
const {
WEB_ROOT,
SEND_PHOTOS,
BASE_URL
} = require('../config');
// ---------------------------------------------------------
// FORMAT TIME
// ---------------------------------------------------------
function formatTime(ms) {
const sec = Math.floor(ms / 1000);
const h = String(Math.floor(sec / 3600)).padStart(2, '0');
const m = String(Math.floor((sec % 3600) / 60)).padStart(2, '0');
const s = String(sec % 60).padStart(2, '0');
return `${h}:${m}:${s}`;
}
// ---------------------------------------------------------
// UPDATE STATUS FILE (legacy, può restare per compatibilità)
// ---------------------------------------------------------
async function updateStatusFile(CURRENT, TOTAL_FILES, start) {
const now = Date.now();
const elapsedMs = now - start;
const avg = elapsedMs / Math.max(CURRENT.value, 1);
const remainingMs = (TOTAL_FILES - CURRENT.value) * avg;
const status = {
current: CURRENT.value,
total: TOTAL_FILES,
percent: Number((CURRENT.value / TOTAL_FILES * 100).toFixed(2)),
eta: formatTime(remainingMs),
elapsed: formatTime(elapsedMs)
};
const statusPath = path.resolve(__dirname, "..", "..", "public/photos/scan_status.json");
await fsp.writeFile(statusPath, JSON.stringify(status));
}
// ---------------------------------------------------------
// SCAN UNA SOLA CARTELLA
// ---------------------------------------------------------
async function scanPhoto(dir, userName, db, CURRENT, TOTAL_FILES, start, onProgress) {
const newFiles = [];
const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db);
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
const userDir = path.join(photosRoot, userName, 'original');
const cartella = dir;
const absCartella = path.join(userDir, cartella);
log(`📁 [SCAN CARTELLA] ${cartella}`);
await scanPhotoCartella(
db,
userName,
cartella,
absCartella,
newFiles,
updateStatusFile, // può restare, ma non è più essenziale
CURRENT,
TOTAL_FILES,
start,
deleteThumbsById,
deleteFromDB,
onProgress // 🔥 PASSIAMO LA CALLBACK
);
// ---------------------------------------------------------
// INVIO AL SERVER REMOTO
// ---------------------------------------------------------
if (SEND_PHOTOS && BASE_URL && newFiles.length > 0) {
log(`📤 [SEND START] newFiles=${newFiles.length}`);
for (const p of newFiles) {
try {
p.user = userName;
await postWithAuth(`${BASE_URL}/photos`, p);
log(`📥 [SENT TO SERVER] ${p.name}`);
} catch (err) {
log(`❌ [SERVER SENT ERROR] ${p.name}${err.message}`);
}
}
} else {
log(`⚠️ [NO SEND] newFiles=${newFiles.length}`);
}
return newFiles;
}
module.exports = scanPhoto;

View file

@ -0,0 +1,62 @@
// api_v1/scanner/scanPhotoCartella.js
const scanCartella = require('./scanCartella');
const scanPhotoSingle = require('./scanPhotoSingle');
const { log } = require('./logger');
async function scanPhotoCartella(
db,
user,
cartella,
absCartella,
newFiles,
updateStatusFile,
CURRENT,
TOTAL_FILES,
start,
deleteThumbsById,
deleteFromDB,
onProgress // 🔥 AGGIUNTO
) {
log(`📁 Scan cartella: ${cartella}`);
// Recupera gli ID già presenti nel DB
const rows = await db('photos')
.where({ user, cartella })
.select('id');
const idsSet = new Set(rows.map(r => r.id));
// Scansiona i file della cartella
for await (const f of scanCartella(user, cartella, absCartella, db)) {
// Aggiorna il contatore
CURRENT.value++;
// 🔥 Aggiorna stato avanzamento (vecchio sistema)
await updateStatusFile(CURRENT, TOTAL_FILES, start);
// 🔥 Aggiorna stato avanzamento (nuovo sistema)
if (onProgress) {
await onProgress();
}
// Processa il file
const meta = await scanPhotoSingle(db, user, cartella, f, newFiles);
// Anche se identico, NON è orfano
idsSet.delete(f.id);
}
// Gestione orfani
for (const orphanId of idsSet) {
log(`🔴 Orfano: ${orphanId}`);
await deleteThumbsById(orphanId);
await deleteFromDB(orphanId, user);
}
}
module.exports = scanPhotoCartella;

View file

@ -0,0 +1,144 @@
// api_v1/scanner/scanPhotoSingle.js
const processFile = require('./processFile');
const { sha256 } = require('./utils');
const { log } = require('./logger');
async function scanPhotoSingle(db, user, cartella, f, newFiles, prefix = '') {
const fileName = f.name;
const id = f.id;
const st = f.stat;
// Recupero precedente SENZA contentHash
let prev = await db("photos")
.select("id", "size_bytes", "mtimeMs", "fast_hash", "path")
.where({ id })
.first();
// Hash veloce basato su size + mtime
const fastHash = sha256(`${st.size}-${st.mtimeMs}`);
// ---------------------------------------------------------
// PATH CAMBIATO = NUOVO FILE
// ---------------------------------------------------------
if (prev && prev.path !== f.path) {
log(`${prefix} 🟢 Nuovo/Modificato (path changed): ${fileName}`);
prev = null;
}
// ---------------------------------------------------------
// NUOVO FILE
// ---------------------------------------------------------
if (!prev) {
log(`${prefix} 🟢 Nuovo: ${fileName}`);
const meta = await processFile(
user,
cartella,
f.relPath,
f.absPath,
f.ext,
st
);
meta.id = id;
meta.path = f.path;
await db("photos").insert({
id: meta.id,
user,
cartella,
name: meta.name,
path: meta.path,
thub1: meta.thub1,
thub2: meta.thub2,
mime_type: meta.mime_type,
width: meta.width,
height: meta.height,
rotation: meta.rotation,
size_bytes: meta.size_bytes,
mtimeMs: meta.mtimeMs,
duration_ms: meta.duration_ms,
taken_at: meta.taken_at,
data: meta.data,
lat: meta.gps?.lat ?? null,
lon: meta.gps?.lng ?? null,
alt: meta.gps?.alt ?? null,
location: meta.location ? JSON.stringify(meta.location) : null,
fast_hash: fastHash,
// ⭐ AGGIUNTA FONDAMENTALE
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
newFiles.push(meta);
return meta;
}
// ---------------------------------------------------------
// IDENTICO (fastHash uguale)
// ---------------------------------------------------------
if (prev.fast_hash === fastHash) {
await db("photos")
.where({ id })
.update({
path: f.path,
size_bytes: st.size,
mtimeMs: st.mtimeMs,
fast_hash: fastHash,
// ⭐ AGGIUNTA: anche un file identico deve aggiornare updated_at
updated_at: new Date().toISOString()
});
return null;
}
// ---------------------------------------------------------
// MODIFICATO (fastHash diverso)
// ---------------------------------------------------------
log(`${prefix} 🟠 Modificato: ${fileName}`);
const meta = await processFile(
user,
cartella,
f.relPath,
f.absPath,
f.ext,
st
);
meta.id = id;
meta.path = f.path;
await db("photos")
.where({ id })
.update({
name: meta.name,
path: meta.path,
thub1: meta.thub1,
thub2: meta.thub2,
mime_type: meta.mime_type,
width: meta.width,
height: meta.height,
rotation: meta.rotation,
size_bytes: meta.size_bytes,
mtimeMs: meta.mtimeMs,
duration_ms: meta.duration_ms,
taken_at: meta.taken_at,
data: meta.data,
lat: meta.gps?.lat ?? null,
lon: meta.gps?.lng ?? null,
alt: meta.gps?.alt ?? null,
location: meta.location ? JSON.stringify(meta.location) : null,
fast_hash: fastHash,
// ⭐ AGGIUNTA FONDAMENTALE
updated_at: new Date().toISOString()
});
newFiles.push(meta);
return meta;
}
module.exports = scanPhotoSingle;

View file

@ -0,0 +1,139 @@
// api_v1/scanner/scanPhotoSingle.js
const processFile = require('./processFile');
const { sha256 } = require('./utils');
const { log } = require('./logger');
async function scanPhotoSingle(db, user, cartella, f, newFiles, prefix = '') {
const fileName = f.name;
const id = f.id;
const st = f.stat;
// Recupero precedente SENZA contentHash
let prev = await db("photos")
.select("id", "size_bytes", "mtimeMs", "fast_hash", "path")
.where({ id })
.first();
// Hash veloce basato su size + mtime
const fastHash = sha256(`${st.size}-${st.mtimeMs}`);
// ---------------------------------------------------------
// PATH CAMBIATO = NUOVO FILE
// ---------------------------------------------------------
if (prev && prev.path !== f.path) {
log(`${prefix} 🟢 Nuovo/Modificato (path changed): ${fileName}`);
prev = null;
}
// ---------------------------------------------------------
// NUOVO FILE
// ---------------------------------------------------------
if (!prev) {
log(`${prefix} 🟢 Nuovo: ${fileName}`);
const meta = await processFile(
user,
cartella,
f.relPath,
f.absPath,
f.ext,
st
);
meta.id = id;
meta.path = f.path;
await db("photos").insert({
id: meta.id,
user,
cartella,
name: meta.name,
path: meta.path,
thub1: meta.thub1,
thub2: meta.thub2,
mime_type: meta.mime_type,
width: meta.width,
height: meta.height,
rotation: meta.rotation,
size_bytes: meta.size_bytes,
mtimeMs: meta.mtimeMs,
duration_ms: meta.duration_ms,
taken_at: meta.taken_at,
data: meta.data,
lat: meta.gps?.lat ?? null,
lon: meta.gps?.lng ?? null,
alt: meta.gps?.alt ?? null,
location: meta.location ? JSON.stringify(meta.location) : null,
fast_hash: fastHash
});
newFiles.push(meta);
return meta;
}
// ---------------------------------------------------------
// IDENTICO (fastHash uguale)
// ---------------------------------------------------------
if (prev.fast_hash === fastHash) {
await db("photos")
.where({ id })
.update({
path: f.path,
size_bytes: st.size,
mtimeMs: st.mtimeMs,
fast_hash: fastHash
});
return null;
}
// ---------------------------------------------------------
// MODIFICATO (fastHash diverso)
// ---------------------------------------------------------
log(`${prefix} 🟠 Modificato: ${fileName}`);
const meta = await processFile(
user,
cartella,
f.relPath,
f.absPath,
f.ext,
st
);
meta.id = id;
meta.path = f.path;
await db("photos")
.where({ id })
.update({
name: meta.name,
path: meta.path,
thub1: meta.thub1,
thub2: meta.thub2,
mime_type: meta.mime_type,
width: meta.width,
height: meta.height,
rotation: meta.rotation,
size_bytes: meta.size_bytes,
mtimeMs: meta.mtimeMs,
duration_ms: meta.duration_ms,
taken_at: meta.taken_at,
data: meta.data,
lat: meta.gps?.lat ?? null,
lon: meta.gps?.lng ?? null,
alt: meta.gps?.alt ?? null,
location: meta.location ? JSON.stringify(meta.location) : null,
fast_hash: fastHash
});
newFiles.push(meta);
return meta;
}
module.exports = scanPhotoSingle;

View file

@ -0,0 +1,161 @@
// api_v1/scanner/scanPhotosUser.js
const path = require('path');
const fsp = require('fs/promises');
const scanPhoto = require('./scanPhoto');
const { log } = require('./logger');
const { WEB_ROOT, SUPPORTED_EXTS } = require('../config');
// ---------------------------------------------------------
// ETA CALCULATOR
// ---------------------------------------------------------
function computeETA(startTime, current, total) {
if (current === 0) return "calcolo...";
const elapsed = (Date.now() - startTime) / 1000; // sec
const rate = current / elapsed;
const remaining = (total - current) / rate;
const m = Math.floor(remaining / 60);
const s = Math.floor(remaining % 60);
return `${m}m ${s}s`;
}
// ---------------------------------------------------------
// PRIMA PASSATA: conta TUTTI i file reali (ricorsiva)
// ---------------------------------------------------------
async function countFilesUser(rootDir) {
let count = 0;
async function walk(dir) {
let entries = [];
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const abs = path.join(dir, e.name);
if (e.isDirectory()) {
await walk(abs);
} else {
const ext = path.extname(e.name).toLowerCase();
if (SUPPORTED_EXTS.has(ext)) {
count++;
}
}
}
}
await walk(rootDir);
return count;
}
// ---------------------------------------------------------
// SECONDA PASSATA: scansiona SOLO le cartelle vere
// ---------------------------------------------------------
async function scanPhotosUser(userName, db) {
log(`🔵 Inizio scan TUTTE le cartelle per user=${userName}`);
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
const userDir = path.join(photosRoot, userName, 'original');
const statusPath = path.join(photosRoot, "scan_status.json");
let entries = [];
try {
entries = await fsp.readdir(userDir, { withFileTypes: true });
} catch {
log(`❌ Nessuna directory per utente ${userName}`);
return [];
}
// 🔥 Filtra SOLO cartelle vere dentro "original"
const folders = entries.filter(e => e.isDirectory()).map(e => e.name);
// ---------------------------------------------------------
// 🔥 RIMOZIONE CARTELLE CANCELLATE DAL FILESYSTEM
// ---------------------------------------------------------
const createDeleteFolderFunctions = require('./deleteFolder');
const { deleteFolderForUser } = createDeleteFolderFunctions(db);
const dbFolders = await db('photos')
.where({ user: userName })
.distinct('cartella')
.pluck('cartella');
for (const dbFolder of dbFolders) {
if (!folders.includes(dbFolder)) {
log(`🗑️ Cartella rimossa dal filesystem: ${dbFolder}`);
await deleteFolderForUser(userName, dbFolder);
}
}
// 🔥 PRIMA PASSATA: conta i file reali
const TOTAL_FILES = await countFilesUser(userDir);
const CURRENT = { value: 0 };
const start = Date.now();
log(`📊 File totali da scansionare: ${TOTAL_FILES}`);
// Scrivi stato iniziale
await fsp.writeFile(
statusPath,
JSON.stringify({
current: 0,
total: TOTAL_FILES,
percent: 0,
eta: "calcolo..."
}, null, 2)
);
const allNewFiles = [];
// 🔥 SECONDA PASSATA: UNA SOLA SCANSIONE PER CARTELLA
for (const cartella of folders) {
log(`📁 Scan cartella utente: ${cartella}`);
const newFiles = await scanPhoto(
cartella,
userName,
db,
CURRENT,
TOTAL_FILES,
start,
async () => {
// 🔥 Callback di aggiornamento progresso
await fsp.writeFile(
statusPath,
JSON.stringify({
current: CURRENT.value,
total: TOTAL_FILES,
percent: Math.round((CURRENT.value / TOTAL_FILES) * 100),
eta: computeETA(start, CURRENT.value, TOTAL_FILES)
}, null, 2)
);
}
);
if (newFiles?.length) {
allNewFiles.push(...newFiles);
}
}
// Stato finale
await fsp.writeFile(
statusPath,
JSON.stringify({
current: TOTAL_FILES,
total: TOTAL_FILES,
percent: 100,
eta: "0m 0s"
}, null, 2)
);
log(`🟣 Scan COMPLETATO per user=${userName}. Nuovi file totali: ${allNewFiles.length}`);
return allNewFiles;
}
module.exports = scanPhotosUser;

View file

@ -0,0 +1,44 @@
// scanner/scanUser.js
const path = require('path');
const fsp = require('fs/promises');
const scanCartella = require('./scanCartella');
async function scanUserRoot(userName, userDir, previousIndexTree) {
console.log(`\n🔵 Inizio scan user: ${userName}`);
const results = [];
// 🔥 SCANSIONA SOLO LA CARTELLA "original"
const originalDir = path.join(userDir, "original");
let entries;
try {
entries = await fsp.readdir(originalDir, { withFileTypes: true });
} catch (err) {
console.error(`❌ Errore lettura originalDir: ${originalDir}`, err);
return results;
}
// 🔥 SCANSIONA SOLO LE SOTTOCARTELLE DI "original"
for (const e of entries) {
if (!e.isDirectory()) continue;
const cartella = e.name;
const absCartella = path.join(originalDir, cartella);
console.log(` 📁 Cartella: ${cartella}`);
const files = await scanCartella(
userName,
cartella,
absCartella,
previousIndexTree
);
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 };

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."
}
]
}

1
b/a.jpg Normal file
View file

@ -0,0 +1 @@
prova

202
come funziona.md Normal file
View file

@ -0,0 +1,202 @@
Perfetto Fabio — ti preparo **la guida completa, chiara e pratica** per integrare il tuo **viewer Flutter Android** con il **WebSocket del tuo server Node.js**, così come funziona oggi nel tuo backend.
Ti spiego:
1. **Come si autentica il client Flutter**
2. **Come si connette al WebSocket**
3. **Quali messaggi riceve** (formato esatto)
4. **Come deve rispondere**
5. **Come integrare tutto nel viewer**
Tutto basato sul tuo backend reale.
---
# ⭐ 1. COME SI AUTENTICA IL CLIENT FLUTTER
Il tuo server usa JWT.
### Passi:
1. Flutter fa login via HTTP:
```
POST /auth/login
{
"email": "...",
"password": "..."
}
```
2. Il server risponde:
```json
{
"token": "<JWT>",
"name": "Fabio"
}
```
3. Flutter salva il token.
4. **Ogni chiamata HTTP successiva** deve avere:
```
Authorization: Bearer <JWT>
```
---
# ⭐ 2. COME SI CONNETTE AL WEBSOCKET
Il tuo server WebSocket accetta connessioni così:
```
ws://<server>:<port>/?token=<JWT>
```
Quindi in Flutter:
```dart
import 'package:web_socket_channel/web_socket_channel.dart';
final channel = WebSocketChannel.connect(
Uri.parse("ws://192.168.1.10:4000/?token=$jwt"),
);
```
### Importante:
- Devi passare **lo stesso JWT** usato per le API HTTP.
- Il server autentica il token e associa la connessione allutente.
---
# ⭐ 3. QUALI MESSAGGI RICEVE FLUTTER (FORMATO ESATTO)
Il tuo backend invia questi eventi:
### 📌 Quando una foto viene aggiunta
```json
{
"type": "added",
"id": "12345"
}
```
### 📌 Quando una foto viene rimossa (DEL)
```json
{
"type": "removed",
"id": "12345"
}
```
### 📌 Quando una cartella viene aggiunta (ADD_DIR)
```json
{
"type": "add_dir",
"folder": "Vacanze/2024"
}
```
### 📌 Quando una cartella viene rimossa (DEL_DIR)
```json
{
"type": "del_dir",
"folder": "Vacanze/2024"
}
```
### 📌 Quando una foto viene soft-deleted / restored
```json
{
"type": "updated",
"id": "12345",
"deleted_at": "2026-03-31T10:00:00Z" // oppure null
}
```
---
# ⭐ 4. COME FLUTTER DEVE GESTIRE I MESSAGGI
Esempio completo:
```dart
channel.stream.listen((message) {
final data = jsonDecode(message);
switch (data["type"]) {
case "added":
// Scarica la foto via /photos/:id
fetchPhoto(data["id"]);
break;
case "removed":
// Rimuovi la foto dalla UI
removePhotoFromUI(data["id"]);
break;
case "updated":
// Aggiorna soft delete
updatePhotoState(data["id"], data["deleted_at"]);
break;
case "add_dir":
// Aggiorna lista cartelle
addFolderToUI(data["folder"]);
break;
case "del_dir":
// Rimuovi cartella dalla UI
removeFolderFromUI(data["folder"]);
break;
}
});
```
---
# ⭐ 5. COME DEVE RISPONDERE FLUTTER AL SERVER
Il tuo server **non richiede risposte** dal client WebSocket.
È **one-way**:
- Il server invia eventi
- Il client li riceve
- Il client aggiorna la UI
Flutter **non deve inviare nulla** al WebSocket.
---
# ⭐ 6. COME INTEGRARE TUTTO NEL VIEWER
### Al login:
- fai login HTTP
- salva il JWT
- apri il WebSocket
### Allavvio del viewer:
1. Carica tutte le foto via:
```
GET /photos
```
2. Avvia il WebSocket
3. Applica in tempo reale:
- added → aggiungi foto
- removed → rimuovi foto
- updated → aggiorna stato
- add_dir → aggiungi cartella
- del_dir → rimuovi cartella
### Dopo 2 minuti (quando WS non è più affidabile):
- usa progressive sync:
```
GET /photos/changes?since=<timestamp>
GET /photos/deleted_hard?since=<timestamp>
```
---

153
db/dbWs.js Normal file
View file

@ -0,0 +1,153 @@
// db/dbWs.js
console.log(">>> dbWs.js CARICATO:", __filename);
const knex = require("./knex");
// ===============================
// INIT TABELLE WS
// ===============================
async function initWsTables() {
// ---- ws_sessions ----
const hasSessions = await knex.schema.hasTable("ws_sessions");
if (!hasSessions) {
await knex.schema.createTable("ws_sessions", (t) => {
t.string("session_id").primary();
t.string("user").notNullable();
t.integer("connected_at").notNullable();
t.integer("last_ack").notNullable();
t.boolean("need_full_sync").notNullable().defaultTo(false);
});
console.log("✔ Tabella 'ws_sessions' creata");
} else {
console.log("✔ Tabella 'ws_sessions' già esistente");
}
// ---- ws_pending_events ----
const hasPending = await knex.schema.hasTable("ws_pending_events");
if (!hasPending) {
await knex.schema.createTable("ws_pending_events", (t) => {
t.string("event_id").primary();
t.string("session_id").notNullable();
t.text("payload").notNullable();
t.integer("sent_at").notNullable();
t.integer("retries").notNullable().defaultTo(0);
});
console.log("✔ Tabella 'ws_pending_events' creata");
} else {
console.log("✔ Tabella 'ws_pending_events' già esistente");
}
}
// ===============================
// SESSIONI
// ===============================
async function createSession(session_id, user) {
const now = Date.now();
await knex("ws_sessions").insert({
session_id,
user,
connected_at: now,
last_ack: now,
need_full_sync: false
});
}
async function getSession(session_id) {
return knex("ws_sessions").where({ session_id }).first();
}
async function updateSessionAck(session_id) {
await knex("ws_sessions")
.where({ session_id })
.update({ last_ack: Date.now() });
}
async function setSessionNeedFullSync(session_id, value) {
await knex("ws_sessions")
.where({ session_id })
.update({ need_full_sync: value });
}
async function clearNeedFullSync(session_id) {
await knex("ws_sessions")
.where({ session_id })
.update({ need_full_sync: false });
console.log(">>> clearNeedFullSync() ESEGUITA per", session_id);
}
async function deleteSession(session_id) {
await knex("ws_sessions").where({ session_id }).del();
await knex("ws_pending_events").where({ session_id }).del();
}
async function getSessionsByUser(user) {
return knex("ws_sessions").where({ user });
}
// ===============================
// PULIZIA COMPLETA (server restart)
// ===============================
async function deleteAllSessions() {
console.log(">>> deleteAllSessions() elimino tutte le sessioni WS");
return knex("ws_sessions").del();
}
async function deleteAllPendingEvents() {
console.log(">>> deleteAllPendingEvents() elimino tutti i pending events");
return knex("ws_pending_events").del();
}
// ===============================
// EVENTI PENDENTI
// ===============================
async function addPendingEvent(event_id, session_id, payload) {
await knex("ws_pending_events").insert({
event_id,
session_id,
payload: JSON.stringify(payload),
sent_at: Date.now(),
retries: 0
});
}
async function deletePendingEvent(event_id) {
await knex("ws_pending_events").where({ event_id }).del();
}
async function getPendingEventsOlderThan(msAgo, limit = 500) {
const threshold = Date.now() - msAgo;
return knex("ws_pending_events")
.where("sent_at", "<", threshold)
.limit(limit);
}
async function updatePendingEventRetry(event_id) {
await knex("ws_pending_events")
.where({ event_id })
.update({
sent_at: Date.now(),
retries: knex.raw("retries + 1")
});
}
module.exports = {
initWsTables,
createSession,
getSession,
updateSessionAck,
setSessionNeedFullSync,
clearNeedFullSync,
deleteSession,
getSessionsByUser,
// ⭐ aggiunte per pulizia server
deleteAllSessions,
deleteAllPendingEvents,
addPendingEvent,
deletePendingEvent,
getPendingEventsOlderThan,
updatePendingEventRetry
};

136
db/dbWs.js.ok Normal file
View file

@ -0,0 +1,136 @@
// db/dbWs.js
console.log(">>> dbWs.js CARICATO:", __filename);
const knex = require("./knex");
// ===============================
// INIT TABELLE WS
// ===============================
async function initWsTables() {
// ---- ws_sessions ----
const hasSessions = await knex.schema.hasTable("ws_sessions");
if (!hasSessions) {
await knex.schema.createTable("ws_sessions", (t) => {
t.string("session_id").primary();
t.string("user").notNullable();
t.integer("connected_at").notNullable();
t.integer("last_ack").notNullable();
t.boolean("need_full_sync").notNullable().defaultTo(false);
});
console.log("✔ Tabella 'ws_sessions' creata");
} else {
console.log("✔ Tabella 'ws_sessions' già esistente");
}
// ---- ws_pending_events ----
const hasPending = await knex.schema.hasTable("ws_pending_events");
if (!hasPending) {
await knex.schema.createTable("ws_pending_events", (t) => {
t.string("event_id").primary();
t.string("session_id").notNullable();
t.text("payload").notNullable();
t.integer("sent_at").notNullable();
t.integer("retries").notNullable().defaultTo(0);
});
console.log("✔ Tabella 'ws_pending_events' creata");
} else {
console.log("✔ Tabella 'ws_pending_events' già esistente");
}
}
// ===============================
// SESSIONI
// ===============================
async function createSession(session_id, user) {
const now = Date.now();
await knex("ws_sessions").insert({
session_id,
user,
connected_at: now,
last_ack: now,
need_full_sync: false
});
}
async function getSession(session_id) {
return knex("ws_sessions").where({ session_id }).first();
}
async function updateSessionAck(session_id) {
await knex("ws_sessions")
.where({ session_id })
.update({ last_ack: Date.now() });
}
async function setSessionNeedFullSync(session_id, value) {
await knex("ws_sessions")
.where({ session_id })
.update({ need_full_sync: value });
}
// 🔥 FUNZIONE MANCANTE — AGGIUNTA ORA
async function clearNeedFullSync(session_id) {
await knex("ws_sessions")
.where({ session_id })
.update({ need_full_sync: false });
console.log(">>> clearNeedFullSync() ESEGUITA per", session_id);
}
async function deleteSession(session_id) {
await knex("ws_sessions").where({ session_id }).del();
await knex("ws_pending_events").where({ session_id }).del();
}
async function getSessionsByUser(user) {
return knex("ws_sessions").where({ user });
}
// ===============================
// EVENTI PENDENTI
// ===============================
async function addPendingEvent(event_id, session_id, payload) {
await knex("ws_pending_events").insert({
event_id,
session_id,
payload: JSON.stringify(payload),
sent_at: Date.now(),
retries: 0
});
}
async function deletePendingEvent(event_id) {
await knex("ws_pending_events").where({ event_id }).del();
}
async function getPendingEventsOlderThan(msAgo, limit = 500) {
const threshold = Date.now() - msAgo;
return knex("ws_pending_events")
.where("sent_at", "<", threshold)
.limit(limit);
}
async function updatePendingEventRetry(event_id) {
await knex("ws_pending_events")
.where({ event_id })
.update({
sent_at: Date.now(),
retries: knex.raw("retries + 1")
});
}
module.exports = {
initWsTables,
createSession,
getSession,
updateSessionAck,
setSessionNeedFullSync,
clearNeedFullSync, // <--- AGGIUNTO QUI
deleteSession,
getSessionsByUser,
addPendingEvent,
deletePendingEvent,
getPendingEventsOlderThan,
updatePendingEventRetry
};

112
db/init.js Normal file
View file

@ -0,0 +1,112 @@
// db/init.js
const db = require('./knex');
const { initWsTables } = require("./dbWs");
async function init() {
// -------------------------------
// 1) Tabella PHOTOS
// -------------------------------
const existsPhotos = await db.schema.hasTable('photos');
if (!existsPhotos) {
await db.schema.createTable('photos', (t) => {
t.string('id').primary();
t.string('user');
t.string('cartella');
t.string('name');
t.string('path');
t.string('thub1');
t.string('thub2');
t.string('mime_type');
t.integer('width');
t.integer('height');
t.integer('rotation');
t.integer('size_bytes');
t.integer('mtimeMs');
t.integer('duration_ms');
t.string('taken_at');
t.string('data');
t.float('lat');
t.float('lon');
t.float('alt');
t.text('location');
t.string('_indexHash');
t.string('fast_hash');
// 🔥 SOFT DELETE + TIMESTAMPS
t.datetime('created_at').defaultTo(db.fn.now());
t.datetime('updated_at').defaultTo(db.fn.now());
t.datetime('deleted_at').nullable();
});
console.log("✔ Tabella 'photos' creata correttamente");
} else {
console.log("✔ Tabella 'photos' già esistente");
// Aggiungi colonne mancanti (retrocompatibilità)
const cols = [
'_indexHash',
'fast_hash',
'created_at',
'updated_at',
'deleted_at'
];
for (const col of cols) {
const exists = await db.schema.hasColumn('photos', col);
if (!exists) {
await db.schema.alterTable('photos', (t) => {
if (col === 'created_at' || col === 'updated_at') {
t.datetime(col).defaultTo(db.fn.now());
} else if (col === 'deleted_at') {
t.datetime(col).nullable();
} else {
t.string(col);
}
});
console.log(`✔ Colonna '${col}' aggiunta`);
}
}
}
// -------------------------------
// 2) RIMOZIONE TABELLA PHOTO_CHANGES
// -------------------------------
const existsChanges = await db.schema.hasTable('photo_changes');
if (existsChanges) {
await db.schema.dropTable('photo_changes');
console.log("✔ Tabella 'photo_changes' eliminata (non più necessaria)");
} else {
console.log("✔ Tabella 'photo_changes' non presente (ok)");
}
// -------------------------------
// 3) NUOVA TABELLA deleted_hard
// -------------------------------
const existsDeletedHard = await db.schema.hasTable('deleted_hard');
if (!existsDeletedHard) {
await db.schema.createTable('deleted_hard', (t) => {
t.string('id').primary();
t.string('user');
t.datetime('deleted_at').defaultTo(db.fn.now());
});
console.log("✔ Tabella 'deleted_hard' creata correttamente");
} else {
console.log("✔ Tabella 'deleted_hard' già esistente");
}
// -------------------------------
// 4) TABELLE WEBSOCKET
// -------------------------------
await initWsTables();
console.log("✔ Tabelle WebSocket inizializzate");
process.exit();
}
init();

12
db/knex.js Normal file
View file

@ -0,0 +1,12 @@
// db/knex.js
const knex = require('knex');
const db = knex({
client: 'better-sqlite3',
connection: {
filename: './api_v1/database.sqlite'
},
useNullAsDefault: true
});
module.exports = db;

40
find-esm.js Normal file
View file

@ -0,0 +1,40 @@
const fs = require("fs");
const path = require("path");
function scan(dir) {
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const item of items) {
const full = path.join(dir, item.name);
if (item.isDirectory()) {
scan(full);
continue;
}
// 1) File .mjs → ESM sicuro
if (full.endsWith(".mjs")) {
console.log("⚠️ FILE .mjs (ESM):", full);
continue;
}
// 2) File .js → controlliamo se contiene import/export
if (full.endsWith(".js")) {
const content = fs.readFileSync(full, "utf8");
if (/^\s*import\s/m.test(content)) {
console.log("⚠️ IMPORT trovato:", full);
}
if (/^\s*export\s/m.test(content)) {
console.log("⚠️ EXPORT trovato:", full);
}
if (/import\.meta\.url/.test(content)) {
console.log("⚠️ import.meta.url trovato:", full);
}
}
}
}
console.log("🔍 Scansione in corso...");
scan(process.cwd());
console.log("✔️ Scansione completata.");

27
generate_token.js Normal file
View file

@ -0,0 +1,27 @@
// generate_token.js
require("dotenv").config();
const jwt = require("jsonwebtoken");
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
console.error("❌ ERRORE: JWT_SECRET non definito nel .env");
process.exit(1);
}
const user = process.argv[2];
if (!user) {
console.error("Uso: node generate_token.js <nome_utente>");
process.exit(1);
}
const token = jwt.sign(
{ name: user },
JWT_SECRET,
{ expiresIn: "365d" } // opzionale
);
console.log(`\n🔑 Token generato per utente "${user}":\n`);
console.log(token);
console.log("\n✔ Copialo nel watcher.\n");

2434
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

18
package.json Normal file
View file

@ -0,0 +1,18 @@
{
"type": "commonjs",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"axios": "^1.13.6",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.8.0",
"dotenv": "^17.3.1",
"exifreader": "^4.37.0",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"knex": "^3.1.0",
"sharp": "^0.34.5",
"ws": "^8.19.0"
}
}

696
public/README.md Normal file
View file

@ -0,0 +1,696 @@
Certo Fabio — ora che tutto il backend e il frontend sono finalmente coerenti, ti preparo un README tecnico completo, scritto come si deve, chiaro, professionale e utile sia per te che per chiunque metterà mano al progetto in futuro.
È pensato per essere messo direttamente in README.md alla radice del progetto.
---
📸 PhotoGallery — README Tecnico Completo
Una piattaforma completa per la gestione di foto multiutente, con:
- autenticazione JWT
- scansione automatica delle cartelle
- thumbnails generate lato server
- sincronizzazione incrementale
- WebSocket realtime
- frontend modulare e reattivo
- mappa globale stile Google Photos
- pannello info EXIF + geolocalizzazione
- bottom sheet multifoto
- modal fullscreen con navigazione
---
🚀 1. Architettura generale
`
backend/
server.js
api_v1/
scanner/
config.js
...
db/
knex.js
routes/
photos.js
public/
index.html
login.html
css/
js/
auth.js
login.js
logout.js
config.js
api.js
state.js
sync.js
gallery.js
modal.js
infoPanel.js
mapGlobal.js
bottomSheet.js
optionsSheet.js
`
---
🔐 2. Autenticazione
Login
POST /auth/login
Richiede:
`json
{ "email": "...", "password": "..." }
`
Risponde:
`json
{ "token": "JWT...", "name": "Fabio" }
`
Il token contiene:
`json
{
"id": 1,
"email": "fabio@example.com",
"name": "Fabio",
"exp": 1234567890
}
`
Logout
POST /auth/logout
Il token viene inserito in denylist.
Middleware
Tutte le rotte (eccetto /auth/*) richiedono:
`
Authorization: Bearer <token>
`
Il middleware aggiunge automaticamente:
`
req.query.user = [req.user.name, "Common"]
`
Quindi il frontend non deve mai passare user=.
---
🗂️ 3. Configurazione
GET /config restituisce:
`json
{
"baseUrl": "https://…",
"pathFull": false,
"galleryRefreshSeconds": 30
}
`
Il frontend usa:
- baseUrl per costruire URL assoluti
- pathFull per capire se i path sono già completi
- galleryRefreshSeconds per il polling
---
🖼️ 4. Foto: API principali
Tutte le foto
GET /photos?user[]=Fabio&user[]=Common
(aggiunto automaticamente dal middleware)
Foto per ID
GET /photos/byIds?id=123
Cambiamenti incrementali
GET /photos/changes?since=<ISO>
Restituisce:
`json
{
"changes": [
{ "photoid": 123, "changetype": "added", "timestamp": "..." },
{ "photoid": 456, "changetype": "removed", "timestamp": "..." }
]
}
`
---
🔄 5. Sincronizzazione
Il frontend implementa:
Full load
- chiamato al primo avvio
- scarica tutte le foto
- salva in localStorage
- aggiorna lastSync
Incremental sync
- usa /photos/changes
- applica solo differenze
- aggiorna la gallery
- aggiorna lastSync
WebSocket realtime
Il server invia:
`
{ type: "added", id }
{ type: "removed", id }
{ type: "add_dir", folder }
{ type: "del_dir", folder }
`
Il frontend aggiorna:
- stato locale
- gallery
- mappa
---
🗺️ 6. Mappa globale
Basata su Leaflet + MarkerCluster.
- clustering automatico
- collage thumbnails nei cluster
- click cluster → bottom sheet
- click foto → modal
- refresh automatico quando cambia la gallery
---
🪟 7. Modal foto/video
Funzionalità:
- navigazione ← →
- click ai bordi
- tastiera
- preload ±3 foto
- supporto video (mp4/webm/mov)
- pannello info integrato
---
8. Info Panel
Mostra:
- nome
- data
- dimensioni
- peso
- MIME
- cartella
- EXIF GPS
- reverse geocoding (se presente)
- mappa dedicata
Autorefresh quando cambi foto nel modal.
---
📚 9. Bottom Sheet
Usato per:
- mostrare gruppi di foto (cluster mappa)
- navigazione rapida
- apertura modal
---
⚙️ 10. Options Sheet
Gestisce:
- ordinamento (asc/desc)
- raggruppamento (auto/day/month/year)
- filtri (folder/location/type)
Aggiorna la gallery in tempo reale.
---
🧠 11. Stato locale
Gestito in state.js:
- localPhotos[]
- lastSync
- cache in localStorage
- funzioni: add/remove/update
---
🧩 12. Costruzione URL media
Tutto passa da:
`js
mediaUrlsFromPhoto(photo)
`
Restituisce:
`js
{ original, preview }
`
Supporta:
- path relativi
- path assoluti
- baseUrl dinamico
- cartella utente
---
🧪 13. Test endtoend consigliato
1. Vai su /login
2. Inserisci email/password
3. Redirect a /
4. Full load → gallery appare
5. Apri una foto → modal
6. Apri info → mappa + EXIF
7. Apri mappa globale → cluster
8. Click cluster → bottom sheet
9. Click foto → modal
10. Cancella una foto dal filesystem → WS → frontend aggiorna
11. Logout → redirect a login
---
🛠️ 14. Requisiti
- Node.js 18+
- SQLite3
- ffmpeg (per thumbnails video)
- ImageMagick (per thumbnails foto)
---
▶️ 15. Avvio
`bash
npm install
node server.js
`
Server su:
`
http://localhost:4000
`
---
🎯 16. TODO / miglioramenti futuri
- virtualizzazione gallery (migliaia di foto)
- ricerca per testo
- timeline orizzontale
- upload drag&drop
- editing EXIF
- preferiti / album
---
💬 17. Contatti
Progetto sviluppato da Fabio
Backend + frontend completamente integrati.
---
Perfetto Fabio — ti preparo il diagramma architetturale completo, chiaro, leggibile e fedele al tuo backend e al tuo frontend attuale.
Niente fronzoli, niente astrazioni inutili: solo ciò che serve davvero per capire come gira tutto.
---
🧩 DIAGRAMMA ARCHITETTURALE — PhotoGallery System
1) Vista generale (HighLevel)
`
┌──────────────────────────┐
│ FRONTEND │
│ (public/, JS modules) │
└─────────────┬────────────┘
│ HTTP + WS
┌─────────────▼────────────┐
│ BACKEND │
│ (Node + Express) │
└─────────────┬────────────┘
│ DB queries
┌─────────────▼────────────┐
│ SQLite DB │
│ (tabella photos) │
└───────────────────────────┘
`
---
2) Dettaglio Frontend (moduli JS)
`
┌──────────────────────────────────────────────────────────────┐
│ FRONTEND │
│ │
│ index.html / login.html │
│ │ │
│ ▼ │
│ auth.js ←→ login.js ←→ logout.js │
│ │ │
│ ▼ │
│ config.js → api.js → state.js → sync.js │
│ │ │ │ │
│ │ │ └── WebSocket │
│ ▼ ▼ │
│ gallery.js ←→ modal.js ←→ infoPanel.js │
│ │ │ │
│ ▼ ▼ │
│ mapGlobal.js ←→ bottomSheet.js ←→ optionsSheet.js │
└──────────────────────────────────────────────────────────────┘
`
Flusso principale:
1. auth.js controlla token → se valido, mostra app
2. config.js carica /config
3. sync.js fa full load → incremental sync → WebSocket
4. state.js mantiene foto locali
5. gallery.js renderizza
6. modal.js apre foto/video
7. infoPanel.js mostra EXIF + mappa
8. mapGlobal.js mostra mappa globale
9. bottomSheet.js mostra strip foto
10. optionsSheet.js gestisce filtri/ordinamento
---
3) Dettaglio Backend (server.js)
`
┌──────────────────────────────────────────────────────────────┐
│ BACKEND │
│ │
│ server.js │
│ │ │
│ ├── STATIC: serve public/ │
│ ├── /config │
│ ├── /auth/login │
│ ├── /auth/logout │
│ │ │
│ ├── JWT middleware │
│ │ ├── verify token │
│ │ ├── denylist │
│ │ └── req.user = { id, email, name } │
│ │ │
│ ├── GET middleware (user filtering) │
│ │ └── req.query.user = [req.user.name, "Common"] │
│ │ │
│ ├── /scan (Admin → tutti, User → solo se stesso) │
│ ├── /apiv1/autoscan (ADD, DEL, ADDDIR, DELDIR) │
│ │ └── WebSocket broadcast │
│ │ │
│ ├── /photos (router SQLite) │
│ │ ├── /photos │
│ │ ├── /photos/byIds │
│ │ └── /photos/changes │
│ │ │
│ ├── /files (serve file statici sicuri) │
│ ├── /initDB /initDBuser │
│ │ │
│ └── ws-server.js (WebSocket) │
└──────────────────────────────────────────────────────────────┘
`
---
4) Flusso completo di una foto (endtoend)
`
[1] File aggiunto nel filesystem
[2] scan_auto → type="ADD"
├── scanFile()
├── scanPhotoSingle()
├── INSERT in DB
├── genera thumbnails
└── WS: { type:"added", id }
[3] Frontend riceve WS
├── getPhotoById(id)
├── addPhotoLocal()
├── refreshGallery()
└── redrawPhotoMarkers()
`
---
5) Flusso login → gallery
`
login.html
login.js → AppAuth.login(email, password)
POST /auth/login
token JWT salvato
redirect → /
index.html
auth.js.isLoggedIn() → OK
config.js → GET /config
sync.js.fullLoad()
GET /photos
state.js.setLocalPhotos()
gallery.js.renderGallery()
mapGlobal.js.redrawPhotoMarkers()
`
---
6) Diagramma WebSocket
`
┌──────────────┐ WS ┌──────────────┐
│ Backend │ ───────────→ │ Frontend │
└──────────────┘ └──────────────┘
│ │
│ added → { id } │
│────────────────────────────────▶│ addPhotoLocal()
│ │ refreshGallery()
│ │ redrawPhotoMarkers()
│ │
│ removed → { id } │
│────────────────────────────────▶│ removePhotoLocal()
│ │ refreshGallery()
│ │ redrawPhotoMarkers()
│ │
│ adddir / deldir │
│────────────────────────────────▶│ incrementalSync()
`
---
7) Diagramma Database
`
┌──────────────────────────────┐
│ photos │
├──────────────────────────────┤
│ id (PK) │
│ user │
│ name │
│ path │
│ cartella │
│ mime_type │
│ width │
│ height │
│ size_bytes │
│ taken_at │
│ gps_lat │
│ gps_lng │
│ gps_alt │
│ location_json │
│ created_at │
└──────────────────────────────┘
`
---
8) Diagramma URL media
`
BASE_URL/photos/<user>/<type>/<cartella>/<file>
Esempi:
original:
https://server/photos/Fabio/original/2024/IMG_001.jpg
thumbs:
https://server/photos/Fabio/thumbs/2024/IMG001thub2.jpg
`
---
9) Diagramma dei moduli JS (dipendenze)
`
auth.js
login.js → logout.js
index.html
config.js → api.js → state.js → sync.js → WebSocket
↓ ↓ ↓
gallery.js ← modal.js ← infoPanel.js
mapGlobal.js ← bottomSheet.js ← optionsSheet.js
`
---
Utente Browser/Frontend Backend (Express) DB (SQLite)
│ │ │ │
│ 1. Apre /login │ │ │
├──────────────────────────▶│ │ │
│ │ │ │
│ │ 2. Inserisce email/password │ │
│ ├────────────────────────────────▶│ POST /auth/login │
│ │ │ │
│ │ │ 3. Verifica utente │
│ │ │ bcrypt.compare() │
│ │ ├────────────────────────▶│
│ │ │ │
│ │ │ 4. Genera JWT │
│ │ │ createToken() │
│ │ │ │
│ │ 5. Riceve token │ │
│ ◀─────────────────────────────────┤ │
│ │ │ │
│ │ 6. Salva token (localStorage) │ │
│ │ 7. Redirect → / │ │
├──────────────────────────▶│ │ │
│ │ │ │
│ │ 8. GET /config │ │
│ ├────────────────────────────────▶│ │
│ │ │ │
│ │ 9. Riceve config │ │
│ ◀─────────────────────────────────┤ │
│ │ │ │
│ │ 10. Full Sync │ │
│ │ GET /photos │ │
│ ├────────────────────────────────▶│ │
│ │ │ 11. Middleware JWT │
│ │ │ req.user = {name,...} │
│ │ │ │
│ │ │ 12. Filtra per utente │
│ │ │ req.query.user=[Fabio,Common]
│ │ │ │
│ │ ├────────────────────────▶│ SELECT * FROM photos WHERE user IN (...)
│ │ │ │
│ │ 13. Riceve lista foto │ │
│ ◀─────────────────────────────────┤ │
│ │ │ │
│ │ 14. state.setLocalPhotos() │ │
│ │ 15. gallery.render() │ │
│ │ 16. mapGlobal.redrawMarkers() │ │
│ │ │ │
│ │ 17. Apre WebSocket │ │
│ ├────────────────────────────────▶│ ws-server │
│ │ │ │
│ │ │ │
│ │ 18. Incremental Sync (polling) │ │
│ │ GET /photos/changes?since=... │ │
│ ├────────────────────────────────▶│ │
│ │ │ │
│ │ 19. Riceve changes │ │
│ ◀─────────────────────────────────┤ │
│ │ │ │
│ │ 20. Applica differenze │ │
│ │ state.add/remove/update │ │
│ │ gallery.refresh() │ │
│ │ mapGlobal.redrawMarkers() │ │
│ │ │ │
│ │ │ │
│ │ 21. Evento reale: file aggiunto │ │
│ │ │ scan_auto → ADD │
│ │ │ INSERT in DB │
│ │ ├────────────────────────▶│
│ │ │ │
│ │ │ 22. WS broadcast │
│ ◀─────────────────────────────────┤ {type:"added", id} │
│ │ │ │
│ │ 23. getPhotoById(id) │ │
│ ├────────────────────────────────▶│ /photos/byIds │
│ │ │ │
│ │ 24. Riceve foto │ │
│ ◀─────────────────────────────────┤ │
│ │ │ │
│ │ 25. state.addPhotoLocal() │ │
│ │ 26. gallery.refresh() │ │
│ │ 27. mapGlobal.redrawMarkers() │ │
│ │ │ │
│ │ │ │
│ 28. Clic su foto │ │ │
├──────────────────────────▶│ modal.open(photo) │ │
│ │ infoPanel.render(photo) │ │
│ │ │ │
│ │ │ │
│ 29. Logout │ │ │
├──────────────────────────▶│ AppAuth.logout() │ │
│ ├────────────────────────────────▶│ POST /auth/logout │
│ │ │ addToDenylist(token) │
│ │ │ │
│ │ 30. clearTokens() │ │
│ │ 31. redirect → /login │ │
└───────────────────────────┴─────────────────────────────────┴─────────────────────────┘

10
public/a Normal file
View file

@ -0,0 +1,10 @@
- async function login(username, password) {
+ async function login(email, password) {
const res = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ username, password })
+ body: JSON.stringify({ email, password })
});
}

91
public/admin.html Normal file
View file

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>Photo Manager</title>
<link rel="stylesheet" href="/css/admin.css">
<style>
#progressContainer {
width: 100%;
background: #ddd;
height: 25px;
border-radius: 5px;
margin-top: 20px;
overflow: hidden;
}
#progressBar {
height: 100%;
width: 0%;
background: #4caf50;
transition: width 0.3s linear;
}
#scanInfo {
font-family: monospace;
margin-top: 10px;
font-size: 16px;
}
#changesBox {
margin-top: 20px;
padding: 10px;
border: 1px solid #aaa;
border-radius: 5px;
background: #f7f7f7;
width: 350px;
}
</style>
</head>
<body>
<div id="app" style="padding:20px;">
<h2>Gestione Foto</h2>
<button id="btnScan">Scansiona Foto</button>
<button id="btnResetDB">Reset DB</button>
<button id="btnReadDBUser">Leggi DB</button>
<button id="btnDeletePhoto">Cancella Foto per ID</button>
<button id="btnFindIdIndex">Cerca ID in index.json</button>
<button id="btnToggleSoft">Toggle Soft Delete (via ID)</button>
<div id="toggleResult"></div>
<button id="btnResetDBuser">Reset DB Utente</button>
<label for="userSelect"><b>Utenti:</b></label>
<select id="userSelect" style="margin-left:10px; margin-bottom:10px;"></select>
<button id="btnSearchPhotoById">Cerca Foto (nuovo /byIds)</button>
<button id="btnBack">Torna alla galleria</button>
<div id="changesBox">
<h4>Controlla /photos/changes</h4>
<label>Since (data/ora):</label><br>
<input type="datetime-local" id="sinceInput" style="width: 100%; margin-top:5px;"><br><br>
<button id="btnShowDBChanges">Mostra cambiamenti DB</button>
<button id="btnShowHardDeleted">Mostra Hard Deleted</button>
</div>
<div id="progressContainer">
<div id="progressBar"></div>
</div>
<div id="scanInfo">
<div id="scanProgress"></div>
<div id="scanEta"></div>
</div>
<pre id="out"></pre>
</div>
<!-- JS modulari -->
<script src="/js/admin/config.js"></script>
<script src="/js/admin/api.js"></script>
<script src="/js/admin/db.js"></script>
<script src="/js/admin/scan.js"></script>
<script src="/js/admin/ui.js"></script>
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init();</script>
</body>
</html>

89
public/admin.html.old Normal file
View file

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>Photo Manager</title>
<link rel="stylesheet" href="/css/admin.css">
<style>
#progressContainer {
width: 100%;
background: #ddd;
height: 25px;
border-radius: 5px;
margin-top: 20px;
overflow: hidden;
}
#progressBar {
height: 100%;
width: 0%;
background: #4caf50;
transition: width 0.3s linear;
}
#scanInfo {
font-family: monospace;
margin-top: 10px;
font-size: 16px;
}
#changesBox {
margin-top: 20px;
padding: 10px;
border: 1px solid #aaa;
border-radius: 5px;
background: #f7f7f7;
width: 350px;
}
</style>
</head>
<body>
<div id="app" style="padding:20px;">
<h2>Gestione Foto</h2>
<button id="btnScan">Scansiona Foto</button>
<button id="btnResetDB">Reset DB</button>
<button id="btnReadDBUser">Leggi DB</button>
<button id="btnDeletePhoto">Cancella Foto per ID</button>
<button id="btnFindIdIndex">Cerca ID in index.json</button>
<button id="btnToggleSoft">Toggle Soft Delete (via ID)</button>
<div id="toggleResult"></div>
<button id="btnResetDBuser">Reset DB Utente</button>
<label for="userSelect"><b>Utenti:</b></label>
<select id="userSelect" style="margin-left:10px; margin-bottom:10px;"></select>
<button id="btnSearchPhotoById">Cerca Foto (nuovo /byIds)</button>
<button id="btnBack">Torna alla galleria</button>
<div id="changesBox">
<h4>Controlla /photos/changes</h4>
<label>Since (data/ora):</label><br>
<input type="datetime-local" id="sinceInput" style="width: 100%; margin-top:5px;"><br><br>
<button id="btnShowChanges">Mostra cambiamenti</button>
</div>
<div id="progressContainer">
<div id="progressBar"></div>
</div>
<div id="scanInfo">
<div id="scanProgress"></div>
<div id="scanEta"></div>
</div>
<pre id="out"></pre>
</div>
<!-- JS modulari -->
<script src="/js/admin/config.js"></script>
<script src="/js/admin/api.js"></script>
<script src="/js/admin/db.js"></script>
<script src="/js/admin/scan.js"></script>
<script src="/js/admin/ui.js"></script>
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init();</script>
</body>
</html>

95
public/css/admin.css Normal file
View file

@ -0,0 +1,95 @@
/* ===============================
ADMIN layout base
=============================== */
body {
font-family: Arial, sans-serif;
background: #fafafa;
margin: 0;
padding: 0;
}
#app {
padding: 20px;
}
h2 {
margin-bottom: 20px;
color: #333;
}
/* ===============================
Pulsanti
=============================== */
button {
padding: 10px 14px;
margin: 5px 5px 5px 0;
border: none;
border-radius: 6px;
background: #1976d2;
color: white;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #125a9c;
}
/* ===============================
Box cambiamenti
=============================== */
#changesBox {
margin-top: 20px;
padding: 10px;
border: 1px solid #aaa;
border-radius: 5px;
background: #f7f7f7;
width: 350px;
}
#changesBox h4 {
margin-top: 0;
}
/* ===============================
Output
=============================== */
pre#out {
margin-top: 20px;
padding: 15px;
background: #eee;
border-radius: 6px;
max-height: 400px;
overflow: auto;
font-size: 14px;
}
/* ===============================
Progress bar
=============================== */
#progressContainer {
width: 100%;
background: #ddd;
height: 25px;
border-radius: 5px;
margin-top: 20px;
overflow: hidden;
}
#progressBar {
height: 100%;
width: 0%;
background: #4caf50;
transition: width 0.3s linear;
}
#scanInfo {
font-family: monospace;
margin-top: 10px;
font-size: 16px;
}

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;
}

80
public/css/gallery.css Normal file
View file

@ -0,0 +1,80 @@
.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;
}
/* ===============================
SOFT DELETE X DIAGONALE
=============================== */
.soft-deleted {
position: relative;
opacity: 0.55;
}
.soft-deleted::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 70%;
height: 3px;
background: rgba(255, 0, 0, 0.85);
transform: translate(-50%, -50%) rotate(45deg);
pointer-events: none;
}
.soft-deleted::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 70%;
height: 3px;
background: rgba(255, 0, 0, 0.85);
transform: translate(-50%, -50%) rotate(-45deg);
pointer-events: none;
}

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; }

307
public/css/modal.css Normal file
View file

@ -0,0 +1,307 @@
/* ===============================
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; }
.modal {
display: none;
}
.modal.open {
display: block;
}

View file

@ -0,0 +1,69 @@
/* ===============================
OPTIONS SHEET bottom sheet
=============================== */
.options-sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fff;
border-radius: 16px 16px 0 0;
max-height: 70vh;
overflow-y: auto;
transform: translateY(100%);
transition: transform 0.25s ease;
z-index: 1001;
}
.options-sheet.open {
transform: translateY(0);
}
/* ===============================
OVERLAY
=============================== */
.sheet-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.35);
display: none;
z-index: 1000;
}
.sheet-overlay.open {
display: block;
}
/* ===============================
CONTENUTO INTERNO
=============================== */
#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; }
}

177
public/index.html Normal file
View file

@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Galleria</title>
<!-- Inizializzazione BASE_URL -->
<script>
window.BASE_URL = "";
window.AppConfig = { ready: true };
window.dispatchEvent(new Event("config:ready"));
</script>
<!-- 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/map.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/utils.css">
<style>
#authLoader {
position: fixed;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
background: #fff;
font-size: 1.4rem;
z-index: 9999;
}
</style>
</head>
<body>
<div id="authLoader">Caricamento…</div>
<div id="app" style="display:none">
<!-- HEADER -->
<header class="topbar">
<h1>Galleria</h1>
<div class="top-buttons">
<label>
<input type="checkbox" id="showDeleted">
Mostra foto eliminate
</label>
<button id="openMapBtn" class="icon-btn">🗺️</button>
<button class="icon-btn" id="optionsBtn"></button>
<button class="icon-btn" id="configBtn">
⚙️
</button>
<button class="icon-btn logout-btn" id="logoutBtn" data-logout data-redirect="/login">
<img src="logout.png" class="logout-icon" alt="Logout">
</button>
</div>
</header>
<!-- GALLERY -->
<div id="gallery"></div>
<!-- MAPPA -->
<div id="globalMap" class="global-map"></div>
<!-- MODAL -->
<div id="modal" class="modal" aria-hidden="true">
<button id="modalClose" class="modal-close">×</button>
<div id="modalMediaContainer" class="modal-media"></div>
<button id="modalPrev" class="modal-nav-btn hidden"></button>
<button id="modalNext" class="modal-nav-btn hidden"></button>
</div>
<!-- INFO PANEL -->
<aside id="infoPanel" class="info-panel" aria-hidden="true"></aside>
<!-- BOTTOM SHEET (MAPPA) -->
<div id="bottomSheet" class="bottom-sheet">
<div class="sheet-header"></div>
<div id="sheetGallery" class="sheet-gallery"></div>
</div>
<!-- OPTIONS SHEET -->
<div id="optionsSheet" class="options-sheet">
<div class="sheet-header"></div>
<div class="sheet-content">
<h3>Ordinamento</h3>
<button class="sheet-btn" data-sort="asc">Data ↑</button>
<button class="sheet-btn" data-sort="desc">Data ↓</button>
<h3>Raggruppamento</h3>
<button class="sheet-btn" data-group="auto">Automatico</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="">Tutto</button>
<button class="sheet-btn" data-filter="folder">Con cartella</button>
<button class="sheet-btn" data-filter="location">Con GPS</button>
<button class="sheet-btn" data-filter="type">Solo immagini</button>
</div>
</div>
<!-- OVERLAY -->
<div id="sheetOverlay" class="sheet-overlay"></div>
</div>
<!-- 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" />
<!-- 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>
<!-- Script -->
<script src="/js/config.js"></script>
<script src="/js/auth.js"></script>
<script src="/js/logout.js"></script>
<script src="/js/api.js"></script>
<script src="/js/state.js"></script>
<script src="/js/sync.js"></script>
<script src="/js/gallery.js"></script>
<script src="/js/mapGlobal.js"></script>
<script src="/js/modal.js"></script>
<script src="/js/infoPanel.js"></script>
<script src="/js/bottomSheet.js"></script>
<script src="/js/optionsSheet.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () => {
const token = localStorage.getItem("token");
if (!token) {
window.location.assign("/login?redirect=/");
return;
}
document.getElementById("authLoader").style.display = "none";
document.getElementById("app").style.display = "block";
document.body.classList.add("authenticated");
});
document.getElementById("openMapBtn").addEventListener("click", () => {
document.getElementById("globalMap").classList.toggle("visible");
});
document.getElementById("configBtn").addEventListener("click", () => {
window.location.href = "/admin.html";
});
</script>
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init();</script>
</body>
</html>

87
public/js/admin/api.js Normal file
View file

@ -0,0 +1,87 @@
// ======================================================
// API CORE REQUEST
// ======================================================
async function apiRequest(method, path, body = null) {
if (!Admin.BASE_URL) {
console.error("BASE_URL non ancora inizializzato");
throw new Error("BASE_URL non inizializzato");
}
const options = {
method,
headers: {
"Authorization": "Bearer " + Admin.token,
"Content-Type": "application/json"
}
};
if (body) {
options.body = JSON.stringify(body);
}
const res = await fetch(`${Admin.BASE_URL}${path}`, options);
if (!res.ok) {
const text = await res.text();
throw new Error(`Errore API ${res.status}: ${text}`);
}
return res.json();
}
// ======================================================
// API GET
// ======================================================
function apiGet(path) {
return apiRequest("GET", path);
}
// ======================================================
// API POST
// ======================================================
function apiPost(path, body = null) {
return apiRequest("POST", path, body);
}
// ======================================================
// API DELETE
// ======================================================
function apiDelete(path) {
return apiRequest("DELETE", path);
}
// ======================================================
// UTILITY: LEGGI DATA/ORA DAL CAMPO SINCE
// ======================================================
function getSinceISO() {
const el = document.getElementById("sinceInput");
if (!el || !el.value) return null;
return new Date(el.value).toISOString();
}
// ======================================================
// GET DB CHANGES (solo modifiche nel DB)
// ======================================================
async function getDBChanges(since, user) {
return apiGet(`/photos/changes?since=${encodeURIComponent(since)}&user=${encodeURIComponent(user)}`);
}
// ======================================================
// GET HARD DELETED (solo hard delete)
// ======================================================
async function getHardDeleted(since, user) {
return apiGet(`/photos/deleted_hard?since=${encodeURIComponent(since)}&user=${encodeURIComponent(user)}`);
}

View file

@ -0,0 +1,72 @@
// ======================================================
// API CORE REQUEST
// Funzione centrale che gestisce TUTTE le chiamate API
// - Applica BASE_URL
// - Applica token
// - Gestisce errori
// - Converte automaticamente in JSON
// ======================================================
async function apiRequest(method, path, body = null) {
// Se BASE_URL non è ancora stato caricato da config.js
if (!Admin.BASE_URL) {
console.error("BASE_URL non ancora inizializzato");
throw new Error("BASE_URL non inizializzato");
}
// Opzioni comuni a tutte le richieste
const options = {
method,
headers: {
"Authorization": "Bearer " + Admin.token,
"Content-Type": "application/json"
}
};
// Se c'è un body, lo serializziamo
if (body) {
options.body = JSON.stringify(body);
}
// Eseguiamo la richiesta
const res = await fetch(`${Admin.BASE_URL}${path}`, options);
// Gestione errori centralizzata
if (!res.ok) {
const text = await res.text();
throw new Error(`Errore API ${res.status}: ${text}`);
}
// Ritorna direttamente il JSON
return res.json();
}
// ======================================================
// API GET
// Richiesta GET semplice
// ======================================================
function apiGet(path) {
return apiRequest("GET", path);
}
// ======================================================
// API POST
// Richiesta POST con body opzionale
// ======================================================
function apiPost(path, body = null) {
return apiRequest("POST", path, body);
}
// ======================================================
// API DELETE
// Richiesta DELETE semplice
// ======================================================
function apiDelete(path) {
return apiRequest("DELETE", path);
}

17
public/js/admin/config.js Normal file
View file

@ -0,0 +1,17 @@
window.Admin = {
BASE_URL: null,
token: localStorage.getItem("token"),
db: []
};
if (!Admin.token) {
window.location.href = "/login";
}
async function loadConfig() {
const res = await fetch("/config");
const cfg = await res.json();
Admin.BASE_URL = cfg.baseUrl;
}
document.addEventListener("DOMContentLoaded", loadConfig);

137
public/js/admin/db.js Normal file
View file

@ -0,0 +1,137 @@
// ===============================
// JWT PARSER
// ===============================
function parseJwt(t) {
try { return JSON.parse(atob(t.split('.')[1])); }
catch { return {}; }
}
// ===============================
// LETTURA DB UTENTE CORRENTE
// ===============================
async function readDBUser() {
const photos = await apiGet(`/photos`);
out(photos); // mostra attive + eliminate
}
// ===============================
// LETTURA DB ADMIN
// ===============================
async function readDB() {
Admin.db = await apiGet(`/photos?user=Admin`);
out(Admin.db);
}
// ===============================
// RESET COMPLETO DB (solo Admin)
// ===============================
async function resetDB() {
await apiPost(`/initDB`);
await readDB(); // aggiorna UI
}
// ===============================
// RESET DB PER UTENTE
// ===============================
async function resetDBuser() {
const payload = parseJwt(Admin.token);
let userToReset = payload.name;
// Se Admin → usa dropdown
if (payload.name === "Admin") {
const sel = document.getElementById("userSelect");
if (!sel || !sel.value) {
alert("Seleziona un utente");
return;
}
userToReset = sel.value;
}
// Conferma
if (!confirm(`Vuoi davvero resettare l'utente "${userToReset}"?`)) {
return;
}
// Costruisci path per API
let path = `/initDBuser`;
if (payload.name === "Admin") {
path += `?user=${encodeURIComponent(userToReset)}`;
}
// Reset tramite API
await apiGet(path);
// Aggiorna UI
await readDB();
// Mostra output dellutente resettato
const outData = await apiGet(`/photos?user=${encodeURIComponent(userToReset)}`);
out(outData);
}
// ===============================
// CANCELLA FOTO PER ID
// ===============================
async function deletePhoto() {
const id = prompt("ID foto da cancellare:");
if (!id) return;
const data = await apiDelete(`/delphoto/${id}`);
out(data);
await readDB();
}
// ===============================
// CERCA ID IN index.json
// ===============================
async function findIdIndex() {
const id = prompt("ID da cercare:");
if (!id) return;
const data = await apiGet(`/findIdIndex/${id}`);
out(data);
}
// ===============================
// CERCA FOTO PER ID (nuovo /byIds)
// ===============================
async function searchPhotoById() {
const id = prompt("ID foto:");
if (!id) return;
const user = parseJwt(Admin.token).name;
const data = await apiGet(`/photos/byIds?id=${id}&user=${user}`);
out(data);
}
// ===============================
// MOSTRA CAMBIAMENTI
// ===============================
async function showChanges() {
const since = document.getElementById("sinceInput").value;
if (!since) return alert("Seleziona una data");
const iso = new Date(since).toISOString();
const user = parseJwt(Admin.token).name;
const data = await apiGet(`/photos/changes?since=${iso}&user=${user}`);
out(data);
}

32
public/js/admin/scan.js Normal file
View file

@ -0,0 +1,32 @@
let scanInterval = null;
async function scan() {
startScanStatusPolling();
await apiGet(`/scan`);
}
function startScanStatusPolling() {
if (scanInterval) clearInterval(scanInterval);
scanInterval = setInterval(async () => {
const res = await fetch(`/photos/scan_status.json?ts=${Date.now()}`);
if (!res.ok) return;
const data = await res.json();
document.getElementById("scanProgress").textContent =
`Progresso: ${data.current}/${data.total} (${data.percent}%)`;
document.getElementById("scanEta").textContent =
`ETA: ${data.eta}`;
document.getElementById("progressBar").style.width = data.percent + "%";
if (data.current >= data.total && data.total > 0) {
clearInterval(scanInterval);
scanInterval = null;
await readDB();
}
}, 1000);
}

110
public/js/admin/ui.js Normal file
View file

@ -0,0 +1,110 @@
// ===============================
// UI FUNCTIONS
// ===============================
// Mostra output JSON nel <pre id="out">
function out(data) {
document.getElementById("out").textContent =
JSON.stringify(data, null, 2);
}
// ===============================
// CARICA LISTA UTENTI (solo Admin)
// ===============================
async function loadUserDropdown() {
try {
const users = await apiGet(`/users`);
const sel = document.getElementById("userSelect");
if (!sel) return;
sel.innerHTML = "";
users.forEach(u => {
const opt = document.createElement("option");
opt.value = u.name;
opt.textContent = u.name;
sel.appendChild(opt);
});
} catch (err) {
console.error("Errore nel caricare gli utenti:", err);
out({ error: "Errore nel caricare gli utenti", details: err.message });
}
}
// ===============================
// TOGGLE SOFT DELETE (via prompt)
// ===============================
async function toggleSoftDeletePrompt() {
const id = prompt("Inserisci l'ID della foto da togglare (soft delete):");
if (!id) {
alert("Nessun ID inserito");
return;
}
try {
const data = await apiPost(`/photos/toggle_soft/${id}`);
out(data);
} catch (err) {
console.error("Errore toggle soft delete:", err);
out({ error: err.message });
}
}
// ===============================
// INIZIALIZZAZIONE UI
// ===============================
document.addEventListener("DOMContentLoaded", async () => {
// 1⃣ Aspetta che config.js carichi Admin.BASE_URL
await loadConfig();
// 2⃣ Inizializza pulsanti (solo se esistono nella pagina)
const bind = (id, fn) => {
const el = document.getElementById(id);
if (el) el.onclick = fn;
};
bind("btnScan", scan);
bind("btnResetDB", resetDB);
bind("btnReadDBUser", readDBUser);
bind("btnDeletePhoto", deletePhoto);
bind("btnFindIdIndex", findIdIndex);
bind("btnResetDBuser", resetDBuser);
bind("btnSearchPhotoById", searchPhotoById);
// ⭐ NUOVO: i due pulsanti richiesti
bind("btnShowDBChanges", async () => {
const since = getSinceISO();
if (!since) return out({ error: "Inserisci una data/ora" });
const payload = parseJwt(Admin.token);
const user = payload.name;
const res = await getDBChanges(since, user);
out(res);
});
bind("btnShowHardDeleted", async () => {
const since = getSinceISO();
if (!since) return out({ error: "Inserisci una data/ora" });
const payload = parseJwt(Admin.token);
const user = payload.name;
const res = await getHardDeleted(since, user);
out(res.deleted || res);
});
// ⭐ Toggle Soft Delete via prompt
bind("btnToggleSoft", toggleSoftDeletePrompt);
// ⭐ Torna alla galleria
bind("btnBack", () => window.location.href = "index.html");
// 3⃣ Carica dropdown utenti SOLO se Admin
const payload = parseJwt(Admin.token);
if (payload.name === "Admin") {
loadUserDropdown();
}
});

65
public/js/admin/ui.js.ok Normal file
View file

@ -0,0 +1,65 @@
// ===============================
// UI FUNCTIONS
// ===============================
// Mostra output JSON nel <pre id="out">
function out(data) {
document.getElementById("out").textContent =
JSON.stringify(data, null, 2);
}
// ===============================
// CARICA LISTA UTENTI (solo Admin)
// ===============================
async function loadUserDropdown() {
try {
// Usa apiGet invece di fetch diretto
const users = await apiGet(`/users`);
const sel = document.getElementById("userSelect");
if (!sel) return;
sel.innerHTML = "";
users.forEach(u => {
const opt = document.createElement("option");
opt.value = u.name;
opt.textContent = u.name;
sel.appendChild(opt);
});
} catch (err) {
console.error("Errore nel caricare gli utenti:", err);
out({ error: "Errore nel caricare gli utenti", details: err.message });
}
}
// ===============================
// INIZIALIZZAZIONE UI
// ===============================
document.addEventListener("DOMContentLoaded", async () => {
// 1⃣ Aspetta che config.js carichi Admin.BASE_URL
await loadConfig();
// 2⃣ Inizializza pulsanti
document.getElementById("btnScan").onclick = scan;
document.getElementById("btnResetDB").onclick = resetDB;
document.getElementById("btnReadDBUser").onclick = readDBUser;
document.getElementById("btnDeletePhoto").onclick = deletePhoto;
document.getElementById("btnFindIdIndex").onclick = findIdIndex;
document.getElementById("btnResetDBuser").onclick = resetDBuser; // definita in db.js
document.getElementById("btnSearchPhotoById").onclick = searchPhotoById;
document.getElementById("btnShowChanges").onclick = showChanges;
document.getElementById("btnBack").onclick = () => window.location.href = "index.html";
// 3⃣ Carica dropdown utenti SOLO se Admin
const payload = parseJwt(Admin.token);
if (payload.name === "Admin") {
loadUserDropdown();
}
});

89
public/js/admin/ui.js.ok2 Normal file
View file

@ -0,0 +1,89 @@
// ===============================
// UI FUNCTIONS
// ===============================
// Mostra output JSON nel <pre id="out">
function out(data) {
document.getElementById("out").textContent =
JSON.stringify(data, null, 2);
}
// ===============================
// CARICA LISTA UTENTI (solo Admin)
// ===============================
async function loadUserDropdown() {
try {
const users = await apiGet(`/users`);
const sel = document.getElementById("userSelect");
if (!sel) return;
sel.innerHTML = "";
users.forEach(u => {
const opt = document.createElement("option");
opt.value = u.name;
opt.textContent = u.name;
sel.appendChild(opt);
});
} catch (err) {
console.error("Errore nel caricare gli utenti:", err);
out({ error: "Errore nel caricare gli utenti", details: err.message });
}
}
// ===============================
// TOGGLE SOFT DELETE (via prompt)
// ===============================
async function toggleSoftDeletePrompt() {
const id = prompt("Inserisci l'ID della foto da togglare (soft delete):");
if (!id) {
alert("Nessun ID inserito");
return;
}
try {
const data = await apiPost(`/photos/toggle_soft/${id}`);
out(data);
} catch (err) {
console.error("Errore toggle soft delete:", err);
out({ error: err.message });
}
}
// ===============================
// INIZIALIZZAZIONE UI
// ===============================
document.addEventListener("DOMContentLoaded", async () => {
// 1⃣ Aspetta che config.js carichi Admin.BASE_URL
await loadConfig();
// 2⃣ Inizializza pulsanti
document.getElementById("btnScan").onclick = scan;
document.getElementById("btnResetDB").onclick = resetDB;
document.getElementById("btnReadDBUser").onclick = readDBUser;
document.getElementById("btnDeletePhoto").onclick = deletePhoto;
document.getElementById("btnFindIdIndex").onclick = findIdIndex;
document.getElementById("btnResetDBuser").onclick = resetDBuser;
document.getElementById("btnSearchPhotoById").onclick = searchPhotoById;
document.getElementById("btnShowChanges").onclick = showChanges;
document.getElementById("btnBack").onclick = () => window.location.href = "index.html";
// ⭐ NUOVO: Toggle Soft Delete via prompt
document.getElementById("btnToggleSoft").onclick = toggleSoftDeletePrompt;
// 3⃣ Carica dropdown utenti SOLO se Admin
const payload = parseJwt(Admin.token);
if (payload.name === "Admin") {
loadUserDropdown();
}
});

66
public/js/api.js Normal file
View file

@ -0,0 +1,66 @@
window.AppConfig = window.AppConfig || { ready: false };
// ===============================================
// WAIT FOR CONFIG
// ===============================================
function waitForConfig() {
return new Promise(resolve => {
if (!window.AppConfig) {
window.AppConfig = { ready: false };
}
if (window.AppConfig.ready) {
return resolve();
}
window.addEventListener("config:ready", resolve, { once: true });
});
}
// ===============================================
// API WRAPPER
// ===============================================
async function apiGet(url) {
const token = localStorage.getItem("token");
const res = await fetch(url, {
headers: { "Authorization": "Bearer " + token }
});
const text = await res.text();
try {
return JSON.parse(text);
} catch {
console.error("❌ Risposta non JSON:", text);
throw new Error("Risposta non valida dal server");
}
}
// ===============================================
// GET ALL PHOTOS (FULL LOAD)
// ===============================================
async function getAllPhotos(user) {
await waitForConfig();
return apiGet(`/photos?user=${encodeURIComponent(user)}`);
}
// ===============================================
// GET PHOTO BY ID
// ===============================================
async function getPhotoById(id) {
await waitForConfig();
const payload = parseJwt(localStorage.getItem("token"));
const user = payload?.name || "Common";
return apiGet(`/photos/byIds?id=${encodeURIComponent(id)}&user=${encodeURIComponent(user)}`);
}
// ===============================================
// GET CHANGES (INCREMENTAL SYNC)
// ===============================================
async function getChanges(since, user) {
await waitForConfig();
return apiGet(`/photos/changes?since=${encodeURIComponent(since)}&user=${encodeURIComponent(user)}`);
}

181
public/js/auth.js Normal file
View file

@ -0,0 +1,181 @@
// ===============================
// AUTH.JS — Login + Token + Redirect + Auto-Login
// ===============================
// parseJwt DEVE essere globale
function parseJwt(token) {
try {
const base64Url = token.split('.')[1];
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 (e) {
console.error("Errore parseJwt:", e);
return null;
}
}
(() => {
const LOGIN_ENDPOINT = "/auth/login";
const TOKEN_KEY = "token";
// -------------------------------
// Helpers token
// -------------------------------
function saveToken(token) {
try {
localStorage.setItem(TOKEN_KEY, token);
} catch (err) {
console.error("Errore salvataggio token:", err);
}
}
function getToken() {
try {
return localStorage.getItem(TOKEN_KEY) || "";
} catch {
return "";
}
}
function clearToken() {
try {
localStorage.removeItem(TOKEN_KEY);
} catch {}
}
// -------------------------------
// JWT decode
// -------------------------------
function decode(token) {
return parseJwt(token);
}
// -------------------------------
// LOGIN
// -------------------------------
async function login(email, password) {
try {
const res = await fetch(LOGIN_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password })
});
if (!res.ok) {
const msg = await res.text();
throw new Error(msg || "Credenziali non valide");
}
const data = await res.json();
if (!data?.token) {
throw new Error("Token mancante nella risposta");
}
saveToken(data.token);
// Notifica globale
window.dispatchEvent(new CustomEvent("login:success", { detail: data }));
// Auto-logout programmato
scheduleAutoLogout();
return data;
} catch (err) {
console.error("Errore login:", err);
throw err;
}
}
// -------------------------------
// CHECK LOGIN
// -------------------------------
function isLoggedIn() {
const token = getToken();
if (!token) return false;
const payload = decode(token);
if (!payload?.exp) return false;
return payload.exp * 1000 > Date.now();
}
// -------------------------------
// USER INFO
// -------------------------------
function getUser() {
const token = getToken();
if (!token) return null;
return decode(token);
}
// -------------------------------
// REQUIRE AUTH
// -------------------------------
function requireAuth(redirectUrl = "/login") {
if (!isLoggedIn()) {
window.location.assign(redirectUrl);
return false;
}
return true;
}
// -------------------------------
// AUTO-LOGOUT
// -------------------------------
let autoLogoutTimer = null;
function scheduleAutoLogout() {
clearTimeout(autoLogoutTimer);
const token = getToken();
if (!token) return;
const payload = decode(token);
const expSec = payload?.exp;
if (!expSec) return;
const msToExp = expSec * 1000 - Date.now();
if (msToExp <= 0) {
window.AppAuth.logout?.({ redirect: true });
return;
}
autoLogoutTimer = setTimeout(() => {
window.AppAuth.logout?.({ redirect: true });
}, msToExp);
}
// -------------------------------
// EXPORT API
// -------------------------------
window.AppAuth = Object.freeze({
login,
logout: (opts) => window.AppAuthLogout?.logout(opts),
isLoggedIn,
getUser,
requireAuth,
scheduleAutoLogout
});
// -------------------------------
// INIT
// -------------------------------
function init() {
if (isLoggedIn()) {
scheduleAutoLogout();
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

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

@ -0,0 +1,123 @@
// ===============================
// BOTTOM SHEET — strip multi-foto (2+); singola → modal diretto
// ===============================
const bottomSheet = document.getElementById("bottomSheet");
const sheetGallery = document.getElementById("sheetGallery");
let optionsSheetRef = document.getElementById("optionsSheet");
// Overlay difensivo
let sheetOverlay = document.getElementById("sheetOverlay");
if (!sheetOverlay) {
sheetOverlay = document.createElement("div");
sheetOverlay.id = "sheetOverlay";
sheetOverlay.className = "sheet-overlay";
document.body.appendChild(sheetOverlay);
}
// ===============================
// APRI BOTTOM SHEET
// ===============================
function openBottomSheet(photoList) {
const list = Array.isArray(photoList) ? photoList : [];
// 0 o 1 foto → apri modal direttamente
if (list.length <= 1) {
const p = list[0];
if (p) {
closeBottomSheet();
window.openModalFromList?.([p], 0);
}
return;
}
// 2+ foto → strip
sheetGallery.innerHTML = "";
list.forEach((photo, index) => {
const { original, preview } = mediaUrlsFromPhoto(photo);
const div = document.createElement("div");
div.className = "sheet-item";
div.tabIndex = 0;
const img = document.createElement("img");
img.className = "sheet-thumb";
img.src = preview;
img.alt = photo?.name || "";
img.loading = "lazy";
const openFromIndex = () => {
closeBottomSheet();
window.openModalFromList?.(list, index);
};
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");
}
// ===============================
// CHIUDI BOTTOM SHEET
// ===============================
function closeBottomSheet() {
bottomSheet.classList.remove("open");
sheetOverlay.classList.remove("open");
}
// ===============================
// OPTIONS SHEET (versione bottomSheet)
// ===============================
// ⚠️ RINOMINATE per evitare conflitti con optionsSheet.js
function openOptionsSheetFromBottom() {
optionsSheetRef?.classList.add("open");
sheetOverlay.classList.add("open");
}
function closeOptionsSheetFromBottom() {
optionsSheetRef?.classList.remove("open");
sheetOverlay.classList.remove("open");
}
// ===============================
// CHIUSURA CLICCANDO FUORI
// ===============================
sheetOverlay.addEventListener("click", () => {
closeBottomSheet();
closeOptionsSheetFromBottom();
});
// Chiudi toccando la maniglia
document.querySelectorAll(".sheet-header").forEach(header => {
header.addEventListener("click", () => {
closeBottomSheet();
closeOptionsSheetFromBottom();
});
});
// ===============================
// EXPORT GLOBALI
// ===============================
window.openBottomSheet = openBottomSheet;
window.closeBottomSheet = closeBottomSheet;
window.openOptionsSheetFromBottom = openOptionsSheetFromBottom;
window.closeOptionsSheetFromBottom = closeOptionsSheetFromBottom;
window.BottomSheet = {
open: openBottomSheet,
close: closeBottomSheet,
openOptions: openOptionsSheetFromBottom,
closeOptions: closeOptionsSheetFromBottom
};

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

@ -0,0 +1,20 @@
// ===============================
// config.js — Configurazione globale
// ===============================
// NON sovrascrivere se già definite
window.BASE_URL = window.BASE_URL ?? "";
window.PATH_FULL = window.PATH_FULL ?? "";
window.PHOTOS_URL = window.PHOTOS_URL ?? "";
window.MEDIA_BASE_ORIGIN = window.MEDIA_BASE_ORIGIN ?? "";
// ===============================
// Utility: normalizza URL dei media
// ===============================
// Ora usa direttamente i path forniti dal backend
function toAbsoluteUrl(pathOrUrl) {
return pathOrUrl ? String(pathOrUrl) : "";
}
window.toAbsoluteUrl = toAbsoluteUrl;

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

@ -0,0 +1,217 @@
// ===============================
// GALLERY — Rendering + Filtri + Ordinamento
// ===============================
// ===============================
// 1. 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);
});
}
// ===============================
// 2. FILTRI
// ===============================
function applyFilters(photos) {
const filter = window.currentFilter;
if (!filter) return photos;
switch (filter) {
case "folder":
return photos.filter(p => p.cartella);
case "location":
return photos.filter(p => p?.gps?.lat && p?.gps?.lng);
case "type":
return photos.filter(p => p?.mime_type?.startsWith("image/"));
default:
return photos;
}
}
// ===============================
// 3. RAGGRUPPAMENTO
// ===============================
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) / 86400000);
if (mode === "day") return formatDay(date);
if (mode === "month") return formatMonth(date);
if (mode === "year") return String(date.getFullYear());
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 String(date.getFullYear());
}
for (const photo of photos) {
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;
}
// ===============================
// 4. 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"
});
}
// ===============================
// 5. RENDER GALLERY
// ===============================
function renderGallery(sections) {
const gallery = document.getElementById("gallery");
if (!gallery) return;
gallery.innerHTML = "";
for (const section of sections) {
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";
thumbDiv.id = "photo_" + photo.id;
// 🔥 NUOVO: se soft-deleted → aggiungi classe
if (photo.deleted_at) {
thumbDiv.classList.add("soft-deleted");
}
const thumb = toAbsoluteUrl(
photo.thub2 || photo.thub1 || photo.path,
photo.name,
"thumbs",
photo.cartella
);
const original = toAbsoluteUrl(
photo.path,
photo.name,
"original",
photo.cartella
);
const img = document.createElement("img");
img.src = thumb;
img.alt = photo?.name || "";
img.loading = "lazy";
thumbDiv.appendChild(img);
if (photo?.mime_type?.startsWith("video/")) {
const play = document.createElement("div");
play.className = "play-icon";
play.textContent = "▶";
thumbDiv.appendChild(play);
}
thumbDiv.addEventListener("click", () => {
window.closeBottomSheet?.();
if (typeof window.openModalFromList === "function") {
window.openModalFromList(section.photos, idx);
} else {
window.openModal?.(original, thumb, photo);
}
});
container.appendChild(thumbDiv);
});
gallery.appendChild(container);
}
}
// ===============================
// 6. REFRESH GALLERY
// ===============================
function refreshGallery() {
let photos = getLocalPhotos();
console.log("[refreshGallery] numero foto:", photos.length);
// 🔥 NUOVO: flag per mostrare i soft delete
const showDeleted = document.getElementById("showDeleted")?.checked;
if (!showDeleted) {
photos = photos.filter(p => !p.deleted_at);
}
const filtered = applyFilters(photos);
const sorted = sortByDate(filtered, window.currentSort || "desc");
const sections = groupByDate(sorted, window.currentGroup || "auto");
renderGallery(sections);
}
// EXPORT
window.sortByDate = sortByDate;
window.applyFilters = applyFilters;
window.groupByDate = groupByDate;
window.renderGallery = renderGallery;
window.refreshGallery = refreshGallery;
// ===============================
// TOGGLE "MOSTRA ELIMINATE"
// ===============================
window.addEventListener("DOMContentLoaded", () => {
const checkbox = document.getElementById("showDeleted");
if (!checkbox) return;
// Ripristina stato salvato
const saved = localStorage.getItem("showDeleted") === "1";
checkbox.checked = saved;
// Rinfresca la gallery al primo avvio
refreshGallery();
// Salva e aggiorna quando cambia
checkbox.addEventListener("change", () => {
localStorage.setItem("showDeleted", checkbox.checked ? "1" : "0");
refreshGallery();
});
});

169
public/js/infoPanel.js Normal file
View file

@ -0,0 +1,169 @@
// ===============================
// PANNELLO INFO + MAPPA
// ===============================
const infoPanel = document.getElementById("infoPanel");
let infoMapInstance = null;
// -------------------------------
// 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 loc = photo.location || {};
const folder = photo.cartella || "-";
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>` : ""}
`;
// Reset mappa precedente
try { infoMapInstance?.remove(); } catch {}
infoMapInstance = null;
// Crea nuova mappa se ci sono coordinate
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(photo) {
renderInfo(photo || window.currentPhoto);
infoPanel.classList.add("open");
infoPanel.setAttribute("aria-hidden", "false");
infoPanel.setAttribute("data-open", "1");
markButtonActive(true);
};
window.closeInfoPanel = function() {
infoPanel.classList.remove("open");
infoPanel.setAttribute("aria-hidden", "true");
infoPanel.setAttribute("data-open", "0");
markButtonActive(false);
try { infoMapInstance?.remove(); } catch {}
infoMapInstance = null;
};
window.toggleInfoPanel = function(photo) {
if (isPanelOpen()) window.closeInfoPanel();
else window.openInfoPanel(photo || window.currentPhoto);
};
// -------------------------------
// Chiudi pannello cliccando FUORI
// -------------------------------
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 su cambio media nel modal
// -------------------------------
(() => {
const mediaContainer = document.getElementById("modalMediaContainer");
if (!mediaContainer) return;
const refreshIfOpen = () => {
if (!isPanelOpen()) return;
const photo = window.currentPhoto;
if (photo) renderInfo(photo);
};
const mo = new MutationObserver(() => {
setTimeout(refreshIfOpen, 0);
});
mo.observe(mediaContainer, { childList: true });
document.getElementById("modalPrev")?.addEventListener("click", () =>
setTimeout(refreshIfOpen, 0)
);
document.getElementById("modalNext")?.addEventListener("click", () =>
setTimeout(refreshIfOpen, 0)
);
document.addEventListener("keydown", (e) => {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
setTimeout(refreshIfOpen, 0);
}
});
})();

64
public/js/login.js Normal file
View file

@ -0,0 +1,64 @@
// ===============================
// LOGIN.JS — Gestione form login
// ===============================
(() => {
const form = document.getElementById("loginForm");
const userInput = document.getElementById("loginUser");
const passInput = document.getElementById("loginPass");
const submitBtn = document.getElementById("loginSubmit");
const errorBox = document.getElementById("loginError");
const redirectUrl =
form?.getAttribute("data-redirect") ||
new URLSearchParams(location.search).get("redirect") ||
"/";
function showError(msg) {
if (!errorBox) return;
errorBox.textContent = msg;
errorBox.classList.add("visible");
}
function clearError() {
if (!errorBox) return;
errorBox.textContent = "";
errorBox.classList.remove("visible");
}
function setLoading(loading) {
submitBtn.disabled = loading;
submitBtn.setAttribute("aria-busy", loading ? "true" : "false");
}
async function handleSubmit(e) {
e.preventDefault();
clearError();
const email = userInput.value.trim();
const password = passInput.value;
if (!email || !password) {
showError("Inserisci email e password");
return;
}
setLoading(true);
try {
await window.AppAuth.login(email, password);
window.location.assign(redirectUrl);
} catch (err) {
showError("Credenziali non valide");
} finally {
setLoading(false);
}
}
function init() {
if (!form) return;
form.addEventListener("submit", handleSubmit);
}
document.addEventListener("DOMContentLoaded", init);
})();

147
public/js/logout.js Normal file
View file

@ -0,0 +1,147 @@
// ===============================
// LOGOUT — pulizia token + revoca + auto-logout JWT
// ===============================
(() => {
const AUTH_LOGOUT_ENDPOINT = "/auth/logout";
const TOKEN_KEYS = ["token", "access_token", "refresh_token"];
// -------------------------------
// Helpers token
// -------------------------------
function getToken() {
try {
return (
localStorage.getItem("token") ||
localStorage.getItem("access_token") ||
""
);
} catch {
return "";
}
}
function clearTokens() {
try {
TOKEN_KEYS.forEach(k => localStorage.removeItem(k));
} catch {}
}
// -------------------------------
// Logout lato server
// -------------------------------
async function serverLogout(token) {
try {
const headers = token ? { Authorization: `Bearer ${token}` } : {};
await fetch(AUTH_LOGOUT_ENDPOINT, { method: "POST", headers });
} catch (err) {
console.warn("Logout server fallito (ignoro):", err);
}
}
// -------------------------------
// Redirect
// -------------------------------
function getRedirectUrl() {
const btn = document.querySelector("[data-logout]");
return btn?.getAttribute("data-redirect") || "/";
}
function redirect(url) {
window.location.assign(url || "/");
}
// -------------------------------
// Auto-logout alla scadenza JWT
// -------------------------------
let autoLogoutTimer = null;
function scheduleAutoLogout() {
clearTimeout(autoLogoutTimer);
const token = getToken();
if (!token) return;
const payload = parseJwt(token); // usa la funzione globale già esistente
const expSec = payload?.exp;
if (!expSec) return;
const msToExp = expSec * 1000 - Date.now();
if (msToExp <= 0) {
doLogout({ doRedirect: true });
return;
}
autoLogoutTimer = setTimeout(() => {
doLogout({ doRedirect: 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({ doRedirect = true } = {}) {
const token = getToken();
setButtonsDisabled(true);
await serverLogout(token);
clearTokens();
try {
window.dispatchEvent(new CustomEvent("logout:success"));
} catch {}
if (doRedirect) redirect(getRedirectUrl());
setButtonsDisabled(false);
}
// -------------------------------
// Bind pulsanti
// -------------------------------
function bindLogoutButtons() {
document.querySelectorAll("[data-logout]").forEach(btn => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
if (btn.disabled) return;
await doLogout({ doRedirect: true });
});
});
}
// -------------------------------
// Public API
// -------------------------------
window.AppAuth = Object.freeze({
logout: (opts) => doLogout(opts),
isLoggedIn: () => !!getToken()
});
// -------------------------------
// Init
// -------------------------------
function init() {
bindLogoutButtons();
scheduleAutoLogout();
document.querySelectorAll("[data-logout]").forEach(btn => {
if (!window.AppAuth.isLoggedIn()) btn.style.display = "none";
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

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

@ -0,0 +1,55 @@
// ===============================
// main.js — Bootstrap dell'app
// ===============================
async function initGallery() {
console.log("=== INIT GALLERY ===");
// 1) CARICO CONFIG
console.log("[initGallery] Chiamo /config...");
let cfg;
try {
cfg = await fetch("/config").then(r => r.json());
} catch (e) {
console.error("❌ Errore caricamento /config:", e);
return;
}
console.log("[initGallery] /config RISPOSTA:", cfg);
// 2) PARAMETRI GLOBALI
window.PATH_FULL = cfg.pathFull;
const payload = parseJwt(localStorage.getItem("token"));
const user = payload?.name || "Common";
const refreshSeconds = cfg.galleryRefreshSeconds || 30;
console.log("[initGallery] Utente:", user);
console.log("[initGallery] refreshSeconds:", refreshSeconds);
// 3) CARICO CACHE LOCALE (se esiste)
const cached = loadLocalState();
if (cached.length > 0) {
console.log(`[initGallery] Cache locale caricata: ${cached.length} foto`);
refreshGallery();
}
// 4) SYNC INIZIALE
console.log("[initGallery] Avvio incrementalSync() iniziale...");
await incrementalSync();
console.log("[initGallery] incrementalSync() COMPLETATO");
// 5) POLLING DI SICUREZZA
if (refreshSeconds > 0) {
console.log(`[initGallery] Polling attivo ogni ${refreshSeconds} secondi...`);
setInterval(async () => {
console.log(">>> TIMER: incrementalSync()");
await incrementalSync();
}, refreshSeconds * 1000);
} else {
console.log("[initGallery] Polling disattivato");
}
}
// Bootstrap
window.addEventListener("DOMContentLoaded", initGallery);

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

@ -0,0 +1,204 @@
// ===============================
// MAPPA GLOBALE — stile Google Photos Web
// ===============================
window.globalMap = null;
window.globalMarkers = null;
document.addEventListener("DOMContentLoaded", () => {
const openBtn = document.getElementById("openMapBtn");
if (!openBtn) return;
openBtn.addEventListener("click", openGlobalMap);
const RADIUS_PX = 50;
const DISABLE_CLUSTER_AT_ZOOM = 18;
const OPEN_STRIP_CHILDREN_MAX = 20;
// ===============================
// APRI / CHIUDI MAPPA
// ===============================
async function openGlobalMap() {
const mapDiv = document.getElementById("globalMap");
const gallery = document.getElementById("gallery");
if (!mapDiv) 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");
await new Promise(r => requestAnimationFrame(r));
if (!window.globalMap) initMap();
else window.globalMap.invalidateSize();
redrawPhotoMarkers();
}
// ===============================
// INIZIALIZZA MAPPA
// ===============================
function initMap() {
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);
window.globalMarkers = L.markerClusterGroup({
showCoverageOnHover: false,
spiderfyOnMaxZoom: true,
disableClusteringAtZoom: DISABLE_CLUSTER_AT_ZOOM,
iconCreateFunction: clusterIconRenderer
});
window.globalMarkers.on("clusterclick", onClusterClick);
window.globalMap.addLayer(window.globalMarkers);
}
// ===============================
// RENDER CLUSTER
// ===============================
function clusterIconRenderer(cluster) {
const count = cluster.getChildCount();
const size = Math.min(92, Math.round(28 + Math.sqrt(count) * 6));
const cls = count > 200 ? "cluster-xl" :
count > 50 ? "cluster-lg" :
count > 10 ? "cluster-md" : "cluster-sm";
const children = cluster.getAllChildMarkers().slice(0, 4);
const thumbs = children
.map(m => m.__photo?.thub2 || m.__photo?.thub1)
.filter(Boolean)
.map(t => toAbsoluteUrl(t, children[0].__photo.name, "thumbs", children[0].__photo.cartella));
const collage = thumbs.length
? `<div class="cluster-collage">${thumbs.map((t,i)=>`<div class="c${i}"><img src="${t}"></div>`).join("")}</div>`
: "";
return L.divIcon({
html: `
<div class="gp-cluster ${cls}" style="width:${size}px;height:${size}px;">
${collage}
<div class="gp-count"><span>${count}</span></div>
</div>`,
className: "marker-cluster-wrapper",
iconSize: L.point(size, size)
});
}
// ===============================
// CLICK SU CLUSTER
// ===============================
function onClusterClick(a) {
const markers = a.layer.getAllChildMarkers();
const count = markers.length;
if (count <= OPEN_STRIP_CHILDREN_MAX ||
window.globalMap.getZoom() >= DISABLE_CLUSTER_AT_ZOOM - 1) {
const photos = markers.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
});
}
}
// ===============================
// ICONA FOTO
// ===============================
function createPhotoIcon(photo) {
const thumb = toAbsoluteUrl(
photo.thub2 || photo.thub1,
photo.name,
"thumbs",
photo.cartella
);
return L.icon({
iconUrl: thumb,
iconSize: [56, 56],
iconAnchor: [28, 28],
className: "photo-marker"
});
}
// ===============================
// APRI MODAL FOTO
// ===============================
function openPhotoModal(photo) {
const thumb = toAbsoluteUrl(
photo.thub2 || photo.thub1 || photo.path,
photo.name,
"thumbs",
photo.cartella
);
const original = toAbsoluteUrl(
photo.path,
photo.name,
"original",
photo.cartella
);
window.closeBottomSheet?.();
window.openModal?.(original, thumb, photo);
}
// ===============================
// REDRAW MARKERS
// ===============================
function redrawPhotoMarkers() {
if (!window.globalMarkers) return;
window.globalMarkers.clearLayers();
const photos = getLocalPhotos();
photos.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", () => openPhotoModal(photo));
window.globalMarkers.addLayer(marker);
});
}
// ===============================
// AGGANCIA REFRESH GALLERY
// ===============================
const originalRefresh = window.refreshGallery;
window.refreshGallery = function (...args) {
originalRefresh?.apply(this, args);
redrawPhotoMarkers();
};
});

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

@ -0,0 +1,374 @@
// ===============================
// MODALE FOTO/VIDEO — Navigazione + Info Panel + Preload
// ===============================
const modal = document.getElementById("modal");
const modalClose = document.getElementById("modalClose");
const modalPrev = document.getElementById("modalPrev");
const modalNext = document.getElementById("modalNext");
window.currentPhoto = null;
window.modalList = [];
window.modalIndex = 0;
// ===============================
// INFO PANEL — Stato + Toggle
// ===============================
let infoOpen = false;
function getInfoPanel() {
return document.getElementById("infoPanel");
}
function isInfoOpen() {
return infoOpen;
}
function openInfo(photo) {
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() {
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 (infoOpen) closeInfo();
else openInfo(photo);
}
// ===============================
// MIME / MEDIA HELPERS
// ===============================
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) {
const 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;
video.setAttribute("webkit-playsinline", "");
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 {}
});
video.addEventListener("error", () => {
const code = video.error?.code;
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)." : "Errore durante il caricamento."}
<br><br>
<ul style="margin:6px 0 0 18px">
<li><a href="${srcOriginal}" target="_blank" rel="noopener" style="color:#fff;text-decoration:underline">Apri in nuova scheda</a></li>
<li>Prova Safari (supporta HEVC) o converti in MP4 (H.264 + AAC)</li>
</ul>
`;
document.getElementById("modalMediaContainer")?.appendChild(msg);
});
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";
if (srcPreview && srcOriginal && srcPreview !== srcOriginal) {
const full = new Image();
full.src = srcOriginal;
full.onload = () => { img.src = srcOriginal; };
}
return img;
}
// ===============================
// URL ASSOLUTI
// ===============================
function mediaUrlsFromPhoto(photo) {
if (!photo) return { original: "", preview: "" };
if (window.PATH_FULL) {
return {
original: photo.path,
preview: photo.thub2 || photo.thub1 || photo.path
};
}
const original = toAbsoluteUrl(
photo.path,
photo.name,
"original",
photo.cartella
);
const preview = toAbsoluteUrl(
photo.thub2 || photo.thub1 || photo.path,
photo.name,
"thumbs",
photo.cartella
);
return { original, preview };
}
// ===============================
// PRELOAD ±N
// ===============================
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 = isProbablyVideo(p, original);
const src = isVideo ? (preview || original) : original;
if (!src) return;
const img = new Image();
img.src = src;
});
}
}
// ===============================
// SET CONTENUTO MODAL
// ===============================
function setModalContent(photo) {
const container = document.getElementById("modalMediaContainer");
container.innerHTML = "";
window.currentPhoto = photo;
const { original, preview } = mediaUrlsFromPhoto(photo);
const isVideo = isProbablyVideo(photo, original);
if (isVideo) {
container.appendChild(createVideoElement(original, preview, photo));
} else {
container.appendChild(createImageElement(original, preview));
}
const infoBtn = document.createElement("button");
infoBtn.id = "modalInfoBtn";
infoBtn.className = "modal-info-btn";
infoBtn.type = "button";
infoBtn.setAttribute("aria-label", "Dettagli");
infoBtn.textContent = "";
infoBtn.addEventListener("click", e => {
e.stopPropagation();
toggleInfo(photo);
});
container.appendChild(infoBtn);
}
// ===============================
// OPEN / CLOSE MODAL
// ===============================
function openModal(photo) {
window.closeBottomSheet?.();
setModalContent(photo);
modal.classList.add("open");
modal.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
}
function closeModal() {
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 {}
}
document.getElementById("modalMediaContainer").innerHTML = "";
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
modalPrev?.classList.add("hidden");
modalNext?.classList.add("hidden");
}
modalClose?.addEventListener("click", e => {
e.stopPropagation();
closeModal();
});
modal.addEventListener("click", e => {
if (e.target === modal) closeModal();
});
// ===============================
// NAVIGAZIONE
// ===============================
function openAt(i) {
const list = window.modalList || [];
if (!list[i]) return;
window.modalIndex = i;
const photo = list[i];
if (isInfoOpen()) openInfo(photo);
openModal(photo);
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);
}
document.addEventListener("keydown", e => {
if (!modal.classList.contains("open")) return;
if (e.key === "ArrowLeft") { e.preventDefault(); showPrev(); }
if (e.key === "ArrowRight") { e.preventDefault(); showNext(); }
});
modal.addEventListener("click", e => {
if (!modal.classList.contains("open")) return;
if (e.target.closest(".modal-info-btn, .modal-close, .modal-nav-btn")) return;
if (e.target === modal) return;
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();
});
// ===============================
// FRECCE
// ===============================
function updateArrows() {
if (!modalPrev || !modalNext) return;
const len = (window.modalList || []).length;
const i = window.modalIndex || 0;
const show = len > 1;
modalPrev.classList.toggle("hidden", !show);
modalNext.classList.toggle("hidden", !show);
modalPrev.classList.toggle("disabled", i <= 0);
modalNext.classList.toggle("disabled", i >= len - 1);
}
modalPrev?.addEventListener("click", e => {
e.stopPropagation();
showPrev();
updateArrows();
});
modalNext?.addEventListener("click", e => {
e.stopPropagation();
showNext();
updateArrows();
});
// EXPORT
window.openModal = openModal;
window.closeModal = closeModal;

59
public/js/optionsSheet.js Normal file
View file

@ -0,0 +1,59 @@
// ===============================
// OPTIONS SHEET — Ordinamento, Raggruppamento, Filtri
// ===============================
document.addEventListener("DOMContentLoaded", () => {
const optionsSheet = document.getElementById("optionsSheet");
const sheetOverlay = document.getElementById("sheetOverlay");
const optionsBtn = document.getElementById("optionsBtn");
// -------------------------------
// APRI / CHIUDI
// -------------------------------
function openOptionsSheet() {
optionsSheet.classList.add("open");
sheetOverlay.classList.add("open");
}
function closeOptionsSheet() {
optionsSheet.classList.remove("open");
sheetOverlay.classList.remove("open");
}
// -------------------------------
// HANDLER CLICK BOTTONI
// -------------------------------
optionsSheet.addEventListener("click", (e) => {
const btn = e.target.closest(".sheet-btn");
if (!btn) return;
const sort = btn.dataset.sort;
const group = btn.dataset.group;
const filter = btn.dataset.filter;
if (sort) window.currentSort = sort;
if (group) window.currentGroup = group;
if (filter) window.currentFilter = filter;
refreshGallery();
closeOptionsSheet();
});
// -------------------------------
// CHIUSURA CLICCANDO FUORI
// -------------------------------
sheetOverlay.addEventListener("click", closeOptionsSheet);
// -------------------------------
// AGGANCIA IL PULSANTE
// -------------------------------
optionsBtn.addEventListener("click", openOptionsSheet);
// -------------------------------
// EXPORT
// -------------------------------
window.openOptionsSheet = openOptionsSheet;
window.closeOptionsSheet = closeOptionsSheet;
});

84
public/js/state.js Normal file
View file

@ -0,0 +1,84 @@
// ===============================
// state.js — Stato condiviso
// ===============================
// ---- STATO FOTO ----
let localPhotos = [];
// ---- LAST SYNC ----
function getLastSync() {
return localStorage.getItem("lastSync");
}
function setLastSync(ts) {
if (ts) localStorage.setItem("lastSync", ts);
}
// ---- FOTO: GET / SET ----
function getLocalPhotos() {
return localPhotos;
}
function setLocalPhotos(photos) {
localPhotos = Array.isArray(photos) ? photos : [];
saveLocalState();
}
// ---- FOTO: ADD / REMOVE ----
function addPhotoLocal(photo) {
if (!photo || !photo.id) return;
// Evita duplicati
localPhotos = localPhotos.filter(p => p.id !== photo.id);
// Aggiungi la versione nuova
localPhotos.push(photo);
saveLocalState();
}
function removePhotoLocal(id) {
const before = localPhotos.length;
localPhotos = localPhotos.filter(p => p.id !== id);
const after = localPhotos.length;
console.log("[removePhotoLocal] id:", id, "prima:", before, "dopo:", after);
saveLocalState();
}
// ---- LOCAL STORAGE CACHE ----
function saveLocalState() {
try {
localStorage.setItem("photosCache", JSON.stringify(localPhotos));
} catch (e) {
console.warn("saveLocalState error:", e);
}
}
function loadLocalState() {
try {
const raw = localStorage.getItem("photosCache");
if (!raw) return [];
const arr = JSON.parse(raw);
if (Array.isArray(arr)) {
// Filtra eventuali soft delete rimaste
localPhotos = arr.filter(p => !p.deleted_at);
return localPhotos;
}
} catch (e) {
console.warn("loadLocalState error:", e);
}
return [];
}
// ---- EXPORT ----
window.getLastSync = getLastSync;
window.setLastSync = setLastSync;
window.getLocalPhotos = getLocalPhotos;
window.setLocalPhotos = setLocalPhotos;
window.addPhotoLocal = addPhotoLocal;
window.removePhotoLocal = removePhotoLocal;
window.saveLocalState = saveLocalState;
window.loadLocalState = loadLocalState;

579
public/js/sync.js Normal file
View file

@ -0,0 +1,579 @@
// ===============================
// sync.js — Full load + Progressive Sync + WS
// Compatibile con server bulk add_dir / del_dir:
// { type:"add_dir", mode:"bulk", since:"ISO", count:n, folder:"..." }
// { type:"del_dir", mode:"bulk", since:"ISO", count:n, folder:"..." }
// ===============================
const WS_URL = "wss://prova-ws.patachina.it";
// retention server-side: 30gg
const RETENTION_DAYS = 30;
const RETENTION_MS = RETENTION_DAYS * 86400000;
// WS reconnect window: 2 minuti
const WS_DORMANT_MS = 120000;
const WS_RECONNECT_DELAY_MS = 1500;
const WS_NEED_FULLSYNC_DELAY_MS = 800;
// processed events ring buffer
const MAX_PROCESSED_EVENTS = 2000;
// gestione burst "added" (utile se server manda per-file sotto soglia)
const BATCH_SIZE = 200;
const FLUSH_DEBOUNCE_MS = 250;
const TOO_MANY_THRESHOLD = 1200;
// ------------------------------------
// RECOVERY DONE (server patched behavior)
// ------------------------------------
let needRecoveryDoneAck = false; // ✅ set true quando auth_ok.need_full_sync=true
function _maybeSendRecoveryDone(ws) {
// invia una sola volta dopo una recovery richiesta dal server
if (!needRecoveryDoneAck) return;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
console.log("✅ [WS] Invio recovery_done al server");
_send(ws, { type: "recovery_done" });
needRecoveryDoneAck = false;
}
// -------------------------------
// AUTH HEADERS
// -------------------------------
function _authHeaders() {
const token = localStorage.getItem("token");
return { Authorization: "Bearer " + token };
}
// -------------------------------
// API HELPERS
// -------------------------------
async function getAllPhotos() {
const res = await fetch(`/photos`, { headers: _authHeaders() });
return await res.json();
}
async function getChanges(since) {
const res = await fetch(`/photos/changes?since=${encodeURIComponent(since)}`, {
headers: _authHeaders(),
});
return await res.json(); // array di foto normalizzate
}
async function getDeletedHard(since) {
const res = await fetch(`/photos/deleted_hard?since=${encodeURIComponent(since)}`, {
headers: _authHeaders(),
});
const json = await res.json();
return json.deleted || []; // [{id, deleted_at}]
}
async function fetchPhotosByIds(ids) {
if (!ids || !ids.length) return [];
const qs = ids.map(id => `id=${encodeURIComponent(id)}`).join("&");
const payload = parseJwt(localStorage.getItem("token") || "");
const user = payload?.name || "Common";
const url = `/photos/byIds?${qs}&user=${encodeURIComponent(user)}`;
const res = await fetch(url, { headers: _authHeaders() });
return await res.json(); // array di foto
}
// -------------------------------
// TIME HELPERS
// -------------------------------
function _nowIso() {
return new Date().toISOString();
}
function _parseIsoMs(iso) {
const t = Date.parse(iso);
return Number.isFinite(t) ? t : 0;
}
function _isTooOldForDelta(lastSyncIso) {
if (!lastSyncIso) return true;
const lastMs = _parseIsoMs(lastSyncIso);
if (!lastMs) return true;
return (Date.now() - lastMs) > RETENTION_MS;
}
// -------------------------------
// MAP UTILS
// -------------------------------
function _toMapById(arr) {
const m = new Map();
for (const p of (arr || [])) {
if (p && p.id != null) m.set(String(p.id), p);
}
return m;
}
// -------------------------------
// FULL LOAD (snapshot completo)
// -------------------------------
async function fullLoad() {
console.log("🟦 FULL LOAD → caricamento completo");
const photos = await getAllPhotos();
console.log(`📥 FULL LOAD → ricevute ${photos.length} foto`);
// 🔥 NON filtriamo i soft delete
setLocalPhotos(photos);
saveLocalState();
refreshGallery();
const now = _nowIso();
setLastSync(now);
console.log(`🕒 FULL LOAD → lastSync = ${now}`);
}
// -------------------------------
// PROGRESSIVE SYNC (entro 30gg: changes + deleted_hard)
// -------------------------------
async function progressiveSync() {
console.log("==============================================");
console.log("🚀 progressiveSync() START");
const lastSync = getLastSync();
const localArr = getLocalPhotos() || [];
console.log(`🕒 lastSync: ${lastSync}`);
console.log(`📸 Foto locali (cache): ${localArr.length}`);
if (!lastSync || localArr.length === 0) {
await fullLoad();
console.log("🏁 progressiveSync() COMPLETATO (fullLoad: no lastSync o cache vuota)");
console.log("==============================================");
return;
}
if (_isTooOldForDelta(lastSync)) {
console.warn(`🟥 lastSync > ${RETENTION_DAYS}gg → FULL LOAD richiesto`);
await fullLoad();
console.log("🏁 progressiveSync() COMPLETATO (fullLoad: lastSync troppo vecchio)");
console.log("==============================================");
return;
}
console.log("🟩 PROGRESSIVE SYNC → changes + deleted_hard");
const changed = await getChanges(lastSync);
console.log(`🟨 changes: ${Array.isArray(changed) ? changed.length : 0}`);
const hardDeleted = await getDeletedHard(lastSync);
console.log(`🟥 deleted_hard: ${hardDeleted.length}`);
const localMap = _toMapById(localArr);
if (Array.isArray(changed)) {
for (const p of changed) {
if (!p || p.id == null) continue;
localMap.set(String(p.id), p);
}
}
for (const d of hardDeleted) {
if (!d || d.id == null) continue;
localMap.delete(String(d.id));
}
const merged = Array.from(localMap.values());
setLocalPhotos(merged);
saveLocalState();
refreshGallery();
const now = _nowIso();
setLastSync(now);
console.log(`🕒 Aggiorno lastSync → ${now}`);
console.log("🏁 progressiveSync() COMPLETATO");
console.log("==============================================");
}
// -------------------------------
// PROGRESSIVE SYNC “MIRATO” usando since del server WS bulk
// (usato per add_dir/del_dir bulk)
// -------------------------------
async function progressiveSyncFrom(sinceIso) {
if (!sinceIso) return progressiveSync();
console.log(`🟦 progressiveSyncFrom(${sinceIso})`);
// se troppo vecchio, fai full
if (_isTooOldForDelta(sinceIso)) {
console.warn("🟥 since troppo vecchio → fullLoad()");
await fullLoad();
return;
}
const localArr = getLocalPhotos() || [];
if (!localArr.length) {
await fullLoad();
return;
}
const changed = await getChanges(sinceIso);
const hardDeleted = await getDeletedHard(sinceIso);
const localMap = _toMapById(localArr);
for (const p of (changed || [])) {
if (!p || p.id == null) continue;
localMap.set(String(p.id), p);
}
for (const d of (hardDeleted || [])) {
if (!d || d.id == null) continue;
localMap.delete(String(d.id));
}
setLocalPhotos(Array.from(localMap.values()));
saveLocalState();
refreshGallery();
// aggiorna lastSync a "now"
setLastSync(_nowIso());
}
// ===============================================
// PROCESSED EVENTS — Ring Buffer (max 2000)
// ===============================================
function _loadProcessedRing() {
try {
const arr = JSON.parse(localStorage.getItem("processed_events") || "[]");
if (!Array.isArray(arr)) return [];
return arr.slice(-MAX_PROCESSED_EVENTS);
} catch {
return [];
}
}
function _saveProcessedRing(ring) {
localStorage.setItem("processed_events", JSON.stringify(ring.slice(-MAX_PROCESSED_EVENTS)));
}
const processedRing = _loadProcessedRing();
const processedSet = new Set(processedRing);
function isProcessed(eventId) {
return !!eventId && processedSet.has(eventId);
}
function markProcessed(eventId) {
if (!eventId) return;
if (processedSet.has(eventId)) return;
processedRing.push(eventId);
processedSet.add(eventId);
while (processedRing.length > MAX_PROCESSED_EVENTS) {
const old = processedRing.shift();
if (old) processedSet.delete(old);
}
_saveProcessedRing(processedRing);
}
// ===============================================
// WS ADDED BURST HANDLER (queue + batch byIds)
// ===============================================
let addedQueue = [];
let addedSet = new Set();
let flushTimer = null;
let flushing = false;
function enqueueAdded(id) {
if (!id) return;
const sid = String(id);
if (addedSet.has(sid)) return;
addedSet.add(sid);
addedQueue.push(sid);
// se burst troppo grande -> fallback a progressive sync
if (addedQueue.length >= TOO_MANY_THRESHOLD) {
console.warn(`🟥 [WS] Burst added (${addedQueue.length}) → fallback progressiveSync()`);
if (flushTimer) clearTimeout(flushTimer);
flushTimer = null;
addedQueue = [];
addedSet.clear();
progressiveSync().then(() => {
// se la recovery era richiesta dal server, invia recovery_done
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
_maybeSendRecoveryDone(wsInstance);
}
});
return;
}
if (!flushTimer) {
flushTimer = setTimeout(() => {
flushTimer = null;
flushAddedQueue();
}, FLUSH_DEBOUNCE_MS);
}
}
async function flushAddedQueue() {
if (flushing) return;
if (addedQueue.length === 0) return;
flushing = true;
try {
const chunk = addedQueue.splice(0, BATCH_SIZE);
chunk.forEach(id => addedSet.delete(id));
const items = await fetchPhotosByIds(chunk);
if (Array.isArray(items) && items.length) {
for (const p of items) addPhotoLocal(p);
saveLocalState();
refreshGallery();
}
if (addedQueue.length > 0) setTimeout(flushAddedQueue, 0);
} catch (e) {
console.error("❌ [WS] flushAddedQueue error:", e);
addedQueue = [];
addedSet.clear();
await progressiveSync();
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
_maybeSendRecoveryDone(wsInstance);
}
} finally {
flushing = false;
}
}
// ===============================================
// WEBSOCKET REAL-TIME (auth + ping/pong + ack)
// ===============================================
let wsInstance = null;
function _ensureSessionId() {
let session_id = localStorage.getItem("ws_session_id");
if (!session_id) {
session_id = crypto.randomUUID();
localStorage.setItem("ws_session_id", session_id);
}
return session_id;
}
function _setLastSeenNow() {
localStorage.setItem("ws_last_seen", String(Date.now()));
}
function _getLastSeen() {
return parseInt(localStorage.getItem("ws_last_seen") || "0", 10);
}
function _safeJsonParse(raw) {
try { return JSON.parse(raw); } catch { return null; }
}
function _send(ws, obj) {
try { ws.send(JSON.stringify(obj)); }
catch (e) { console.warn("⚠️ [WS] send failed:", e); }
}
function _ack(ws, event_id) {
if (!event_id) return;
_send(ws, { type: "ack", event_id });
}
function startWebSocket() {
const token = localStorage.getItem("token");
if (!token) {
console.error("❌ [WS] Nessun token JWT trovato");
return;
}
if (wsInstance && (
wsInstance.readyState === WebSocket.OPEN ||
wsInstance.readyState === WebSocket.CONNECTING
)) {
console.log("⚠️ [WS] Connessione già attiva, ignoro startWebSocket()");
return;
}
if (wsInstance) {
try { wsInstance.close(); } catch (e) {}
}
console.log("🔌 [WS] Creo nuova connessione WebSocket...");
wsInstance = new WebSocket(WS_URL);
const ws = wsInstance;
const session_id = _ensureSessionId();
ws.onopen = () => {
//console.log("🟢 [WS] Connesso → invio token JWT + session_id");
console.log(`🟢 [WS OPEN] Connesso. session_id=${session_id}`);
_send(ws, { type: "auth", token, session_id });
};
ws.onmessage = async (ev) => {
console.log("📩 [WS RAW] Messaggio ricevuto:", ev.data);
_setLastSeenNow();
const msg = _safeJsonParse(ev.data);
if (!msg) {
console.error("❌ [WS] Errore parsing JSON");
return;
}
console.log("📩 [WS PARSED]:", msg);
if (msg.type === "auth_ok") {
console.log("🔐 WS autenticato come:", msg.user, "session:", msg.session_id);
if (msg.need_full_sync) {
console.warn("🟥 [WS] Server richiede FULL RECOVERY");
needRecoveryDoneAck = true;
setTimeout(async () => {
try {
console.log("🔄 [WS] Eseguo progressiveSync() per recovery");
await progressiveSync();
} catch (e) {
console.error("❌ [WS] Errore progressiveSync durante recovery:", e);
} finally {
_maybeSendRecoveryDone(ws);
}
}, WS_NEED_FULLSYNC_DELAY_MS);
}
return;
}
if (msg.type === "ping") {
_send(ws, { type: "pong" });
return;
}
const event_id = msg.event_id;
if (event_id && isProcessed(event_id)) {
console.log("♻️ [WS] Evento già processato, invio solo ACK:", event_id);
_ack(ws, event_id);
return;
}
const finalize = () => {
if (event_id) {
markProcessed(event_id);
_ack(ws, event_id);
}
};
// ✅ BULK ADD_DIR: progressive sync da since
if (msg.type === "add_dir") {
if (msg.mode === "bulk") {
console.log(`📦 [WS] add_dir bulk folder=${msg.folder} count=${msg.count} → progressiveSyncFrom(since)`);
await progressiveSyncFrom(msg.since);
} else {
console.log(`📁 [WS] add_dir folder=${msg.folder}`);
}
finalize();
return;
}
// ✅ BULK DEL_DIR: progressive sync da since
if (msg.type === "del_dir") {
if (msg.mode === "bulk") {
console.log(`📦 [WS] del_dir bulk folder=${msg.folder} count=${msg.count} → progressiveSyncFrom(since)`);
await progressiveSyncFrom(msg.since);
} else {
console.log(`📁 [WS] del_dir folder=${msg.folder}`);
}
finalize();
return;
}
// ADDED (per-file): enqueue e batch byIds
if (msg.type === "added") {
enqueueAdded(msg.id);
finalize();
return;
}
// HARD DELETE (per-file)
if (msg.type === "del") {
removePhotoLocal(msg.id);
saveLocalState();
refreshGallery();
finalize();
return;
}
// removed (API)
if (msg.type === "removed") {
removePhotoLocal(msg.id);
saveLocalState();
refreshGallery();
finalize();
return;
}
// updated (soft delete / restore)
if (msg.type === "updated") {
updateLocalPhoto(msg.id, { deleted_at: msg.deleted_at });
saveLocalState();
refreshGallery();
finalize();
return;
}
// add_dir_done / del_dir_done (opzionali)
if (msg.type === "add_dir_done" || msg.type === "del_dir_done") {
console.log(`✅ [WS] ${msg.type} folder=${msg.folder} count=${msg.count}`);
finalize();
return;
}
console.log(" [WS] Evento non gestito:", msg);
// fallback
if (event_id) {
console.log(" [WS] Evento non gestito, ACK comunque:", event_id);
finalize();
}
};
ws.onclose = () => {
//console.warn("❌ [WS] Connessione chiusa");
console.warn(`❌ [WS CLOSE] Connessione chiusa. session_id=${session_id}`);
if (wsInstance === ws) wsInstance = null;
const now = Date.now();
const lastSeen = _getLastSeen();
if (now - lastSeen < WS_DORMANT_MS) {
//console.log("🔄 [WS] reconnect immediato");
console.log("🔄 [WS] Tentativo di reconnect...");
setTimeout(startWebSocket, WS_RECONNECT_DELAY_MS);
} else {
console.log("🟦 [WS] sessione dormiente → progressiveSync/full al prossimo avvio");
// localStorage.removeItem("ws_session_id");
// opzionale: anche last_seen
// localStorage.removeItem("ws_last_seen");
}
};
ws.onerror = (err) => {
//console.error("⚠️ [WS] Errore WebSocket:", err);
console.error("⚠️ [WS ERROR]", err);
};
}
// ===============================
// INIT
// ===============================
document.addEventListener("DOMContentLoaded", () => {
if (AppAuth.isLoggedIn()) {
progressiveSync();
startWebSocket();
}
});

567
public/js/sync.js.ok Normal file
View file

@ -0,0 +1,567 @@
// ===============================
// sync.js — Full load + Progressive Sync + WS
// Compatibile con server bulk add_dir / del_dir:
// { type:"add_dir", mode:"bulk", since:"ISO", count:n, folder:"..." }
// { type:"del_dir", mode:"bulk", since:"ISO", count:n, folder:"..." }
// ===============================
const WS_URL = "wss://prova-ws.patachina.it";
// retention server-side: 30gg
const RETENTION_DAYS = 30;
const RETENTION_MS = RETENTION_DAYS * 86400000;
// WS reconnect window: 2 minuti
const WS_DORMANT_MS = 120000;
const WS_RECONNECT_DELAY_MS = 1500;
const WS_NEED_FULLSYNC_DELAY_MS = 800;
// processed events ring buffer
const MAX_PROCESSED_EVENTS = 2000;
// gestione burst "added" (utile se server manda per-file sotto soglia)
const BATCH_SIZE = 200;
const FLUSH_DEBOUNCE_MS = 250;
const TOO_MANY_THRESHOLD = 1200;
// ------------------------------------
// RECOVERY DONE (server patched behavior)
// ------------------------------------
let needRecoveryDoneAck = false; // ✅ set true quando auth_ok.need_full_sync=true
function _maybeSendRecoveryDone(ws) {
// invia una sola volta dopo una recovery richiesta dal server
if (!needRecoveryDoneAck) return;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
console.log("✅ [WS] Invio recovery_done al server");
_send(ws, { type: "recovery_done" });
needRecoveryDoneAck = false;
}
// -------------------------------
// AUTH HEADERS
// -------------------------------
function _authHeaders() {
const token = localStorage.getItem("token");
return { Authorization: "Bearer " + token };
}
// -------------------------------
// API HELPERS
// -------------------------------
async function getAllPhotos() {
const res = await fetch(`/photos`, { headers: _authHeaders() });
return await res.json();
}
async function getChanges(since) {
const res = await fetch(`/photos/changes?since=${encodeURIComponent(since)}`, {
headers: _authHeaders(),
});
return await res.json(); // array di foto normalizzate
}
async function getDeletedHard(since) {
const res = await fetch(`/photos/deleted_hard?since=${encodeURIComponent(since)}`, {
headers: _authHeaders(),
});
const json = await res.json();
return json.deleted || []; // [{id, deleted_at}]
}
async function fetchPhotosByIds(ids) {
if (!ids || !ids.length) return [];
const qs = ids.map(id => `id=${encodeURIComponent(id)}`).join("&");
const payload = parseJwt(localStorage.getItem("token") || "");
const user = payload?.name || "Common";
const url = `/photos/byIds?${qs}&user=${encodeURIComponent(user)}`;
const res = await fetch(url, { headers: _authHeaders() });
return await res.json(); // array di foto
}
// -------------------------------
// TIME HELPERS
// -------------------------------
function _nowIso() {
return new Date().toISOString();
}
function _parseIsoMs(iso) {
const t = Date.parse(iso);
return Number.isFinite(t) ? t : 0;
}
function _isTooOldForDelta(lastSyncIso) {
if (!lastSyncIso) return true;
const lastMs = _parseIsoMs(lastSyncIso);
if (!lastMs) return true;
return (Date.now() - lastMs) > RETENTION_MS;
}
// -------------------------------
// MAP UTILS
// -------------------------------
function _toMapById(arr) {
const m = new Map();
for (const p of (arr || [])) {
if (p && p.id != null) m.set(String(p.id), p);
}
return m;
}
// -------------------------------
// FULL LOAD (snapshot completo)
// -------------------------------
async function fullLoad() {
console.log("🟦 FULL LOAD → caricamento completo");
const photos = await getAllPhotos();
console.log(`📥 FULL LOAD → ricevute ${photos.length} foto`);
// 🔥 NON filtriamo i soft delete
setLocalPhotos(photos);
saveLocalState();
refreshGallery();
const now = _nowIso();
setLastSync(now);
console.log(`🕒 FULL LOAD → lastSync = ${now}`);
}
// -------------------------------
// PROGRESSIVE SYNC (entro 30gg: changes + deleted_hard)
// -------------------------------
async function progressiveSync() {
console.log("==============================================");
console.log("🚀 progressiveSync() START");
const lastSync = getLastSync();
const localArr = getLocalPhotos() || [];
console.log(`🕒 lastSync: ${lastSync}`);
console.log(`📸 Foto locali (cache): ${localArr.length}`);
if (!lastSync || localArr.length === 0) {
await fullLoad();
console.log("🏁 progressiveSync() COMPLETATO (fullLoad: no lastSync o cache vuota)");
console.log("==============================================");
return;
}
if (_isTooOldForDelta(lastSync)) {
console.warn(`🟥 lastSync > ${RETENTION_DAYS}gg → FULL LOAD richiesto`);
await fullLoad();
console.log("🏁 progressiveSync() COMPLETATO (fullLoad: lastSync troppo vecchio)");
console.log("==============================================");
return;
}
console.log("🟩 PROGRESSIVE SYNC → changes + deleted_hard");
const changed = await getChanges(lastSync);
console.log(`🟨 changes: ${Array.isArray(changed) ? changed.length : 0}`);
const hardDeleted = await getDeletedHard(lastSync);
console.log(`🟥 deleted_hard: ${hardDeleted.length}`);
const localMap = _toMapById(localArr);
if (Array.isArray(changed)) {
for (const p of changed) {
if (!p || p.id == null) continue;
localMap.set(String(p.id), p);
}
}
for (const d of hardDeleted) {
if (!d || d.id == null) continue;
localMap.delete(String(d.id));
}
const merged = Array.from(localMap.values());
setLocalPhotos(merged);
saveLocalState();
refreshGallery();
const now = _nowIso();
setLastSync(now);
console.log(`🕒 Aggiorno lastSync → ${now}`);
console.log("🏁 progressiveSync() COMPLETATO");
console.log("==============================================");
}
// -------------------------------
// PROGRESSIVE SYNC “MIRATO” usando since del server WS bulk
// (usato per add_dir/del_dir bulk)
// -------------------------------
async function progressiveSyncFrom(sinceIso) {
if (!sinceIso) return progressiveSync();
console.log(`🟦 progressiveSyncFrom(${sinceIso})`);
// se troppo vecchio, fai full
if (_isTooOldForDelta(sinceIso)) {
console.warn("🟥 since troppo vecchio → fullLoad()");
await fullLoad();
return;
}
const localArr = getLocalPhotos() || [];
if (!localArr.length) {
await fullLoad();
return;
}
const changed = await getChanges(sinceIso);
const hardDeleted = await getDeletedHard(sinceIso);
const localMap = _toMapById(localArr);
for (const p of (changed || [])) {
if (!p || p.id == null) continue;
localMap.set(String(p.id), p);
}
for (const d of (hardDeleted || [])) {
if (!d || d.id == null) continue;
localMap.delete(String(d.id));
}
setLocalPhotos(Array.from(localMap.values()));
saveLocalState();
refreshGallery();
// aggiorna lastSync a "now"
setLastSync(_nowIso());
}
// ===============================================
// PROCESSED EVENTS — Ring Buffer (max 2000)
// ===============================================
function _loadProcessedRing() {
try {
const arr = JSON.parse(localStorage.getItem("processed_events") || "[]");
if (!Array.isArray(arr)) return [];
return arr.slice(-MAX_PROCESSED_EVENTS);
} catch {
return [];
}
}
function _saveProcessedRing(ring) {
localStorage.setItem("processed_events", JSON.stringify(ring.slice(-MAX_PROCESSED_EVENTS)));
}
const processedRing = _loadProcessedRing();
const processedSet = new Set(processedRing);
function isProcessed(eventId) {
return !!eventId && processedSet.has(eventId);
}
function markProcessed(eventId) {
if (!eventId) return;
if (processedSet.has(eventId)) return;
processedRing.push(eventId);
processedSet.add(eventId);
while (processedRing.length > MAX_PROCESSED_EVENTS) {
const old = processedRing.shift();
if (old) processedSet.delete(old);
}
_saveProcessedRing(processedRing);
}
// ===============================================
// WS ADDED BURST HANDLER (queue + batch byIds)
// ===============================================
let addedQueue = [];
let addedSet = new Set();
let flushTimer = null;
let flushing = false;
function enqueueAdded(id) {
if (!id) return;
const sid = String(id);
if (addedSet.has(sid)) return;
addedSet.add(sid);
addedQueue.push(sid);
// se burst troppo grande -> fallback a progressive sync
if (addedQueue.length >= TOO_MANY_THRESHOLD) {
console.warn(`🟥 [WS] Burst added (${addedQueue.length}) → fallback progressiveSync()`);
if (flushTimer) clearTimeout(flushTimer);
flushTimer = null;
addedQueue = [];
addedSet.clear();
progressiveSync().then(() => {
// se la recovery era richiesta dal server, invia recovery_done
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
_maybeSendRecoveryDone(wsInstance);
}
});
return;
}
if (!flushTimer) {
flushTimer = setTimeout(() => {
flushTimer = null;
flushAddedQueue();
}, FLUSH_DEBOUNCE_MS);
}
}
async function flushAddedQueue() {
if (flushing) return;
if (addedQueue.length === 0) return;
flushing = true;
try {
const chunk = addedQueue.splice(0, BATCH_SIZE);
chunk.forEach(id => addedSet.delete(id));
const items = await fetchPhotosByIds(chunk);
if (Array.isArray(items) && items.length) {
for (const p of items) addPhotoLocal(p);
saveLocalState();
refreshGallery();
}
if (addedQueue.length > 0) setTimeout(flushAddedQueue, 0);
} catch (e) {
console.error("❌ [WS] flushAddedQueue error:", e);
addedQueue = [];
addedSet.clear();
await progressiveSync();
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
_maybeSendRecoveryDone(wsInstance);
}
} finally {
flushing = false;
}
}
// ===============================================
// WEBSOCKET REAL-TIME (auth + ping/pong + ack)
// ===============================================
let wsInstance = null;
function _ensureSessionId() {
let session_id = localStorage.getItem("ws_session_id");
if (!session_id) {
session_id = crypto.randomUUID();
localStorage.setItem("ws_session_id", session_id);
}
return session_id;
}
function _setLastSeenNow() {
localStorage.setItem("ws_last_seen", String(Date.now()));
}
function _getLastSeen() {
return parseInt(localStorage.getItem("ws_last_seen") || "0", 10);
}
function _safeJsonParse(raw) {
try { return JSON.parse(raw); } catch { return null; }
}
function _send(ws, obj) {
try { ws.send(JSON.stringify(obj)); }
catch (e) { console.warn("⚠️ [WS] send failed:", e); }
}
function _ack(ws, event_id) {
if (!event_id) return;
_send(ws, { type: "ack", event_id });
}
function startWebSocket() {
const token = localStorage.getItem("token");
if (!token) {
console.error("❌ [WS] Nessun token JWT trovato");
return;
}
if (wsInstance && (
wsInstance.readyState === WebSocket.OPEN ||
wsInstance.readyState === WebSocket.CONNECTING
)) {
console.log("⚠️ [WS] Connessione già attiva, ignoro startWebSocket()");
return;
}
if (wsInstance) {
try { wsInstance.close(); } catch (e) {}
}
console.log("🔌 [WS] Creo nuova connessione WebSocket...");
wsInstance = new WebSocket(WS_URL);
const ws = wsInstance;
const session_id = _ensureSessionId();
ws.onopen = () => {
console.log("🟢 [WS] Connesso → invio token JWT + session_id");
_send(ws, { type: "auth", token, session_id });
};
ws.onmessage = async (ev) => {
console.log("📩 [WS RAW] Messaggio ricevuto:", ev.data);
_setLastSeenNow();
const msg = _safeJsonParse(ev.data);
if (!msg) {
console.error("❌ [WS] Errore parsing JSON");
return;
}
console.log("📩 [WS PARSED]:", msg);
if (msg.type === "auth_ok") {
console.log("🔐 WS autenticato come:", msg.user, "session:", msg.session_id);
if (msg.need_full_sync) {
console.warn("⚠️ [WS] Server richiede recovery → progressiveSync() ritardato");
needRecoveryDoneAck = true;
setTimeout(async () => {
try {
await progressiveSync();
} finally {
_maybeSendRecoveryDone(ws);
}
}, WS_NEED_FULLSYNC_DELAY_MS);
}
return;
}
if (msg.type === "ping") {
_send(ws, { type: "pong" });
return;
}
const event_id = msg.event_id;
if (event_id && isProcessed(event_id)) {
console.log("♻️ [WS] Evento già processato, invio solo ACK:", event_id);
_ack(ws, event_id);
return;
}
const finalize = () => {
if (event_id) {
markProcessed(event_id);
_ack(ws, event_id);
}
};
// ✅ BULK ADD_DIR: progressive sync da since
if (msg.type === "add_dir") {
if (msg.mode === "bulk") {
console.log(`📦 [WS] add_dir bulk folder=${msg.folder} count=${msg.count} → progressiveSyncFrom(since)`);
await progressiveSyncFrom(msg.since);
} else {
console.log(`📁 [WS] add_dir folder=${msg.folder}`);
}
finalize();
return;
}
// ✅ BULK DEL_DIR: progressive sync da since
if (msg.type === "del_dir") {
if (msg.mode === "bulk") {
console.log(`📦 [WS] del_dir bulk folder=${msg.folder} count=${msg.count} → progressiveSyncFrom(since)`);
await progressiveSyncFrom(msg.since);
} else {
console.log(`📁 [WS] del_dir folder=${msg.folder}`);
}
finalize();
return;
}
// ADDED (per-file): enqueue e batch byIds
if (msg.type === "added") {
enqueueAdded(msg.id);
finalize();
return;
}
// HARD DELETE (per-file)
if (msg.type === "del") {
removePhotoLocal(msg.id);
saveLocalState();
refreshGallery();
finalize();
return;
}
// removed (API)
if (msg.type === "removed") {
removePhotoLocal(msg.id);
saveLocalState();
refreshGallery();
finalize();
return;
}
// updated (soft delete / restore)
if (msg.type === "updated") {
updateLocalPhoto(msg.id, { deleted_at: msg.deleted_at });
saveLocalState();
refreshGallery();
finalize();
return;
}
// add_dir_done / del_dir_done (opzionali)
if (msg.type === "add_dir_done" || msg.type === "del_dir_done") {
console.log(`✅ [WS] ${msg.type} folder=${msg.folder} count=${msg.count}`);
finalize();
return;
}
// fallback
if (event_id) {
console.log(" [WS] Evento non gestito, ACK comunque:", event_id);
finalize();
}
};
ws.onclose = () => {
console.warn("❌ [WS] Connessione chiusa");
if (wsInstance === ws) wsInstance = null;
const now = Date.now();
const lastSeen = _getLastSeen();
if (now - lastSeen < WS_DORMANT_MS) {
console.log("🔄 [WS] reconnect immediato");
setTimeout(startWebSocket, WS_RECONNECT_DELAY_MS);
} else {
console.log("🟦 [WS] sessione dormiente → progressiveSync/full al prossimo avvio");
localStorage.removeItem("ws_session_id");
// opzionale: anche last_seen
// localStorage.removeItem("ws_last_seen");
}
};
ws.onerror = (err) => {
console.error("⚠️ [WS] Errore WebSocket:", err);
};
}
// ===============================
// INIT
// ===============================
document.addEventListener("DOMContentLoaded", () => {
if (AppAuth.isLoggedIn()) {
progressiveSync();
startWebSocket();
}
});

115
public/login.html Normal file
View file

@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Accedi</title>
<link rel="stylesheet" href="/css/login.css">
<style>
/* Mini stile integrato (puoi spostarlo in login.css) */
body {
margin: 0;
padding: 0;
font-family: system-ui, sans-serif;
background: #f5f5f5;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.login-box {
background: white;
padding: 28px;
border-radius: 12px;
width: 100%;
max-width: 360px;
box-shadow: 0 4px 18px rgba(0,0,0,0.1);
}
h1 {
margin: 0 0 20px;
font-size: 1.6rem;
text-align: center;
}
.input-group {
margin-bottom: 16px;
}
label {
display: block;
font-size: 0.9rem;
margin-bottom: 6px;
}
input {
width: 100%;
padding: 10px;
border-radius: 8px;
border: 1px solid #ccc;
font-size: 1rem;
}
button {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
background: #0078ff;
color: white;
font-size: 1rem;
cursor: pointer;
margin-top: 10px;
}
button[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
#loginError {
margin-top: 12px;
padding: 10px;
border-radius: 8px;
background: #ffdddd;
color: #900;
display: none;
}
#loginError.visible {
display: block;
}
</style>
</head>
<body>
<div class="login-box">
<h1>Accedi</h1>
<form id="loginForm" data-redirect="/">
<div class="input-group">
<label for="loginUser">Email</label>
<input id="loginUser" type="email">
</div>
<div class="input-group">
<label for="loginPass">Password</label>
<input id="loginPass" type="password" autocomplete="current-password" required>
</div>
<button id="loginSubmit" type="submit">Entra</button>
<div id="loginError"></div>
</form>
</div>
<!-- Script -->
<script src="/js/auth.js"></script>
<script src="/js/login.js"></script>
</body>
</html>

BIN
public/logout.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 B

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 MiB

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