first commit

This commit is contained in:
Fabio 2026-01-27 13:16:48 +01:00
commit 16ae0a05ed
21 changed files with 1653 additions and 0 deletions

4
.dockerignore Normal file
View file

@ -0,0 +1,4 @@
.git
node_modules
dist
*.log

11
Dockerfile Normal file
View file

@ -0,0 +1,11 @@
# Usa NGINX leggero e sicuro
FROM nginx:alpine
# Copia i file statici nella root di NGINX
COPY public/ /usr/share/nginx/html
# (Opzionale) Config personalizzata NGINX per caching e SPA routing
# COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
# NGINX parte automaticamente con limmagine ufficiale

7
docker-compose.yml Normal file
View file

@ -0,0 +1,7 @@
services:
myappsui:
build: .
container_name: myappsui
ports:
- "11003:80" # HOST:CONTAINER
restart: unless-stopped

47
public/index.html Normal file
View file

@ -0,0 +1,47 @@
<!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 type="module" src="./src/main.js"></script>
<!-- <script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script>
eruda.init();
</script> -->
</body>
</html>

48
public/src/api.js Normal file
View file

@ -0,0 +1,48 @@
// src/api.js
import { state } from './state.js';
export async function login(email, password) {
try {
const res = await fetch(`${state.URI}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const data = await res.json();
return data.token;
} catch (err) {
alert(err);
return null;
}
}
export async function getLinks(Storage, renderApps, loadAppOrder) {
try {
const token = await login(state.USER, state.PASSW);
if (!token) throw new Error('User o Password errati');
const res = await fetch(`${state.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();
state.appsData = json.map((a, i) => {
let icon = null;
if (a.icon && a.icon.data && a.icon.mime) {
const base64 = btoa(String.fromCharCode(...a.icon.data.data));
icon = `data:${a.icon.mime};base64,${base64}`;
}
return { id: a.id ?? `app-${i}`, name: a.name, url: a.url, icon };
});
await Storage.saveApps(state.appsData);
await loadAppOrder(Storage);
renderApps();
return true;
} catch (err) {
console.error(err);
return null;
}
}

View file

@ -0,0 +1,159 @@
// src/contextmenu.js
import { state } from './state.js';
import { renderApps } from './render.js';
import { saveOrder } from './order.js';
/** Mostra il menu alla posizione (x,y) per la app con id `id`. */
export function showContextMenuFor(id, x, y) {
state.contextMenuTargetId = id;
const menu = document.getElementById('context-menu');
if (!menu) {
console.warn('[contextmenu] Elemento #context-menu non trovato.');
return;
}
// Posizionamento (con piccolo clamping per evitare overflow bordo)
const vw = Math.max(document.documentElement.clientWidth, window.innerWidth || 0);
const vh = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
// Dimensioni stimate del menu per clamping (fallback se non renderizzato)
const estW = Math.max(menu.offsetWidth || 180, 180);
const estH = Math.max(menu.offsetHeight || 120, 120);
const left = Math.min(Math.max(8, x), vw - estW - 8);
const top = Math.min(Math.max(8, y), vh - estH - 8);
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
menu.classList.remove('hidden');
}
/** Nasconde il menu contestuale. */
export function hideContextMenu() {
const menu = document.getElementById('context-menu');
if (!menu) return;
menu.classList.add('hidden');
state.contextMenuTargetId = null;
}
/**
* Inizializza le azioni del menu.
* Richiede `Storage` per persistere i cambiamenti.
*/
export function initContextMenuActions(Storage) {
// Listener per apertura menu da altri moduli (es. edit.js)
document.addEventListener('show-context-for', e => {
const { id, x, y } = e.detail || {};
if (!id || typeof x !== 'number' || typeof y !== 'number') return;
showContextMenuFor(id, x, y);
});
const menu = document.getElementById('context-menu');
if (!menu) {
console.warn('[contextmenu] #context-menu non trovato in init.');
return;
}
/**
* Gestione azione menu su pointerdown in fase di cattura
* - Prende levento prima di eventuali global close
* - Funziona su mouse/touch/pen
*/
const onActionPointerDown = async (e) => {
// Non far uscire gli eventi, evita chiusure premature
e.stopPropagation();
e.preventDefault();
const btn = e.target.closest('button');
if (!btn) return;
const targetId = state.contextMenuTargetId;
if (!targetId) {
console.warn('[contextmenu] Nessun targetId — menu aperto senza id.');
hideContextMenu();
return;
}
const app = state.appsData.find(a => a.id === targetId);
if (!app) {
console.warn('[contextmenu] Target app non trovata:', targetId);
hideContextMenu();
return;
}
const action = btn.dataset.action;
// --- RINOMINA ---
if (action === 'rename') {
const nuovoNome = prompt('Nuovo nome app:', app.name ?? '');
if (nuovoNome && nuovoNome.trim() && nuovoNome.trim() !== app.name) {
app.name = nuovoNome.trim();
try {
await Storage.saveApps(state.appsData);
await saveOrder(Storage);
renderApps();
} catch (err) {
console.error('[contextmenu] Errore salvataggio nuovo nome:', err);
alert('Impossibile salvare il nuovo nome in memoria locale.');
}
}
hideContextMenu();
return;
}
// --- CAMBIA ICONA ---
if (action === 'change-icon') {
const nuovoUrl = prompt('URL nuova icona:', app.icon ?? '');
if (nuovoUrl && nuovoUrl.trim()) {
app.icon = nuovoUrl.trim();
try {
await Storage.saveApps(state.appsData);
await saveOrder(Storage);
renderApps();
} catch (err) {
console.error('[contextmenu] Errore salvataggio icona:', err);
alert('Impossibile salvare la nuova icona in memoria locale.');
}
}
hideContextMenu();
return;
}
// --- RIMUOVI ---
if (action === 'remove') {
if (confirm('Rimuovere questa app dalla griglia?')) {
try {
// 1) Rimuovi lapp dai dati
const beforeLen = state.appsData.length;
state.appsData = state.appsData.filter(a => a.id !== app.id);
// 2) Rimuovi lid dallordine
const beforeOrderLen = state.appsOrder.length;
state.appsOrder = state.appsOrder.filter(id => id !== app.id);
// 3) Salva entrambe le cose (prima apps, poi ordine)
await Storage.saveApps(state.appsData);
await saveOrder(Storage);
// 4) Re-render
renderApps();
// (Optional) Log di verifica
console.debug('[remove] appsData:', beforeLen, '→', state.appsData.length);
console.debug('[remove] appsOrder:', beforeOrderLen, '→', state.appsOrder.length);
} catch (err) {
console.error('[contextmenu] Errore salvataggio rimozione:', err);
alert('Impossibile salvare le modifiche (rimozione app).');
}
}
hideContextMenu();
return;
}
};
// Registra il listener: pointerdown in cattura per affidabilità mobile/desktop
menu.addEventListener('pointerdown', onActionPointerDown, { capture: true });
}

268
public/src/contextmenu.js Normal file
View file

@ -0,0 +1,268 @@
// src/contextmenu.js
import { state } from './state.js';
import { renderApps } from './render.js';
import { saveOrder } from './order.js';
/** Mostra il menu alla posizione (x,y) per la app con id `id`. */
export function showContextMenuFor(id, x, y) {
state.contextMenuTargetId = id;
const menu = document.getElementById('context-menu');
if (!menu) {
console.warn('[contextmenu] Elemento #context-menu non trovato.');
return;
}
// Clamping per evitare overflow bordo
const vw = Math.max(document.documentElement.clientWidth, window.innerWidth || 0);
const vh = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
const estW = Math.max(menu.offsetWidth || 180, 180);
const estH = Math.max(menu.offsetHeight || 120, 120);
const left = Math.min(Math.max(8, x), vw - estW - 8);
const top = Math.min(Math.max(8, y), vh - estH - 8);
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
menu.classList.remove('hidden');
}
/** Nasconde il menu contestuale. */
export function hideContextMenu() {
const menu = document.getElementById('context-menu');
if (!menu) return;
menu.classList.add('hidden');
state.contextMenuTargetId = null;
}
/* ============================
Utility per "Cambia icona"
============================ */
/** Crea/ritorna un file input nascosto per icone. */
function ensureHiddenFileInput() {
let input = document.getElementById('context-icon-file');
if (!input) {
input = document.createElement('input');
input.type = 'file';
input.id = 'context-icon-file';
input.accept = 'image/*';
input.style.position = 'fixed';
input.style.left = '-9999px';
input.style.top = '-9999px';
input.style.width = '1px';
input.style.height = '1px';
input.style.opacity = '0';
document.body.appendChild(input);
}
return input;
}
/** Promessa che risolve con il File selezionato, o null se annullato. */
function pickImageFile() {
return new Promise((resolve) => {
const input = ensureHiddenFileInput();
const onChange = () => {
input.removeEventListener('change', onChange);
resolve(input.files && input.files[0] ? input.files[0] : null);
// reset value per permettere nuova scelta dello stesso file
input.value = '';
};
input.addEventListener('change', onChange, { once: true });
input.click();
});
}
/** Carica un File immagine in un HTMLImageElement. */
function fileToImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Impossibile leggere il file.'));
reader.onload = () => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Impossibile caricare limmagine.'));
img.src = reader.result;
};
reader.readAsDataURL(file);
});
}
/** Verifica supporto WebP nel canvas. */
function supportsWebP() {
try {
const c = document.createElement('canvas');
const d = c.toDataURL('image/webp');
return d.startsWith('data:image/webp');
} catch {
return false;
}
}
/**
* Converte unimmagine in DataURL WebP (qualità 0..1).
* - Ritaglia al centro per ottenere un quadrato consistente.
* - Ridimensiona a targetEdge px (default 256).
*/
function imageToWebPDataURL(img, { quality = 0.9, targetEdge = 256 } = {}) {
const sw = img.naturalWidth || img.width;
const sh = img.naturalHeight || img.height;
const side = Math.min(sw, sh);
// Ritaglio centrale per icone uniformi (quadrate)
const sx = Math.floor((sw - side) / 2);
const sy = Math.floor((sh - side) / 2);
const canvas = document.createElement('canvas');
canvas.width = targetEdge;
canvas.height = targetEdge;
const ctx = canvas.getContext('2d');
// Preserva trasparenza
ctx.clearRect(0, 0, targetEdge, targetEdge);
ctx.drawImage(img, sx, sy, side, side, 0, 0, targetEdge, targetEdge);
// Se WebP non è supportato, avvisa e ritorna PNG
if (!supportsWebP()) {
alert('Il tuo browser non supporta WebP. Salverò in PNG come fallback.');
return canvas.toDataURL('image/png'); // qualità PNG non è parametrica
}
return canvas.toDataURL('image/webp', quality);
}
/* ============================
Inizializzazione azioni
============================ */
export function initContextMenuActions(Storage) {
// Apertura menu
document.addEventListener('show-context-for', e => {
const { id, x, y } = e.detail || {};
if (!id || typeof x !== 'number' || typeof y !== 'number') return;
showContextMenuFor(id, x, y);
});
const menu = document.getElementById('context-menu');
if (!menu) {
console.warn('[contextmenu] #context-menu non trovato in init.');
return;
}
/**
* Azioni del menu su pointerdown in capture:
* - Evita conflitti con chiusure globali
* - Funziona su mouse/touch/pen
*/
const onActionPointerDown = async (e) => {
e.stopPropagation();
e.preventDefault();
const btn = e.target.closest('button');
if (!btn) return;
const targetId = state.contextMenuTargetId;
if (!targetId) {
console.warn('[contextmenu] Nessun targetId — menu aperto senza id.');
hideContextMenu();
return;
}
const app = state.appsData.find(a => a.id === targetId);
if (!app) {
console.warn('[contextmenu] Target app non trovata:', targetId);
hideContextMenu();
return;
}
const action = btn.dataset.action;
// --- RINOMINA ---
if (action === 'rename') {
const nuovoNome = prompt('Nuovo nome app:', app.name ?? '');
if (nuovoNome && nuovoNome.trim() && nuovoNome.trim() !== app.name) {
app.name = nuovoNome.trim();
try {
await Storage.saveApps(state.appsData);
await saveOrder(Storage);
renderApps();
} catch (err) {
console.error('[contextmenu] Errore salvataggio nuovo nome:', err);
alert('Impossibile salvare il nuovo nome in memoria locale.');
}
}
hideContextMenu();
return;
}
// --- CAMBIA ICONA (file picker + WebP 90 + salvataggio locale) ---
if (action === 'change-icon') {
try {
const file = await pickImageFile();
if (!file) {
hideContextMenu();
return; // utente ha annullato
}
// (Opzionale) Limita tipi/size
const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/gif'];
if (file.type && !validTypes.includes(file.type)) {
alert('Formato non supportato. Usa PNG, JPEG o WebP.');
hideContextMenu();
return;
}
// Carica immagine e converte in WebP qualità 0.9, 256x256 (quadrato)
const img = await fileToImage(file);
const dataUrl = imageToWebPDataURL(img, { quality: 0.9, targetEdge: 256 });
// Aggiorna lapp con licona locale (Data URL)
app.icon = dataUrl;
// Persisti modifiche
await Storage.saveApps(state.appsData);
await saveOrder(Storage);
// Re-render
renderApps();
} catch (err) {
console.error('[contextmenu] Errore cambio icona:', err);
alert('Impossibile cambiare licona. Riprova con unimmagine diversa.');
}
hideContextMenu();
return;
}
// --- RIMUOVI ---
if (action === 'remove') {
if (confirm('Rimuovere questa app dalla griglia?')) {
try {
// 1) Rimuovi dai dati
state.appsData = state.appsData.filter(a => a.id !== app.id);
// 2) Rimuovi dallordine
state.appsOrder = state.appsOrder.filter(id => id !== app.id);
// 3) Salva entrambe
await Storage.saveApps(state.appsData);
await saveOrder(Storage);
// 4) Re-render
renderApps();
} catch (err) {
console.error('[contextmenu] Errore salvataggio rimozione:', err);
alert('Impossibile salvare le modifiche (rimozione app).');
}
}
hideContextMenu();
return;
}
};
// Registra il listener
menu.addEventListener('pointerdown', onActionPointerDown, { capture: true });
}

