From 16ae0a05ed8978e19c1bc7866ede17ac0090e9ab Mon Sep 17 00:00:00 2001 From: Fabio Date: Tue, 27 Jan 2026 13:16:48 +0100 Subject: [PATCH] first commit --- .dockerignore | 4 + Dockerfile | 11 ++ docker-compose.yml | 7 + public/index.html | 47 ++++++ public/src/api.js | 48 ++++++ public/src/contextmenu - Copia.js | 159 ++++++++++++++++++ public/src/contextmenu.js | 268 ++++++++++++++++++++++++++++++ public/src/drag.js | 236 ++++++++++++++++++++++++++ public/src/edit.js | 178 ++++++++++++++++++++ public/src/main.js | 34 ++++ public/src/order.js | 17 ++ public/src/render.js | 23 +++ public/src/setup.js | 56 +++++++ public/src/starter.js | 29 ++++ public/src/state.js | 33 ++++ public/src/storage.js | 14 ++ public/src/storage.native.js | 77 +++++++++ public/src/storage.web.js | 67 ++++++++ public/src/storage/index.js | 16 ++ public/src/zoom.js | 90 ++++++++++ public/style.css | 239 ++++++++++++++++++++++++++ 21 files changed, 1653 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 public/index.html create mode 100644 public/src/api.js create mode 100644 public/src/contextmenu - Copia.js create mode 100644 public/src/contextmenu.js create mode 100644 public/src/drag.js create mode 100644 public/src/edit.js create mode 100644 public/src/main.js create mode 100644 public/src/order.js create mode 100644 public/src/render.js create mode 100644 public/src/setup.js create mode 100644 public/src/starter.js create mode 100644 public/src/state.js create mode 100644 public/src/storage.js create mode 100644 public/src/storage.native.js create mode 100644 public/src/storage.web.js create mode 100644 public/src/storage/index.js create mode 100644 public/src/zoom.js create mode 100644 public/style.css diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f71ed12 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +node_modules +dist +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e441618 --- /dev/null +++ b/Dockerfile @@ -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 l’immagine ufficiale diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..70b8731 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +services: + myappsui: + build: . + container_name: myappsui + ports: + - "11003:80" # HOST:CONTAINER + restart: unless-stopped diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..532e4f8 --- /dev/null +++ b/public/index.html @@ -0,0 +1,47 @@ + + + + + Launcher + + + + + + + + + + +
+ + + + + + + + diff --git a/public/src/api.js b/public/src/api.js new file mode 100644 index 0000000..ce51351 --- /dev/null +++ b/public/src/api.js @@ -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; + } +} diff --git a/public/src/contextmenu - Copia.js b/public/src/contextmenu - Copia.js new file mode 100644 index 0000000..9b76958 --- /dev/null +++ b/public/src/contextmenu - Copia.js @@ -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 l’evento 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 l’app dai dati + const beforeLen = state.appsData.length; + state.appsData = state.appsData.filter(a => a.id !== app.id); + + // 2) Rimuovi l’id dall’ordine + 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 }); +} + diff --git a/public/src/contextmenu.js b/public/src/contextmenu.js new file mode 100644 index 0000000..8407ca8 --- /dev/null +++ b/public/src/contextmenu.js @@ -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 l’immagine.')); + 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 un’immagine 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 l’app con l’icona 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 l’icona. Riprova con un’immagine 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 dall’ordine + 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 }); +} diff --git a/public/src/drag.js b/public/src/drag.js new file mode 100644 index 0000000..e6f4804 --- /dev/null +++ b/public/src/drag.js @@ -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); + }); +} diff --git a/public/src/edit.js b/public/src/edit.js new file mode 100644 index 0000000..fd76850 --- /dev/null +++ b/public/src/edit.js @@ -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(); + }); +} + diff --git a/public/src/main.js b/public/src/main.js new file mode 100644 index 0000000..543222f --- /dev/null +++ b/public/src/main.js @@ -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); + } + }); +}); +`` + diff --git a/public/src/order.js b/public/src/order.js new file mode 100644 index 0000000..d0d4bc2 --- /dev/null +++ b/public/src/order.js @@ -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); +} diff --git a/public/src/render.js b/public/src/render.js new file mode 100644 index 0000000..6b4e943 --- /dev/null +++ b/public/src/render.js @@ -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 = ` + ${app.name} + ${app.name} + `; + div.addEventListener('click', () => { + if (!state.editMode) window.open(app.url, '_blank', 'noopener'); + }); + folderEl.appendChild(div); + }); +} diff --git a/public/src/setup.js b/public/src/setup.js new file mode 100644 index 0000000..904ce04 --- /dev/null +++ b/public/src/setup.js @@ -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'); +} diff --git a/public/src/starter.js b/public/src/starter.js new file mode 100644 index 0000000..37ac839 --- /dev/null +++ b/public/src/starter.js @@ -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(); +} diff --git a/public/src/state.js b/public/src/state.js new file mode 100644 index 0000000..14b8808 --- /dev/null +++ b/public/src/state.js @@ -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, + + // Long‑press / 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; +} diff --git a/public/src/storage.js b/public/src/storage.js new file mode 100644 index 0000000..ee1093c --- /dev/null +++ b/public/src/storage.js @@ -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; diff --git a/public/src/storage.native.js b/public/src/storage.native.js new file mode 100644 index 0000000..d217c1f --- /dev/null +++ b/public/src/storage.native.js @@ -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; diff --git a/public/src/storage.web.js b/public/src/storage.web.js new file mode 100644 index 0000000..43b7afe --- /dev/null +++ b/public/src/storage.web.js @@ -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; diff --git a/public/src/storage/index.js b/public/src/storage/index.js new file mode 100644 index 0000000..5867cf7 --- /dev/null +++ b/public/src/storage/index.js @@ -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; +} diff --git a/public/src/zoom.js b/public/src/zoom.js new file mode 100644 index 0000000..263f020 --- /dev/null +++ b/public/src/zoom.js @@ -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 }); +} +`` diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..1205829 --- /dev/null +++ b/public/style.css @@ -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; +}