Add PWA manifest, service worker, and install hint.
Enables home-screen installation with per-viewer scope and platform-specific guidance in EN/DE. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,7 +9,7 @@ import sys
|
|||||||
import webbrowser
|
import webbrowser
|
||||||
from pathlib import Path
|
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 werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from db import (
|
from db import (
|
||||||
@@ -58,6 +58,28 @@ configure_app(app)
|
|||||||
|
|
||||||
DATA_DIR = Path(os.environ.get("DATA_DIR", Path(__file__).parent / "data"))
|
DATA_DIR = Path(os.environ.get("DATA_DIR", Path(__file__).parent / "data"))
|
||||||
DB_PATH = DEFAULT_DB
|
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:
|
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_id>")
|
viewer_bp = Blueprint("viewer", __name__, url_prefix="/v/<viewer_id>")
|
||||||
|
|
||||||
|
|
||||||
|
@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("/")
|
@viewer_bp.route("/")
|
||||||
def viewer_index(viewer_id: str):
|
def viewer_index(viewer_id: str):
|
||||||
_resolve_viewer_db(viewer_id)
|
_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")
|
@viewer_bp.route("/api/snapshot/latest")
|
||||||
@@ -333,9 +372,19 @@ def api_import(viewer_id: str):
|
|||||||
app.register_blueprint(viewer_bp)
|
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("/")
|
@app.route("/")
|
||||||
def landing():
|
def landing():
|
||||||
return render_template("landing.html")
|
return render_template("landing.html", manifest_href="/manifest.webmanifest")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/viewers")
|
@app.post("/api/viewers")
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ async function init() {
|
|||||||
await I18n.init();
|
await I18n.init();
|
||||||
applyStaticI18n();
|
applyStaticI18n();
|
||||||
setupLanguage();
|
setupLanguage();
|
||||||
|
await Pwa.init();
|
||||||
setupViewerBanner();
|
setupViewerBanner();
|
||||||
setupNav();
|
setupNav();
|
||||||
setupUpload();
|
setupUpload();
|
||||||
@@ -123,6 +124,7 @@ function setupLanguage() {
|
|||||||
sel.addEventListener("change", async (e) => {
|
sel.addEventListener("change", async (e) => {
|
||||||
await I18n.setPreference(e.target.value);
|
await I18n.setPreference(e.target.value);
|
||||||
applyStaticI18n();
|
applyStaticI18n();
|
||||||
|
Pwa.refreshHint();
|
||||||
resetLocaleDependentPanels();
|
resetLocaleDependentPanels();
|
||||||
if (state.data) renderAll();
|
if (state.data) renderAll();
|
||||||
const gs = document.getElementById("global-search");
|
const gs = document.getElementById("global-search");
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 459 KiB |
@@ -2,6 +2,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
await I18n.init();
|
await I18n.init();
|
||||||
applyStaticI18n();
|
applyStaticI18n();
|
||||||
setupLanguage();
|
setupLanguage();
|
||||||
|
await Pwa.init();
|
||||||
setupCreate();
|
setupCreate();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ function setupLanguage() {
|
|||||||
sel.addEventListener("change", async (e) => {
|
sel.addEventListener("change", async (e) => {
|
||||||
await I18n.setPreference(e.target.value);
|
await I18n.setPreference(e.target.value);
|
||||||
applyStaticI18n();
|
applyStaticI18n();
|
||||||
|
Pwa.refreshHint();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,6 +226,15 @@
|
|||||||
"copied": "Kopiert!",
|
"copied": "Kopiert!",
|
||||||
"copyPrompt": "Viewer-Link kopieren:"
|
"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": {
|
"viewerDb": {
|
||||||
"title": "Viewer-Backup",
|
"title": "Viewer-Backup",
|
||||||
"helpTitle": "Was ist das?",
|
"helpTitle": "Was ist das?",
|
||||||
|
|||||||
@@ -226,6 +226,15 @@
|
|||||||
"copied": "Copied!",
|
"copied": "Copied!",
|
||||||
"copyPrompt": "Copy your viewer link:"
|
"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": {
|
"viewerDb": {
|
||||||
"title": "Viewer backup",
|
"title": "Viewer backup",
|
||||||
"helpTitle": "What is this?",
|
"helpTitle": "What is this?",
|
||||||
|
|||||||
+110
@@ -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;
|
||||||
@@ -855,6 +855,50 @@ body.inv-chart-modal-open {
|
|||||||
border-radius: 6px;
|
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 {
|
.viewer-copy-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
|
|||||||
@@ -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))
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<link rel="manifest" href="{{ manifest_href }}">
|
||||||
|
<meta name="theme-color" content="#1a1d27">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
||||||
|
<script src="/static/pwa.js" defer></script>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<div id="pwa-install-hint" class="pwa-hint" hidden>
|
||||||
|
<div class="pwa-hint-text">
|
||||||
|
<strong data-i18n="pwa.hintTitle">Install as app</strong>
|
||||||
|
<p id="pwa-hint-body" data-i18n="pwa.hintBody">Add this viewer to your home screen for quick access.</p>
|
||||||
|
</div>
|
||||||
|
<div class="pwa-hint-actions">
|
||||||
|
<button type="button" id="pwa-install-btn" class="viewer-copy-btn" hidden data-i18n="pwa.install">Install</button>
|
||||||
|
<button type="button" id="pwa-hint-dismiss" class="import-report-dismiss" data-i18n-title="actions.dismiss" title="Dismiss">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Idle Fantasy Viewer</title>
|
<title>Idle Fantasy Viewer</title>
|
||||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||||
|
{% include '_pwa_head.html' %}
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
{% include '_analytics.html' %}
|
{% include '_analytics.html' %}
|
||||||
<script src="/static/vendor/chart.umd.min.js" defer></script>
|
<script src="/static/vendor/chart.umd.min.js" defer></script>
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
<button class="nav-btn" data-tab="backup" data-i18n="nav.backup">Backup</button>
|
<button class="nav-btn" data-tab="backup" data-i18n="nav.backup">Backup</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
|
{% include '_pwa_hint.html' %}
|
||||||
<label class="lang-label" for="locale-select">
|
<label class="lang-label" for="locale-select">
|
||||||
<span data-i18n="settings.language">Language</span>
|
<span data-i18n="settings.language">Language</span>
|
||||||
<select id="locale-select" class="select-input lang-select">
|
<select id="locale-select" class="select-input lang-select">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Idle Fantasy Viewer</title>
|
<title>Idle Fantasy Viewer</title>
|
||||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||||
|
{% include '_pwa_head.html' %}
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
{% include '_analytics.html' %}
|
{% include '_analytics.html' %}
|
||||||
<script src="/static/i18n.js" defer></script>
|
<script src="/static/i18n.js" defer></script>
|
||||||
@@ -41,6 +42,8 @@
|
|||||||
<p class="landing-hint" id="create-status" hidden></p>
|
<p class="landing-hint" id="create-status" hidden></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include '_pwa_hint.html' %}
|
||||||
|
|
||||||
<div class="landing-warning">
|
<div class="landing-warning">
|
||||||
<strong data-i18n="viewer.warningTitle">Important</strong>
|
<strong data-i18n="viewer.warningTitle">Important</strong>
|
||||||
<p data-i18n="viewer.warningBody">
|
<p data-i18n="viewer.warningBody">
|
||||||
|
|||||||
Reference in New Issue
Block a user