236
public/src/drag.js Normal file
View file

@ -0,0 +1,236 @@
// src/drag.js
import { state } from './state.js';
import { saveOrder } from './order.js';
import { renderApps } from './render.js';
import { hideContextMenu } from './contextmenu.js';
/** Ottieni posizione pointer/touch */
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,
};
}
function startDrag(icon, pos) {
const folderEl = document.getElementById('folder');
state.draggingId = icon.dataset.id;
const r = icon.getBoundingClientRect();
state.dragOffsetX = pos.pageX - r.left;
state.dragOffsetY = pos.pageY - r.top;
state.draggingIcon = icon;
state.draggingIcon.classList.add('dragging');
// Posizionamento “float”
Object.assign(state.draggingIcon.style, {
position: 'fixed',
left: `${r.left}px`,
top: `${r.top}px`,
width: `${r.width}px`,
height: `${r.height}px`,
zIndex: '1000',
pointerEvents: 'none',
transform: 'translate3d(0,0,0)',
});
// Placeholder
state.placeholderEl = document.createElement('div');
state.placeholderEl.className = 'app-icon placeholder';
state.placeholderEl.style.visibility = 'hidden';
folderEl.insertBefore(state.placeholderEl, icon);
hideContextMenu();
}
function updateDragPosition(pos) {
if (!state.draggingIcon || !state.placeholderEl) return;
const x = pos.pageX - state.dragOffsetX;
const y = pos.pageY - state.dragOffsetY;
state.draggingIcon.style.left = `${x}px`;
state.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 === state.placeholderEl) return;
const folderEl = document.getElementById('folder');
const targetRect = targetIcon.getBoundingClientRect();
const isBefore = centerX < targetRect.left + targetRect.width / 2;
// Evita inserimenti ridondanti
const currentNext = state.placeholderEl.nextSibling;
if (isBefore && currentNext === targetIcon) return;
if (!isBefore && state.placeholderEl === targetIcon.nextSibling) return;
folderEl.insertBefore(state.placeholderEl, isBefore ? targetIcon : targetIcon.nextSibling);
}
function endDrag(Storage) {
if (!state.draggingIcon || !state.placeholderEl) return;
const folderEl = document.getElementById('folder');
const children = Array.from(folderEl.children).filter(el => el !== state.draggingIcon && el !== state.placeholderEl);
const finalIndex = children.indexOf(state.placeholderEl.previousSibling) + 1;
// Ripristina stile icona
state.draggingIcon.classList.remove('dragging');
Object.assign(state.draggingIcon.style, {
position: '',
left: '',
top: '',
width: '',
height: '',
zIndex: '',
pointerEvents: '',
transform: '',
});
// Aggiorna ordine
const currentIndex = state.appsOrder.indexOf(state.draggingId);
if (currentIndex !== -1 && currentIndex !== finalIndex) {
state.appsOrder.splice(currentIndex, 1);
state.appsOrder.splice(finalIndex, 0, state.draggingId);
saveOrder(Storage);
}
// Rimuovi placeholder
if (state.placeholderEl.parentNode) {
state.placeholderEl.parentNode.removeChild(state.placeholderEl);
}
state.draggingIcon = null;
state.placeholderEl = null;
state.dragStartX = 0;
state.dragStartY = 0;
renderApps();
}
export function initDragHandlers(Storage) {
// TOUCH
document.addEventListener('touchstart', e => {
if (!state.editMode) return;
if (e.touches.length !== 1) return;
if (state.contextMenuTargetId) return;
const pos = getPointerPosition(e);
const icon = e.touches[0].target.closest('.app-icon');
if (!icon) return;
state.dragStartX = pos.clientX;
state.dragStartY = pos.clientY;
state.draggingIcon = null;
state.draggingId = null;
}, { passive: true });
document.addEventListener('touchmove', e => {
if (!state.editMode) return;
if (e.touches.length !== 1) return;
const pos = getPointerPosition(e);
if (!state.draggingIcon) {
const dx = pos.clientX - state.dragStartX;
const dy = pos.clientY - state.dragStartY;
if (Math.hypot(dx, dy) > 10) {
const icon = e.touches[0].target.closest('.app-icon');
if (icon) {
if (state.longPressTimer) {
clearTimeout(state.longPressTimer);
state.longPressTimer = null;
state.longPressTarget = null;
}
startDrag(icon, pos);
}
}
} else {
updateDragPosition(pos);
e.preventDefault();
}
}, { passive: false });
document.addEventListener('touchend', e => {
if (!state.editMode) return;
if (!state.draggingIcon) {
state.dragStartX = 0;
state.dragStartY = 0;
return;
}
if (!e.touches || e.touches.length === 0) {
endDrag(Storage);
}
}, { passive: true });
// MOUSE
document.addEventListener('mousedown', e => {
if (!state.editMode) return;
if (e.button !== 0) return;
if (state.contextMenuTargetId) return;
const icon = e.target.closest('.app-icon');
if (!icon) return;
const pos = getPointerPosition(e);
state.dragStartX = pos.clientX;
state.dragStartY = pos.clientY;
state.draggingIcon = null;
state.draggingId = null;
});
document.addEventListener('mousemove', e => {
if (!state.editMode) return;
const pos = getPointerPosition(e);
if (!state.draggingIcon) {
if (!state.dragStartX && !state.dragStartY) return;
const dx = pos.clientX - state.dragStartX;
const dy = pos.clientY - state.dragStartY;
if (Math.hypot(dx, dy) > 10) {
const icon = e.target.closest('.app-icon');
if (icon) {
if (state.longPressTimer) {
clearTimeout(state.longPressTimer);
state.longPressTimer = null;
state.longPressTarget = null;
}
startDrag(icon, pos);
}
}
} else {
updateDragPosition(pos);
}
});
document.addEventListener('mouseup', () => {
if (!state.editMode) return;
if (!state.draggingIcon) {
state.dragStartX = 0;
state.dragStartY = 0;
return;
}
endDrag(Storage);
});
}

