first commit
This commit is contained in:
commit
16ae0a05ed
21 changed files with 1653 additions and 0 deletions
4
.dockerignore
Normal file
4
.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
11
Dockerfile
Normal file
11
Dockerfile
Normal 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 l’immagine ufficiale
|
||||||
7
docker-compose.yml
Normal file
7
docker-compose.yml
Normal 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
47
public/index.html
Normal 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
48
public/src/api.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
159
public/src/contextmenu - Copia.js
Normal file
159
public/src/contextmenu - Copia.js
Normal 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 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 });
|
||||||
|
}
|
||||||
|
|
||||||
268
public/src/contextmenu.js
Normal file
268
public/src/contextmenu.js
Normal 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 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 });
|
||||||
|
}
|
||||||
236
public/src/drag.js
Normal file
236
public/src/drag.js
Normal 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
178
public/src/edit.js
Normal 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
34
public/src/main.js
Normal 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
17
public/src/order.js
Normal 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
23
public/src/render.js
Normal 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
56
public/src/setup.js
Normal 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
29
public/src/starter.js
Normal 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
33
public/src/state.js
Normal 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,
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
14
public/src/storage.js
Normal file
14
public/src/storage.js
Normal 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;
|
||||||
77
public/src/storage.native.js
Normal file
77
public/src/storage.native.js
Normal 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
67
public/src/storage.web.js
Normal 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;
|
||||||
16
public/src/storage/index.js
Normal file
16
public/src/storage/index.js
Normal 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
90
public/src/zoom.js
Normal 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
239
public/style.css
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue