diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..954c783 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "sakunpanthi.gitea-integration" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8101bfe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.fontSize": 18 +} \ No newline at end of file diff --git a/zuss/app.js b/zuss/app.js index 237c337..2cec45f 100644 --- a/zuss/app.js +++ b/zuss/app.js @@ -1,321 +1,394 @@ /* WFW-Aushang PWA * - Listet Dateien aus public WebDAV Share * - Öffnet PDFs über PDF.js - * - Kioskiges Install/Update Overlay + * - Install/Update Overlay mit beforeinstallprompt.prompt() */ -const SHARE_TOKEN = "T9e7WESBXxy6rSD"; -const SHARE_ROOT = "/"; // im Share -const DAV_BASE = `https://home.x-s.at/public.php/dav/files/${SHARE_TOKEN}`; - -const PDFJS_VIEWER = "https://home.x-s.at/pdfjs/web/viewer.html?file="; - -// Versioning für Overlay / Update-Erkennung -const APP_VERSION = "2025.12.24.2"; -const DISMISS_KEY = "wfw_overlay_dismissed_for"; +const APP_VERSION = "2025.12.24.6"; +const TOKEN = "T9e7WESBXxy6rSD"; +const BASE = "https://home.x-s.at"; +const SHARE_DAV = `${BASE}/public.php/dav/files/${TOKEN}`; +const PDFJS = `${BASE}/pdfjs/web/viewer.html?file=`; +// relative to /zuss/ const VERSION_URL = "./version.json"; +const LIST_START_DIR = "/"; // share root -function $(id){ return document.getElementById(id); } +let deferredInstallPrompt = null; +let latestKnown = APP_VERSION; -function applyTheme(theme){ - document.documentElement.setAttribute("data-theme", theme); - localStorage.setItem("wfw_theme", theme); +const $ = (id) => document.getElementById(id); + +function showError(msg) { + const el = $("err"); + el.style.display = "block"; + el.textContent = msg; } -function initTheme(){ - const saved = localStorage.getItem("wfw_theme"); - if (saved) return applyTheme(saved); - const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; - applyTheme(prefersDark ? "dark" : "light"); +function clearError() { + const el = $("err"); + el.style.display = "none"; + el.textContent = ""; } +function setStatus(msg) { $("status").textContent = msg; } -function isInstalledPWA(){ - const m = window.matchMedia ? window.matchMedia("(display-mode: standalone)") : null; - const m2 = window.matchMedia ? window.matchMedia("(display-mode: fullscreen)") : null; - const isStandalone = !!(m && m.matches); - const isFullscreen = !!(m2 && m2.matches); - const isIOSStandalone = window.navigator.standalone === true; - return isStandalone || isFullscreen || isIOSStandalone; -} - -function showOverlay({title, text, primaryText, hint=""}){ +function showOverlay(title, text, primaryText, showPrimary = true) { $("ovTitle").textContent = title; $("ovText").textContent = text; $("ovPrimaryBtn").textContent = primaryText; - $("ovHint").textContent = hint; - $("installOverlay").hidden = false; + $("ovPrimaryBtn").style.display = showPrimary ? "flex" : "none"; + $("ovHint").style.display = "none"; + $("installOverlay").style.display = "block"; } -function hideOverlay(){ - $("installOverlay").hidden = true; +function hideOverlay() { + $("installOverlay").style.display = "none"; } -function markOverlayDismissed(latestVersion){ - sessionStorage.setItem(DISMISS_KEY, latestVersion || APP_VERSION); -} -function isOverlayDismissed(latestVersion){ - const v = sessionStorage.getItem(DISMISS_KEY); - return v && v === (latestVersion || APP_VERSION); +function showManualInstallHint() { + $("ovHint").style.display = "block"; + $("ovHint").textContent = + "Hinweis: Falls kein Install-Dialog erscheint: Chrome Menü (⋮) → „App installieren“ / „Zum Startbildschirm hinzufügen“."; } -let deferredPrompt = null; -window.addEventListener("beforeinstallprompt", (e) => { - e.preventDefault(); - deferredPrompt = e; -}); +function compareVersions(a, b) { + // "2025.12.24.3" style; compare numeric segments + const pa = String(a).split(".").map(n => parseInt(n, 10) || 0); + const pb = String(b).split(".").map(n => parseInt(n, 10) || 0); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const da = pa[i] || 0, db = pb[i] || 0; + if (da > db) return 1; + if (da < db) return -1; + } + return 0; +} -// --- Service Worker --- -async function registerSW(){ - if (!("serviceWorker" in navigator)) return null; +async function fetchLatestVersion() { try { - const reg = await navigator.serviceWorker.register("./sw.js"); - // Optional: updaten, falls neue Version - try { await reg.update(); } catch {} - return reg; - } catch { - return null; + const r = await fetch(VERSION_URL, { cache: "no-store" }); + if (!r.ok) throw new Error(`version.json HTTP ${r.status}`); + const j = await r.json(); + if (j && j.version) { + latestKnown = j.version; + sessionStorage.setItem("wfw_latest", latestKnown); + } + } catch (e) { + // not fatal + console.warn("Could not fetch version.json:", e); } } -// --- Install / Update Overlay --- -async function checkInstallOrUpdateOverlay(reg){ - let latest = APP_VERSION; - try { - const r = await fetch(VERSION_URL, { cache: "no-store" }); - if (r.ok) { - const j = await r.json(); - if (j && typeof j.version === "string") latest = j.version; - } - } catch {} +function getInstalledVersion() { + return localStorage.getItem("wfw_installed_version") || null; +} +function markInstalledVersion(v) { + localStorage.setItem("wfw_installed_version", v); +} +function markOverlayDismissed(v) { + localStorage.setItem("wfw_dismissed_version", v); +} +function getDismissedVersion() { + return localStorage.getItem("wfw_dismissed_version") || null; +} - const installed = isInstalledPWA(); - const outdated = (latest !== APP_VERSION); +async function checkInstallOrUpdateOverlay() { + await fetchLatestVersion(); - // Wenn installiert UND aktuell -> kein Overlay - if (installed && !outdated) return; - - // Wenn veraltet -> "Aktualisieren" erzwingen (kioskig) - if (outdated) { - showOverlay({ - title: "Update verfügbar", - text: `Es gibt eine neue Version (${latest}). Bitte aktualisieren.`, - primaryText: "Aktualisieren", - hint: "Tipp: Nach dem Update wird die App neu geladen." - }); + const installed = getInstalledVersion(); + const dismissed = getDismissedVersion(); + const isStandalone = window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true; + // If installed version < latest -> show update overlay + if (installed && compareVersions(installed, latestKnown) < 0) { + showOverlay( + "Update verfügbar", + `Neue Version verfügbar (${latestKnown}). Bitte updaten für die neuesten Fixes.`, + "Update jetzt" + ); $("ovPrimaryBtn").onclick = async () => { - try { await reg?.update?.(); } catch {} - // Cache-bust / reload + try { + // ask SW to update + reload + const reg = await navigator.serviceWorker.getRegistration(); + if (reg) await reg.update(); + } catch {} + markInstalledVersion(latestKnown); location.reload(); }; - - $("ovContinueBtn").onclick = () => hideOverlay(); + $("ovContinueBtn").onclick = () => { + markOverlayDismissed(latestKnown); + hideOverlay(); + }; return; } - // Nicht installiert -> "Installieren" (oder Hinweis wenn Prompt fehlt) - const canInstall = !!deferredPrompt && !installed; + // If not installed (or standalone not detected), show install overlay until latest dismissed/installed + if (!installed && dismissed !== latestKnown && !isStandalone) { + showOverlay( + "WFW-Aushang", + "Installiere mich!", + "Installieren" + ); - if (canInstall) { - showOverlay({ - title: "App installieren", - text: "Installiert läuft WFW-Aushang schneller und im Kiosk-Modus.", - primaryText: "Installieren", - hint: "" - }); - - $("ovPrimaryBtn").onclick = async () => { - try { - deferredPrompt.prompt(); - await deferredPrompt.userChoice; - } catch {} - deferredPrompt = null; + $("ovPrimaryBtn").onclick = async (e) => { + e.preventDefault(); e.stopPropagation(); + await doInstall(); + }; + $("ovContinueBtn").onclick = (e) => { + e.preventDefault(); e.stopPropagation(); + markOverlayDismissed(latestKnown); hideOverlay(); }; - - } else { - // Fallback: wenn Chrome (noch) keinen Install-Prompt gibt - showOverlay({ - title: "App installieren", - text: "Wenn der Install-Button fehlt: Chrome Menü (⋮) → „App installieren“.", - primaryText: "OK", - hint: "" - }); - - $("ovPrimaryBtn").onclick = () => hideOverlay(); } - - $("ovContinueBtn").onclick = () => hideOverlay(); } -// --- WebDAV Listing via PROPFIND --- -async function propfind(url, depth=1){ - const res = await fetch(url, { +async function doInstall() { + if (!deferredInstallPrompt) { + showManualInstallHint(); + return; + } + try { + deferredInstallPrompt.prompt(); // <-- IMPORTANT + const res = await deferredInstallPrompt.userChoice; + deferredInstallPrompt = null; + + if (res && res.outcome === "accepted") { + // We'll also set installed version on appinstalled event + $("ovTitle").textContent = "Installiert ✓"; + $("ovText").textContent = "Danke! Du kannst die App jetzt wie eine normale App starten."; + markInstalledVersion(latestKnown); + setTimeout(() => hideOverlay(), 800); + } else { + showManualInstallHint(); + } + } catch (e) { + console.warn("Install prompt failed:", e); + showManualInstallHint(); + } +} + +/** WebDAV listing **/ +async function propfind(dir) { + const url = `${SHARE_DAV}${dir.endsWith("/") ? dir : dir + "/"}`; + const body = ` + + + + + + + + +`; + const r = await fetch(url, { method: "PROPFIND", - headers: { "Depth": String(depth) } + headers: { "Depth": "1", "Content-Type": "application/xml" }, + body }); - - if (!res.ok) { - const t = await res.text().catch(()=> ""); - throw new Error(`PROPFIND ${res.status} ${res.statusText} ${t.slice(0,200)}`); + if (!r.ok) { + const t = await r.text().catch(()=> ""); + throw new Error(`PROPFIND ${r.status} ${r.statusText}\n${t.slice(0, 400)}`); } - return await res.text(); + return await r.text(); } -function parseDavResponse(xmlText){ - const parser = new DOMParser(); - const xml = parser.parseFromString(xmlText, "application/xml"); - const responses = [...xml.getElementsByTagNameNS("*","response")]; +function parseDavMultistatus(xmlText, dir) { + const p = new DOMParser(); + const xml = p.parseFromString(xmlText, "application/xml"); + const responses = Array.from(xml.getElementsByTagNameNS("DAV:", "response")); - const items = responses.map(r => { - const hrefEl = r.getElementsByTagNameNS("*","href")[0]; - const href = hrefEl ? hrefEl.textContent : ""; + const items = []; - const propstat = r.getElementsByTagNameNS("*","propstat")[0]; - const prop = propstat ? propstat.getElementsByTagNameNS("*","prop")[0] : null; + for (const resp of responses) { + const hrefEl = resp.getElementsByTagNameNS("DAV:", "href")[0]; + if (!hrefEl) continue; + const href = decodeURIComponent(hrefEl.textContent || ""); - const isCollection = !!(prop && prop.getElementsByTagNameNS("*","collection")[0]); + // Skip the directory itself (first response) + const shareRoot = `/public.php/dav/files/${TOKEN}`; + if (href.endsWith(shareRoot + (dir === "/" ? "/" : dir))) continue; - const sizeEl = prop ? prop.getElementsByTagNameNS("*","getcontentlength")[0] : null; - const size = sizeEl ? parseInt(sizeEl.textContent || "0", 10) : 0; + const displayEl = resp.getElementsByTagNameNS("DAV:", "displayname")[0]; + const name = displayEl ? (displayEl.textContent || "").trim() : ""; - const modEl = prop ? prop.getElementsByTagNameNS("*","getlastmodified")[0] : null; - const lastmod = modEl ? modEl.textContent : ""; + const rtEl = resp.getElementsByTagNameNS("DAV:", "resourcetype")[0]; + const isCollection = rtEl && rtEl.getElementsByTagNameNS("DAV:", "collection").length > 0; - return { href, isCollection, size, lastmod }; - }); + const ctEl = resp.getElementsByTagNameNS("DAV:", "getcontenttype")[0]; + const contentType = ctEl ? (ctEl.textContent || "").trim() : ""; + const lmEl = resp.getElementsByTagNameNS("DAV:", "getlastmodified")[0]; + const lastMod = lmEl ? (lmEl.textContent || "").trim() : ""; + + items.push({ + href, + name: name || href.split("/").filter(Boolean).slice(-1)[0] || "", + isDir: !!isCollection, + contentType, + lastMod + }); + } + + // directories first, then by name + items.sort((a,b)=> (a.isDir === b.isDir ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))); return items; } -function humanSize(bytes){ - if (!bytes || bytes < 1024) return bytes ? `${bytes} B` : ""; - const kb = bytes/1024; - if (kb < 1024) return `${kb.toFixed(0)} KB`; - const mb = kb/1024; - return `${mb.toFixed(1)} MB`; +function toPdfJsUrl(publicPath) { + // publicPath must be the share file path relative to share root, starting with "/" + const fileUrl = `${SHARE_DAV}${publicPath}`; // direct public dav file URL + return `${PDFJS}${encodeURIComponent(fileUrl)}#zoom=page-width`; } -function safeDecode(s){ - try { return decodeURIComponent(s); } catch { return s; } -} - -function extractNameFromHref(href){ - const noQuery = href.split("?")[0]; - const parts = noQuery.split("/").filter(Boolean); - const last = parts[parts.length-1] || ""; - return safeDecode(last); -} - -function pdfUrlFromHref(href){ - // href ist serverseitig percent-encoded; wir wollen eine saubere absolute URL für public.php/dav - // href kommt meist als /public.php/dav/files//... oder voll - if (href.startsWith("http")) return href; - return `https://home.x-s.at${href}`; -} - -function openPdfInPdfJs(fileUrl){ - // PDF.js erwartet file=ENCODED_URL - const full = PDFJS_VIEWER + encodeURIComponent(fileUrl) + "#zoom=page-width"; - window.location.href = full; -} - -function renderList(files){ +function renderList(items, currentDir) { const list = $("list"); list.innerHTML = ""; - if (!files.length) { - $("status").textContent = "Keine PDFs gefunden."; + // no folder browsing (kiosk): show only PDFs in root+subdirs? you asked "only directory content" of share root. + // We'll show only currentDir content; no folder nav UI. + const pdfs = items.filter(it => !it.isDir && (it.name.toLowerCase().endsWith(".pdf") || it.contentType.includes("pdf"))); + + if (pdfs.length === 0) { + setStatus("Keine PDFs gefunden."); return; } - for (const f of files) { - const row = document.createElement("div"); - row.className = "item"; + setStatus(`PDFs: ${pdfs.length}`); - const icon = document.createElement("div"); - icon.className = "pdfIcon"; - icon.textContent = "📄"; + for (const it of pdfs) { + const a = document.createElement("a"); + a.className = "row"; + a.href = "#"; + a.addEventListener("click", (e) => { + e.preventDefault(); - const name = document.createElement("div"); - name.className = "name"; - name.textContent = f.name; + // Build relative path within share + // href includes /public.php/dav/files//...; we want path after that + const marker = `/public.php/dav/files/${TOKEN}`; + const idx = it.href.indexOf(marker); + let rel = "/"; + if (idx >= 0) rel = it.href.slice(idx + marker.length); + if (!rel.startsWith("/")) rel = "/" + rel; - const meta = document.createElement("div"); - meta.className = "meta"; - meta.textContent = humanSize(f.size); - - row.appendChild(icon); - row.appendChild(name); - row.appendChild(meta); - - row.onclick = () => openPdfInPdfJs(f.url); - - list.appendChild(row); - } - - $("status").textContent = `PDFs: ${files.length}`; -} - -async function load(){ - $("status").textContent = "Lade Liste…"; - - const basePath = SHARE_ROOT.replace(/^\/?/, "/").replace(/\/?$/, "/"); - const url = DAV_BASE + basePath; // z.B. .../TOKEN/ - - const xml = await propfind(url, 1); - const items = parseDavResponse(xml); - - // Erstes Element ist meistens das Verzeichnis selbst -> rausfiltern - const files = items - .filter(it => !it.isCollection) - .map(it => { - const name = extractNameFromHref(it.href); - return { - name, - size: it.size || 0, - url: pdfUrlFromHref(it.href) - }; - }) - .filter(f => f.name.toLowerCase().endsWith(".pdf")); - - // Sortierung: nach Dateiname absteigend (oft Datum im Namen) - files.sort((a,b) => b.name.localeCompare(a.name, "de")); - - renderList(files); -} - -function closeAushang(){ - // kiosk-typisch: zurück oder tab schließen (falls erlaubt) - try { window.close(); } catch {} - history.back(); -} - -// Boot -document.addEventListener("DOMContentLoaded", async () => { - initTheme(); - - $("themeBtn").addEventListener("click", () => { - const cur = document.documentElement.getAttribute("data-theme") || "light"; - applyTheme(cur === "dark" ? "light" : "dark"); - }); - - $("refreshBtn").addEventListener("click", () => load()); - $("closeBtn").addEventListener("click", closeAushang); - - const reg = await registerSW(); - await checkInstallOrUpdateOverlay(reg); - - try { - await load(); - } catch (e) { - $("status").textContent = "Fehler beim Laden der Liste."; - // In kiosk mode: Fehlertext kurz, Details nur im Overlay-Hint - showOverlay({ - title: "Fehler", - text: "Die Liste konnte nicht geladen werden.", - primaryText: "OK", - hint: String(e).slice(0, 140) + window.location.href = toPdfJsUrl(rel); }); - $("ovPrimaryBtn").onclick = () => hideOverlay(); - $("ovContinueBtn").onclick = () => hideOverlay(); + + const ico = document.createElement("div"); + ico.className = "ico"; + ico.innerHTML = ` + + + + `; + + const mid = document.createElement("div"); + const nm = document.createElement("div"); + nm.className = "name"; + nm.textContent = it.name; + const sub = document.createElement("div"); + sub.className = "sub"; + sub.textContent = it.lastMod ? it.lastMod : ""; + mid.appendChild(nm); + mid.appendChild(sub); + + const badge = document.createElement("div"); + badge.className = "badge"; + badge.textContent = "PDF"; + + a.appendChild(ico); + a.appendChild(mid); + a.appendChild(document.createElement("div")).className = "spacer"; + a.appendChild(badge); + + list.appendChild(a); } -}); \ No newline at end of file +} + +async function loadList() { + clearError(); + setStatus("Lade Liste…"); + try { + const xml = await propfind(LIST_START_DIR); + const items = parseDavMultistatus(xml, LIST_START_DIR); + renderList(items, LIST_START_DIR); + } catch (e) { + console.error(e); + setStatus("Fehler"); + showError(String(e && e.message ? e.message : e)); + } +} + +async function registerSW() { + if (!("serviceWorker" in navigator)) return; + try { + const reg = await navigator.serviceWorker.register("./sw.js", { scope: "./" }); + // Trigger update check + reg.update().catch(()=>{}); + } catch (e) { + console.warn("SW register failed:", e); + } +} + +function applyTheme(t){ + document.documentElement.setAttribute("data-theme", t); + localStorage.setItem("wfw_theme", t); + $("themeBtn").textContent = t === "dark" ? "🌙 Dunkel" : "☀️ Hell"; +} + +function initTheme(){ + const saved = localStorage.getItem("wfw_theme"); + if (saved){ + applyTheme(saved); + } else { + // Default: System + const prefersLight = window.matchMedia("(prefers-color-scheme: light)").matches; + applyTheme(prefersLight ? "light" : "dark"); + } +} + + +window.addEventListener("appinstalled", () => { + // user installed the app + markInstalledVersion(latestKnown); + markOverlayDismissed(latestKnown); + hideOverlay(); +}); + +window.addEventListener("beforeinstallprompt", (e) => { + // We want our own overlay, so we prevent default banner + e.preventDefault(); + deferredInstallPrompt = e; + // Show overlay if not yet installed + checkInstallOrUpdateOverlay(); +}); + +// Extra safety: capture clicks even if something gets weird on mobile +document.addEventListener("click", (e) => { + const t = e.target; + if (!t) return; + + if (t.id === "ovContinueBtn") { + e.preventDefault(); e.stopPropagation(); + markOverlayDismissed(latestKnown); + hideOverlay(); + } + if (t.id === "ovPrimaryBtn") { + // If handler was overwritten, still attempt install/update + // (doInstall will show manual hint if prompt not available) + } +}, { capture: true }); + +document.addEventListener("DOMContentLoaded", async () => { + $("refreshBtn").addEventListener("click", (e) => { e.preventDefault(); loadList(); }); + $("closeBtn").addEventListener("click", (e) => { + e.preventDefault(); + // Kiosk-ish: close tab if possible, else just go back + window.close(); + setTimeout(() => { history.back(); }, 150); + }); + initTheme(); + $("themeBtn").addEventListener("click", () => { + const current = document.documentElement.getAttribute("data-theme") || "dark"; + applyTheme(current === "dark" ? "light" : "dark"); + }); + await registerSW(); + await checkInstallOrUpdateOverlay(); + await loadList(); +}); diff --git a/zuss/index.html b/zuss/index.html index bc6e45d..2cd8969 100644 --- a/zuss/index.html +++ b/zuss/index.html @@ -1,285 +1,173 @@ - + - + + + + + + + + WFW-Aushang - - - - - - - - - - -