178
public/src/edit.js Normal file
View file

@ -0,0 +1,178 @@
// src/edit.js
import { state } from './state.js';
import { showContextMenuFor, hideContextMenu } from './contextmenu.js';
export function enterEditMode() {
state.editMode = true;
document.body.classList.add('edit-mode');
}
export function exitEditMode() {
state.editMode = false;
document.body.classList.remove('edit-mode');
hideContextMenu();
}
/**
* Configurazioni desktop
*/
const RIGHT_LONG_PRESS_CFG = {
duration: 500, // ms per long click destro → wiggle
tolerancePx: 12, // px di tolleranza movimento durante il long click destro
};
const RIGHT_DBLCLICK_WINDOW = 380; // ms per rilevare doppio click destro
export function initLongPressHandlers() {
// =========================
// TOUCH (Android) — invariato
// =========================
document.addEventListener('touchstart', e => {
if (e.touches.length !== 1) return;
const icon = e.touches[0].target.closest('.app-icon');
if (icon) {
state.longPressTarget = icon;
state.longPressTimer = setTimeout(() => {
if (!state.editMode) {
enterEditMode();
state.justEnteredEditMode = true;
if (navigator.vibrate) navigator.vibrate(10);
return;
}
if (state.justEnteredEditMode) {
state.justEnteredEditMode = false;
return;
}
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;
}
state.longPressTimer = setTimeout(() => {
if (state.editMode) exitEditMode();
}, 350);
}, { passive: true });
document.addEventListener('touchmove', e => {
if (!state.longPressTimer) return;
const touch = e.touches[0];
const r = state.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(state.longPressTimer);
state.longPressTimer = null;
state.longPressTarget = null;
}
}, { passive: true });
document.addEventListener('touchend', () => {
if (state.longPressTimer) {
clearTimeout(state.longPressTimer);
state.longPressTimer = null;
state.longPressTarget = null;
}
state.justEnteredEditMode = false;
}, { passive: true });
// =========================
// MOUSE (Desktop)
// =========================
// Stato interno long click destro
let rightPressTimer = null;
let rightPressStart = { x: 0, y: 0 };
let rightPressIcon = null;
let rightPressActive = false;
// Stato interno doppio click destro
let lastRightClickTime = 0;
let lastRightClickIconId = null;
// 1) Long click destro → entra in wiggle mode
document.addEventListener('mousedown', e => {
if (e.button !== 2) return; // solo tasto destro
const icon = e.target.closest('.app-icon');
if (!icon) return;
// Sopprimi il menu nativo, lo gestiamo noi
e.preventDefault();
// Avvio long press
rightPressIcon = icon;
rightPressStart = { x: e.clientX, y: e.clientY };
rightPressActive = true;
rightPressTimer = setTimeout(() => {
rightPressTimer = null;
// Entra in wiggle se non lo è già
if (!state.editMode) {
enterEditMode();
}
rightPressActive = false;
}, RIGHT_LONG_PRESS_CFG.duration);
// 2) Doppio click destro → menu contestuale (SOLO se già in wiggle)
const now = performance.now();
const sameIcon = lastRightClickIconId === icon.dataset.id;
if (state.editMode && sameIcon && (now - lastRightClickTime) <= RIGHT_DBLCLICK_WINDOW) {
// Rilevato doppio click destro
const r = icon.getBoundingClientRect();
showContextMenuFor(icon.dataset.id, r.left + r.width / 2, r.top + r.height);
// reset “doppio”
lastRightClickTime = 0;
lastRightClickIconId = null;
// Importante: annulla eventuale long-press in corso
if (rightPressTimer) clearTimeout(rightPressTimer);
rightPressTimer = null;
rightPressActive = false;
return;
}
// Memorizza per possibile doppio
lastRightClickTime = now;
lastRightClickIconId = icon.dataset.id;
});
// Annulla long press destro se ti muovi troppo
document.addEventListener('mousemove', e => {
if (!rightPressActive || !rightPressIcon) return;
const dx = e.clientX - rightPressStart.x;
const dy = e.clientY - rightPressStart.y;
if (Math.hypot(dx, dy) > RIGHT_LONG_PRESS_CFG.tolerancePx) {
if (rightPressTimer) clearTimeout(rightPressTimer);
rightPressTimer = null;
rightPressActive = false;
rightPressIcon = null;
}
});
// Se rilasci prima del timeout, il long press non scatta
document.addEventListener('mouseup', e => {
if (e.button === 2) {
if (rightPressTimer) clearTimeout(rightPressTimer);
rightPressTimer = null;
rightPressActive = false;
rightPressIcon = null;
}
});
// Gestione menu nativo: lo blocchiamo sempre, il nostro menu è gestito via doppio click destro
document.addEventListener('contextmenu', e => {
const icon = e.target.closest('.app-icon');
if (!icon) return;
// Evita il menu del browser
e.preventDefault();
// Non apriamo nulla qui: il menu è sul doppio click destro (gestito sopra).
});
}
export function initGlobalCloseHandlers() {
document.addEventListener('pointerdown', e => {
const isIcon = e.target.closest('.app-icon');
const isMenu = e.target.closest('#context-menu');
const menuHidden = document.getElementById('context-menu').classList.contains('hidden');
if (!isMenu && !isIcon && !menuHidden) hideContextMenu();
if (!isIcon && state.editMode) exitEditMode();
});
}

34
public/src/main.js Normal file
View file

@ -0,0 +1,34 @@
// src/main.js
import { initStorage } from './storage/index.js';
import { showSetupPage, hideSetupPage } from './setup.js';
import { setConfig } from './state.js';
import { startLauncher } from './starter.js';
document.addEventListener('DOMContentLoaded', async () => {
const Storage = await initStorage();
const cfg = await Storage.loadConfig();
if (!cfg) {
await showSetupPage(Storage);
} else {
setConfig({ url: cfg.url, user: cfg.user, password: cfg.password });
hideSetupPage();
await startLauncher(Storage);
}
// 6 click per aprire la setup page
let tapCount = 0;
let tapTimer = null;
document.addEventListener('click', async () => {
tapCount++;
if (tapTimer) clearTimeout(tapTimer);
tapTimer = setTimeout(() => { tapCount = 0; }, 600);
if (tapCount >= 6) {
tapCount = 0;
await showSetupPage(Storage);
}
});
});
``

17
public/src/order.js Normal file
View file

@ -0,0 +1,17 @@
// src/order.js
import { state } from './state.js';
export async function loadAppOrder(Storage) {
const stored = await Storage.loadAppsOrder();
if (stored && Array.isArray(stored)) {
state.appsOrder = stored.filter(id => state.appsData.some(a => a.id === id));
state.appsData.forEach(a => { if (!state.appsOrder.includes(a.id)) state.appsOrder.push(a.id); });
} else {
state.appsOrder = state.appsData.map(a => a.id);
}
}
export async function saveOrder(Storage) {
await Storage.saveAppsOrder(state.appsOrder);
}

23
public/src/render.js Normal file
View file

@ -0,0 +1,23 @@
// src/render.js
import { state } from './state.js';
export function renderApps() {
const folderEl = document.getElementById('folder');
folderEl.innerHTML = '';
state.appsOrder.forEach(id => {
const app = state.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 (!state.editMode) window.open(app.url, '_blank', 'noopener');
});
folderEl.appendChild(div);
});
}

