first commit
This commit is contained in:
commit
7560875884
38 changed files with 6912 additions and 0 deletions
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
|
.tmp/
|
||||||
|
.temp/
|
||||||
|
|
||||||
|
# Capacitor / Cordova
|
||||||
|
android/
|
||||||
|
ios/
|
||||||
|
www/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
#.env
|
||||||
|
#.env.*
|
||||||
|
#!.env.example
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor folders
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
.cache/
|
||||||
|
.parcel-cache/
|
||||||
|
.next/
|
||||||
|
.nuxt/
|
||||||
|
.svelte-kit/
|
||||||
76
README.md
Normal file
76
README.md
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# App per vedere tutte le mie app in un unica schermata
|
||||||
|
|
||||||
|
si compone di
|
||||||
|
|
||||||
|
- un server che condivide la lista delle apps
|
||||||
|
fa utilizzo di un server già attivo mongoDB
|
||||||
|
- la UI del server
|
||||||
|
che permette di inserire e modificare tutti i dati delle apps
|
||||||
|
- una app che funziona sia su smartphone che su PC
|
||||||
|
|
||||||
|
## Server
|
||||||
|
|
||||||
|
andare in
|
||||||
|
|
||||||
|
cd server/backend
|
||||||
|
|
||||||
|
installare
|
||||||
|
|
||||||
|
npm ci install
|
||||||
|
|
||||||
|
editare .env
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# === SERVER CONFIG ===
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# === JWT CONFIG ===
|
||||||
|
# Cambialo SEMPRE in produzione
|
||||||
|
JWT_SECRET=master66
|
||||||
|
|
||||||
|
# === MONGO CONFIG ===
|
||||||
|
# In locale:
|
||||||
|
# MONGO_URI=mongodb://localhost:27017/mydb
|
||||||
|
#
|
||||||
|
# In Docker (usato dal docker-compose):
|
||||||
|
MONGO_URI=mongodb://root:example@192.168.1.3:27017/myapphttps?authSource=admin
|
||||||
|
# === UPLOADS ===
|
||||||
|
# Cartella dove Express serve le icone
|
||||||
|
UPLOAD_DIR=uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
avviare
|
||||||
|
|
||||||
|
npm start
|
||||||
|
|
||||||
|
il server parte su 182.168.1.3:3000 ed ho settato nginx come
|
||||||
|
|
||||||
|
my.patachina2.casacam.net
|
||||||
|
|
||||||
|
## User Interface del server
|
||||||
|
|
||||||
|
|
||||||
|
andare in
|
||||||
|
|
||||||
|
```
|
||||||
|
cd server/frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
far partire la UI x es su porta 8282
|
||||||
|
|
||||||
|
npx http-server . -c-1 -p 8282
|
||||||
|
|
||||||
|
da qui si possono modificare le apps che vogliamo visualizzare
|
||||||
|
|
||||||
|
## App principale
|
||||||
|
|
||||||
|
|
||||||
|
andare in
|
||||||
|
|
||||||
|
```
|
||||||
|
cd app
|
||||||
|
```
|
||||||
|
far partire la App x es su porta 8181
|
||||||
|
|
||||||
|
npx http-server . -c-1 -p 8181
|
||||||
|
|
||||||
2
app/README.md
Normal file
2
app/README.md
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
|
||||||
|
npx http-server .
|
||||||
1006
app/app.js
Normal file
1006
app/app.js
Normal file
File diff suppressed because it is too large
Load diff
983
app/app.js.old
Normal file
983
app/app.js.old
Normal file
|
|
@ -0,0 +1,983 @@
|
||||||
|
// ============================================================================
|
||||||
|
// LAUNCHER — VERSIONE COMPLETA E OTTIMIZZATA (A)
|
||||||
|
// BLOCCO 1/6 — Variabili globali + Storage + Config + Setup Page
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VARIABILI GLOBALI
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let URI;
|
||||||
|
let USER;
|
||||||
|
let PASSW;
|
||||||
|
|
||||||
|
let appsData = []; // Lista completa delle app (nome, icona, url)
|
||||||
|
let appsOrder = []; // Ordine delle icone nella griglia
|
||||||
|
let editMode = false; // Modalità wiggle stile iOS
|
||||||
|
|
||||||
|
// Zoom
|
||||||
|
let zoomLevel;
|
||||||
|
let zoomMax;
|
||||||
|
let initialPinchDistance = null;
|
||||||
|
let lastTapTime = 0;
|
||||||
|
let zoomAnimFrame = null;
|
||||||
|
|
||||||
|
// Long‑press / drag
|
||||||
|
let longPressTimer = null;
|
||||||
|
let longPressTarget = null;
|
||||||
|
let contextMenuTargetId = null;
|
||||||
|
|
||||||
|
let draggingIcon = null;
|
||||||
|
let draggingId = null;
|
||||||
|
let dragOffsetX = 0;
|
||||||
|
let dragOffsetY = 0;
|
||||||
|
let dragStartX = 0;
|
||||||
|
let dragStartY = 0;
|
||||||
|
let placeholderEl = null;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CRITTOGRAFIA E STORAGE
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const SECRET_KEY = "chiave-super-segreta-123";
|
||||||
|
|
||||||
|
// Salva configurazione (URL, user, pass)
|
||||||
|
function saveConfig(url, user, password) {
|
||||||
|
const data = { url, user, password };
|
||||||
|
URI = url;
|
||||||
|
USER = user;
|
||||||
|
PASSW = password;
|
||||||
|
|
||||||
|
const encrypted = CryptoJS.AES.encrypt(
|
||||||
|
JSON.stringify(data),
|
||||||
|
SECRET_KEY
|
||||||
|
).toString();
|
||||||
|
|
||||||
|
localStorage.setItem("launcherConfig", encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carica configurazione
|
||||||
|
function loadConfig() {
|
||||||
|
const encrypted = localStorage.getItem("launcherConfig");
|
||||||
|
if (!encrypted) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bytes = CryptoJS.AES.decrypt(encrypted, SECRET_KEY);
|
||||||
|
const obj = JSON.parse(bytes.toString(CryptoJS.enc.Utf8));
|
||||||
|
|
||||||
|
URI = obj.url;
|
||||||
|
USER = obj.user;
|
||||||
|
PASSW = obj.password;
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salva apps scaricate dal server
|
||||||
|
function saveApps(jsonApps) {
|
||||||
|
const encrypted = CryptoJS.AES.encrypt(
|
||||||
|
JSON.stringify(jsonApps),
|
||||||
|
SECRET_KEY
|
||||||
|
).toString();
|
||||||
|
|
||||||
|
localStorage.setItem("jsonApps", encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carica apps salvate in locale
|
||||||
|
function loadApps() {
|
||||||
|
const encrypted = localStorage.getItem("jsonApps");
|
||||||
|
if (!encrypted) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bytes = CryptoJS.AES.decrypt(encrypted, SECRET_KEY);
|
||||||
|
return JSON.parse(bytes.toString(CryptoJS.enc.Utf8));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SETUP PAGE (6 TAP PER APRIRE + AUTOCOMPILAZIONE + "Aggiorna ora")
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function showSetupPage() {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
|
||||||
|
if (cfg) {
|
||||||
|
document.getElementById("cfg-url").value = cfg.url;
|
||||||
|
document.getElementById("cfg-user").value = cfg.user;
|
||||||
|
document.getElementById("cfg-pass").value = cfg.password;
|
||||||
|
|
||||||
|
// Mostra il pulsante "Aggiorna ora" solo se esiste già una config
|
||||||
|
document.getElementById("cfg-refresh").style.display = "block";
|
||||||
|
} else {
|
||||||
|
// Nessuna config → nascondi il pulsante
|
||||||
|
document.getElementById("cfg-refresh").style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("setup-page").classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSetupPage() {
|
||||||
|
document.getElementById("setup-page").classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6 tap per aprire la setup page
|
||||||
|
let tapCount = 0;
|
||||||
|
let tapTimer = null;
|
||||||
|
|
||||||
|
document.addEventListener("click", () => {
|
||||||
|
tapCount++;
|
||||||
|
|
||||||
|
if (tapTimer) clearTimeout(tapTimer);
|
||||||
|
|
||||||
|
tapTimer = setTimeout(() => {
|
||||||
|
tapCount = 0;
|
||||||
|
}, 600);
|
||||||
|
|
||||||
|
if (tapCount >= 6) {
|
||||||
|
tapCount = 0;
|
||||||
|
showSetupPage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LAUNCHER — VERSIONE COMPLETA E OTTIMIZZATA (A)
|
||||||
|
// BLOCCO 2/6 — API login, getLinks, ordine apps, render, startLauncher
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LOGIN API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function login(email, password) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${URI}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`HTTP ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return data.token;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
alert(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET LINKS (scarica apps dal server + salva + aggiorna ordine)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function getLinks() {
|
||||||
|
try {
|
||||||
|
const token = await login(USER, PASSW);
|
||||||
|
if (!token) throw new Error("User o Password errati");
|
||||||
|
|
||||||
|
const res = await fetch(`${URI}/links`, {
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Server errato o non risponde");
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
// Normalizza apps
|
||||||
|
appsData = json.map((a, i) => ({
|
||||||
|
id: a.id || `app-${i}`,
|
||||||
|
name: a.name,
|
||||||
|
url: a.url,
|
||||||
|
icon: `${URI}${a.icon}`
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Salva in locale
|
||||||
|
saveApps(appsData);
|
||||||
|
|
||||||
|
// Aggiorna ordine
|
||||||
|
loadAppOrder();
|
||||||
|
|
||||||
|
// Render
|
||||||
|
renderApps();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CARICAMENTO ORDINE APPS
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function loadAppOrder() {
|
||||||
|
const stored = localStorage.getItem("appsOrder");
|
||||||
|
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
|
||||||
|
// Mantieni solo ID validi
|
||||||
|
appsOrder = parsed.filter(id => appsData.some(a => a.id === id));
|
||||||
|
|
||||||
|
// Aggiungi eventuali nuove app non presenti nell'ordine salvato
|
||||||
|
appsData.forEach(a => {
|
||||||
|
if (!appsOrder.includes(a.id)) appsOrder.push(a.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Primo avvio → ordine naturale
|
||||||
|
appsOrder = appsData.map(a => a.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveOrder() {
|
||||||
|
localStorage.setItem("appsOrder", JSON.stringify(appsOrder));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RENDER DELLA GRIGLIA
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function renderApps() {
|
||||||
|
const folderEl = document.getElementById("folder");
|
||||||
|
folderEl.innerHTML = "";
|
||||||
|
|
||||||
|
appsOrder.forEach(id => {
|
||||||
|
const app = appsData.find(a => a.id === id);
|
||||||
|
if (!app) return;
|
||||||
|
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "app-icon";
|
||||||
|
div.dataset.id = app.id;
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<img src="${app.icon}" alt="${app.name}">
|
||||||
|
<span>${app.name}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
div.addEventListener("click", () => {
|
||||||
|
if (!editMode) window.open(app.url, "_blank", "noopener");
|
||||||
|
});
|
||||||
|
|
||||||
|
folderEl.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// START LAUNCHER (carica locale → render → init UI)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function startLauncher() {
|
||||||
|
|
||||||
|
// 1️⃣ Carica apps salvate in locale
|
||||||
|
const saved = loadApps();
|
||||||
|
if (saved) {
|
||||||
|
appsData = saved;
|
||||||
|
console.log("Apps caricate da localStorage:", appsData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ Carica ordine
|
||||||
|
loadAppOrder();
|
||||||
|
|
||||||
|
// 3️⃣ Render immediato (istantaneo)
|
||||||
|
renderApps();
|
||||||
|
|
||||||
|
// ❌ Nessun aggiornamento automatico dal server
|
||||||
|
// getLinks();
|
||||||
|
|
||||||
|
// 4️⃣ Inizializza UI (zoom, drag, wiggle, menu…)
|
||||||
|
initZoomHandlers();
|
||||||
|
initLongPressHandlers();
|
||||||
|
initDragHandlers();
|
||||||
|
initContextMenuActions();
|
||||||
|
initGlobalCloseHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LAUNCHER — VERSIONE COMPLETA E OTTIMIZZATA (A)
|
||||||
|
// BLOCCO 3/6 — Zoom stile iPhone (pinch, elasticità, wheel)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Calcolo dinamico dello zoom massimo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function computeDynamicMaxZoom() {
|
||||||
|
return Math.min(window.innerWidth / 85, 4.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Carica lo zoom salvato in locale
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function loadInitialZoom() {
|
||||||
|
const v = parseFloat(localStorage.getItem("zoomLevel"));
|
||||||
|
if (!isFinite(v) || v <= 0) return 1;
|
||||||
|
return Math.min(Math.max(v, 0.5), computeDynamicMaxZoom());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Applica lo zoom (aggiorna CSS + salva in locale)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function applyZoom(z) {
|
||||||
|
zoomLevel = (!isFinite(z) || z <= 0) ? 1 : z;
|
||||||
|
document.documentElement.style.setProperty("--zoom", zoomLevel);
|
||||||
|
localStorage.setItem("zoomLevel", String(zoomLevel));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Distanza tra due dita (pinch)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function getPinchDistance(touches) {
|
||||||
|
const [a, b] = touches;
|
||||||
|
return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Elasticità ai limiti (effetto iOS)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function elasticEase(x) {
|
||||||
|
return Math.sin(x * Math.PI * 0.5) * 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Inizializzazione completa dello zoom
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function initZoomHandlers() {
|
||||||
|
zoomMax = computeDynamicMaxZoom();
|
||||||
|
zoomLevel = loadInitialZoom();
|
||||||
|
applyZoom(zoomLevel);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// PINCH SU MOBILE
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
|
||||||
|
// Previeni scroll durante pinch
|
||||||
|
document.addEventListener("touchmove", e => {
|
||||||
|
if (e.touches.length === 2) e.preventDefault();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Inizio pinch (NO double tap zoom)
|
||||||
|
document.addEventListener("touchstart", e => {
|
||||||
|
|
||||||
|
// Inizio pinch
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
initialPinchDistance = getPinchDistance(e.touches);
|
||||||
|
if (zoomAnimFrame) cancelAnimationFrame(zoomAnimFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nessuna azione sul doppio tap
|
||||||
|
lastTapTime = Date.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pinch in corso
|
||||||
|
document.addEventListener("touchmove", e => {
|
||||||
|
if (e.touches.length === 2 && initialPinchDistance) {
|
||||||
|
const newDist = getPinchDistance(e.touches);
|
||||||
|
const scale = newDist / initialPinchDistance;
|
||||||
|
|
||||||
|
let newZoom = zoomLevel * scale;
|
||||||
|
zoomMax = computeDynamicMaxZoom();
|
||||||
|
|
||||||
|
// Elasticità ai limiti
|
||||||
|
if (newZoom > zoomMax) newZoom = zoomMax + (newZoom - zoomMax) * 0.25;
|
||||||
|
if (newZoom < 0.5) newZoom = 0.5 - (0.5 - newZoom) * 0.25;
|
||||||
|
|
||||||
|
applyZoom(newZoom);
|
||||||
|
initialPinchDistance = newDist;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Fine pinch → animazione elastica verso limite
|
||||||
|
document.addEventListener("touchend", e => {
|
||||||
|
if (e.touches.length < 2 && initialPinchDistance) {
|
||||||
|
initialPinchDistance = null;
|
||||||
|
|
||||||
|
zoomMax = computeDynamicMaxZoom();
|
||||||
|
const target = Math.min(Math.max(zoomLevel, 0.5), zoomMax);
|
||||||
|
const start = zoomLevel;
|
||||||
|
const duration = 250;
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
function animate(t) {
|
||||||
|
const p = Math.min((t - startTime) / duration, 1);
|
||||||
|
const eased = start + (target - start) * elasticEase(p);
|
||||||
|
applyZoom(eased);
|
||||||
|
if (p < 1) zoomAnimFrame = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomAnimFrame = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// ZOOM CON WHEEL SU DESKTOP
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
document.addEventListener("wheel", e => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
zoomMax = computeDynamicMaxZoom();
|
||||||
|
const direction = e.deltaY < 0 ? 1 : -1;
|
||||||
|
const factor = 1 + direction * 0.1;
|
||||||
|
|
||||||
|
let newZoom = zoomLevel * factor;
|
||||||
|
|
||||||
|
// Elasticità
|
||||||
|
if (newZoom > zoomMax) newZoom = zoomMax + (newZoom - zoomMax) * 0.25;
|
||||||
|
if (newZoom < 0.5) newZoom = 0.5 - (0.5 - newZoom) * 0.25;
|
||||||
|
|
||||||
|
applyZoom(newZoom);
|
||||||
|
}, { passive: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LAUNCHER — VERSIONE COMPLETA E OTTIMIZZATA (A)
|
||||||
|
// BLOCCO 4/6 — Long‑press, Edit Mode, Context Menu, Global Close
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// EDIT MODE (wiggle stile iOS)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function enterEditMode() {
|
||||||
|
editMode = true;
|
||||||
|
document.body.classList.add("edit-mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitEditMode() {
|
||||||
|
editMode = false;
|
||||||
|
document.body.classList.remove("edit-mode");
|
||||||
|
hideContextMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MENU CONTESTUALE
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function showContextMenuFor(id, x, y) {
|
||||||
|
contextMenuTargetId = id;
|
||||||
|
const menu = document.getElementById("context-menu");
|
||||||
|
menu.style.left = `${x}px`;
|
||||||
|
menu.style.top = `${y}px`;
|
||||||
|
menu.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideContextMenu() {
|
||||||
|
const menu = document.getElementById("context-menu");
|
||||||
|
menu.classList.add("hidden");
|
||||||
|
contextMenuTargetId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LONG PRESS HANDLERS (TOUCH + MOUSE)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function initLongPressHandlers() {
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// TOUCH LONG PRESS
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
document.addEventListener("touchstart", e => {
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const icon = touch.target.closest(".app-icon");
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
longPressTarget = icon;
|
||||||
|
|
||||||
|
longPressTimer = setTimeout(() => {
|
||||||
|
|
||||||
|
// Primo long‑press → entra in edit mode
|
||||||
|
if (!editMode) {
|
||||||
|
enterEditMode();
|
||||||
|
if (navigator.vibrate) navigator.vibrate(10);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se già in edit mode → apri menu contestuale
|
||||||
|
const r = icon.getBoundingClientRect();
|
||||||
|
showContextMenuFor(
|
||||||
|
icon.dataset.id,
|
||||||
|
r.left + r.width / 2,
|
||||||
|
r.top + r.height
|
||||||
|
);
|
||||||
|
if (navigator.vibrate) navigator.vibrate(10);
|
||||||
|
|
||||||
|
}, 350);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long press fuori dalle icone → esci da edit mode
|
||||||
|
longPressTimer = setTimeout(() => {
|
||||||
|
if (editMode) exitEditMode();
|
||||||
|
}, 350);
|
||||||
|
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// Cancella long‑press se l’utente si muove troppo
|
||||||
|
document.addEventListener("touchmove", e => {
|
||||||
|
if (!longPressTimer) return;
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const r = longPressTarget?.getBoundingClientRect();
|
||||||
|
|
||||||
|
const dx = touch.clientX - (r?.left ?? touch.clientX);
|
||||||
|
const dy = touch.clientY - (r?.top ?? touch.clientY);
|
||||||
|
|
||||||
|
if (Math.hypot(dx, dy) > 15) {
|
||||||
|
clearTimeout(longPressTimer);
|
||||||
|
longPressTimer = null;
|
||||||
|
longPressTarget = null;
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// Fine touch → cancella long‑press
|
||||||
|
document.addEventListener("touchend", () => {
|
||||||
|
if (longPressTimer) {
|
||||||
|
clearTimeout(longPressTimer);
|
||||||
|
longPressTimer = null;
|
||||||
|
longPressTarget = null;
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// MOUSE LONG PRESS
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
document.addEventListener("mousedown", e => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
|
||||||
|
const icon = e.target.closest(".app-icon");
|
||||||
|
longPressTarget = icon ?? null;
|
||||||
|
|
||||||
|
longPressTimer = setTimeout(() => {
|
||||||
|
|
||||||
|
if (!editMode) {
|
||||||
|
enterEditMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
const r = icon.getBoundingClientRect();
|
||||||
|
showContextMenuFor(
|
||||||
|
icon.dataset.id,
|
||||||
|
r.left + r.width / 2,
|
||||||
|
r.top + r.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}, 350);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancella long‑press se il mouse si muove troppo
|
||||||
|
document.addEventListener("mousemove", e => {
|
||||||
|
if (!longPressTimer) return;
|
||||||
|
|
||||||
|
if (longPressTarget) {
|
||||||
|
const r = longPressTarget.getBoundingClientRect();
|
||||||
|
const dx = e.clientX - (r.left + r.width / 2);
|
||||||
|
const dy = e.clientY - (r.top + r.height / 2);
|
||||||
|
|
||||||
|
if (Math.hypot(dx, dy) > 15) {
|
||||||
|
clearTimeout(longPressTimer);
|
||||||
|
longPressTimer = null;
|
||||||
|
longPressTarget = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse up → cancella long‑press
|
||||||
|
document.addEventListener("mouseup", () => {
|
||||||
|
if (longPressTimer) {
|
||||||
|
clearTimeout(longPressTimer);
|
||||||
|
longPressTimer = null;
|
||||||
|
longPressTarget = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CHIUSURA MENU E USCITA DA EDIT MODE
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function initGlobalCloseHandlers() {
|
||||||
|
document.addEventListener("pointerdown", e => {
|
||||||
|
const isIcon = e.target.closest(".app-icon");
|
||||||
|
const isMenu = e.target.closest("#context-menu");
|
||||||
|
|
||||||
|
// 1️⃣ Clic fuori dal menu → chiudi menu
|
||||||
|
if (!isMenu && !isIcon && !document.getElementById("context-menu").classList.contains("hidden")) {
|
||||||
|
hideContextMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ Clic fuori dalle icone → esci da edit mode
|
||||||
|
if (!isIcon && editMode) {
|
||||||
|
exitEditMode();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LAUNCHER — VERSIONE COMPLETA E OTTIMIZZATA (A)
|
||||||
|
// BLOCCO 5/6 — Drag & Drop stile iPhone (FIXED)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Utility per ottenere posizione del puntatore (touch + mouse)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function getPointerPosition(e) {
|
||||||
|
if (e.touches && e.touches.length > 0) {
|
||||||
|
return {
|
||||||
|
pageX: e.touches[0].pageX,
|
||||||
|
pageY: e.touches[0].pageY,
|
||||||
|
clientX: e.touches[0].clientX,
|
||||||
|
clientY: e.touches[0].clientY
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pageX: e.pageX,
|
||||||
|
pageY: e.pageY,
|
||||||
|
clientX: e.clientX,
|
||||||
|
clientY: e.clientY
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/* Inizio drag: icona flottante + placeholder nel layout */
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function startDrag(icon, pos) {
|
||||||
|
const folderEl = document.getElementById("folder");
|
||||||
|
|
||||||
|
draggingId = icon.dataset.id;
|
||||||
|
|
||||||
|
const r = icon.getBoundingClientRect();
|
||||||
|
dragOffsetX = pos.pageX - r.left;
|
||||||
|
dragOffsetY = pos.pageY - r.top;
|
||||||
|
|
||||||
|
draggingIcon = icon;
|
||||||
|
draggingIcon.classList.add("dragging");
|
||||||
|
draggingIcon.style.position = "fixed";
|
||||||
|
draggingIcon.style.left = `${r.left}px`;
|
||||||
|
draggingIcon.style.top = `${r.top}px`;
|
||||||
|
draggingIcon.style.width = `${r.width}px`;
|
||||||
|
draggingIcon.style.height = `${r.height}px`;
|
||||||
|
draggingIcon.style.zIndex = "1000";
|
||||||
|
draggingIcon.style.pointerEvents = "none";
|
||||||
|
draggingIcon.style.transform = "translate3d(0,0,0)";
|
||||||
|
|
||||||
|
// Placeholder nel layout (slot vuoto)
|
||||||
|
placeholderEl = document.createElement("div");
|
||||||
|
placeholderEl.className = "app-icon placeholder";
|
||||||
|
placeholderEl.style.visibility = "hidden";
|
||||||
|
|
||||||
|
// Inserisci il placeholder dove stava l’icona
|
||||||
|
folderEl.insertBefore(placeholderEl, icon);
|
||||||
|
|
||||||
|
hideContextMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Aggiorna posizione icona trascinata + posizione placeholder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function updateDragPosition(pos) {
|
||||||
|
if (!draggingIcon || !placeholderEl) return;
|
||||||
|
|
||||||
|
const x = pos.pageX - dragOffsetX;
|
||||||
|
const y = pos.pageY - dragOffsetY;
|
||||||
|
|
||||||
|
draggingIcon.style.left = `${x}px`;
|
||||||
|
draggingIcon.style.top = `${y}px`;
|
||||||
|
|
||||||
|
const centerX = pos.clientX;
|
||||||
|
const centerY = pos.clientY;
|
||||||
|
|
||||||
|
const elem = document.elementFromPoint(centerX, centerY);
|
||||||
|
const targetIcon = elem && elem.closest(".app-icon:not(.dragging)");
|
||||||
|
if (!targetIcon || targetIcon === placeholderEl) return;
|
||||||
|
|
||||||
|
const folderEl = document.getElementById("folder");
|
||||||
|
const targetRect = targetIcon.getBoundingClientRect();
|
||||||
|
const isBefore = centerY < targetRect.top + targetRect.height / 2;
|
||||||
|
|
||||||
|
if (isBefore) {
|
||||||
|
folderEl.insertBefore(placeholderEl, targetIcon);
|
||||||
|
} else {
|
||||||
|
folderEl.insertBefore(placeholderEl, targetIcon.nextSibling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fine drag: aggiorna appsOrder in base alla posizione del placeholder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function endDrag() {
|
||||||
|
if (!draggingIcon || !placeholderEl) {
|
||||||
|
draggingIcon = null;
|
||||||
|
placeholderEl = null;
|
||||||
|
dragStartX = 0;
|
||||||
|
dragStartY = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderEl = document.getElementById("folder");
|
||||||
|
|
||||||
|
// Tutti i figli, inclusa la placeholder
|
||||||
|
const children = Array.from(folderEl.children);
|
||||||
|
const finalIndex = children.indexOf(placeholderEl);
|
||||||
|
|
||||||
|
// Ripristina icona visuale
|
||||||
|
draggingIcon.classList.remove("dragging");
|
||||||
|
draggingIcon.style.position = "";
|
||||||
|
draggingIcon.style.left = "";
|
||||||
|
draggingIcon.style.top = "";
|
||||||
|
draggingIcon.style.width = "";
|
||||||
|
draggingIcon.style.height = "";
|
||||||
|
draggingIcon.style.zIndex = "";
|
||||||
|
draggingIcon.style.pointerEvents = "";
|
||||||
|
draggingIcon.style.transform = "";
|
||||||
|
|
||||||
|
if (finalIndex !== -1) {
|
||||||
|
const currentIndex = appsOrder.indexOf(draggingId);
|
||||||
|
if (currentIndex !== -1 && currentIndex !== finalIndex) {
|
||||||
|
appsOrder.splice(currentIndex, 1);
|
||||||
|
appsOrder.splice(finalIndex, 0, draggingId);
|
||||||
|
saveOrder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placeholderEl && placeholderEl.parentNode) {
|
||||||
|
placeholderEl.parentNode.removeChild(placeholderEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
draggingIcon = null;
|
||||||
|
placeholderEl = null;
|
||||||
|
dragStartX = 0;
|
||||||
|
dragStartY = 0;
|
||||||
|
|
||||||
|
// Ridisegna in base al nuovo ordine
|
||||||
|
renderApps();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Inizializzazione Drag & Drop (touch + mouse)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function initDragHandlers() {
|
||||||
|
|
||||||
|
// TOUCH DRAG
|
||||||
|
document.addEventListener("touchstart", e => {
|
||||||
|
if (!editMode) return;
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
if (contextMenuTargetId) return;
|
||||||
|
|
||||||
|
const pos = getPointerPosition(e);
|
||||||
|
const icon = e.touches[0].target.closest(".app-icon");
|
||||||
|
if (!icon) return;
|
||||||
|
|
||||||
|
dragStartX = pos.clientX;
|
||||||
|
dragStartY = pos.clientY;
|
||||||
|
draggingIcon = null;
|
||||||
|
draggingId = null;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
document.addEventListener("touchmove", e => {
|
||||||
|
if (!editMode) return;
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
|
||||||
|
const pos = getPointerPosition(e);
|
||||||
|
|
||||||
|
if (!draggingIcon) {
|
||||||
|
const dx = pos.clientX - dragStartX;
|
||||||
|
const dy = pos.clientY - dragStartY;
|
||||||
|
if (Math.hypot(dx, dy) > 10) {
|
||||||
|
const icon = e.touches[0].target.closest(".app-icon");
|
||||||
|
if (icon) {
|
||||||
|
if (longPressTimer) {
|
||||||
|
clearTimeout(longPressTimer);
|
||||||
|
longPressTimer = null;
|
||||||
|
longPressTarget = null;
|
||||||
|
}
|
||||||
|
startDrag(icon, pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateDragPosition(pos);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
document.addEventListener("touchend", e => {
|
||||||
|
if (!editMode) return;
|
||||||
|
if (!draggingIcon) {
|
||||||
|
dragStartX = 0;
|
||||||
|
dragStartY = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!e.touches || e.touches.length === 0) {
|
||||||
|
endDrag();
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// MOUSE DRAG
|
||||||
|
document.addEventListener("mousedown", e => {
|
||||||
|
if (!editMode) return;
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
if (contextMenuTargetId) return;
|
||||||
|
|
||||||
|
const icon = e.target.closest(".app-icon");
|
||||||
|
if (!icon) return;
|
||||||
|
|
||||||
|
const pos = getPointerPosition(e);
|
||||||
|
dragStartX = pos.clientX;
|
||||||
|
dragStartY = pos.clientY;
|
||||||
|
draggingIcon = null;
|
||||||
|
draggingId = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", e => {
|
||||||
|
if (!editMode) return;
|
||||||
|
|
||||||
|
const pos = getPointerPosition(e);
|
||||||
|
|
||||||
|
if (!draggingIcon) {
|
||||||
|
if (!dragStartX && !dragStartY) return;
|
||||||
|
|
||||||
|
const dx = pos.clientX - dragStartX;
|
||||||
|
const dy = pos.clientY - dragStartY;
|
||||||
|
if (Math.hypot(dx, dy) > 10) {
|
||||||
|
const icon = e.target.closest(".app-icon");
|
||||||
|
if (icon) {
|
||||||
|
if (longPressTimer) {
|
||||||
|
clearTimeout(longPressTimer);
|
||||||
|
longPressTimer = null;
|
||||||
|
longPressTarget = null;
|
||||||
|
}
|
||||||
|
startDrag(icon, pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateDragPosition(pos);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("mouseup", () => {
|
||||||
|
if (!editMode) return;
|
||||||
|
if (!draggingIcon) {
|
||||||
|
dragStartX = 0;
|
||||||
|
dragStartY = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
endDrag();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LAUNCHER — VERSIONE COMPLETA E
|
||||||
|
// OTTIMIZZATA (A) BLOCCO 6/6 — Context Menu
|
||||||
|
// Actions + Config Save + Init Globale
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MENU CONTESTUALE — AZIONI (rename, change icon, remove)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function initContextMenuActions() {
|
||||||
|
const menu = document.getElementById("context-menu");
|
||||||
|
|
||||||
|
menu.addEventListener("click", e => {
|
||||||
|
const btn = e.target.closest("button");
|
||||||
|
if (!btn || !contextMenuTargetId) return;
|
||||||
|
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
const app = appsData.find(a => a.id === contextMenuTargetId);
|
||||||
|
if (!app) return;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// RINOMINA APP
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
if (action === "rename") {
|
||||||
|
const nuovoNome = prompt("Nuovo nome app:", app.name);
|
||||||
|
if (nuovoNome && nuovoNome.trim()) {
|
||||||
|
app.name = nuovoNome.trim();
|
||||||
|
renderApps();
|
||||||
|
saveOrder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// CAMBIA ICONA
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
if (action === "change-icon") {
|
||||||
|
const nuovaIcona = prompt("URL nuova icona:", app.icon);
|
||||||
|
if (nuovaIcona && nuovaIcona.trim()) {
|
||||||
|
app.icon = nuovaIcona.trim();
|
||||||
|
renderApps();
|
||||||
|
saveOrder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// RIMUOVI APP DALLA GRIGLIA
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
if (action === "remove") {
|
||||||
|
if (confirm("Rimuovere questa app dalla griglia?")) {
|
||||||
|
appsOrder = appsOrder.filter(id => id !== app.id);
|
||||||
|
saveOrder();
|
||||||
|
renderApps();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideContextMenu();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AGGIORNA ORA — aggiorna apps dal server senza cambiare config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
document.getElementById("cfg-refresh").addEventListener("click", async () => {
|
||||||
|
|
||||||
|
const cfg = loadConfig();
|
||||||
|
if (!cfg) {
|
||||||
|
alert("Config mancante. Inserisci URL, user e password.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await getLinks();
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
hideSetupPage();
|
||||||
|
startLauncher(); // Torna subito alla schermata principale
|
||||||
|
} else {
|
||||||
|
alert("Impossibile aggiornare le app dal server.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SALVATAGGIO CONFIG + RESTART COMPLETO
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
document.getElementById("cfg-save").addEventListener("click", async () => {
|
||||||
|
const url = document.getElementById("cfg-url").value;
|
||||||
|
const user = document.getElementById("cfg-user").value;
|
||||||
|
const pass = document.getElementById("cfg-pass").value;
|
||||||
|
|
||||||
|
saveConfig(url, user, pass);
|
||||||
|
|
||||||
|
const ok = await getLinks();
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
hideSetupPage();
|
||||||
|
startLauncher();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// INIT GLOBALE — DOMContentLoaded
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
|
||||||
|
if (!cfg) {
|
||||||
|
showSetupPage();
|
||||||
|
} else {
|
||||||
|
hideSetupPage();
|
||||||
|
startLauncher();
|
||||||
|
}
|
||||||
|
});
|
||||||
48
app/index.html
Normal file
48
app/index.html
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Launcher</title>
|
||||||
|
|
||||||
|
<!-- Blocca lo zoom del browser -->
|
||||||
|
<meta name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="setup-page" class="hidden">
|
||||||
|
<h2>Configurazione</h2>
|
||||||
|
|
||||||
|
<button id="cfg-refresh">Aggiorna ora</button>
|
||||||
|
|
||||||
|
<label>URL</label>
|
||||||
|
<input id="cfg-url" type="text">
|
||||||
|
|
||||||
|
<label>User</label>
|
||||||
|
<input id="cfg-user" type="text">
|
||||||
|
|
||||||
|
<label>Password</label>
|
||||||
|
<input id="cfg-pass" type="password">
|
||||||
|
|
||||||
|
<button id="cfg-save">Salva</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Griglia icone -->
|
||||||
|
<div class="folder" id="folder"></div>
|
||||||
|
|
||||||
|
<!-- Menu contestuale -->
|
||||||
|
<div id="context-menu" class="context-menu hidden">
|
||||||
|
<button data-action="rename">Rinomina</button>
|
||||||
|
<button data-action="change-icon">Cambia icona</button>
|
||||||
|
<button data-action="remove">Rimuovi</button>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
|
||||||
|
<!-- --> <script src="https://cdn.jsdelivr.net/npm/eruda"></script>
|
||||||
|
<script>
|
||||||
|
eruda.init();
|
||||||
|
</script> <!-- -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4
app/start.sh
Executable file
4
app/start.sh
Executable file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Avvia un server HTTP sulla porta 11002 senza cache
|
||||||
|
npx http-server . -c-1 -p 11002
|
||||||
259
app/style.css
Normal file
259
app/style.css
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
/* ============================================================
|
||||||
|
BASE PAGE
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-x: hidden; /* impedisce pan orizzontale */
|
||||||
|
max-width: 100%;
|
||||||
|
touch-action: pan-y; /* solo scroll verticale */
|
||||||
|
background: #ffffff;
|
||||||
|
/*background: radial-gradient(circle at top, #f8f9ff 0%, #e6e8ef 60%, #dcdfe6 100%);*/
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
color: #1a1a1a;
|
||||||
|
min-height: 100vh; /* evita scroll inutile se poche icone */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Impedisce selezione testo e highlight blu Android */
|
||||||
|
* {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variabile di zoom globale */
|
||||||
|
:root {
|
||||||
|
--zoom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
GRIGLIA ICONE
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.folder {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(
|
||||||
|
auto-fill,
|
||||||
|
minmax(calc(85px * var(--zoom)), 1fr)
|
||||||
|
);
|
||||||
|
gap: calc(16px * var(--zoom));
|
||||||
|
padding-top: 24px;
|
||||||
|
padding-left: 24px;
|
||||||
|
padding-right: 24px;
|
||||||
|
padding-bottom: 0;
|
||||||
|
justify-items: start; /* più coerente con iOS */
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
transition: grid-template-columns 0.15s ease-out,
|
||||||
|
gap 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contenitore icona — versione glass */
|
||||||
|
.app-icon {
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
transition: transform 0.18s ease, filter 0.18s ease;
|
||||||
|
|
||||||
|
/* GLASS */
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
|
||||||
|
border-radius: calc(20px * var(--zoom));
|
||||||
|
padding: calc(6px * var(--zoom));
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icona PNG */
|
||||||
|
.app-icon img {
|
||||||
|
width: calc(78px * var(--zoom));
|
||||||
|
height: calc(78px * var(--zoom));
|
||||||
|
border-radius: calc(16px * var(--zoom));
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
box-shadow:
|
||||||
|
0 4px 10px rgba(0, 0, 0, 0.12),
|
||||||
|
0 8px 24px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Etichetta */
|
||||||
|
.app-icon span {
|
||||||
|
display: block;
|
||||||
|
margin-top: calc(6px * var(--zoom));
|
||||||
|
font-size: calc(11px * var(--zoom));
|
||||||
|
color: #3a3a3a;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
transition: font-size 0.18s ease-out,
|
||||||
|
margin-top 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
WIGGLE MODE
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
@keyframes wiggle {
|
||||||
|
0% { transform: rotate(-2deg) scale(1.02); }
|
||||||
|
50% { transform: rotate( 2deg) scale(0.98); }
|
||||||
|
100% { transform: rotate(-2deg) scale(1.02); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-mode .app-icon:not(.dragging) img {
|
||||||
|
animation: wiggle 0.25s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icona trascinata */
|
||||||
|
.app-icon.dragging {
|
||||||
|
opacity: 0.9;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder invisibile */
|
||||||
|
.app-icon.placeholder {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
MENU CONTESTUALE — ANDROID MATERIAL + RESPONSIVE ALLO ZOOM
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
#context-menu {
|
||||||
|
position: fixed;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: calc(14px * var(--zoom));
|
||||||
|
min-width: calc(180px * var(--zoom));
|
||||||
|
padding: calc(8px * var(--zoom)) 0;
|
||||||
|
z-index: 2000;
|
||||||
|
|
||||||
|
/* Ombra Material */
|
||||||
|
box-shadow:
|
||||||
|
0 calc(6px * var(--zoom)) calc(20px * var(--zoom)) rgba(0,0,0,0.18),
|
||||||
|
0 calc(2px * var(--zoom)) calc(6px * var(--zoom)) rgba(0,0,0,0.12);
|
||||||
|
|
||||||
|
/* Animazione apertura */
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.85);
|
||||||
|
transform-origin: top center;
|
||||||
|
transition: opacity 120ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#context-menu:not(.hidden) {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#context-menu.hidden {
|
||||||
|
display: block;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulsanti del menù */
|
||||||
|
#context-menu button {
|
||||||
|
all: unset;
|
||||||
|
width: 100%;
|
||||||
|
padding: calc(14px * var(--zoom)) calc(18px * var(--zoom));
|
||||||
|
font-size: calc(15px * var(--zoom));
|
||||||
|
color: #222;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(12px * var(--zoom));
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ripple effect */
|
||||||
|
#context-menu button::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.08);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
#context-menu button:active::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Separatore tra voci */
|
||||||
|
#context-menu button + button {
|
||||||
|
border-top: 1px solid rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Voce "Rimuovi" in rosso */
|
||||||
|
#context-menu button:last-child {
|
||||||
|
color: #d11a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Permette drag sia mouse che touch */
|
||||||
|
.app-icon {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Evita che l'immagine intercetti eventi */
|
||||||
|
.app-icon img {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allineamento stile iOS, evita offset su PC */
|
||||||
|
.folder {
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
PAGINA INIZIALE
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
#setup-page {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: #f5f5f7;
|
||||||
|
padding: 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
#setup-page.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#setup-page input {
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
#setup-page button {
|
||||||
|
padding: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #007aff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cfg-refresh {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./server/backend
|
||||||
|
container_name: backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- MONGO_URI=mongodb://root:example@192.168.1.3:27017/myapphttps?authSource=admin
|
||||||
|
- JWT_SECRET=master66
|
||||||
|
- PORT=11001
|
||||||
|
ports:
|
||||||
|
- "11001:11001"
|
||||||
|
volumes:
|
||||||
|
- /home/nvme/dockerdata/myapps/icons:/app/uploads
|
||||||
|
|
||||||
|
frontend_server:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: frontend_server
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "11003:80"
|
||||||
|
volumes:
|
||||||
|
- ./server/frontend:/usr/share/nginx/html:ro
|
||||||
|
|
||||||
|
app_static:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: app_static
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "11002:80"
|
||||||
|
volumes:
|
||||||
|
- ./app:/usr/share/nginx/html:ro
|
||||||
2
docker_run.sh
Executable file
2
docker_run.sh
Executable file
|
|
@ -0,0 +1,2 @@
|
||||||
|
#!/bin/bash
|
||||||
|
sudo docker compose up -d
|
||||||
61
server/README.md
Normal file
61
server/README.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Server Json per condivisione delle mie apps
|
||||||
|
|
||||||
|
Utilizza MongoDB su 192.168.1.3 con user root e password example
|
||||||
|
|
||||||
|
## Installazione ed avvio server
|
||||||
|
|
||||||
|
vai su server e installa i packages
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd server
|
||||||
|
npm ci install
|
||||||
|
```
|
||||||
|
|
||||||
|
far partire il server con
|
||||||
|
|
||||||
|
```
|
||||||
|
node index.js
|
||||||
|
```
|
||||||
|
o con
|
||||||
|
```
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
è settato per far partire su porta 3000
|
||||||
|
|
||||||
|
## User interface per inserire i dati
|
||||||
|
|
||||||
|
vai su frontend ed avvia la UI
|
||||||
|
|
||||||
|
```
|
||||||
|
cd frontend
|
||||||
|
npx http-server . -c-1 -p 8282
|
||||||
|
```
|
||||||
|
|
||||||
|
il comando -c-1 toglie la cache
|
||||||
|
-p indica la porta
|
||||||
|
|
||||||
|
## Altri strumenti per l'utilizzo
|
||||||
|
|
||||||
|
nella directory server c'è
|
||||||
|
|
||||||
|
./link.sh
|
||||||
|
|
||||||
|
che estrae la lista usando curl
|
||||||
|
|
||||||
|
oppure il comando in js
|
||||||
|
|
||||||
|
node list.js
|
||||||
|
|
||||||
|
che estrae la lista
|
||||||
|
|
||||||
|
nel folder how_use le api e i vari comandi in js
|
||||||
|
|
||||||
|
## Installazione in docker con mongoDB incluso (non testato)
|
||||||
|
|
||||||
|
Come avviarlo
|
||||||
|
|
||||||
|
```
|
||||||
|
cd project
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
16
server/backend/.env
Normal file
16
server/backend/.env
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# === SERVER CONFIG ===
|
||||||
|
PORT=11001
|
||||||
|
|
||||||
|
# === JWT CONFIG ===
|
||||||
|
# Cambialo SEMPRE in produzione
|
||||||
|
JWT_SECRET=master66
|
||||||
|
|
||||||
|
# === MONGO CONFIG ===
|
||||||
|
# In locale:
|
||||||
|
# MONGO_URI=mongodb://localhost:27017/mydb
|
||||||
|
#
|
||||||
|
# In Docker (usato dal docker-compose):
|
||||||
|
MONGO_URI=mongodb://root:example@192.168.1.3:27017/myapphttps?authSource=admin
|
||||||
|
# === UPLOADS ===
|
||||||
|
# Cartella dove Express serve le icone
|
||||||
|
UPLOAD_DIR=uploads
|
||||||
17
server/backend/.env.example
Normal file
17
server/backend/.env.example
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# === SERVER CONFIG ===
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# === JWT CONFIG ===
|
||||||
|
# Cambialo SEMPRE in produzione
|
||||||
|
JWT_SECRET=supersegreto-cambialo
|
||||||
|
|
||||||
|
# === MONGO CONFIG ===
|
||||||
|
# In locale:
|
||||||
|
# MONGO_URI=mongodb://localhost:27017/mydb
|
||||||
|
#
|
||||||
|
# In Docker (usato dal docker-compose):
|
||||||
|
MONGO_URI=mongodb://mongo:27017/mydb
|
||||||
|
|
||||||
|
# === UPLOADS ===
|
||||||
|
# Cartella dove Express serve le icone
|
||||||
|
UPLOAD_DIR=uploads
|
||||||
38
server/backend/Dockerfile
Normal file
38
server/backend/Dockerfile
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# -----------------------------
|
||||||
|
# 1) Build stage
|
||||||
|
# -----------------------------
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copio solo package.json per sfruttare la cache Docker
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Installa solo le dipendenze necessarie
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Copio il resto del codice
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# 2) Runtime stage
|
||||||
|
# -----------------------------
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copio solo node_modules dal builder
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Copio il codice applicativo
|
||||||
|
COPY --from=builder /app ./
|
||||||
|
|
||||||
|
# Utente non-root per sicurezza
|
||||||
|
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Porta interna del backend (3000)
|
||||||
|
EXPOSE 11001
|
||||||
|
|
||||||
|
# Avvio del server
|
||||||
|
CMD ["node", "index.js"]
|
||||||
132
server/backend/appMetadata.js
Normal file
132
server/backend/appMetadata.js
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
// appMetadata.js
|
||||||
|
import axios from "axios";
|
||||||
|
import * as cheerio from "cheerio";
|
||||||
|
import sizeOf from "image-size";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
export async function getAppMetadata(baseUrl) {
|
||||||
|
console.log(baseUrl);
|
||||||
|
try {
|
||||||
|
const res = await axios.get(baseUrl, { timeout: 3000 });
|
||||||
|
const $ = cheerio.load(res.data);
|
||||||
|
|
||||||
|
// -------------------------------
|
||||||
|
// 1. Nome più corto
|
||||||
|
// -------------------------------
|
||||||
|
const nameCandidates = [
|
||||||
|
$('meta[property="og:site_name"]').attr("content"),
|
||||||
|
$('meta[name="application-name"]').attr("content"),
|
||||||
|
$('meta[property="og:title"]').attr("content"),
|
||||||
|
$("title").text().trim()
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const name = nameCandidates.length > 0
|
||||||
|
? nameCandidates.sort((a, b) => a.length - b.length)[0]
|
||||||
|
: "no_name";
|
||||||
|
|
||||||
|
// -------------------------------
|
||||||
|
// 2. Icone HTML
|
||||||
|
// -------------------------------
|
||||||
|
const htmlIcons = [];
|
||||||
|
$("link[rel*='icon']").each((_, el) => {
|
||||||
|
const href = $(el).attr("href");
|
||||||
|
const sizes = $(el).attr("sizes") || "";
|
||||||
|
if (href) htmlIcons.push({ href, sizes });
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------
|
||||||
|
// 3. Manifest.json
|
||||||
|
// -------------------------------
|
||||||
|
let manifestIcons = [];
|
||||||
|
const manifestHref = $('link[rel="manifest"]').attr("href");
|
||||||
|
|
||||||
|
if (manifestHref) {
|
||||||
|
try {
|
||||||
|
const manifestUrl = new URL(manifestHref, baseUrl).href;
|
||||||
|
const manifestRes = await axios.get(manifestUrl, { timeout: 3000 });
|
||||||
|
const manifest = manifestRes.data;
|
||||||
|
|
||||||
|
if (manifest.icons && Array.isArray(manifest.icons)) {
|
||||||
|
manifestIcons = manifest.icons.map(icon => ({
|
||||||
|
href: icon.src,
|
||||||
|
sizes: icon.sizes || ""
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------
|
||||||
|
// 4. Fallback 4 icone
|
||||||
|
// -------------------------------
|
||||||
|
const fallbackPaths = [
|
||||||
|
"/favicon.ico",
|
||||||
|
"/favicon.png",
|
||||||
|
"/icon.png",
|
||||||
|
"/apple-touch-icon.png"
|
||||||
|
];
|
||||||
|
|
||||||
|
const fallbackIcons = fallbackPaths.map(p => ({
|
||||||
|
href: p,
|
||||||
|
sizes: ""
|
||||||
|
}));
|
||||||
|
|
||||||
|
// -------------------------------
|
||||||
|
// 5. Unisci tutte le icone
|
||||||
|
// -------------------------------
|
||||||
|
const allIcons = [...htmlIcons, ...manifestIcons, ...fallbackIcons];
|
||||||
|
|
||||||
|
// -------------------------------
|
||||||
|
// 6. Determina dimensione reale (PNG, ICO, SVG)
|
||||||
|
// -------------------------------
|
||||||
|
const iconsWithRealSize = [];
|
||||||
|
|
||||||
|
for (const icon of allIcons) {
|
||||||
|
try {
|
||||||
|
const url = new URL(icon.href, baseUrl).href;
|
||||||
|
const imgRes = await axios.get(url, { responseType: "arraybuffer" });
|
||||||
|
|
||||||
|
let width = 0;
|
||||||
|
|
||||||
|
// ---- PNG / JPG / ICO ----
|
||||||
|
try {
|
||||||
|
const dim = sizeOf(imgRes.data);
|
||||||
|
if (dim.width) width = dim.width;
|
||||||
|
} catch {
|
||||||
|
// Non è un formato supportato da image-size
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SVG → converti in PNG e misura ----
|
||||||
|
if (width === 0) {
|
||||||
|
try {
|
||||||
|
const pngBuffer = await sharp(imgRes.data).png().toBuffer();
|
||||||
|
const dim = sizeOf(pngBuffer);
|
||||||
|
if (dim.width) width = dim.width;
|
||||||
|
} catch {
|
||||||
|
// SVG non convertibile → ignora
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width > 0) {
|
||||||
|
iconsWithRealSize.push({ url, size: width });
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
// icona non accessibile → ignora
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------
|
||||||
|
// 7. Scegli la più grande
|
||||||
|
// -------------------------------
|
||||||
|
iconsWithRealSize.sort((a, b) => b.size - a.size);
|
||||||
|
|
||||||
|
const icon = iconsWithRealSize.length > 0
|
||||||
|
? iconsWithRealSize[0].url
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { name, icon };
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
return { name: "no_name", icon: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
57
server/backend/index.js
Normal file
57
server/backend/index.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import express from "express";
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import cors from "cors";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import linksRouter from "./routes/links.js";
|
||||||
|
import authRouter from "./routes/auth.js";
|
||||||
|
import metadataRouter from "./routes/metadata.js";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Static folder per le icone
|
||||||
|
app.use("/uploads", express.static("uploads"));
|
||||||
|
|
||||||
|
// Auth routes
|
||||||
|
app.use("/auth", authRouter);
|
||||||
|
|
||||||
|
// Link routes (protette)
|
||||||
|
app.use("/links", linksRouter);
|
||||||
|
|
||||||
|
// link per metadata
|
||||||
|
app.use("/metadata", metadataRouter);
|
||||||
|
|
||||||
|
// Connessione Mongo (URL da env con fallback)
|
||||||
|
const MONGO_URI = process.env.MONGO_URI || "mongodb://mongo:27017/mydb";
|
||||||
|
|
||||||
|
mongoose
|
||||||
|
.connect(MONGO_URI)
|
||||||
|
.then(() => {
|
||||||
|
console.log("MongoDB connesso");
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error("❌ Errore di connessione a MongoDB:", err.message);
|
||||||
|
process.exit(1); // termina il processo
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
const server = app.listen(PORT, () => {
|
||||||
|
console.log(`API su http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('error', (err) => {
|
||||||
|
if (err.code === 'EADDRINUSE') {
|
||||||
|
console.error(`❌ Porta ${PORT} già in uso. Arresto del processo.`);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.error('Errore del server:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
16
server/backend/middleware/auth.js
Normal file
16
server/backend/middleware/auth.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
|
export function authMiddleware(req, res, next) {
|
||||||
|
const authHeader = req.headers.authorization || "";
|
||||||
|
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
||||||
|
|
||||||
|
if (!token) return res.status(401).json({ error: "Token mancante" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, process.env.JWT_SECRET || "devsecret");
|
||||||
|
req.userId = payload.userId;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(401).json({ error: "Token non valido" });
|
||||||
|
}
|
||||||
|
}
|
||||||
14
server/backend/models/Link.js
Normal file
14
server/backend/models/Link.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
|
||||||
|
const LinkSchema = new mongoose.Schema({
|
||||||
|
url: { type: String, required: true },
|
||||||
|
name: { type: String, required: true },
|
||||||
|
icon: {
|
||||||
|
data: { type: Buffer, required: false },
|
||||||
|
mime: { type: String, required: false },
|
||||||
|
size: { type: Number, required: false }
|
||||||
|
},
|
||||||
|
owner: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default mongoose.model("Link", LinkSchema);
|
||||||
8
server/backend/models/User.js
Normal file
8
server/backend/models/User.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
|
||||||
|
const UserSchema = new mongoose.Schema({
|
||||||
|
email: { type: String, required: true, unique: true },
|
||||||
|
passwordHash: { type: String, required: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default mongoose.model("User", UserSchema);
|
||||||
2953
server/backend/package-lock.json
generated
Normal file
2953
server/backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
server/backend/package.json
Normal file
20
server/backend/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"cheerio": "^1.1.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"icojs": "^0.20.1",
|
||||||
|
"image-size": "^2.0.2",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"mongoose": "^9.0.2",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"sharp": "^0.34.5"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
server/backend/routes/auth.js
Normal file
42
server/backend/routes/auth.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import express from "express";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import User from "../models/User.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Registrazione
|
||||||
|
router.post("/register", async (req, res) => {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
if (!email || !password)
|
||||||
|
return res.status(400).json({ error: "Email e password richiesti" });
|
||||||
|
|
||||||
|
const existing = await User.findOne({ email });
|
||||||
|
if (existing) return res.status(400).json({ error: "Email già registrata" });
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
const user = await User.create({ email, passwordHash });
|
||||||
|
|
||||||
|
res.json({ id: user._id, email: user.email });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login
|
||||||
|
router.post("/login", async (req, res) => {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
const user = await User.findOne({ email });
|
||||||
|
if (!user) return res.status(400).json({ error: "Credenziali non valide" });
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||||
|
if (!valid) return res.status(400).json({ error: "Credenziali non valide" });
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ userId: user._id },
|
||||||
|
process.env.JWT_SECRET || "devsecret",
|
||||||
|
{ expiresIn: "7d" }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ token });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
162
server/backend/routes/links.js
Normal file
162
server/backend/routes/links.js
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import express from "express";
|
||||||
|
import multer from "multer";
|
||||||
|
import axios from "axios";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import Link from "../models/Link.js";
|
||||||
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { parseICO } from "icojs";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Multer in-memory (niente filesystem)
|
||||||
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
|
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
// Scarica immagine remota come Buffer
|
||||||
|
async function downloadImageAsBuffer(url) {
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
maxRedirects: 5,
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "Mozilla/5.0",
|
||||||
|
"Accept": "image/*"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: Buffer.from(response.data),
|
||||||
|
mime: response.headers["content-type"] || ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converte immagine → WebP 128x128 contain
|
||||||
|
async function processIcon(buffer, mime) {
|
||||||
|
let inputBuffer = buffer;
|
||||||
|
|
||||||
|
// Se è ICO → converti in PNG
|
||||||
|
if (mime === "image/x-icon" || mime === "image/vnd.microsoft.icon") {
|
||||||
|
const images = await parseICO(buffer);
|
||||||
|
|
||||||
|
if (!images.length) {
|
||||||
|
throw new Error("ICO non valido");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prendiamo l’immagine più grande dentro l’ICO
|
||||||
|
const best = images.reduce((a, b) => (a.width > b.width ? a : b));
|
||||||
|
|
||||||
|
inputBuffer = Buffer.from(best.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ora Sharp può lavorare
|
||||||
|
return await sharp(inputBuffer)
|
||||||
|
.resize(128, 128, {
|
||||||
|
fit: "contain",
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
||||||
|
})
|
||||||
|
.webp({ quality: 90 })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// GET LINKS
|
||||||
|
// ===============================
|
||||||
|
router.get("/", async (req, res) => {
|
||||||
|
const links = await Link.find({ owner: req.userId });
|
||||||
|
res.json(links);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// CREATE LINK
|
||||||
|
// ===============================
|
||||||
|
router.post("/", upload.single("icon"), async (req, res) => {
|
||||||
|
const { url, name, iconURL } = req.body;
|
||||||
|
|
||||||
|
let originalBuffer = null;
|
||||||
|
|
||||||
|
// Caso 1: upload file
|
||||||
|
if (req.file) {
|
||||||
|
originalBuffer = req.file.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caso 2: URL remoto
|
||||||
|
else if (iconURL) {
|
||||||
|
originalBuffer = await downloadImageAsBuffer(iconURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
let processedIcon = null;
|
||||||
|
|
||||||
|
if (originalBuffer) {
|
||||||
|
processedIcon = await processIcon(originalBuffer.buffer, originalBuffer.mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const link = await Link.create({
|
||||||
|
url,
|
||||||
|
name,
|
||||||
|
owner: req.userId,
|
||||||
|
icon: processedIcon
|
||||||
|
? {
|
||||||
|
data: processedIcon,
|
||||||
|
mime: "image/webp",
|
||||||
|
size: processedIcon.length
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(link);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// UPDATE LINK
|
||||||
|
// ===============================
|
||||||
|
router.put("/:id", upload.single("icon"), async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, url, iconURL } = req.body;
|
||||||
|
|
||||||
|
const link = await Link.findOne({ _id: id, owner: req.userId });
|
||||||
|
if (!link) return res.status(404).json({ error: "Link non trovato" });
|
||||||
|
|
||||||
|
let originalBuffer = null;
|
||||||
|
|
||||||
|
if (req.file) {
|
||||||
|
originalBuffer = req.file.buffer;
|
||||||
|
} else if (iconURL) {
|
||||||
|
originalBuffer = await downloadImageAsBuffer(iconURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = { name, url };
|
||||||
|
|
||||||
|
if (originalBuffer) {
|
||||||
|
const processedIcon = await processIcon(originalBuffer.buffer, originalBuffer.mime);
|
||||||
|
update.icon = {
|
||||||
|
data: processedIcon,
|
||||||
|
mime: "image/webp",
|
||||||
|
size: processedIcon.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await Link.findOneAndUpdate(
|
||||||
|
{ _id: id, owner: req.userId },
|
||||||
|
update,
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// DELETE LINK
|
||||||
|
// ===============================
|
||||||
|
router.delete("/:id", async (req, res) => {
|
||||||
|
const link = await Link.findOneAndDelete({
|
||||||
|
_id: req.params.id,
|
||||||
|
owner: req.userId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!link) return res.status(404).json({ error: "Link non trovato" });
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
104
server/backend/routes/metadata.js
Normal file
104
server/backend/routes/metadata.js
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import express from "express";
|
||||||
|
import axios from "axios";
|
||||||
|
import * as cheerio from "cheerio";
|
||||||
|
import { URL } from "url";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Normalizza URL relativi → assoluti
|
||||||
|
function normalize(base, relative) {
|
||||||
|
try {
|
||||||
|
return new URL(relative, base).href;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scarica HTML con fallback CORS
|
||||||
|
async function fetchHTML(url) {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(url, {
|
||||||
|
timeout: 8000,
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "Mozilla/5.0"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/", async (req, res) => {
|
||||||
|
const siteUrl = req.query.url;
|
||||||
|
if (!siteUrl) return res.json({ error: "Missing URL" });
|
||||||
|
|
||||||
|
const html = await fetchHTML(siteUrl);
|
||||||
|
if (!html) return res.json({ name: null, icon: null });
|
||||||
|
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
// -----------------------------------------
|
||||||
|
// 1. Trova il nome più corto
|
||||||
|
// -----------------------------------------
|
||||||
|
let names = [];
|
||||||
|
|
||||||
|
const title = $("title").text().trim();
|
||||||
|
if (title) names.push(title);
|
||||||
|
|
||||||
|
$('meta[name="application-name"]').each((i, el) => {
|
||||||
|
const v = $(el).attr("content");
|
||||||
|
if (v) names.push(v.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
$('meta[property="og:site_name"]').each((i, el) => {
|
||||||
|
const v = $(el).attr("content");
|
||||||
|
if (v) names.push(v.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
const shortestName = names.length
|
||||||
|
? names.sort((a, b) => a.length - b.length)[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// -----------------------------------------
|
||||||
|
// 2. Trova l’icona più grande
|
||||||
|
// -----------------------------------------
|
||||||
|
let icons = [];
|
||||||
|
|
||||||
|
$('link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"], link[rel="apple-touch-icon-precomposed"]').each((i, el) => {
|
||||||
|
const href = $(el).attr("href");
|
||||||
|
if (!href) return;
|
||||||
|
|
||||||
|
const sizeAttr = $(el).attr("sizes");
|
||||||
|
let size = 0;
|
||||||
|
|
||||||
|
if (sizeAttr && sizeAttr.includes("x")) {
|
||||||
|
const parts = sizeAttr.split("x");
|
||||||
|
size = parseInt(parts[0]) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
icons.push({
|
||||||
|
url: normalize(siteUrl, href),
|
||||||
|
size
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// fallback favicon
|
||||||
|
icons.push({
|
||||||
|
url: normalize(siteUrl, "/favicon.ico"),
|
||||||
|
size: 16
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ordina per dimensione
|
||||||
|
icons = icons.filter(i => i.url);
|
||||||
|
icons.sort((a, b) => b.size - a.size);
|
||||||
|
|
||||||
|
const bestIcon = icons.length ? icons[0].url : null;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
name: shortestName,
|
||||||
|
icon: bestIcon
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
BIN
server/backend/uploads/1767548820927-1000084863.jpg
Normal file
BIN
server/backend/uploads/1767548820927-1000084863.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
BIN
server/backend/uploads/1767548868131.png
Normal file
BIN
server/backend/uploads/1767548868131.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
122
server/frontend/api.js
Normal file
122
server/frontend/api.js
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
const API_BASE = "https://myapps_svr.patachina2.casacam.net";
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// AUTH
|
||||||
|
// ------------------------------
|
||||||
|
|
||||||
|
export async function login(email, password) {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || "Errore login");
|
||||||
|
return data.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(email, password) {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || "Errore registrazione");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// LINKS
|
||||||
|
// ------------------------------
|
||||||
|
|
||||||
|
export async function getLinks(token) {
|
||||||
|
const res = await fetch(`${API_BASE}/links`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: "application/json"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || "Errore caricamento link");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*export async function createLink(token, { name, url, iconFile }) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("name", name);
|
||||||
|
formData.append("url", url);
|
||||||
|
if (iconFile) formData.append("icon", iconFile);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/links`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || "Errore creazione link");
|
||||||
|
return data;
|
||||||
|
}*/
|
||||||
|
|
||||||
|
export async function createLink(token, { name, url, iconFile, iconURL }) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("name", name);
|
||||||
|
formData.append("url", url);
|
||||||
|
|
||||||
|
if (iconFile) {
|
||||||
|
formData.append("icon", iconFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconURL) {
|
||||||
|
formData.append("iconURL", iconURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/links`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || "Errore creazione link");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLink(token, id) {
|
||||||
|
const res = await fetch(`${API_BASE}/links/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || "Errore eliminazione link");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLink(token, id, { name, url, iconFile }) {
|
||||||
|
const formData = new FormData();
|
||||||
|
if (name) formData.append("name", name);
|
||||||
|
if (url) formData.append("url", url);
|
||||||
|
if (iconFile) formData.append("icon", iconFile);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/links/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || "Errore aggiornamento link");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
273
server/frontend/app.js
Normal file
273
server/frontend/app.js
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
import {
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
getLinks,
|
||||||
|
createLink,
|
||||||
|
deleteLink,
|
||||||
|
updateLink
|
||||||
|
} from "./api.js";
|
||||||
|
|
||||||
|
const URL_SVR = "https://myapps_svr.patachina2.casacam.net";
|
||||||
|
|
||||||
|
let token = null;
|
||||||
|
let autoIconURL = null;
|
||||||
|
let editingId = null;
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// MOSTRA DIMENSIONI IMMAGINE
|
||||||
|
// ===============================
|
||||||
|
function showImageSize(imgElement, sizeElement) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
sizeElement.textContent = `${img.width} × ${img.height} px`;
|
||||||
|
sizeElement.style.display = "block";
|
||||||
|
};
|
||||||
|
img.src = imgElement.src;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// AUTH
|
||||||
|
// ===============================
|
||||||
|
function setToken(t) {
|
||||||
|
token = t;
|
||||||
|
document.getElementById("authSection").style.display = token ? "none" : "block";
|
||||||
|
document.getElementById("linkSection").style.display = token ? "block" : "none";
|
||||||
|
if (token) loadLinks();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("loginForm").addEventListener("submit", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const t = await login(e.target.email.value, e.target.password.value);
|
||||||
|
setToken(t);
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById("authStatus").textContent = err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("registerForm").addEventListener("submit", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await register(e.target.email.value, e.target.password.value);
|
||||||
|
document.getElementById("authStatus").textContent = "Registrato! Ora accedi.";
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById("authStatus").textContent = err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// LOAD LINKS
|
||||||
|
// ===============================
|
||||||
|
/*async function loadLinks() {
|
||||||
|
const links = await getLinks(token);
|
||||||
|
const list = document.getElementById("list");
|
||||||
|
|
||||||
|
list.innerHTML = links
|
||||||
|
.map(
|
||||||
|
link => `
|
||||||
|
<div class="item" data-id="${link._id}">
|
||||||
|
${link.icon ? `<img src="${URL_SVR}${link.icon}">` : ""}
|
||||||
|
<div class="info">
|
||||||
|
<strong>${link.name}</strong><br>
|
||||||
|
<a href="${link.url}" target="_blank">${link.url}</a>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="editBtn" data-id="${link._id}">Modifica</button>
|
||||||
|
<button class="deleteBtn" data-id="${link._id}">Elimina</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}*/
|
||||||
|
async function loadLinks() {
|
||||||
|
const links = await getLinks(token);
|
||||||
|
alert(links);
|
||||||
|
const list = document.getElementById("list");
|
||||||
|
|
||||||
|
list.innerHTML = links
|
||||||
|
.map(link => {
|
||||||
|
let iconHtml = "";
|
||||||
|
|
||||||
|
if (link.icon && link.icon.data && link.icon.mime) {
|
||||||
|
const base64 = btoa(
|
||||||
|
String.fromCharCode(...link.icon.data.data)
|
||||||
|
);
|
||||||
|
iconHtml = `<img src="data:${link.icon.mime};base64,${base64}" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="item" data-id="${link._id}">
|
||||||
|
${iconHtml}
|
||||||
|
<div class="info">
|
||||||
|
<strong>${link.name}</strong><br>
|
||||||
|
<a href="${link.url}" target="_blank">${link.url}</a>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="editBtn" data-id="${link._id}">Edit</button>
|
||||||
|
<button class="deleteBtn" data-id="${link._id}">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
/*
|
||||||
|
list.innerHTML = links
|
||||||
|
.map(link => `
|
||||||
|
<div class="item" data-id="${link._id}">
|
||||||
|
${link.icon ? `<img src="${URL_SVR}/links/icon/${link._id}" class="icon">` : ""}
|
||||||
|
<div class="info">
|
||||||
|
<strong>${link.name}</strong><br>
|
||||||
|
<a href="${link.url}" target="_blank">${link.url}</a>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="editBtn" data-id="${link._id}">✏️</button>
|
||||||
|
<button class="deleteBtn" data-id="${link._id}">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
.join("");*/
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// METADATA (icona automatica)
|
||||||
|
// ===============================
|
||||||
|
document.getElementById("fetchMetaBtn").addEventListener("click", async () => {
|
||||||
|
const url = document.getElementById("urlInput").value.trim();
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
const res = await fetch(`${URL_SVR}/metadata?url=${encodeURIComponent(url)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
document.getElementById("nameInput").value = data.name || "";
|
||||||
|
autoIconURL = data.icon || null;
|
||||||
|
|
||||||
|
// L’icona automatica è l’ultima scelta → reset input manuale
|
||||||
|
const fileInput = document.getElementById("iconInput");
|
||||||
|
fileInput.value = "";
|
||||||
|
|
||||||
|
const preview = document.getElementById("iconPreview");
|
||||||
|
const sizeBox = document.getElementById("iconSize");
|
||||||
|
|
||||||
|
if (autoIconURL) {
|
||||||
|
preview.src = autoIconURL;
|
||||||
|
preview.style.display = "block";
|
||||||
|
sizeBox.style.display = "none";
|
||||||
|
showImageSize(preview, sizeBox);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// ANTEPRIMA ICONA MANUALE
|
||||||
|
// ===============================
|
||||||
|
document.getElementById("iconInput").addEventListener("change", e => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
autoIconURL = null; // manuale vince
|
||||||
|
|
||||||
|
const preview = document.getElementById("iconPreview");
|
||||||
|
const sizeBox = document.getElementById("iconSize");
|
||||||
|
|
||||||
|
preview.src = URL.createObjectURL(file);
|
||||||
|
preview.style.display = "block";
|
||||||
|
sizeBox.style.display = "none";
|
||||||
|
|
||||||
|
showImageSize(preview, sizeBox);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// CREAZIONE LINK
|
||||||
|
// ===============================
|
||||||
|
document.getElementById("linkForm").addEventListener("submit", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const raw = new FormData(e.target);
|
||||||
|
const manualFile = raw.get("icon");
|
||||||
|
const hasManualFile = manualFile instanceof File && manualFile.size > 0;
|
||||||
|
|
||||||
|
await createLink(token, {
|
||||||
|
name: raw.get("name"),
|
||||||
|
url: raw.get("url"),
|
||||||
|
iconFile: hasManualFile ? manualFile : null,
|
||||||
|
iconURL: !hasManualFile ? autoIconURL : null
|
||||||
|
});
|
||||||
|
|
||||||
|
autoIconURL = null;
|
||||||
|
document.getElementById("iconPreview").style.display = "none";
|
||||||
|
document.getElementById("iconSize").style.display = "none";
|
||||||
|
|
||||||
|
e.target.reset();
|
||||||
|
loadLinks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// EDIT
|
||||||
|
// ===============================
|
||||||
|
document.getElementById("list").addEventListener("click", e => {
|
||||||
|
const id = e.target.dataset.id;
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
if (e.target.classList.contains("deleteBtn")) {
|
||||||
|
deleteLink(token, id).then(loadLinks);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target.classList.contains("editBtn")) {
|
||||||
|
editingId = id;
|
||||||
|
|
||||||
|
const item = e.target.closest(".item");
|
||||||
|
const name = item.querySelector("strong").textContent;
|
||||||
|
const url = item.querySelector("a").textContent;
|
||||||
|
|
||||||
|
const form = document.getElementById("editForm");
|
||||||
|
form.name.value = name;
|
||||||
|
form.url.value = url;
|
||||||
|
|
||||||
|
document.getElementById("iconPreviewEdit").style.display = "none";
|
||||||
|
document.getElementById("iconSizeEdit").style.display = "none";
|
||||||
|
|
||||||
|
document.getElementById("editModal").style.display = "flex";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ANTEPRIMA MANUALE IN EDIT
|
||||||
|
document.getElementById("iconInputEdit").addEventListener("change", e => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const preview = document.getElementById("iconPreviewEdit");
|
||||||
|
const sizeBox = document.getElementById("iconSizeEdit");
|
||||||
|
|
||||||
|
preview.src = URL.createObjectURL(file);
|
||||||
|
preview.style.display = "block";
|
||||||
|
sizeBox.style.display = "none";
|
||||||
|
|
||||||
|
showImageSize(preview, sizeBox);
|
||||||
|
});
|
||||||
|
|
||||||
|
// SALVA EDIT
|
||||||
|
document.getElementById("editForm").addEventListener("submit", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const name = e.target.name.value;
|
||||||
|
const url = e.target.url.value;
|
||||||
|
const iconFile = e.target.icon.files[0] || null;
|
||||||
|
|
||||||
|
await updateLink(token, editingId, {
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
iconFile,
|
||||||
|
iconURL: null
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("editModal").style.display = "none";
|
||||||
|
loadLinks();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("closeModal").addEventListener("click", () => {
|
||||||
|
document.getElementById("editModal").style.display = "none";
|
||||||
|
});
|
||||||
|
|
||||||
|
setToken(null);
|
||||||
83
server/frontend/getAppMetadata.js
Normal file
83
server/frontend/getAppMetadata.js
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
<script>0
|
||||||
|
async function getAppMetadata(baseUrl) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(baseUrl, { mode: "cors" });
|
||||||
|
const html = await res.text();
|
||||||
|
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(html, "text/html");
|
||||||
|
|
||||||
|
// -------------------------------
|
||||||
|
// 1. Raccogli tutti i nomi possibili
|
||||||
|
// -------------------------------
|
||||||
|
const nameCandidates = [
|
||||||
|
doc.querySelector('meta[property="og:site_name"]')?.content,
|
||||||
|
doc.querySelector('meta[name="application-name"]')?.content,
|
||||||
|
doc.querySelector('meta[property="og:title"]')?.content,
|
||||||
|
doc.querySelector("title")?.textContent?.trim()
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const name = nameCandidates.length > 0
|
||||||
|
? nameCandidates.sort((a, b) => a.length - b.length)[0]
|
||||||
|
: "no_name";
|
||||||
|
|
||||||
|
// -------------------------------
|
||||||
|
// 2. Raccogli icone dall’HTML
|
||||||
|
// -------------------------------
|
||||||
|
const htmlIcons = [...doc.querySelectorAll("link[rel*='icon']")].map(link => ({
|
||||||
|
href: link.getAttribute("href"),
|
||||||
|
sizes: link.getAttribute("sizes") || ""
|
||||||
|
}));
|
||||||
|
|
||||||
|
// -------------------------------
|
||||||
|
// 3. Cerca il manifest.json
|
||||||
|
// -------------------------------
|
||||||
|
let manifestIcons = [];
|
||||||
|
const manifestLink = doc.querySelector('link[rel="manifest"]');
|
||||||
|
|
||||||
|
if (manifestLink) {
|
||||||
|
try {
|
||||||
|
const manifestUrl = new URL(manifestLink.href, baseUrl).href;
|
||||||
|
const manifestRes = await fetch(manifestUrl, { mode: "cors" });
|
||||||
|
const manifestJson = await manifestRes.json();
|
||||||
|
|
||||||
|
if (manifestJson.icons && Array.isArray(manifestJson.icons)) {
|
||||||
|
manifestIcons = manifestJson.icons.map(icon => ({
|
||||||
|
href: icon.src,
|
||||||
|
sizes: icon.sizes || ""
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Manifest non accessibile o non valido
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------
|
||||||
|
// 4. Unisci icone HTML + manifest
|
||||||
|
// -------------------------------
|
||||||
|
const allIcons = [...htmlIcons, ...manifestIcons];
|
||||||
|
|
||||||
|
// -------------------------------
|
||||||
|
// 5. Ordina per dimensione (più grande prima)
|
||||||
|
// -------------------------------
|
||||||
|
allIcons.sort((a, b) => {
|
||||||
|
const sizeA = parseInt(a.sizes.split("x")[0]) || 0;
|
||||||
|
const sizeB = parseInt(b.sizes.split("x")[0]) || 0;
|
||||||
|
return sizeB - sizeA;
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------
|
||||||
|
// 6. Risolvi URL assoluto
|
||||||
|
// -------------------------------
|
||||||
|
let icon = null;
|
||||||
|
if (allIcons.length > 0) {
|
||||||
|
icon = new URL(allIcons[0].href, baseUrl).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, icon };
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
return { name: "no_name", icon: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
92
server/frontend/index.html
Normal file
92
server/frontend/index.html
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Link Manager</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<h1>Link Manager</h1>
|
||||||
|
|
||||||
|
<!-- AUTH -->
|
||||||
|
<section id="authSection">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Accedi</h2>
|
||||||
|
<form id="loginForm">
|
||||||
|
<input type="email" name="email" placeholder="Email" required>
|
||||||
|
<input type="password" name="password" placeholder="Password" required>
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3>Oppure registrati</h3>
|
||||||
|
<form id="registerForm">
|
||||||
|
<input type="email" name="email" placeholder="Email" required>
|
||||||
|
<input type="password" name="password" placeholder="Password" required>
|
||||||
|
<button type="submit">Registrati</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="authStatus"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- LINKS -->
|
||||||
|
<section id="linkSection" style="display:none;">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Nuovo link</h2>
|
||||||
|
<form id="linkForm">
|
||||||
|
|
||||||
|
<div style="display:flex; gap:10px; align-items:center;">
|
||||||
|
<input type="text" name="url" id="urlInput" placeholder="URL" required style="flex:1;">
|
||||||
|
<button type="button" id="fetchMetaBtn">Cerca</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="text" name="name" id="nameInput" placeholder="Nome" required>
|
||||||
|
|
||||||
|
<!-- Icona manuale -->
|
||||||
|
<input type="file" id="iconInput" name="icon" accept="image/*">
|
||||||
|
|
||||||
|
<!-- Anteprima -->
|
||||||
|
<img id="iconPreview" style="width:64px; height:64px; margin-top:10px; display:none;">
|
||||||
|
<div id="iconSize" style="margin-top:5px; font-size:12px; color:#666; display:none;"></div>
|
||||||
|
|
||||||
|
<button type="submit">Salva</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>I tuoi link</h2>
|
||||||
|
<div id="list"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="app.js"></script>
|
||||||
|
|
||||||
|
<!-- MODAL EDIT -->
|
||||||
|
<div id="editModal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3>Modifica link</h3>
|
||||||
|
|
||||||
|
<form id="editForm">
|
||||||
|
<input type="text" name="name" placeholder="Nome">
|
||||||
|
<input type="text" name="url" placeholder="URL">
|
||||||
|
|
||||||
|
<input type="file" id="iconInputEdit" name="icon" accept="image/*">
|
||||||
|
|
||||||
|
<img id="iconPreviewEdit" style="width:64px; height:64px; margin-top:10px; display:none;">
|
||||||
|
<div id="iconSizeEdit" style="margin-top:5px; font-size:12px; color:#666; display:none;"></div>
|
||||||
|
|
||||||
|
<button type="submit">Salva modifiche</button>
|
||||||
|
<button type="button" id="closeModal">Annulla</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
|
||||||
|
<script>eruda.init();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4
server/frontend/start.sh
Executable file
4
server/frontend/start.sh
Executable file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Avvia un server HTTP sulla porta 11002 senza cache
|
||||||
|
npx http-server . -c-1 -p 11003
|
||||||
85
server/frontend/style.css
Normal file
85
server/frontend/style.css
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "SF Pro", sans-serif;
|
||||||
|
background: #f5f5f7;
|
||||||
|
margin: 0;
|
||||||
|
padding: 40px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
padding: 25px;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
background: #007aff;
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #0063cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
#list .item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
#list img {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
16
varie/a.js
Normal file
16
varie/a.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// a.js
|
||||||
|
import { getAppMetadata } from "./appMetadata.js";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.argv[2];
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
console.log("Uso: node a.js <URL>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getAppMetadata(url);
|
||||||
|
console.log(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
29
varie/l
Executable file
29
varie/l
Executable file
|
|
@ -0,0 +1,29 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
API_URL="http://192.168.1.3:3000"
|
||||||
|
EMAIL="fabio.micheluz@gmail.com"
|
||||||
|
PASSWORD="master66"
|
||||||
|
|
||||||
|
echo "➡️ Effettuo login..."
|
||||||
|
|
||||||
|
# Login e estrazione token
|
||||||
|
TOKEN=$(curl -s -X POST "$API_URL/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" | jq -r '.token')
|
||||||
|
|
||||||
|
# Controllo token
|
||||||
|
if [ "$TOKEN" == "null" ] || [ -z "$TOKEN" ]; then
|
||||||
|
echo "❌ Errore: impossibile ottenere il token. Controlla email/password."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔑 Token ottenuto."
|
||||||
|
|
||||||
|
echo "➡️ Richiedo lista link..."
|
||||||
|
|
||||||
|
# Richiesta protetta
|
||||||
|
curl -s -X GET "$API_URL/links" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Accept: application/json" | jq .
|
||||||
|
|
||||||
|
echo "✅ Fine."
|
||||||
29
varie/l.sh
Executable file
29
varie/l.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
API_URL="https://myapps_svr.patachina2.casacam.net"
|
||||||
|
EMAIL="fabio.micheluz@gmail.com"
|
||||||
|
PASSWORD="master66"
|
||||||
|
|
||||||
|
echo "➡️ Effettuo login..."
|
||||||
|
|
||||||
|
# Login e estrazione token
|
||||||
|
TOKEN=$(curl -s -X POST "$API_URL/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" | jq -r '.token')
|
||||||
|
|
||||||
|
# Controllo token
|
||||||
|
if [ "$TOKEN" == "null" ] || [ -z "$TOKEN" ]; then
|
||||||
|
echo "❌ Errore: impossibile ottenere il token. Controlla email/password."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔑 Token ottenuto."
|
||||||
|
|
||||||
|
echo "➡️ Richiedo lista link..."
|
||||||
|
|
||||||
|
# Richiesta protetta
|
||||||
|
curl -s -X GET "$API_URL/links" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Accept: application/json" | jq .
|
||||||
|
|
||||||
|
echo "✅ Fine."
|
||||||
26
varie/list.js
Normal file
26
varie/list.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
async function login(email, password) {
|
||||||
|
const res = await fetch("http://192.168.1.3:3000/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return data.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLinks() {
|
||||||
|
const token = await login("fabio.micheluz@gmail.com", "master66");
|
||||||
|
|
||||||
|
const res = await fetch("http://192.168.1.3:3000/links", {
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
console.log(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLinks();
|
||||||
29
varie/list.sh
Executable file
29
varie/list.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
API_URL="http://192.168.1.3:3000"
|
||||||
|
EMAIL="fabio.micheluz@gmail.com"
|
||||||
|
PASSWORD="master66"
|
||||||
|
|
||||||
|
echo "➡️ Effettuo login..."
|
||||||
|
|
||||||
|
# Login e estrazione token
|
||||||
|
TOKEN=$(curl -s -X POST "$API_URL/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" | jq -r '.token')
|
||||||
|
|
||||||
|
# Controllo token
|
||||||
|
if [ "$TOKEN" == "null" ] || [ -z "$TOKEN" ]; then
|
||||||
|
echo "❌ Errore: impossibile ottenere il token. Controlla email/password."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔑 Token ottenuto."
|
||||||
|
|
||||||
|
echo "➡️ Richiedo lista link..."
|
||||||
|
|
||||||
|
# Richiesta protetta
|
||||||
|
curl -s -X GET "$API_URL/links" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Accept: application/json" | jq .
|
||||||
|
|
||||||
|
echo "✅ Fine."
|
||||||
30
varie/list1.js
Normal file
30
varie/list1.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
const URI = "https://my.patachina2.casacam.net";
|
||||||
|
const USER = "fabio.micheluz@gmail.com";
|
||||||
|
const PASSW = "master66";
|
||||||
|
|
||||||
|
async function login(email, password) {
|
||||||
|
const res = await fetch(`${URI}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return data.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLinks() {
|
||||||
|
const token = await login(USER, PASSW);
|
||||||
|
|
||||||
|
const res = await fetch(`${URI}/links`, {
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
console.log(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLinks();
|
||||||
Loading…
Reference in a new issue