first commit
1
.Fabio.last_event
Normal file
|
|
@ -0,0 +1 @@
|
|||
1774133507738
|
||||
41
.env
Normal 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
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
thumbs/
|
||||
db.json
|
||||
69
README.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Galleria con json-server e protetto con JWT S6J
|
||||
|
||||
## Installazione
|
||||
|
||||
clonare questa repo e installare tutte le dipendenze con `npm ci`
|
||||
|
||||
|
||||
## Start/Stop servers
|
||||
|
||||
| Description | Script |
|
||||
| ------------------------- | -------------------- |
|
||||
| Start server senza auth | `npm start-no-auth` |
|
||||
| Start server con auth | `npm run start` |
|
||||
|
||||
## Tools
|
||||
|
||||
| Description | Script |
|
||||
| ------------------------------ | ------------------- |
|
||||
| Generate user hashed passwords | `npm run hash` |
|
||||
|
||||
|
||||
[json-server api reference](https://github.com/typicode/json-server)
|
||||
|
||||
## Come usarlo
|
||||
|
||||
clonare e poi installare con
|
||||
|
||||
```
|
||||
npm ci
|
||||
```
|
||||
|
||||
nel file .env ci sono tutti i dati da modificare
|
||||
|
||||
poi inserire in user.json user e password utilizzati per fare il login
|
||||
|
||||
la password da inserire è criptata e viene generata con npm run hash
|
||||
|
||||
il nome viene utilizzato come cartella da scansionare, si trova dentro photos
|
||||
|
||||
es:
|
||||
```
|
||||
name: Fabio
|
||||
|
||||
public/photos
|
||||
└── Fabio
|
||||
└── original
|
||||
└── 2017Irlanda19-29ago
|
||||
├── IMG_0092.JPG
|
||||
├── IMG_0099.JPG
|
||||
├── IMG_0100.JPG
|
||||
```
|
||||
poi dentro Fabio genererà thumbs con tutti i thumbs
|
||||
|
||||
- npm run start
|
||||
- su IP:4000 ci sarà la galleria e andando su impostazioni si potrà fare lo scan di tutte le foto
|
||||
|
||||
dopo aver fatto lo scan è possibile richiedere il json al server con tutte le informazioni anche senza autorizzazione
|
||||
|
||||
basta farlo partire con npm run start-no-auth e le info si possono vedere con
|
||||
|
||||
ip:4000/photos
|
||||
|
||||
- npm start
|
||||
|
||||
|
||||
---
|
||||
|
||||
Inspired in this [post](https://www.techiediaries.com/fake-api-jwt-json-server/) by [Techiediaries](https://www.techiediaries.com/)
|
||||
|
||||
278
README_WS.md
Normal 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 dell’utente:
|
||||
- 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”
|
||||
|
||||
✔ Multi‑dispositivo perfetto
|
||||
|
||||
✔ Queue pulita dopo 2 minuti
|
||||
|
||||
✔ Sync completo automatico quando serve
|
||||
|
||||
✔ WS leggero (ping solo on‑demand)
|
||||
|
||||
✔ 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
|
|
@ -0,0 +1 @@
|
|||
prova
|
||||
4
api_v1/admin_secret.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"email": "admin@gmail.com",
|
||||
"password": "master66"
|
||||
}
|
||||
25
api_v1/config.js
Normal 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
96
api_v1/geo.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
const axios = require("axios");
|
||||
|
||||
// Funzione principale
|
||||
async function loc(lng, lat) {
|
||||
const primary = await place(lng, lat); // Geoapify
|
||||
const fallback = await placePhoton(lng, lat); // Photon
|
||||
|
||||
// Se Geoapify fallisce → usa Photon
|
||||
if (!primary) return fallback;
|
||||
|
||||
// Se Geoapify manca city → prendi da Photon
|
||||
if (!primary.city && fallback?.city) {
|
||||
primary.city = fallback.city;
|
||||
}
|
||||
|
||||
// Se Geoapify manca postcode → prendi da Photon
|
||||
if (!primary.postcode && fallback?.postcode) {
|
||||
primary.postcode = fallback.postcode;
|
||||
}
|
||||
|
||||
// Se Geoapify manca address → prendi da Photon
|
||||
if (!primary.address && fallback?.address) {
|
||||
primary.address = fallback.address;
|
||||
}
|
||||
|
||||
// Se Geoapify manca region → prendi da Photon
|
||||
if (!primary.region && fallback?.region) {
|
||||
primary.region = fallback.region;
|
||||
}
|
||||
|
||||
// Se Geoapify manca county_code → Photon NON lo fornisce
|
||||
// quindi non possiamo riempirlo
|
||||
|
||||
return primary;
|
||||
}
|
||||
|
||||
// Geoapify (sorgente principale)
|
||||
async function place(lng, lat) {
|
||||
const apiKey = "6dc7fb95a3b246cfa0f3bcef5ce9ed9a";
|
||||
const url = `https://api.geoapify.com/v1/geocode/reverse?lat=${lat}&lon=${lng}&apiKey=${apiKey}`;
|
||||
|
||||
try {
|
||||
const r = await axios.get(url);
|
||||
|
||||
if (r.status !== 200) return undefined;
|
||||
if (!r.data.features || r.data.features.length === 0) return undefined;
|
||||
|
||||
const k = r.data.features[0].properties;
|
||||
|
||||
return {
|
||||
continent: k?.timezone?.name?.split("/")?.[0] || undefined,
|
||||
country: k?.country || undefined,
|
||||
region: k?.state || undefined,
|
||||
postcode: k?.postcode || undefined,
|
||||
city: k?.city || undefined,
|
||||
county_code: k?.county_code || undefined,
|
||||
address: k?.address_line1 || undefined,
|
||||
timezone: k?.timezone?.name || undefined,
|
||||
time: k?.timezone?.offset_STD || undefined
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Photon (fallback)
|
||||
async function placePhoton(lng, lat) {
|
||||
try {
|
||||
const url = `https://photon.patachina.it/reverse?lon=${lng}&lat=${lat}`;
|
||||
const r = await axios.get(url);
|
||||
|
||||
if (!r.data || !r.data.features || r.data.features.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const p = r.data.features[0].properties;
|
||||
|
||||
return {
|
||||
continent: undefined, // Photon non lo fornisce
|
||||
country: p.country || undefined,
|
||||
region: p.state || undefined,
|
||||
postcode: p.postcode || undefined,
|
||||
city: p.city || p.town || p.village || undefined,
|
||||
county_code: undefined, // Photon non fornisce codici ISO
|
||||
address: p.street ? `${p.street} ${p.housenumber || ""}`.trim() : undefined,
|
||||
timezone: undefined,
|
||||
time: undefined
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = loc;
|
||||
64
api_v1/scanner/debugVideoDates.js
Normal 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);
|
||||
});
|
||||
73
api_v1/scanner/deleteFolder.js
Normal 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 };
|
||||
};
|
||||
19
api_v1/scanner/deleteWithAuth.js
Normal 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}`);
|
||||
}
|
||||
};
|
||||
81
api_v1/scanner/elevation.js
Normal 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
|
|
@ -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
|
|
@ -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 };
|
||||
108
api_v1/scanner/orphanCleanup.js
Normal 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
|
||||
};
|
||||
};
|
||||
24
api_v1/scanner/postWithAuth.js
Normal 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;
|
||||
};
|
||||
206
api_v1/scanner/processFile.js
Normal 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;
|
||||
97
api_v1/scanner/recordChange.js
Normal 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
|
|
@ -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
|
||||
};
|
||||
68
api_v1/scanner/scanCartella.js
Normal 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
|
|
@ -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;
|
||||
53
api_v1/scanner/scanFile1.js
Normal 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;
|
||||
55
api_v1/scanner/scanFileEntry.js
Normal 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;
|
||||
76
api_v1/scanner/scanNewCartella.js
Normal 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
|
||||
};
|
||||
75
api_v1/scanner/scanNewCartella.js.old
Normal 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
|
|
@ -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;
|
||||
101
api_v1/scanner/scanPhoto.js.old
Normal 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;
|
||||
62
api_v1/scanner/scanPhotoCartella.js
Normal 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;
|
||||
144
api_v1/scanner/scanPhotoSingle.js
Normal 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;
|
||||
139
api_v1/scanner/scanPhotoSingle.js.old
Normal 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;
|
||||
161
api_v1/scanner/scanPhotosUser.js
Normal 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;
|
||||
44
api_v1/scanner/scanUser.js
Normal 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
|
|
@ -0,0 +1,31 @@
|
|||
const sharp = require('sharp');
|
||||
const { exec } = require('child_process');
|
||||
|
||||
function createVideoThumbnail(videoPath, thumbMinPath, thumbAvgPath) {
|
||||
return new Promise((resolve) => {
|
||||
const cmd = `
|
||||
ffmpeg -y -i "${videoPath}" -ss 00:00:01.000 -vframes 1 "${thumbAvgPath}" &&
|
||||
ffmpeg -y -i "${thumbAvgPath}" -vf "scale=100:-1" "${thumbMinPath}"
|
||||
`;
|
||||
exec(cmd, () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function createThumbnails(filePath, thumbMinPath, thumbAvgPath) {
|
||||
try {
|
||||
await sharp(filePath)
|
||||
.resize({ width: 100, height: 100, fit: 'inside', withoutEnlargement: true })
|
||||
.withMetadata()
|
||||
.toFile(thumbMinPath);
|
||||
|
||||
await sharp(filePath)
|
||||
.resize({ width: 400, withoutEnlargement: true })
|
||||
.withMetadata()
|
||||
.toFile(thumbAvgPath);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Errore creazione thumbnails:', err.message, filePath);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createVideoThumbnail, createThumbnails };
|
||||
41
api_v1/scanner/utils.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
function toPosix(p) {
|
||||
return p.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function sha256(s) {
|
||||
return crypto.createHash('sha256').update(s).digest('hex');
|
||||
}
|
||||
|
||||
function inferMimeFromExt(ext) {
|
||||
switch (ext.toLowerCase()) {
|
||||
case '.jpg':
|
||||
case '.jpeg': return 'image/jpeg';
|
||||
case '.png': return 'image/png';
|
||||
case '.webp': return 'image/webp';
|
||||
case '.heic':
|
||||
case '.heif': return 'image/heic';
|
||||
case '.mp4': return 'video/mp4';
|
||||
case '.mov': return 'video/quicktime';
|
||||
case '.m4v': return 'video/x-m4v';
|
||||
default: return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
function parseExifDateUtc(s) {
|
||||
if (!s) return null;
|
||||
const re = /^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$/;
|
||||
const m = re.exec(s);
|
||||
if (!m) return null;
|
||||
const dt = new Date(Date.UTC(+m[1], +m[2]-1, +m[3], +m[4], +m[5], +m[6]));
|
||||
return dt.toISOString();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
toPosix,
|
||||
sha256,
|
||||
inferMimeFromExt,
|
||||
parseExifDateUtc
|
||||
};
|
||||
17
api_v1/scanner/video.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
const { exec } = require('child_process');
|
||||
|
||||
function probeVideo(videoPath) {
|
||||
return new Promise((resolve) => {
|
||||
const cmd = `ffprobe -v quiet -print_format json -show_format -show_streams "${videoPath}"`;
|
||||
exec(cmd, (err, stdout) => {
|
||||
if (err) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(stdout));
|
||||
} catch {
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { probeVideo };
|
||||
51
api_v1/tools.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Required libraries
|
||||
*/
|
||||
const bcrypt = require('bcrypt')
|
||||
const readLine = require('readline')
|
||||
const async = require('async')
|
||||
|
||||
// Password hash method
|
||||
const hashPassword = plain => bcrypt.hashSync(plain, 8)
|
||||
|
||||
// Ask user password method
|
||||
function askPassword(question) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const rl = readLine.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
})
|
||||
|
||||
rl.question(question, answer => {
|
||||
rl.close()
|
||||
resolve(answer)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Generate hash password method
|
||||
async function generateHash() {
|
||||
try {
|
||||
console.log('**********************************')
|
||||
console.log('** Password hash script **')
|
||||
console.log('**********************************')
|
||||
|
||||
const passwordAnswer = await askPassword(
|
||||
'Please give me a password to hash: '
|
||||
)
|
||||
|
||||
if (passwordAnswer != '') {
|
||||
const hashedPassword = hashPassword(passwordAnswer)
|
||||
const compare = bcrypt.compareSync(passwordAnswer, hashedPassword)
|
||||
await console.log('Hashed password:', hashedPassword)
|
||||
await console.log('Valdiation:', compare)
|
||||
} else {
|
||||
console.log('You need write something. Script aborted!')
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
return process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
generateHash()
|
||||
22
api_v1/users.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"users": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Admin",
|
||||
"email": "admin@gmail.com",
|
||||
"password": "$2b$08$g0UWN6RnN7e.8rX3fuXSSOSJDTvucu./0FAU.yXp0wx4SJXyeaU3."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Fabio",
|
||||
"email": "fabio@gmail.com",
|
||||
"password": "$2b$08$g0UWN6RnN7e.8rX3fuXSSOSJDTvucu./0FAU.yXp0wx4SJXyeaU3."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Jessica",
|
||||
"email": "jessie@libero.it",
|
||||
"password": "$2b$08$g0UWN6RnN7e.8rX3fuXSSOSJDTvucu./0FAU.yXp0wx4SJXyeaU3."
|
||||
}
|
||||
]
|
||||
}
|
||||
1
b/a.jpg
Normal file
|
|
@ -0,0 +1 @@
|
|||
prova
|
||||
202
come funziona.md
Normal 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 all’utente.
|
||||
|
||||
---
|
||||
|
||||
# ⭐ 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
|
||||
|
||||
### All’avvio 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
18
package.json
Normal 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
|
|
@ -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 multi‑utente, con:
|
||||
|
||||
- autenticazione JWT
|
||||
- scansione automatica delle cartelle
|
||||
- thumbnails generate lato server
|
||||
- sincronizzazione incrementale
|
||||
- WebSocket real‑time
|
||||
- frontend modulare e reattivo
|
||||
- mappa globale stile Google Photos
|
||||
- pannello info EXIF + geolocalizzazione
|
||||
- bottom sheet multi‑foto
|
||||
- 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 real‑time
|
||||
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
|
||||
|
||||
Auto‑refresh 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 end‑to‑end 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 (High‑Level)
|
||||
|
||||
`
|
||||
┌──────────────────────────┐
|
||||
│ 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 (end‑to‑end)
|
||||
|
||||
`
|
||||
[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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,27 @@
|
|||
:root { --header-h: 60px; }
|
||||
|
||||
/* Safe-area iOS */
|
||||
@supports (top: env(safe-area-inset-top)) {
|
||||
:root { --safe-top: env(safe-area-inset-top); }
|
||||
}
|
||||
@supports not (top: env(safe-area-inset-top)) {
|
||||
:root { --safe-top: 0px; }
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
200
public/css/bottomSheet.css
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/* =========================================
|
||||
Variabili globali
|
||||
========================================= */
|
||||
:root {
|
||||
--header-height: 60px; /* cambia se il tuo header è più alto/basso */
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
MAPPA GLOBALE (contenitore sotto l’header)
|
||||
========================================= */
|
||||
.global-map {
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
width: 100%;
|
||||
display: none; /* visibile solo con .open */
|
||||
z-index: 10; /* sotto a bottom-sheet (9999) e modal (10000) */
|
||||
background: #000; /* evita flash bianco durante init */
|
||||
}
|
||||
|
||||
.global-map.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Leaflet riempie il contenitore */
|
||||
.global-map .leaflet-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Marker immagine (miniatura) */
|
||||
.leaflet-marker-icon.photo-marker {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
|
||||
border: 2px solid rgba(255,255,255,0.9);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Nascondi la gallery quando la mappa è aperta */
|
||||
.gallery.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
BOTTOM SHEET — struttura base comune
|
||||
========================================= */
|
||||
.bottom-sheet {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
|
||||
background: rgba(255,255,255,0.95);
|
||||
backdrop-filter: blur(6px);
|
||||
border-top: 1px solid #ddd;
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.15);
|
||||
|
||||
display: none; /* diventa flex con .open */
|
||||
flex-direction: column;
|
||||
z-index: 9999; /* molto alto: il modal starà sopra (10000) */
|
||||
}
|
||||
|
||||
.bottom-sheet.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Maniglia superiore */
|
||||
.sheet-header {
|
||||
height: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sheet-header::before {
|
||||
content: "";
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
background: #bbb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
BOTTOM SHEET FOTO (strip bassa come nel vecchio)
|
||||
========================================= */
|
||||
.photo-strip {
|
||||
height: 140px; /* altezza originale della strip */
|
||||
overflow-y: hidden; /* niente scroll verticale */
|
||||
overflow-x: auto; /* scroll orizzontale per le foto */
|
||||
}
|
||||
|
||||
/* Contenitore elementi della strip — compatibile con id e class */
|
||||
#sheetGallery,
|
||||
.sheet-gallery {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
/* Singolo elemento della strip */
|
||||
.sheet-item {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
|
||||
background: #eee;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
/* Miniatura della foto nella strip */
|
||||
.sheet-thumb,
|
||||
.sheet-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 8px; /* alias; la .sheet-item ha già 10px */
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
BOTTOM SHEET OPZIONI (⋮) — menu grande
|
||||
========================================= */
|
||||
.options-sheet {
|
||||
height: auto;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sheet-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.sheet-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
text-align: left;
|
||||
background: #f5f5f5;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sheet-btn:hover {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
#optionsSheet h3 {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
OVERLAY per chiusura sheet/option
|
||||
========================================= */
|
||||
.sheet-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.0); /* invisibile ma cliccabile */
|
||||
display: none;
|
||||
z-index: 80; /* appena sotto il bottom sheet */
|
||||
}
|
||||
|
||||
.sheet-overlay.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
MODAL sopra allo sheet
|
||||
========================================= */
|
||||
.modal.open {
|
||||
z-index: 10000 !important; /* sopra al bottom sheet (9999) */
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
Piccoli affinamenti facoltativi
|
||||
========================================= */
|
||||
/* scrollbar sottile solo per la strip (opzionale) */
|
||||
#sheetGallery::-webkit-scrollbar,
|
||||
.sheet-gallery::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
#sheetGallery::-webkit-scrollbar-thumb,
|
||||
.sheet-gallery::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.25);
|
||||
border-radius: 4px;
|
||||
}
|
||||
80
public/css/gallery.css
Normal 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
|
|
@ -0,0 +1,85 @@
|
|||
/* ===============================
|
||||
Header compatto
|
||||
=============================== */
|
||||
header {
|
||||
padding: 4px 10px; /* era 10px 15px */
|
||||
background: #333;
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Titolo più piccolo e senza margini extra */
|
||||
header h1 {
|
||||
font-size: 18px; /* ridotto */
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Contenitore bottoni in alto a destra */
|
||||
.top-buttons {
|
||||
display: flex;
|
||||
gap: 6px; /* era 10px */
|
||||
}
|
||||
|
||||
/* Bottoni icona più compatti */
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px; /* era 22px */
|
||||
padding: 3px 6px; /* era 6px 10px */
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
line-height: 1;
|
||||
min-height: 32px; /* tap target minimo desktop */
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
/* Logout rotondo: riduciamo la “bolla” */
|
||||
.icon-btn.logout-btn {
|
||||
--size: 28px; /* era 36px */
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* PNG del logout in scala con l’header */
|
||||
.logout-icon {
|
||||
width: 18px; /* era 22px */
|
||||
height: 18px;
|
||||
display: block;
|
||||
filter: brightness(0) invert(1);
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
Visibilità Logout robusta
|
||||
=============================== */
|
||||
|
||||
/* Base: nascosto (prima del login o se non autenticato) */
|
||||
#logoutBtn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Quando autenticato, mostra il bottone coerente con gli altri icon-btn */
|
||||
body.authenticated #logoutBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Se esistono regole più forti altrove che lo nascondono,
|
||||
puoi temporaneamente forzare:
|
||||
body.authenticated #logoutBtn { display: inline-flex !important; } */
|
||||
88
public/css/infoPanel.css
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/* ===============================
|
||||
Variabili (scala tipografica pannello info)
|
||||
Modifica qui per regolare tutto il pannello
|
||||
=============================== */
|
||||
:root {
|
||||
--info-font: 14px; /* base testo pannello (prima ~16px) */
|
||||
--info-line: 1.4; /* interlinea per migliorare leggibilità */
|
||||
|
||||
--info-heading: 15px; /* dimensione titoli h3 nel pannello */
|
||||
--info-h3-mt: 6px; /* margin-top h3 */
|
||||
--info-h3-mb: 10px; /* margin-bottom h3 */
|
||||
|
||||
--info-row-gap: 8px; /* spazio verticale tra righe (era 10px) */
|
||||
--info-label-w: 100px; /* larghezza colonna etichette (era 110px) */
|
||||
|
||||
--info-map-h: 220px; /* altezza mappa (era 250px) */
|
||||
--info-map-mt: 15px; /* spazio sopra la mappa */
|
||||
|
||||
--info-spacer-h: 16px; /* altezza degli spacer */
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
PANNELLO INFO
|
||||
=============================== */
|
||||
.info-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 320px;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
box-shadow: -2px 0 6px rgba(0,0,0,0.25);
|
||||
overflow-y: auto;
|
||||
z-index: 10000;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
/* Scala tipografica via variabili */
|
||||
font-size: var(--info-font);
|
||||
line-height: var(--info-line);
|
||||
}
|
||||
|
||||
.info-panel.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Titoli un filo più compatti */
|
||||
.info-panel h3 {
|
||||
font-size: var(--info-heading);
|
||||
margin: var(--info-h3-mt) 0 var(--info-h3-mb);
|
||||
}
|
||||
|
||||
/* Righe e label */
|
||||
.info-row {
|
||||
margin-bottom: var(--info-row-gap);
|
||||
}
|
||||
|
||||
.info-row b {
|
||||
display: inline-block;
|
||||
width: var(--info-label-w);
|
||||
}
|
||||
|
||||
/* Mappa nel pannello */
|
||||
.info-map {
|
||||
width: 100%;
|
||||
height: var(--info-map-h);
|
||||
margin-top: var(--info-map-mt);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
/* Spacer verticali */
|
||||
.info-spacer {
|
||||
height: var(--info-spacer-h);
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
(Opzionale) Mobile: un filo più grande < 480px
|
||||
Decommenta se vuoi mantenere leggibilità maggiore su schermi piccoli
|
||||
=============================== */
|
||||
/*
|
||||
@media (max-width: 480px) {
|
||||
.info-panel { font-size: 15px; }
|
||||
.info-panel h3 { font-size: 16px; }
|
||||
}
|
||||
*/
|
||||
27
public/css/login.css
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
|
||||
.login-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 20000;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
color: red;
|
||||
font-size: 14px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
117
public/css/map.css
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/* ===============================
|
||||
MAPPA GLOBALE
|
||||
=============================== */
|
||||
|
||||
/* La mappa occupa tutto lo schermo SOTTO l’header */
|
||||
.global-map {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(var(--header-h, 60px) + var(--safe-top, 0px)); /* niente hard-code */
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
|
||||
display: none; /* chiusa di default */
|
||||
}
|
||||
|
||||
/* Quando è aperta, visibile */
|
||||
.global-map.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* La Leaflet container deve riempire il contenitore */
|
||||
.global-map,
|
||||
.global-map .leaflet-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Nasconde la gallery quando la mappa è aperta */
|
||||
.gallery.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
MARKER FOTO
|
||||
=============================== */
|
||||
|
||||
.photo-marker {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.photo-marker img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
CLUSTER
|
||||
=============================== */
|
||||
|
||||
.photo-cluster {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.cluster-back {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
opacity: 0.5;
|
||||
filter: blur(1px);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.cluster-front {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ===============================
|
||||
MARKER CLUSTER
|
||||
=============================== */
|
||||
|
||||
|
||||
.marker-cluster-wrapper { background: transparent; border: 0; }
|
||||
.gp-cluster {
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||
border: 3px solid rgba(255,255,255,0.85);
|
||||
transition: width .12s, height .12s, font-size .12s;
|
||||
}
|
||||
.gp-cluster .cluster-collage { position:absolute; inset:0; display:grid; grid-template-columns: repeat(2,1fr); grid-template-rows: repeat(2,1fr); }
|
||||
.gp-cluster .cluster-collage div img { width:100%; height:100%; object-fit:cover; display:block; }
|
||||
.gp-cluster .gp-count {
|
||||
position:absolute; right:6px; bottom:6px;
|
||||
background: rgba(0,0,0,0.55); padding:4px 7px; border-radius:12px;
|
||||
color:#fff; font-weight:700; font-size:12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.gp-cluster.cluster-sm .gp-count { font-size:11px; }
|
||||
.gp-cluster.cluster-md .gp-count { font-size:13px; }
|
||||
.gp-cluster.cluster-lg .gp-count { font-size:15px; }
|
||||
.gp-cluster.cluster-xl .gp-count { font-size:17px; }
|
||||
307
public/css/modal.css
Normal 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 all’area */
|
||||
#modalMediaContainer img,
|
||||
#modalMediaContainer video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
background: #000; /* evita flash bianco */
|
||||
position: relative; /* crea contesto */
|
||||
z-index: 1; /* sotto ai pulsanti */
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
PULSANTE CHIUSURA (X)
|
||||
=============================== */
|
||||
|
||||
/* FISSO sopra al video, con safe-area per iPhone */
|
||||
.modal-close {
|
||||
position: fixed; /* <-- chiave: resta sopra al video anche con stacking strani */
|
||||
top: calc(8px + env(safe-area-inset-top));
|
||||
right: calc(12px + env(safe-area-inset-right));
|
||||
z-index: 10001; /* il modal è 9999 */
|
||||
|
||||
background: rgba(0,0,0,0.35);
|
||||
color: #fff;
|
||||
border-radius: 22px;
|
||||
min-width: 44px; /* target minimo consigliato */
|
||||
height: 44px;
|
||||
padding: 0 10px;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
border: 1px solid rgba(255,255,255,0.25);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
user-select: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* area di hit più ampia senza cambiare il look */
|
||||
.modal-close::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -8px; /* allarga di 8px tutt’intorno */
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.modal-close:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.modal-close:focus-visible {
|
||||
outline: 2px solid #4c9ffe;
|
||||
outline-offset: 2px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
PULSANTE INFO (ℹ️)
|
||||
=============================== */
|
||||
|
||||
.modal-info-btn {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 16px;
|
||||
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
cursor: pointer;
|
||||
border: 1px solid #d0d0d0;
|
||||
font-size: 20px;
|
||||
z-index: 10000; /* sopra al media, sotto alla X */
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
|
||||
/* 🔒 Disattiva selezione e popup dizionario */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.modal-info-btn:hover {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.modal-info-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.modal-info-btn:focus-visible {
|
||||
outline: 2px solid #4c9ffe;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
/* ℹ️ evidenziato quando il pannello info è aperto */
|
||||
.modal-info-btn.active {
|
||||
background: #f7f7f7;
|
||||
border-color: #cfcfcf;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
(OPZIONALE) LINK "APRI ORIGINALE ↗"
|
||||
=============================== */
|
||||
|
||||
.modal-open-original {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 56px; /* lascia spazio alla X */
|
||||
background: rgba(255,255,255,0.95);
|
||||
color: #000;
|
||||
border-radius: 16px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #d0d0d0;
|
||||
font-size: 13px;
|
||||
z-index: 10000; /* sopra al media */
|
||||
text-decoration: none;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.modal-open-original:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.modal-open-original:focus-visible {
|
||||
outline: 2px solid #4c9ffe;
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
MODAL STATE UTILI
|
||||
=============================== */
|
||||
|
||||
body.no-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* High contrast / accessibility (opzionale) */
|
||||
@media (prefers-contrast: more) {
|
||||
.modal {
|
||||
background: rgba(0,0,0,0.9);
|
||||
}
|
||||
.modal-close,
|
||||
.modal-info-btn,
|
||||
.modal-open-original {
|
||||
border-color: #000;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Riduci animazioni se l’utente lo preferisce */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.modal,
|
||||
.modal-content {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ===============================
|
||||
FRECCE DI NAVIGAZIONE < >
|
||||
=============================== */
|
||||
|
||||
.modal-nav-btn {
|
||||
position: fixed; /* fisso: resta sopra a video/immagine */
|
||||
top: calc(50% + env(safe-area-inset-top));
|
||||
transform: translateY(-50%);
|
||||
z-index: 10000; /* sopra al media, sotto alla X (10001) */
|
||||
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255,255,255,0.25);
|
||||
background: rgba(0,0,0,0.35);
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
|
||||
transition: background-color .15s ease, transform .05s ease;
|
||||
}
|
||||
|
||||
.modal-nav-btn:hover { background: rgba(0,0,0,0.5); }
|
||||
.modal-nav-btn:active { transform: translateY(-50%) translateY(1px); }
|
||||
.modal-nav-btn:focus-visible {
|
||||
outline: 2px solid #4c9ffe;
|
||||
outline-offset: 2px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.modal-nav-btn.prev { left: calc(12px + env(safe-area-inset-left)); }
|
||||
.modal-nav-btn.next { right: calc(12px + env(safe-area-inset-right)); }
|
||||
|
||||
/* Nascondi automaticamente se c'è un solo elemento */
|
||||
.modal-nav-btn.hidden { display: none !important; }
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal.open {
|
||||
display: block;
|
||||
}
|
||||
69
public/css/optionsSheet.css
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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)}`);
|
||||
}
|
||||
72
public/js/admin/api.js.old
Normal 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
|
|
@ -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
|
|
@ -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 dell’utente 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
After Width: | Height: | Size: 451 B |
BIN
public/photos/Common/original/varie/IMG_20220619_135541.jpg
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
public/photos/Common/original/varie/IMG_20220619_135542.jpg
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
public/photos/Common/original/varie/IMG_20220619_135543.jpg
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
public/photos/Common/original/varie/IMG_20220619_135636.jpg
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
public/photos/Common/original/varie/IMG_20220619_135641.jpg
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
public/photos/Common/original/varie/IMG_20220619_135643.jpg
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
public/photos/Common/original/varie/IMG_20220619_135741.jpg
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
BIN
public/photos/Common/original/varie/IMG_20220619_135744.jpg
Normal file
|
After Width: | Height: | Size: 4.6 MiB |
BIN
public/photos/Common/original/varie/IMG_20220619_135745.jpg
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
BIN
public/photos/Fabio/original/2017Irlanda19-29ago/IMG_0107.JPG
Normal file
|
After Width: | Height: | Size: 3 MiB |