56
public/src/setup.js Normal file
View file

@ -0,0 +1,56 @@
// src/setup.js
import { setConfig } from './state.js';
import { getLinks } from './api.js';
import { startLauncher } from './starter.js';
import { loadAppOrder } from './order.js';
import { renderApps } from './render.js';
export async function showSetupPage(Storage) {
const cfg = await Storage.loadConfig();
if (cfg) {
document.getElementById('cfg-url').value = cfg.url;
document.getElementById('cfg-user').value = cfg.user;
document.getElementById('cfg-pass').value = cfg.password;
document.getElementById('cfg-refresh').style.display = 'block';
} else {
document.getElementById('cfg-refresh').style.display = 'none';
}
document.getElementById('setup-page').classList.remove('hidden');
// Bottone "Aggiorna ora"
document.getElementById('cfg-refresh').onclick = async () => {
const cfg = await Storage.loadConfig();
if (!cfg) {
alert('Config mancante. Inserisci URL, user e password.');
return;
}
setConfig({ url: cfg.url, user: cfg.user, password: cfg.password });
const ok = await getLinks(Storage, renderApps, loadAppOrder);
if (ok) {
hideSetupPage();
await startLauncher(Storage);
} else {
alert('Impossibile aggiornare le app dal server.');
}
};
// Bottone "Salva"
document.getElementById('cfg-save').onclick = async () => {
const url = document.getElementById('cfg-url').value;
const user = document.getElementById('cfg-user').value;
const pass = document.getElementById('cfg-pass').value;
await Storage.saveConfig({ url, user, password: pass });
setConfig({ url, user, password: pass });
const ok = await getLinks(Storage, renderApps, loadAppOrder);
if (ok) {
hideSetupPage();
await startLauncher(Storage);
}
};
}
export function hideSetupPage() {
document.getElementById('setup-page').classList.add('hidden');
}

29
public/src/starter.js Normal file
View file

@ -0,0 +1,29 @@
// src/starter.js
import { state } from './state.js';
import { renderApps } from './render.js';
import { loadAppOrder } from './order.js';
import { initZoomHandlers } from './zoom.js';
import { initLongPressHandlers, initGlobalCloseHandlers } from './edit.js';
import { initDragHandlers } from './drag.js';
import { initContextMenuActions } from './contextmenu.js';
export async function startLauncher(Storage) {
// Carica apps salvate
const saved = await Storage.loadApps();
if (saved) state.appsData = saved;
// Carica ordine
await loadAppOrder(Storage);
// Render iniziale
renderApps();
// Inizializzazioni
initZoomHandlers();
initLongPressHandlers();
initDragHandlers(Storage);
initContextMenuActions(Storage); // <-- IMPORTANTE
initGlobalCloseHandlers();
}

33
public/src/state.js Normal file
View file

@ -0,0 +1,33 @@
// src/state.js
export const state = {
URI: null,
USER: null,
PASSW: null,
appsData: [],
appsOrder: [],
editMode: false, // wiggle mode
zoomLevel: 1,
zoomMax: 4,
initialPinchDistance: null,
// Longpress / drag / context
longPressTimer: null,
longPressTarget: null,
justEnteredEditMode: false,
contextMenuTargetId: null,
draggingIcon: null,
draggingId: null,
dragOffsetX: 0,
dragOffsetY: 0,
dragStartX: 0,
dragStartY: 0,
placeholderEl: null,
};
export function setConfig({ url, user, password }) {
state.URI = url; state.USER = user; state.PASSW = password;
}

