diff --git a/app.py b/app.py index 1fd42ae..c5832da 100644 --- a/app.py +++ b/app.py @@ -9,7 +9,7 @@ import sys import webbrowser from pathlib import Path -from flask import Blueprint, Flask, abort, jsonify, render_template, request, send_file +from flask import Blueprint, Flask, abort, jsonify, render_template, request, send_file, send_from_directory from werkzeug.utils import secure_filename from db import ( @@ -58,6 +58,28 @@ configure_app(app) DATA_DIR = Path(os.environ.get("DATA_DIR", Path(__file__).parent / "data")) DB_PATH = DEFAULT_DB +STATIC_DIR = Path(__file__).parent / "static" + +PWA_MANIFEST = { + "name": "Idle Fantasy Viewer", + "short_name": "IF Viewer", + "description": "Save viewer for Idle Fantasy – skills, inventory, quests and history.", + "display": "standalone", + "background_color": "#0f1117", + "theme_color": "#1a1d27", + "icons": [ + {"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png"}, + {"src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable"}, + ], +} + + +def _pwa_manifest(start_url: str, scope: str) -> dict: + return {**PWA_MANIFEST, "start_url": start_url, "scope": scope} + + +def _serve_sw(): + return send_from_directory(STATIC_DIR, "sw.js", mimetype="application/javascript") def get_data_dir() -> Path: @@ -82,10 +104,27 @@ def _resolve_viewer_db(viewer_id: str) -> Path: viewer_bp = Blueprint("viewer", __name__, url_prefix="/v/") +@viewer_bp.route("/manifest.webmanifest") +def manifest_viewer(viewer_id: str): + _resolve_viewer_db(viewer_id) + base = f"/v/{viewer_id}/" + return jsonify(_pwa_manifest(base, base)), 200, {"Content-Type": "application/manifest+json"} + + +@viewer_bp.route("/sw.js") +def sw_viewer(viewer_id: str): + _resolve_viewer_db(viewer_id) + return _serve_sw() + + @viewer_bp.route("/") def viewer_index(viewer_id: str): _resolve_viewer_db(viewer_id) - return render_template("index.html", viewer_id=viewer_id) + return render_template( + "index.html", + viewer_id=viewer_id, + manifest_href=f"/v/{viewer_id}/manifest.webmanifest", + ) @viewer_bp.route("/api/snapshot/latest") @@ -333,9 +372,19 @@ def api_import(viewer_id: str): app.register_blueprint(viewer_bp) +@app.route("/manifest.webmanifest") +def manifest_root(): + return jsonify(_pwa_manifest("/", "/")), 200, {"Content-Type": "application/manifest+json"} + + +@app.route("/sw.js") +def sw_root(): + return _serve_sw() + + @app.route("/") def landing(): - return render_template("landing.html") + return render_template("landing.html", manifest_href="/manifest.webmanifest") @app.post("/api/viewers") diff --git a/static/app.js b/static/app.js index 8c4a78e..ee6c564 100644 --- a/static/app.js +++ b/static/app.js @@ -70,6 +70,7 @@ async function init() { await I18n.init(); applyStaticI18n(); setupLanguage(); + await Pwa.init(); setupViewerBanner(); setupNav(); setupUpload(); @@ -123,6 +124,7 @@ function setupLanguage() { sel.addEventListener("change", async (e) => { await I18n.setPreference(e.target.value); applyStaticI18n(); + Pwa.refreshHint(); resetLocaleDependentPanels(); if (state.data) renderAll(); const gs = document.getElementById("global-search"); diff --git a/static/icon-192.png b/static/icon-192.png new file mode 100644 index 0000000..d22f6fd Binary files /dev/null and b/static/icon-192.png differ diff --git a/static/icon-512.png b/static/icon-512.png new file mode 100644 index 0000000..3b23f63 Binary files /dev/null and b/static/icon-512.png differ diff --git a/static/landing.js b/static/landing.js index cb6a8d4..84c2ed5 100644 --- a/static/landing.js +++ b/static/landing.js @@ -2,6 +2,7 @@ document.addEventListener("DOMContentLoaded", async () => { await I18n.init(); applyStaticI18n(); setupLanguage(); + await Pwa.init(); setupCreate(); }); @@ -17,6 +18,7 @@ function setupLanguage() { sel.addEventListener("change", async (e) => { await I18n.setPreference(e.target.value); applyStaticI18n(); + Pwa.refreshHint(); }); } diff --git a/static/locales/de.json b/static/locales/de.json index 67f4233..fa14d7e 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -226,6 +226,15 @@ "copied": "Kopiert!", "copyPrompt": "Viewer-Link kopieren:" }, + "pwa": { + "hintTitle": "Als App installieren", + "hintBody": "Installiere diesen Viewer für schnellen Zugriff über Startbildschirm oder App-Liste.", + "hintIos": "Tippe auf Teilen, dann „Zum Home-Bildschirm“.", + "hintAndroid": "Öffne das Browser-Menü und tippe auf „App installieren“ oder „Zum Startbildschirm“.", + "hintDesktop": "Nutze das Install-Symbol in der Adressleiste oder Browser-Menü → App installieren.", + "install": "Installieren", + "installed": "App installiert – öffne sie über Startbildschirm oder App-Liste." + }, "viewerDb": { "title": "Viewer-Backup", "helpTitle": "Was ist das?", diff --git a/static/locales/en.json b/static/locales/en.json index 0f110dd..7dc2d69 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -226,6 +226,15 @@ "copied": "Copied!", "copyPrompt": "Copy your viewer link:" }, + "pwa": { + "hintTitle": "Install as app", + "hintBody": "Install this viewer for quick access from your home screen or app list.", + "hintIos": "Tap Share, then \"Add to Home Screen\".", + "hintAndroid": "Open the browser menu and tap \"Install app\" or \"Add to Home screen\".", + "hintDesktop": "Use the install icon in the address bar or the browser menu → Install app.", + "install": "Install", + "installed": "App installed – open it from your home screen or app list." + }, "viewerDb": { "title": "Viewer backup", "helpTitle": "What is this?", diff --git a/static/pwa.js b/static/pwa.js new file mode 100644 index 0000000..82119bb --- /dev/null +++ b/static/pwa.js @@ -0,0 +1,110 @@ +/* PWA – service worker registration and install hint */ + +const Pwa = (() => { + const DISMISS_KEY = "pwa-hint-dismissed"; + let deferredPrompt = null; + let refreshInstallHint = null; + + function isStandalone() { + return window.matchMedia("(display-mode: standalone)").matches + || window.navigator.standalone === true; + } + + function detectPlatform() { + const ua = navigator.userAgent; + if (/iPad|iPhone|iPod/.test(ua)) return "ios"; + if (/Android/.test(ua)) return "android"; + return "desktop"; + } + + function swConfig() { + const viewerId = document.body?.dataset?.viewerId; + if (viewerId && viewerId !== "local") { + return { url: `/v/${viewerId}/sw.js`, scope: `/v/${viewerId}/` }; + } + return { url: "/sw.js", scope: "/" }; + } + + async function registerServiceWorker() { + if (!("serviceWorker" in navigator)) return; + const { url, scope } = swConfig(); + try { + await navigator.serviceWorker.register(url, { scope }); + } catch { + /* SW is optional – manual install instructions still apply */ + } + } + + function platformHintKey() { + const platform = detectPlatform(); + if (platform === "ios") return "pwa.hintIos"; + if (platform === "android") return "pwa.hintAndroid"; + return "pwa.hintDesktop"; + } + + function setupInstallHint() { + if (isStandalone()) return null; + if (localStorage.getItem(DISMISS_KEY) === "1") return null; + + const hint = document.getElementById("pwa-install-hint"); + if (!hint) return null; + + const body = document.getElementById("pwa-hint-body"); + const installBtn = document.getElementById("pwa-install-btn"); + const dismissBtn = document.getElementById("pwa-hint-dismiss"); + + const updateHintText = () => { + const title = hint.querySelector("[data-i18n='pwa.hintTitle']"); + if (title) title.textContent = t("pwa.hintTitle"); + if (body && !deferredPrompt) body.textContent = t(platformHintKey()); + if (body && deferredPrompt) body.textContent = t("pwa.hintBody"); + if (dismissBtn) dismissBtn.title = t("actions.dismiss"); + if (installBtn && !installBtn.hidden) installBtn.textContent = t("pwa.install"); + }; + + hint.hidden = false; + updateHintText(); + + window.addEventListener("beforeinstallprompt", (e) => { + e.preventDefault(); + deferredPrompt = e; + if (installBtn) installBtn.hidden = false; + if (body) body.textContent = t("pwa.hintBody"); + updateHintText(); + }); + + dismissBtn?.addEventListener("click", () => { + localStorage.setItem(DISMISS_KEY, "1"); + hint.hidden = true; + }); + + installBtn?.addEventListener("click", async () => { + if (!deferredPrompt) return; + deferredPrompt.prompt(); + const { outcome } = await deferredPrompt.userChoice; + deferredPrompt = null; + installBtn.hidden = true; + if (outcome === "accepted") { + if (body) body.textContent = t("pwa.installed"); + setTimeout(() => { hint.hidden = true; }, 3000); + } else { + updateHintText(); + } + }); + + return updateHintText; + } + + async function init() { + await registerServiceWorker(); + refreshInstallHint = setupInstallHint(); + } + + function refreshHint() { + refreshInstallHint?.(); + } + + return { init, refreshHint }; +})(); + +window.Pwa = Pwa; diff --git a/static/style.css b/static/style.css index 62e415d..04ae89f 100644 --- a/static/style.css +++ b/static/style.css @@ -855,6 +855,50 @@ body.inv-chart-modal-open { border-radius: 6px; } +/* PWA install hint */ +.pwa-hint { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + margin-bottom: 12px; + padding: 10px 12px; + border-radius: var(--radius); + border: 1px solid rgba(108, 140, 255, 0.35); + background: rgba(108, 140, 255, 0.08); +} + +.pwa-hint-text { + flex: 1; + min-width: 0; +} + +.pwa-hint-text strong { + display: block; + color: var(--accent); + font-size: 0.85rem; + margin-bottom: 4px; +} + +.pwa-hint-text p { + margin: 0; + font-size: 0.75rem; + color: var(--text-muted); + line-height: 1.4; +} + +.pwa-hint-actions { + display: flex; + align-items: flex-start; + gap: 6px; + flex-shrink: 0; +} + +.landing-card .pwa-hint { + margin-top: 8px; + margin-bottom: 0; +} + .viewer-copy-btn { flex-shrink: 0; padding: 8px 12px; diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..009a06b --- /dev/null +++ b/static/sw.js @@ -0,0 +1,38 @@ +const CACHE = "if-viewer-static-v1"; +const ASSETS = [ + "/static/style.css", + "/static/favicon.svg", + "/static/icon-192.png", + "/static/icon-512.png", + "/static/i18n.js", + "/static/locales/en.json", + "/static/locales/de.json", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE) + .then((cache) => cache.addAll(ASSETS)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return; + + const url = new URL(event.request.url); + if (url.pathname.includes("/api/")) return; + if (!url.pathname.startsWith("/static/")) return; + + event.respondWith( + caches.match(event.request).then((cached) => cached || fetch(event.request)) + ); +}); diff --git a/templates/_pwa_head.html b/templates/_pwa_head.html new file mode 100644 index 0000000..7242a7b --- /dev/null +++ b/templates/_pwa_head.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/templates/_pwa_hint.html b/templates/_pwa_hint.html new file mode 100644 index 0000000..224c799 --- /dev/null +++ b/templates/_pwa_hint.html @@ -0,0 +1,10 @@ + diff --git a/templates/index.html b/templates/index.html index 5a1be4d..12065ff 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5,6 +5,7 @@ Idle Fantasy Viewer + {% include '_pwa_head.html' %} {% include '_analytics.html' %} @@ -33,6 +34,7 @@