first commit
41
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Node
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
.tmp/
|
||||
.temp/
|
||||
|
||||
# Capacitor / Cordova
|
||||
android/
|
||||
ios/
|
||||
www/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor folders
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
.parcel-cache/
|
||||
.next/
|
||||
.nuxt/
|
||||
.svelte-kit/
|
||||
2
app/README.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
npx http-server .
|
||||
789
app/app.js
Normal file
|
|
@ -0,0 +1,789 @@
|
|||
//const URI = "https://my.patachina2.casacam.net";
|
||||
//const USER = "fabio.micheluz@gmail.com";
|
||||
//const PASSW = "master66";
|
||||
|
||||
// ==========================================================================
|
||||
// Salvataggio dati
|
||||
// ==========================================================================
|
||||
|
||||
const SECRET_KEY = "chiave-super-segreta-123"; // puoi cambiarla
|
||||
|
||||
function saveConfig(url, user, password) {
|
||||
const data = { url, user, password };
|
||||
|
||||
const encrypted = CryptoJS.AES.encrypt(
|
||||
JSON.stringify(data),
|
||||
SECRET_KEY
|
||||
).toString();
|
||||
|
||||
localStorage.setItem("launcherConfig", encrypted);
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function showSetupPage() {
|
||||
document.getElementById("setup-page").classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideSetupPage() {
|
||||
document.getElementById("setup-page").classList.add("hidden");
|
||||
}
|
||||
|
||||
let tapCount = 0;
|
||||
let tapTimer = null;
|
||||
|
||||
document.addEventListener("click", () => {
|
||||
tapCount++;
|
||||
|
||||
if (tapTimer) clearTimeout(tapTimer);
|
||||
|
||||
tapTimer = setTimeout(() => {
|
||||
tapCount = 0;
|
||||
}, 600);
|
||||
|
||||
if (tapCount >= 6) {
|
||||
tapCount = 0;
|
||||
showSetupPage();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
// ==========================================================================
|
||||
// Salva config
|
||||
// ==========================================================================
|
||||
document.getElementById("cfg-save").addEventListener("click", () => {
|
||||
const url = document.getElementById("cfg-url").value;
|
||||
const user = document.getElementById("cfg-user").value;
|
||||
const pass = document.getElementById("cfg-pass").value;
|
||||
|
||||
saveConfig(url, user, pass);
|
||||
hideSetupPage();
|
||||
startLauncher();
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
// Blocca il menu contestuale nativo
|
||||
document.addEventListener("contextmenu", e => e.preventDefault());
|
||||
|
||||
// ==========================================================================
|
||||
// RIFERIMENTI DOM
|
||||
// ==========================================================================
|
||||
const folderEl = document.getElementById("folder");
|
||||
const contextMenuEl = document.getElementById("context-menu");
|
||||
|
||||
// ==========================================================================
|
||||
// STATO GLOBALE
|
||||
// ==========================================================================
|
||||
let appsData = [];
|
||||
let appsOrder = [];
|
||||
let editMode = false;
|
||||
|
||||
// Zoom
|
||||
let zoomLevel;
|
||||
let zoomMax;
|
||||
let initialPinchDistance = null;
|
||||
let lastTapTime = 0;
|
||||
let zoomAnimFrame = null;
|
||||
|
||||
// Long‑press / drag
|
||||
let longPressTimer = null;
|
||||
let longPressTarget = null;
|
||||
let contextMenuTargetId = null;
|
||||
const MOVE_TOLERANCE = 18;
|
||||
|
||||
let draggingIcon = null;
|
||||
let draggingId = null;
|
||||
let dragOffsetX = 0;
|
||||
let dragOffsetY = 0;
|
||||
let dragStartX = 0;
|
||||
let dragStartY = 0;
|
||||
|
||||
// ==========================================================================
|
||||
// CARICAMENTO APPS
|
||||
// ==========================================================================
|
||||
function loadOrder() {
|
||||
try {
|
||||
const val = localStorage.getItem("appsOrder");
|
||||
if (!val) return null;
|
||||
const parsed = JSON.parse(val);
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveOrder() {
|
||||
localStorage.setItem("appsOrder", JSON.stringify(appsOrder));
|
||||
}
|
||||
|
||||
function renderApps() {
|
||||
folderEl.innerHTML = "";
|
||||
|
||||
appsOrder.forEach(id => {
|
||||
const app = appsData.find(a => a.id === id);
|
||||
if (!app) return;
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.className = "app-icon";
|
||||
div.dataset.id = app.id;
|
||||
|
||||
div.innerHTML = `
|
||||
<img src="${app.icon}" alt="${app.name}">
|
||||
<span>${app.name}</span>
|
||||
`;
|
||||
|
||||
div.addEventListener("click", () => {
|
||||
if (!editMode) window.open(app.url, "_blank", "noopener");
|
||||
});
|
||||
|
||||
folderEl.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadApps() {
|
||||
const apps = await fetch("apps.json").then(r => r.json());
|
||||
console.log(apps);
|
||||
appsData = apps.map((app, i) => ({
|
||||
id: app.id || `app-${i}`,
|
||||
name: app.name,
|
||||
url: app.url,
|
||||
icon: app.icon
|
||||
}));
|
||||
|
||||
const stored = loadOrder();
|
||||
if (stored) {
|
||||
appsOrder = stored.filter(id => appsData.some(a => a.id === id));
|
||||
appsData.forEach(a => {
|
||||
if (!appsOrder.includes(a.id)) appsOrder.push(a.id);
|
||||
});
|
||||
} else {
|
||||
appsOrder = appsData.map(a => a.id);
|
||||
}
|
||||
|
||||
renderApps();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// UTILITY POINTER (TOUCH + MOUSE)
|
||||
// ==========================================================================
|
||||
function getPointerPosition(e) {
|
||||
if (e.touches && e.touches.length > 0) {
|
||||
return {
|
||||
pageX: e.touches[0].pageX,
|
||||
pageY: e.touches[0].pageY,
|
||||
clientX: e.touches[0].clientX,
|
||||
clientY: e.touches[0].clientY
|
||||
};
|
||||
}
|
||||
return {
|
||||
pageX: e.pageX,
|
||||
pageY: e.pageY,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ZOOM STILE IPHONE (PINCH ELASTICO) + WHEEL SU PC
|
||||
// ==========================================================================
|
||||
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) {
|
||||
zoomLevel = (!isFinite(z) || z <= 0) ? 1 : z;
|
||||
document.documentElement.style.setProperty("--zoom", zoomLevel);
|
||||
localStorage.setItem("zoomLevel", String(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;
|
||||
}
|
||||
|
||||
function initZoomHandlers() {
|
||||
zoomMax = computeDynamicMaxZoom();
|
||||
zoomLevel = loadInitialZoom();
|
||||
applyZoom(zoomLevel);
|
||||
|
||||
// Pinch su mobile
|
||||
document.addEventListener("touchmove", e => {
|
||||
if (e.touches.length === 2) e.preventDefault();
|
||||
}, { passive: false });
|
||||
|
||||
document.addEventListener("touchstart", e => {
|
||||
if (e.touches.length === 2) {
|
||||
initialPinchDistance = getPinchDistance(e.touches);
|
||||
if (zoomAnimFrame) cancelAnimationFrame(zoomAnimFrame);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (e.touches.length === 1 && now - lastTapTime < 300) {
|
||||
zoomMax = computeDynamicMaxZoom();
|
||||
applyZoom(Math.min(zoomLevel * 1.15, zoomMax));
|
||||
}
|
||||
lastTapTime = now;
|
||||
});
|
||||
|
||||
document.addEventListener("touchmove", e => {
|
||||
if (e.touches.length === 2 && initialPinchDistance) {
|
||||
const newDist = getPinchDistance(e.touches);
|
||||
const scale = newDist / initialPinchDistance;
|
||||
|
||||
let newZoom = zoomLevel * scale;
|
||||
zoomMax = computeDynamicMaxZoom();
|
||||
|
||||
if (newZoom > zoomMax) newZoom = zoomMax + (newZoom - zoomMax) * 0.25;
|
||||
if (newZoom < 0.5) newZoom = 0.5 - (0.5 - newZoom) * 0.25;
|
||||
|
||||
applyZoom(newZoom);
|
||||
initialPinchDistance = newDist;
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
document.addEventListener("touchend", e => {
|
||||
if (e.touches.length < 2 && initialPinchDistance) {
|
||||
initialPinchDistance = null;
|
||||
|
||||
zoomMax = computeDynamicMaxZoom();
|
||||
const target = Math.min(Math.max(zoomLevel, 0.5), zoomMax);
|
||||
const start = zoomLevel;
|
||||
const duration = 250;
|
||||
const startTime = performance.now();
|
||||
|
||||
function animate(t) {
|
||||
const p = Math.min((t - startTime) / duration, 1);
|
||||
const eased = start + (target - start) * elasticEase(p);
|
||||
applyZoom(eased);
|
||||
if (p < 1) zoomAnimFrame = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
zoomAnimFrame = requestAnimationFrame(animate);
|
||||
}
|
||||
});
|
||||
|
||||
// Zoom con wheel su PC
|
||||
document.addEventListener("wheel", e => {
|
||||
// Se vuoi zoomare solo con CTRL, scommenta:
|
||||
// if (!e.ctrlKey) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
zoomMax = computeDynamicMaxZoom();
|
||||
|
||||
const direction = e.deltaY < 0 ? 1 : -1;
|
||||
const factor = 1 + direction * 0.1;
|
||||
|
||||
let newZoom = zoomLevel * factor;
|
||||
|
||||
if (newZoom > zoomMax) newZoom = zoomMax + (newZoom - zoomMax) * 0.25;
|
||||
if (newZoom < 0.5) newZoom = 0.5 - (0.5 - newZoom) * 0.25;
|
||||
|
||||
applyZoom(newZoom);
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// EDIT MODE + MENU CONTESTUALE + WIGGLE
|
||||
// ==========================================================================
|
||||
function enterEditMode() {
|
||||
editMode = true;
|
||||
document.body.classList.add("edit-mode");
|
||||
}
|
||||
|
||||
function exitEditMode() {
|
||||
editMode = false;
|
||||
document.body.classList.remove("edit-mode");
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
function showContextMenuFor(id, x, y) {
|
||||
contextMenuTargetId = id;
|
||||
contextMenuEl.style.left = `${x}px`;
|
||||
contextMenuEl.style.top = `${y}px`;
|
||||
contextMenuEl.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideContextMenu() {
|
||||
contextMenuEl.classList.add("hidden");
|
||||
contextMenuTargetId = null;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// LONG PRESS MOBILE + PC
|
||||
// ==========================================================================
|
||||
function initLongPressHandlers() {
|
||||
// --- TOUCH ---
|
||||
document.addEventListener("touchstart", e => {
|
||||
if (e.touches.length !== 1) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const icon = touch.target.closest(".app-icon");
|
||||
|
||||
if (icon) {
|
||||
longPressTarget = icon;
|
||||
|
||||
longPressTimer = setTimeout(() => {
|
||||
if (!editMode) {
|
||||
enterEditMode();
|
||||
if (navigator.vibrate) navigator.vibrate(10);
|
||||
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;
|
||||
}
|
||||
|
||||
// Long press fuori icone → esce da edit mode
|
||||
longPressTimer = setTimeout(() => {
|
||||
if (editMode) exitEditMode();
|
||||
}, 350);
|
||||
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener("touchmove", e => {
|
||||
if (!longPressTimer) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const dx = touch.clientX - (longPressTarget?.getBoundingClientRect().left ?? touch.clientX);
|
||||
const dy = touch.clientY - (longPressTarget?.getBoundingClientRect().top ?? touch.clientY);
|
||||
|
||||
if (Math.hypot(dx, dy) > 15) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
longPressTarget = null;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener("touchend", () => {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
longPressTarget = null;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
// --- MOUSE ---
|
||||
document.addEventListener("mousedown", e => {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
const icon = e.target.closest(".app-icon");
|
||||
|
||||
longPressTarget = icon ?? null;
|
||||
|
||||
longPressTimer = setTimeout(() => {
|
||||
if (!editMode) {
|
||||
enterEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
if (icon) {
|
||||
const r = icon.getBoundingClientRect();
|
||||
showContextMenuFor(
|
||||
icon.dataset.id,
|
||||
r.left + r.width / 2,
|
||||
r.top + r.height
|
||||
);
|
||||
}
|
||||
}, 350);
|
||||
});
|
||||
|
||||
document.addEventListener("mousemove", e => {
|
||||
if (!longPressTimer) return;
|
||||
|
||||
if (longPressTarget) {
|
||||
const r = longPressTarget.getBoundingClientRect();
|
||||
const dx = e.clientX - (r.left + r.width / 2);
|
||||
const dy = e.clientY - (r.top + r.height / 2);
|
||||
|
||||
if (Math.hypot(dx, dy) > 15) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
longPressTarget = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mouseup", () => {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
longPressTarget = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// DRAG FLUIDO STILE IPHONE CON PLACEHOLDER + FIX "SOTTO IL DITO"
|
||||
// ==========================================================================
|
||||
function startDrag(icon, pos) {
|
||||
draggingId = icon.dataset.id;
|
||||
|
||||
const r = icon.getBoundingClientRect();
|
||||
dragOffsetX = pos.pageX - r.left;
|
||||
dragOffsetY = pos.pageY - r.top;
|
||||
|
||||
draggingIcon = icon;
|
||||
draggingIcon.classList.add("dragging");
|
||||
draggingIcon.style.position = "fixed";
|
||||
draggingIcon.style.left = `${r.left}px`;
|
||||
draggingIcon.style.top = `${r.top}px`;
|
||||
draggingIcon.style.width = `${r.width}px`;
|
||||
draggingIcon.style.height = `${r.height}px`;
|
||||
draggingIcon.style.zIndex = "1000";
|
||||
draggingIcon.style.pointerEvents = "none";
|
||||
draggingIcon.style.transform = "translate3d(0,0,0)";
|
||||
|
||||
const placeholder = icon.cloneNode(true);
|
||||
placeholder.classList.add("placeholder");
|
||||
placeholder.style.visibility = "hidden";
|
||||
icon.parentNode.insertBefore(placeholder, icon);
|
||||
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
function updateDragPosition(pos) {
|
||||
if (!draggingIcon) return;
|
||||
|
||||
const x = pos.pageX - dragOffsetX;
|
||||
const y = pos.pageY - dragOffsetY;
|
||||
|
||||
draggingIcon.style.left = `${x}px`;
|
||||
draggingIcon.style.top = `${y}px`;
|
||||
|
||||
const elem = document.elementFromPoint(pos.clientX, pos.clientY);
|
||||
const targetIcon = elem && elem.closest(".app-icon:not(.dragging):not(.placeholder)");
|
||||
if (!targetIcon) return;
|
||||
|
||||
const from = appsOrder.indexOf(draggingId);
|
||||
const to = appsOrder.indexOf(targetIcon.dataset.id);
|
||||
if (from === -1 || to === -1 || from === to) return;
|
||||
|
||||
appsOrder.splice(from, 1);
|
||||
appsOrder.splice(to, 0, draggingId);
|
||||
saveOrder();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// DROP PRECISO NELLA CELLA CORRETTA
|
||||
// ==========================================================================
|
||||
function endDrag() {
|
||||
if (!draggingIcon) return;
|
||||
|
||||
const icon = draggingIcon;
|
||||
draggingIcon = null;
|
||||
|
||||
const placeholder = folderEl.querySelector(".app-icon.placeholder");
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
const left = parseFloat(icon.style.left) || 0;
|
||||
const top = parseFloat(icon.style.top) || 0;
|
||||
const dropXClient = left + icon.offsetWidth / 2;
|
||||
const dropYClient = top + icon.offsetHeight / 2;
|
||||
|
||||
const elem = document.elementFromPoint(dropXClient, dropYClient);
|
||||
const targetIcon = elem && elem.closest(".app-icon:not(.dragging)");
|
||||
|
||||
if (targetIcon) {
|
||||
const from = appsOrder.indexOf(icon.dataset.id);
|
||||
const to = appsOrder.indexOf(targetIcon.dataset.id);
|
||||
|
||||
if (from !== -1 && to !== -1 && from !== to) {
|
||||
appsOrder.splice(from, 1);
|
||||
appsOrder.splice(to, 0, icon.dataset.id);
|
||||
saveOrder();
|
||||
}
|
||||
}
|
||||
|
||||
icon.classList.remove("dragging");
|
||||
icon.style.position = "";
|
||||
icon.style.left = "";
|
||||
icon.style.top = "";
|
||||
icon.style.width = "";
|
||||
icon.style.height = "";
|
||||
icon.style.zIndex = "";
|
||||
icon.style.pointerEvents = "";
|
||||
icon.style.transform = "";
|
||||
|
||||
renderApps();
|
||||
}
|
||||
|
||||
function initDragHandlers() {
|
||||
// --- TOUCH ---
|
||||
document.addEventListener("touchstart", e => {
|
||||
if (!editMode) return;
|
||||
if (e.touches.length !== 1) return;
|
||||
if (contextMenuTargetId) return;
|
||||
|
||||
const pos = getPointerPosition(e);
|
||||
const icon = e.touches[0].target.closest(".app-icon");
|
||||
if (!icon) return;
|
||||
|
||||
dragStartX = pos.clientX;
|
||||
dragStartY = pos.clientY;
|
||||
draggingIcon = null;
|
||||
draggingId = null;
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener("touchmove", e => {
|
||||
if (!editMode) return;
|
||||
if (e.touches.length !== 1) return;
|
||||
|
||||
const pos = getPointerPosition(e);
|
||||
|
||||
if (!draggingIcon) {
|
||||
const dx = pos.clientX - dragStartX;
|
||||
const dy = pos.clientY - dragStartY;
|
||||
if (Math.hypot(dx, dy) > 10) {
|
||||
const icon = e.touches[0].target.closest(".app-icon");
|
||||
if (icon) {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
longPressTarget = null;
|
||||
}
|
||||
startDrag(icon, pos);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updateDragPosition(pos);
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
document.addEventListener("touchend", e => {
|
||||
if (!editMode) return;
|
||||
if (draggingIcon && (!e.touches || e.touches.length === 0)) {
|
||||
endDrag();
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
// --- MOUSE ---
|
||||
document.addEventListener("mousedown", e => {
|
||||
if (!editMode) return;
|
||||
if (e.button !== 0) return;
|
||||
if (contextMenuTargetId) return;
|
||||
|
||||
const icon = e.target.closest(".app-icon");
|
||||
if (!icon) return;
|
||||
|
||||
const pos = getPointerPosition(e);
|
||||
dragStartX = pos.clientX;
|
||||
dragStartY = pos.clientY;
|
||||
draggingIcon = null;
|
||||
draggingId = null;
|
||||
});
|
||||
|
||||
document.addEventListener("mousemove", e => {
|
||||
if (!editMode) return;
|
||||
|
||||
const pos = getPointerPosition(e);
|
||||
|
||||
if (!draggingIcon) {
|
||||
if (!dragStartX && !dragStartY) return;
|
||||
|
||||
const dx = pos.clientX - dragStartX;
|
||||
const dy = pos.clientY - dragStartY;
|
||||
if (Math.hypot(dx, dy) > 10) {
|
||||
const icon = e.target.closest(".app-icon");
|
||||
if (icon) {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
longPressTarget = null;
|
||||
}
|
||||
startDrag(icon, pos);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updateDragPosition(pos);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mouseup", () => {
|
||||
if (!editMode) return;
|
||||
dragStartX = 0;
|
||||
dragStartY = 0;
|
||||
if (draggingIcon) {
|
||||
endDrag();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// MENU CONTESTUALE: AZIONI
|
||||
// ==========================================================================
|
||||
function initContextMenuActions() {
|
||||
contextMenuEl.addEventListener("click", e => {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn || !contextMenuTargetId) return;
|
||||
|
||||
const action = btn.dataset.action;
|
||||
const app = appsData.find(a => a.id === contextMenuTargetId);
|
||||
if (!app) return;
|
||||
|
||||
if (action === "rename") {
|
||||
const nuovoNome = prompt("Nuovo nome app:", app.name);
|
||||
if (nuovoNome && nuovoNome.trim()) {
|
||||
app.name = nuovoNome.trim();
|
||||
renderApps();
|
||||
saveOrder();
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "change-icon") {
|
||||
const nuovaIcona = prompt("URL nuova icona:", app.icon);
|
||||
if (nuovaIcona && nuovaIcona.trim()) {
|
||||
app.icon = nuovaIcona.trim();
|
||||
renderApps();
|
||||
saveOrder();
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "remove") {
|
||||
if (confirm("Rimuovere questa app dalla griglia?")) {
|
||||
appsOrder = appsOrder.filter(id => id !== app.id);
|
||||
saveOrder();
|
||||
renderApps();
|
||||
}
|
||||
}
|
||||
|
||||
hideContextMenu();
|
||||
});
|
||||
}
|
||||
|
||||
function initGlobalCloseHandlers() {
|
||||
document.addEventListener("pointerdown", e => {
|
||||
const isIcon = e.target.closest(".app-icon");
|
||||
const isMenu = e.target.closest("#context-menu");
|
||||
|
||||
// 1️⃣ Clic fuori dal menu → chiudi menu
|
||||
if (!isMenu && !isIcon && !contextMenuEl.classList.contains("hidden")) {
|
||||
hideContextMenu();
|
||||
}
|
||||
|
||||
// 2️⃣ Clic fuori dalle icone → esci da wiggle mode
|
||||
if (!isIcon && editMode) {
|
||||
exitEditMode();
|
||||
}
|
||||
});
|
||||
}
|
||||
// ==========================================================================
|
||||
// LOAD APPS
|
||||
// ==========================================================================
|
||||
|
||||
async function login(email, password) {
|
||||
const res = await fetch(`${URI}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
return data.token;
|
||||
}
|
||||
|
||||
async function getLinks() {
|
||||
const token = await login(USER, PASSW);
|
||||
|
||||
const res = await fetch(`${URI}/links`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Accept": "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
//console.log(json);
|
||||
appsData = json.map((json, i) => ({
|
||||
id: json.id || `app-${i}`,
|
||||
name: json.name,
|
||||
url: json.url,
|
||||
icon: `${URI}${json.icon}`
|
||||
}));
|
||||
console.log(appsData);
|
||||
|
||||
const stored = loadOrder();
|
||||
if (stored) {
|
||||
appsOrder = stored.filter(id => appsData.some(a => a.id === id));
|
||||
appsData.forEach(a => {
|
||||
if (!appsOrder.includes(a.id)) appsOrder.push(a.id);
|
||||
});
|
||||
} else {
|
||||
appsOrder = appsData.map(a => a.id);
|
||||
}
|
||||
|
||||
renderApps();
|
||||
|
||||
}
|
||||
|
||||
|
||||
const config = loadConfig();
|
||||
let URI;
|
||||
let USER;
|
||||
let PASSW;
|
||||
|
||||
|
||||
if (!config) {
|
||||
showSetupPage();
|
||||
} else {
|
||||
hideSetupPage();
|
||||
startLauncher(); // la tua funzione
|
||||
}
|
||||
|
||||
|
||||
// ==========================================================================
|
||||
// INIT GLOBALE
|
||||
// ==========================================================================
|
||||
|
||||
|
||||
async function startLauncher() {
|
||||
//(async function init() {
|
||||
//await loadApps();
|
||||
const conf = loadConfig();
|
||||
URI = conf.url;
|
||||
USER = conf.user;
|
||||
PASSW = conf.password
|
||||
await getLinks();
|
||||
initZoomHandlers();
|
||||
initLongPressHandlers();
|
||||
initDragHandlers();
|
||||
initContextMenuActions();
|
||||
initGlobalCloseHandlers();
|
||||
// })();
|
||||
}
|
||||
});
|
||||
41
app/index.html
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<!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>
|
||||
|
||||
<label>URL</label>
|
||||
<input id="cfg-url" type="text">
|
||||
|
||||
<label>User</label>
|
||||
<input id="cfg-user" type="text">
|
||||
|
||||
<label>Password</label>
|
||||
<input id="cfg-pass" type="password">
|
||||
|
||||
<button id="cfg-save">Salva</button>
|
||||
</div>
|
||||
|
||||
<!-- Griglia icone -->
|
||||
<div class="folder" id="folder"></div>
|
||||
|
||||
<!-- Menu contestuale -->
|
||||
<div id="context-menu" class="context-menu hidden">
|
||||
<button data-action="rename">Rinomina</button>
|
||||
<button data-action="change-icon">Cambia icona</button>
|
||||
<button data-action="remove">Rimuovi</button>
|
||||
</div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
256
app/style.css
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
/* ============================================================
|
||||
BASE PAGE
|
||||
============================================================ */
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden; /* impedisce pan orizzontale */
|
||||
max-width: 100%;
|
||||
touch-action: pan-y; /* solo scroll verticale */
|
||||
background: #ffffff;
|
||||
/*background: radial-gradient(circle at top, #f8f9ff 0%, #e6e8ef 60%, #dcdfe6 100%);*/
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
color: #1a1a1a;
|
||||
min-height: 100vh; /* evita scroll inutile se poche icone */
|
||||
}
|
||||
|
||||
/* Impedisce selezione testo e highlight blu Android */
|
||||
* {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Variabile di zoom globale */
|
||||
:root {
|
||||
--zoom: 1;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
GRIGLIA ICONE
|
||||
============================================================ */
|
||||
|
||||
.folder {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(calc(85px * var(--zoom)), 1fr)
|
||||
);
|
||||
gap: calc(16px * var(--zoom));
|
||||
padding-top: 24px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
padding-bottom: 0;
|
||||
justify-items: start; /* più coerente con iOS */
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
transition: grid-template-columns 0.15s ease-out,
|
||||
gap 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* Contenitore icona — versione glass */
|
||||
.app-icon {
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
transition: transform 0.18s ease, filter 0.18s ease;
|
||||
|
||||
/* GLASS */
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
|
||||
border-radius: calc(20px * var(--zoom));
|
||||
padding: calc(6px * var(--zoom));
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Icona PNG */
|
||||
.app-icon img {
|
||||
width: calc(78px * var(--zoom));
|
||||
height: calc(78px * var(--zoom));
|
||||
border-radius: calc(16px * var(--zoom));
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
|
||||
box-shadow:
|
||||
0 4px 10px rgba(0, 0, 0, 0.12),
|
||||
0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Etichetta */
|
||||
.app-icon span {
|
||||
display: block;
|
||||
margin-top: calc(6px * var(--zoom));
|
||||
font-size: calc(11px * var(--zoom));
|
||||
color: #3a3a3a;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
transition: font-size 0.18s ease-out,
|
||||
margin-top 0.18s ease-out;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
WIGGLE MODE
|
||||
============================================================ */
|
||||
|
||||
@keyframes wiggle {
|
||||
0% { transform: rotate(-2deg) scale(1.02); }
|
||||
50% { transform: rotate( 2deg) scale(0.98); }
|
||||
100% { transform: rotate(-2deg) scale(1.02); }
|
||||
}
|
||||
|
||||
.edit-mode .app-icon:not(.dragging) img {
|
||||
animation: wiggle 0.25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Icona trascinata */
|
||||
.app-icon.dragging {
|
||||
opacity: 0.9;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Placeholder invisibile */
|
||||
.app-icon.placeholder {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
MENU CONTESTUALE — ANDROID MATERIAL + RESPONSIVE ALLO ZOOM
|
||||
============================================================ */
|
||||
|
||||
#context-menu {
|
||||
position: fixed;
|
||||
background: #ffffff;
|
||||
border-radius: calc(14px * var(--zoom));
|
||||
min-width: calc(180px * var(--zoom));
|
||||
padding: calc(8px * var(--zoom)) 0;
|
||||
z-index: 2000;
|
||||
|
||||
/* Ombra Material */
|
||||
box-shadow:
|
||||
0 calc(6px * var(--zoom)) calc(20px * var(--zoom)) rgba(0,0,0,0.18),
|
||||
0 calc(2px * var(--zoom)) calc(6px * var(--zoom)) rgba(0,0,0,0.12);
|
||||
|
||||
/* Animazione apertura */
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
transform-origin: top center;
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
#context-menu:not(.hidden) {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
#context-menu.hidden {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Pulsanti del menù */
|
||||
#context-menu button {
|
||||
all: unset;
|
||||
width: 100%;
|
||||
padding: calc(14px * var(--zoom)) calc(18px * var(--zoom));
|
||||
font-size: calc(15px * var(--zoom));
|
||||
color: #222;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(12px * var(--zoom));
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Ripple effect */
|
||||
#context-menu button::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.08);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
#context-menu button:active::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Separatore tra voci */
|
||||
#context-menu button + button {
|
||||
border-top: 1px solid rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
/* Voce "Rimuovi" in rosso */
|
||||
#context-menu button:last-child {
|
||||
color: #d11a2a;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Permette drag sia mouse che touch */
|
||||
.app-icon {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Evita che l'immagine intercetti eventi */
|
||||
.app-icon img {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Allineamento stile iOS, evita offset su PC */
|
||||
.folder {
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PAGINA INIZIALE
|
||||
============================================================ */
|
||||
|
||||
#setup-page {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #f5f5f7;
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
#setup-page.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#setup-page input {
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
#setup-page button {
|
||||
padding: 14px;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
background: #007aff;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
14
server/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --production
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p uploads
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
61
server/README.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Server Json per condivisione delle mie apps
|
||||
|
||||
Utilizza MongoDB su 192.168.1.3 con user root e password example
|
||||
|
||||
## Installazione ed avvio server
|
||||
|
||||
vai su server e installa i packages
|
||||
|
||||
```sh
|
||||
cd server
|
||||
npm ci install
|
||||
```
|
||||
|
||||
far partire il server con
|
||||
|
||||
```
|
||||
node index.js
|
||||
```
|
||||
o con
|
||||
```
|
||||
npm start
|
||||
```
|
||||
|
||||
è settato per far partire su porta 3000
|
||||
|
||||
## User interface per inserire i dati
|
||||
|
||||
vai su frontend ed avvia la UI
|
||||
|
||||
```
|
||||
cd frontend
|
||||
npx http-server . -c-1 -p 8282
|
||||
```
|
||||
|
||||
il comando -c-1 toglie la cache
|
||||
-p indica la porta
|
||||
|
||||
## Altri strumenti per l'utilizzo
|
||||
|
||||
nella directory server c'è
|
||||
|
||||
./link.sh
|
||||
|
||||
che estrae la lista usando curl
|
||||
|
||||
oppure il comando in js
|
||||
|
||||
node list.js
|
||||
|
||||
che estrae la lista
|
||||
|
||||
nel folder how_use le api e i vari comandi in js
|
||||
|
||||
## Installazione in docker con mongoDB incluso (non testato)
|
||||
|
||||
Come avviarlo
|
||||
|
||||
```
|
||||
cd project
|
||||
docker-compose up --build
|
||||
```
|
||||
17
server/backend/.env.example
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# === SERVER CONFIG ===
|
||||
PORT=3000
|
||||
|
||||
# === JWT CONFIG ===
|
||||
# Cambialo SEMPRE in produzione
|
||||
JWT_SECRET=supersegreto-cambialo
|
||||
|
||||
# === MONGO CONFIG ===
|
||||
# In locale:
|
||||
# MONGO_URI=mongodb://localhost:27017/mydb
|
||||
#
|
||||
# In Docker (usato dal docker-compose):
|
||||
MONGO_URI=mongodb://mongo:27017/mydb
|
||||
|
||||
# === UPLOADS ===
|
||||
# Cartella dove Express serve le icone
|
||||
UPLOAD_DIR=uploads
|
||||
33
server/backend/index.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import express from "express";
|
||||
import mongoose from "mongoose";
|
||||
import cors from "cors";
|
||||
import dotenv from "dotenv";
|
||||
import linksRouter from "./routes/links.js";
|
||||
import authRouter from "./routes/auth.js";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Static folder per le icone
|
||||
app.use("/uploads", express.static("uploads"));
|
||||
|
||||
// Auth routes
|
||||
app.use("/auth", authRouter);
|
||||
|
||||
// Link routes (protette)
|
||||
app.use("/links", linksRouter);
|
||||
|
||||
// Connessione Mongo (URL da env con fallback)
|
||||
const MONGO_URI = process.env.MONGO_URI || "mongodb://mongo:27017/mydb";
|
||||
|
||||
mongoose
|
||||
.connect(MONGO_URI)
|
||||
.then(() => console.log("MongoDB connesso"))
|
||||
.catch(err => console.error(err));
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => console.log(`API su http://localhost:${PORT}`));
|
||||
26
server/backend/list.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
async function login(email, password) {
|
||||
const res = await fetch("http://192.168.1.3:3000/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
return data.token;
|
||||
}
|
||||
|
||||
async function getLinks() {
|
||||
const token = await login("fabio.micheluz@gmail.com", "master66");
|
||||
|
||||
const res = await fetch("http://192.168.1.3:3000/links", {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Accept": "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
console.log(json);
|
||||
}
|
||||
|
||||
getLinks();
|
||||
29
server/backend/list.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/bash
|
||||
|
||||
API_URL="http://192.168.1.3:3000"
|
||||
EMAIL="fabio.micheluz@gmail.com"
|
||||
PASSWORD="master66"
|
||||
|
||||
echo "➡️ Effettuo login..."
|
||||
|
||||
# Login e estrazione token
|
||||
TOKEN=$(curl -s -X POST "$API_URL/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" | jq -r '.token')
|
||||
|
||||
# Controllo token
|
||||
if [ "$TOKEN" == "null" ] || [ -z "$TOKEN" ]; then
|
||||
echo "❌ Errore: impossibile ottenere il token. Controlla email/password."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔑 Token ottenuto."
|
||||
|
||||
echo "➡️ Richiedo lista link..."
|
||||
|
||||
# Richiesta protetta
|
||||
curl -s -X GET "$API_URL/links" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Accept: application/json" | jq .
|
||||
|
||||
echo "✅ Fine."
|
||||
30
server/backend/list1.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
const URI = "https://my.patachina2.casacam.net";
|
||||
const USER = "fabio.micheluz@gmail.com";
|
||||
const PASSW = "master66";
|
||||
|
||||
async function login(email, password) {
|
||||
const res = await fetch(`${URI}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
return data.token;
|
||||
}
|
||||
|
||||
async function getLinks() {
|
||||
const token = await login(USER, PASSW);
|
||||
|
||||
const res = await fetch(`${URI}/links`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Accept": "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
console.log(json);
|
||||
}
|
||||
|
||||
getLinks();
|
||||
16
server/backend/middleware/auth.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
|
||||
export function authMiddleware(req, res, next) {
|
||||
const authHeader = req.headers.authorization || "";
|
||||
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
||||
|
||||
if (!token) return res.status(401).json({ error: "Token mancante" });
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET || "devsecret");
|
||||
req.userId = payload.userId;
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: "Token non valido" });
|
||||
}
|
||||
}
|
||||
10
server/backend/models/Link.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import mongoose from "mongoose";
|
||||
|
||||
const LinkSchema = new mongoose.Schema({
|
||||
url: { type: String, required: true },
|
||||
name: { type: String, required: true },
|
||||
icon: { type: String, required: false },
|
||||
owner: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }
|
||||
});
|
||||
|
||||
export default mongoose.model("Link", LinkSchema);
|
||||
8
server/backend/models/User.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import mongoose from "mongoose";
|
||||
|
||||
const UserSchema = new mongoose.Schema({
|
||||
email: { type: String, required: true, unique: true },
|
||||
passwordHash: { type: String, required: true }
|
||||
});
|
||||
|
||||
export default mongoose.model("User", UserSchema);
|
||||
1380
server/backend/package-lock.json
generated
Normal file
15
server/backend/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mongoose": "^9.0.2",
|
||||
"multer": "^2.0.2"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
}
|
||||
}
|
||||
42
server/backend/routes/auth.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import express from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
import jwt from "jsonwebtoken";
|
||||
import User from "../models/User.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Registrazione
|
||||
router.post("/register", async (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
if (!email || !password)
|
||||
return res.status(400).json({ error: "Email e password richiesti" });
|
||||
|
||||
const existing = await User.findOne({ email });
|
||||
if (existing) return res.status(400).json({ error: "Email già registrata" });
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
const user = await User.create({ email, passwordHash });
|
||||
|
||||
res.json({ id: user._id, email: user.email });
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post("/login", async (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
const user = await User.findOne({ email });
|
||||
if (!user) return res.status(400).json({ error: "Credenziali non valide" });
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!valid) return res.status(400).json({ error: "Credenziali non valide" });
|
||||
|
||||
const token = jwt.sign(
|
||||
{ userId: user._id },
|
||||
process.env.JWT_SECRET || "devsecret",
|
||||
{ expiresIn: "7d" }
|
||||
);
|
||||
|
||||
res.json({ token });
|
||||
});
|
||||
|
||||
export default router;
|
||||
77
server/backend/routes/links.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import express from "express";
|
||||
import multer from "multer";
|
||||
import Link from "../models/Link.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Config upload
|
||||
const storage = multer.diskStorage({
|
||||
destination: "uploads/",
|
||||
filename: (req, file, cb) => {
|
||||
const unique = Date.now() + "-" + file.originalname;
|
||||
cb(null, unique);
|
||||
}
|
||||
});
|
||||
const upload = multer({ storage });
|
||||
|
||||
// Tutte le rotte protette
|
||||
router.use(authMiddleware);
|
||||
|
||||
// GET /links - lista dei link dell'utente
|
||||
router.get("/", async (req, res) => {
|
||||
const links = await Link.find({ owner: req.userId });
|
||||
res.json(links);
|
||||
});
|
||||
|
||||
// POST /links - crea nuovo link con eventuale icona
|
||||
router.post("/", upload.single("icon"), async (req, res) => {
|
||||
const { url, name } = req.body;
|
||||
const iconPath = req.file ? `/uploads/${req.file.filename}` : null;
|
||||
|
||||
const link = await Link.create({
|
||||
url,
|
||||
name,
|
||||
icon: iconPath,
|
||||
owner: req.userId
|
||||
});
|
||||
|
||||
res.json(link);
|
||||
});
|
||||
|
||||
// DELETE /links/:id
|
||||
router.delete("/:id", async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const link = await Link.findOneAndDelete({
|
||||
_id: id,
|
||||
owner: req.userId
|
||||
});
|
||||
|
||||
if (!link) return res.status(404).json({ error: "Link non trovato" });
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// PUT /links/:id
|
||||
router.put("/:id", upload.single("icon"), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { name, url } = req.body;
|
||||
|
||||
const update = {};
|
||||
if (name) update.name = name;
|
||||
if (url) update.url = url;
|
||||
if (req.file) update.icon = `/uploads/${req.file.filename}`;
|
||||
|
||||
const link = await Link.findOneAndUpdate(
|
||||
{ _id: id, owner: req.userId },
|
||||
update,
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!link) return res.status(404).json({ error: "Link non trovato" });
|
||||
|
||||
res.json(link);
|
||||
});
|
||||
|
||||
export default router;
|
||||
BIN
server/backend/uploads/1767085843931-google.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
server/backend/uploads/1767085872529-github.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
server/backend/uploads/1767087426549-a.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
server/backend/uploads/1767103637269-a.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
server/backend/uploads/1767103690515-github.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
server/backend/uploads/1767193346029-google.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
server/backend/uploads/1767193354089-github.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
server/backend/uploads/1767193354094-github.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
40
server/docker-compose.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
version: "3.9"
|
||||
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:7
|
||||
container_name: mongo
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "27017:27017"
|
||||
volumes:
|
||||
- mongo_data:/data/db
|
||||
|
||||
api:
|
||||
build: ./server
|
||||
container_name: api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- MONGO_URI=mongodb://mongo:27017/mydb
|
||||
- JWT_SECRET=supersegreto-cambialo
|
||||
- PORT=3000
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./server/uploads:/app/uploads
|
||||
depends_on:
|
||||
- mongo
|
||||
|
||||
frontend:
|
||||
image: nginx:alpine
|
||||
container_name: frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./frontend:/usr/share/nginx/html:ro
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
volumes:
|
||||
mongo_data:
|
||||
98
server/frontend/api.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
const API_BASE = "http://192.168.1.3:3000";
|
||||
|
||||
// ------------------------------
|
||||
// AUTH
|
||||
// ------------------------------
|
||||
|
||||
export async function login(email, password) {
|
||||
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Errore login");
|
||||
return data.token;
|
||||
}
|
||||
|
||||
export async function register(email, password) {
|
||||
const res = await fetch(`${API_BASE}/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Errore registrazione");
|
||||
return data;
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// LINKS
|
||||
// ------------------------------
|
||||
|
||||
export async function getLinks(token) {
|
||||
const res = await fetch(`${API_BASE}/links`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Errore caricamento link");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createLink(token, { name, url, iconFile }) {
|
||||
const formData = new FormData();
|
||||
formData.append("name", name);
|
||||
formData.append("url", url);
|
||||
if (iconFile) formData.append("icon", iconFile);
|
||||
|
||||
const res = await fetch(`${API_BASE}/links`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Errore creazione link");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteLink(token, id) {
|
||||
const res = await fetch(`${API_BASE}/links/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Errore eliminazione link");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateLink(token, id, { name, url, iconFile }) {
|
||||
const formData = new FormData();
|
||||
if (name) formData.append("name", name);
|
||||
if (url) formData.append("url", url);
|
||||
if (iconFile) formData.append("icon", iconFile);
|
||||
|
||||
const res = await fetch(`${API_BASE}/links/${id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Errore aggiornamento link");
|
||||
return data;
|
||||
}
|
||||
|
||||
191
server/frontend/app.js
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import {
|
||||
login,
|
||||
register,
|
||||
getLinks,
|
||||
createLink,
|
||||
deleteLink,
|
||||
updateLink
|
||||
} from "./api.js";
|
||||
|
||||
const authSection = document.getElementById("authSection");
|
||||
const linkSection = document.getElementById("linkSection");
|
||||
const authStatus = document.getElementById("authStatus");
|
||||
const list = document.getElementById("list");
|
||||
const editModal = document.getElementById("editModal");
|
||||
const editForm = document.getElementById("editForm");
|
||||
const closeModal = document.getElementById("closeModal");
|
||||
|
||||
let editingId = null;
|
||||
let token = null;
|
||||
|
||||
// ======================================================
|
||||
// AUTH
|
||||
// ======================================================
|
||||
|
||||
function setToken(t) {
|
||||
token = t;
|
||||
|
||||
if (token) {
|
||||
authSection.style.display = "none";
|
||||
linkSection.style.display = "block";
|
||||
loadLinks();
|
||||
} else {
|
||||
authSection.style.display = "block";
|
||||
linkSection.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("loginForm").addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const email = e.target.email.value;
|
||||
const password = e.target.password.value;
|
||||
|
||||
try {
|
||||
const t = await login(email, password);
|
||||
setToken(t);
|
||||
} catch (err) {
|
||||
authStatus.textContent = err.message;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("registerForm").addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const email = e.target.email.value;
|
||||
const password = e.target.password.value;
|
||||
|
||||
try {
|
||||
await register(email, password);
|
||||
authStatus.textContent = "Registrato! Ora effettua il login.";
|
||||
} catch (err) {
|
||||
authStatus.textContent = err.message;
|
||||
}
|
||||
});
|
||||
|
||||
// ======================================================
|
||||
// LINKS
|
||||
// ======================================================
|
||||
|
||||
async function loadLinks() {
|
||||
const links = await getLinks(token);
|
||||
|
||||
list.innerHTML = links
|
||||
.map(
|
||||
link => `
|
||||
<div class="item" data-id="${link._id}">
|
||||
${link.icon ? `<img src="http://192.168.1.3:3000${link.icon}">` : ""}
|
||||
|
||||
<div class="info">
|
||||
<strong>${link.name}</strong><br>
|
||||
<a href="${link.url}" target="_blank">${link.url}</a>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="editBtn" data-id="${link._id}">Modifica</button>
|
||||
<button class="deleteBtn" data-id="${link._id}">Elimina</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// CREAZIONE LINK
|
||||
// ======================================================
|
||||
|
||||
document.getElementById("linkForm").addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const iconFile = formData.get("icon");
|
||||
|
||||
await createLink(token, {
|
||||
name: formData.get("name"),
|
||||
url: formData.get("url"),
|
||||
iconFile: iconFile.size > 0 ? iconFile : null
|
||||
});
|
||||
|
||||
e.target.reset();
|
||||
loadLinks();
|
||||
});
|
||||
|
||||
// ======================================================
|
||||
// AZIONI: MODIFICA + ELIMINA
|
||||
// ======================================================
|
||||
|
||||
list.addEventListener("click", async e => {
|
||||
const id = e.target.dataset.id;
|
||||
if (!id) return;
|
||||
|
||||
// -------------------------
|
||||
// ELIMINA
|
||||
// -------------------------
|
||||
if (e.target.classList.contains("deleteBtn")) {
|
||||
if (confirm("Vuoi davvero eliminare questo link?")) {
|
||||
await deleteLink(token, id);
|
||||
loadLinks();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// MODIFICA
|
||||
// -------------------------
|
||||
/* if (e.target.classList.contains("editBtn")) {
|
||||
const newName = prompt("Nuovo nome:");
|
||||
const newUrl = prompt("Nuovo URL:");
|
||||
|
||||
if (!newName && !newUrl) return;
|
||||
|
||||
await updateLink(token, id, {
|
||||
name: newName,
|
||||
url: newUrl
|
||||
});
|
||||
|
||||
loadLinks();
|
||||
}*/
|
||||
|
||||
if (e.target.classList.contains("editBtn")) {
|
||||
const id = e.target.dataset.id;
|
||||
editingId = id;
|
||||
|
||||
// Precompila i campi
|
||||
const item = e.target.closest(".item");
|
||||
const name = item.querySelector("strong").textContent;
|
||||
const url = item.querySelector("a").textContent;
|
||||
|
||||
editForm.name.value = name;
|
||||
editForm.url.value = url;
|
||||
editForm.icon.value = ""; // reset file input
|
||||
|
||||
editModal.style.display = "flex";
|
||||
}
|
||||
|
||||
closeModal.addEventListener("click", () => {
|
||||
editModal.style.display = "none";
|
||||
});
|
||||
|
||||
editForm.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const name = editForm.name.value;
|
||||
const url = editForm.url.value;
|
||||
const iconFile = editForm.icon.files[0] || null;
|
||||
|
||||
await updateLink(token, editingId, {
|
||||
name,
|
||||
url,
|
||||
iconFile
|
||||
});
|
||||
|
||||
editModal.style.display = "none";
|
||||
loadLinks();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ======================================================
|
||||
// INIT
|
||||
// ======================================================
|
||||
|
||||
setToken(null);
|
||||
98
server/frontend/app.js.old
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import {
|
||||
login,
|
||||
register,
|
||||
getLinks,
|
||||
createLink,
|
||||
deleteLink,
|
||||
updateLink
|
||||
} from "./api.js";
|
||||
|
||||
const authSection = document.getElementById("authSection");
|
||||
const linkSection = document.getElementById("linkSection");
|
||||
const authStatus = document.getElementById("authStatus");
|
||||
const list = document.getElementById("list");
|
||||
|
||||
let token = null;
|
||||
|
||||
// ------------------------------
|
||||
// AUTH
|
||||
// ------------------------------
|
||||
|
||||
function setToken(t) {
|
||||
token = t;
|
||||
if (token) {
|
||||
authSection.style.display = "none";
|
||||
linkSection.style.display = "block";
|
||||
loadLinks();
|
||||
} else {
|
||||
authSection.style.display = "block";
|
||||
linkSection.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("loginForm").addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const email = e.target.email.value;
|
||||
const password = e.target.password.value;
|
||||
|
||||
try {
|
||||
const t = await login(email, password);
|
||||
setToken(t);
|
||||
} catch (err) {
|
||||
authStatus.textContent = err.message;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("registerForm").addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const email = e.target.email.value;
|
||||
const password = e.target.password.value;
|
||||
|
||||
try {
|
||||
await register(email, password);
|
||||
authStatus.textContent = "Registrato! Ora effettua il login.";
|
||||
} catch (err) {
|
||||
authStatus.textContent = err.message;
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------
|
||||
// LINKS
|
||||
// ------------------------------
|
||||
|
||||
async function loadLinks() {
|
||||
const links = await getLinks(token);
|
||||
|
||||
list.innerHTML = links
|
||||
.map(
|
||||
link => `
|
||||
<div class="item">
|
||||
${link.icon ? `<img src="http://192.168.1.3:3000${link.icon}">` : ""}
|
||||
<div>
|
||||
<strong>${link.name}</strong><br>
|
||||
<a href="${link.url}" target="_blank">${link.url}</a>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
document.getElementById("linkForm").addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const iconFile = formData.get("icon");
|
||||
|
||||
await createLink(token, {
|
||||
name: formData.get("name"),
|
||||
url: formData.get("url"),
|
||||
iconFile: iconFile.size > 0 ? iconFile : null
|
||||
});
|
||||
|
||||
e.target.reset();
|
||||
loadLinks();
|
||||
});
|
||||
|
||||
// Init
|
||||
setToken(null);
|
||||
71
server/frontend/index.html
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Link Manager</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<h1>Link Manager</h1>
|
||||
|
||||
<!-- AUTH -->
|
||||
<section id="authSection">
|
||||
<div class="card">
|
||||
<h2>Accedi</h2>
|
||||
<form id="loginForm">
|
||||
<input type="email" name="email" placeholder="Email" required>
|
||||
<input type="password" name="password" placeholder="Password" required>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
|
||||
<h3>Oppure registrati</h3>
|
||||
<form id="registerForm">
|
||||
<input type="email" name="email" placeholder="Email" required>
|
||||
<input type="password" name="password" placeholder="Password" required>
|
||||
<button type="submit">Registrati</button>
|
||||
</form>
|
||||
|
||||
<div id="authStatus"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- LINKS -->
|
||||
<section id="linkSection" style="display:none;">
|
||||
<div class="card">
|
||||
<h2>Nuovo link</h2>
|
||||
<form id="linkForm">
|
||||
<input type="text" name="name" placeholder="Nome" required>
|
||||
<input type="text" name="url" placeholder="URL" required>
|
||||
<input type="file" name="icon" accept="image/*">
|
||||
<button type="submit">Salva</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>I tuoi link</h2>
|
||||
<div id="list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
<div id="editModal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<h3>Modifica link</h3>
|
||||
|
||||
<form id="editForm">
|
||||
<input type="text" name="name" placeholder="Nome">
|
||||
<input type="text" name="url" placeholder="URL">
|
||||
<input type="file" name="icon" accept="image/*">
|
||||
|
||||
<button type="submit">Salva modifiche</button>
|
||||
<button type="button" id="closeModal">Annulla</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
85
server/frontend/style.css
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro", sans-serif;
|
||||
background: #f5f5f7;
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 700px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #ccc;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: #007aff;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #0063cc;
|
||||
}
|
||||
|
||||
#list .item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
#list img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background: rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
width: 300px;
|
||||
}
|
||||