14
public/src/storage.js Normal file
View file

@ -0,0 +1,14 @@
// storage.js — API astratta
export const Storage = {
saveConfig: async (data) => {},
loadConfig: async () => {},
saveApps: async (apps) => {},
loadApps: async () => {},
saveAppsOrder: async (order) => {},
loadAppsOrder: async () => {}
};
export default Storage;

View file

@ -0,0 +1,77 @@
// storage.native.js — implementazione Capacitor (Preferences + CryptoJS)
import { Preferences } from "@capacitor/preferences";
import { Storage } from "./storage.js";
const SECRET_KEY = "chiave-super-segreta-123";
// ---------------- CONFIG ----------------
Storage.saveConfig = async (data) => {
const encrypted = CryptoJS.AES.encrypt(
JSON.stringify(data),
SECRET_KEY
).toString();
await Preferences.set({
key: "launcherConfig",
value: encrypted
});
};
Storage.loadConfig = async () => {
const { value } = await Preferences.get({ key: "launcherConfig" });
if (!value) return null;
try {
const bytes = CryptoJS.AES.decrypt(value, SECRET_KEY);
return JSON.parse(bytes.toString(CryptoJS.enc.Utf8));
} catch {
return null;
}
};
// ---------------- APPS ----------------
Storage.saveApps = async (apps) => {
const encrypted = CryptoJS.AES.encrypt(
JSON.stringify(apps),
SECRET_KEY
).toString();
await Preferences.set({
key: "jsonApps",
value: encrypted
});
};
Storage.loadApps = async () => {
const { value } = await Preferences.get({ key: "jsonApps" });
if (!value) return null;
try {
const bytes = CryptoJS.AES.decrypt(value, SECRET_KEY);
return JSON.parse(bytes.toString(CryptoJS.enc.Utf8));
} catch {
return null;
}
};
// ---------------- ORDER ----------------
Storage.saveAppsOrder = async (order) => {
await Preferences.set({
key: "appsOrder",
value: JSON.stringify(order)
});
};
Storage.loadAppsOrder = async () => {
const { value } = await Preferences.get({ key: "appsOrder" });
if (!value) return null;
try {
return JSON.parse(value);
} catch {
return null;
}
};
export default Storage;

