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