67
public/src/storage.web.js Normal file
View file

@ -0,0 +1,67 @@
// storage.web.js — implementazione Web (localStorage + CryptoJS)
import { Storage } from "./storage.js";
const SECRET_KEY = "chiave-super-segreta-123";
// ---------------- CONFIG ----------------
Storage.saveConfig = async (data) => {
const encrypted = CryptoJS.AES.encrypt(
JSON.stringify(data),
SECRET_KEY
).toString();
localStorage.setItem("launcherConfig", encrypted);
};
Storage.loadConfig = async () => {
const encrypted = localStorage.getItem("launcherConfig");
if (!encrypted) return null;
try {
const bytes = CryptoJS.AES.decrypt(encrypted, SECRET_KEY);
return JSON.parse(bytes.toString(CryptoJS.enc.Utf8));
} catch {
return null;
}
};
// ---------------- APPS ----------------
Storage.saveApps = async (apps) => {
const encrypted = CryptoJS.AES.encrypt(
JSON.stringify(apps),
SECRET_KEY
).toString();
localStorage.setItem("jsonApps", encrypted);
};
Storage.loadApps = async () => {
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;
}
};
// ---------------- ORDER ----------------
Storage.saveAppsOrder = async (order) => {
localStorage.setItem("appsOrder", JSON.stringify(order));
};
Storage.loadAppsOrder = async () => {
const raw = localStorage.getItem("appsOrder");
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
};
export default Storage;

View file

@ -0,0 +1,16 @@
// src/storage/index.js
let Storage = null;
export async function initStorage() {
if (Storage) return Storage;
if (window.Capacitor) {
const mod = await import('../storage.native.js');
Storage = mod.default;
} else {
const mod = await import('../storage.web.js');
Storage = mod.default;
}
window.Storage = Storage;
return Storage;
}

90
public/src/zoom.js Normal file
View file

@ -0,0 +1,90 @@
// src/zoom.js
import { state } from './state.js';
function computeDynamicMaxZoom() {
return Math.min(window.innerWidth / 85, 4.0);
}
function loadInitialZoom() {
const v = parseFloat(localStorage.getItem('zoomLevel'));
if (!isFinite(v) || v <= 0) return 1;
return Math.min(Math.max(v, 0.5), computeDynamicMaxZoom());
}
function applyZoom(z) {
state.zoomLevel = (!isFinite(z) || z <= 0) ? 1 : z;
document.documentElement.style.setProperty('--zoom', state.zoomLevel);
localStorage.setItem('zoomLevel', String(state.zoomLevel));
}
function getPinchDistance(touches) {
const [a, b] = touches;
return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
}
function elasticEase(x) {
return Math.sin(x * Math.PI * 0.5) * 1.05;
}
export function initZoomHandlers() {
state.zoomMax = computeDynamicMaxZoom();
state.zoomLevel = loadInitialZoom();
applyZoom(state.zoomLevel);
// Evita di bloccare lo scroll quando non in wiggle mode
document.addEventListener('touchmove', e => {
if (!state.editMode) return;
if (e.touches.length === 2) e.preventDefault();
}, { passive: false });
document.addEventListener('touchstart', e => {
if (!state.editMode) return;
if (e.touches.length === 2) {
state.initialPinchDistance = getPinchDistance(e.touches);
}
});
document.addEventListener('touchmove', e => {
if (!state.editMode) return;
if (e.touches.length === 2 && state.initialPinchDistance) {
const newDist = getPinchDistance(e.touches);
const scale = newDist / state.initialPinchDistance;
let newZoom = state.zoomLevel * scale;
state.zoomMax = computeDynamicMaxZoom();
if (newZoom > state.zoomMax) newZoom = state.zoomMax + (newZoom - state.zoomMax) * 0.25;
if (newZoom < 0.5) newZoom = 0.5 - (0.5 - newZoom) * 0.25;
applyZoom(newZoom);
state.initialPinchDistance = newDist;
e.preventDefault();
}
}, { passive: false });
document.addEventListener('touchend', e => {
if (!state.editMode) return;
if (e.touches.length < 2 && state.initialPinchDistance) {
state.initialPinchDistance = null;
state.zoomMax = computeDynamicMaxZoom();
const target = Math.min(Math.max(state.zoomLevel, 0.5), state.zoomMax);
const start = state.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) requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
}
});
document.addEventListener('wheel', e => {
if (!state.editMode) return;
e.preventDefault();
state.zoomMax = computeDynamicMaxZoom();
const direction = e.deltaY < 0 ? 1 : -1;
const factor = 1 + direction * 0.1;
let newZoom = state.zoomLevel * factor;
if (newZoom > state.zoomMax) newZoom = state.zoomMax + (newZoom - state.zoomMax) * 0.25;
if (newZoom < 0.5) newZoom = 0.5 - (0.5 - newZoom) * 0.25;
applyZoom(newZoom);
}, { passive: false });
}
``

239
public/style.css Normal file
View file

@ -0,0 +1,239 @@
/* ============================================================
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;
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: 24px;
justify-items: start;
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;
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;
}
.app-icon.dragging {
opacity: 0.9;
z-index: 1000;
}
.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;
isolation: isolate;
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);
opacity: 0;
transform: scale(0.85);
transform-origin: top center;
transition: opacity 120ms ease, transform 120ms ease, visibility 0s linear 120ms;
visibility: hidden;
pointer-events: none;
}
#context-menu:not(.hidden) {
opacity: 1;
transform: scale(1);
visibility: visible;
pointer-events: auto;
transition: opacity 120ms ease, transform 120ms ease;
}
/* Pulsanti del menù */
#context-menu button {
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;
background: none;
border: none;
-webkit-appearance: none;
appearance: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
/* 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;
}
/* ============================================================
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;
}