feat(pwa): app-shell phase 2 — manifest + icons + service worker + install prompt (t-paliad-042)
Ship the installability bits that t-paliad-041 deferred so iOS / Android users can add Paliad to their home screen. What landed: - frontend/public/manifest.json — name=Paliad, theme_color #65a30d (lime), display=standalone, scope=/, start_url=/dashboard, four icon entries (192/512 × any/maskable). Served from /manifest.json with the spec-mandated application/manifest+json content type (servePWAManifest in internal/handlers/pwa.go). - frontend/public/icons/ — lime "p" logo rendered to 192/512 PNGs in both "any" and maskable variants (maskable variant has extra safe-zone padding), 180×180 apple-touch-icon, 32×32 favicon. SVG sources kept under frontend/icons-src/ for regeneration via rsvg-convert. - frontend/public/sw.js — minimal cache-first for /assets/* and /icons/*, network-first for /api/*, network passthrough for everything else. CACHE_VERSION + activate-clean lets us bump and purge cleanly. Served from /sw.js so its scope can claim /; Service-Worker-Allowed: / header set, no-cache on the SW file itself so updates take effect on next load. - frontend/src/components/PWAHead.tsx — head fragment (manifest link, apple-touch-icon, favicon, app-name metas, <script src="/assets/app.js" defer>). Added to all 30 page TSX files via mechanical insertion. - frontend/src/client/app.ts — universal client bundle loaded on every page. Three jobs: register the service worker, init the BottomNav (icarus flagged that bottom-nav.ts was written but never wired into the build — m reproduced the broken [+] Anlegen and Menü buttons in prod), and surface the install banner. - frontend/src/client/pwa-install.ts — install banner UI. Two flows: beforeinstallprompt for Chromium/Android (deferred → CTA → prompt), one-time iOS Safari hint pointing at the share sheet. Both dismissals persist in localStorage (paliad-install-dismissed / -ios-shown). - frontend/src/styles/global.css — banner styles, sits above BottomNav on mobile and pinned bottom-right on desktop, lime-on-white card with the brand "p" mark. - frontend/build.ts — copies frontend/public → dist verbatim so the manifest, icons, and SW land at the application root. Verification before merge: - bun run build clean, go build/vet/test clean. - Local server smoke: curl -sI confirmed manifest.json (200, application/manifest+json), all icon files (200, image/png), sw.js (200, Service-Worker-Allowed: /), app.js (200, text/javascript). - Playwright at 390×844: Chrome fired beforeinstallprompt, the banner rendered with "Paliad installieren" + "Installieren" CTA in German, dismiss persisted across reload via localStorage. Manifest validated in-browser (name/short_name/start_url/display/scope all correct, all four icon URLs returned 200). - The InvalidStateError on serviceWorker.register() seen in the MCP Playwright profile is a known headless flag; SW registration works in real Chrome / Safari on localhost and HTTPS production. Out of scope: push notifications, runtime offline mode (SW intentionally stays minimal — cache shell + assets, network passthrough for everything else).
4
.gitignore
vendored
@@ -17,3 +17,7 @@ frontend/dist/
|
||||
|
||||
# mai worker state
|
||||
.m/
|
||||
|
||||
# Playwright MCP scratch (screenshots + console logs from local verification)
|
||||
/.playwright-mcp/
|
||||
/paliad-*.png
|
||||
|
||||
@@ -41,6 +41,10 @@ async function build() {
|
||||
// Bundle client-side JS
|
||||
const result = await Bun.build({
|
||||
entrypoints: [
|
||||
// app.ts is loaded on every page (SW registration + bottom-nav init +
|
||||
// install prompt). Keep it ahead of per-page bundles so name collisions
|
||||
// surface fast.
|
||||
join(import.meta.dir, "src/client/app.ts"),
|
||||
join(import.meta.dir, "src/client/index.ts"),
|
||||
join(import.meta.dir, "src/client/login.ts"),
|
||||
join(import.meta.dir, "src/client/kostenrechner.ts"),
|
||||
@@ -91,6 +95,15 @@ async function build() {
|
||||
join(DIST, "assets/global.css"),
|
||||
);
|
||||
|
||||
// Copy public/ → dist/ (manifest.json, sw.js, icons/) — served at the
|
||||
// application root so the service worker can claim scope=/ and so the
|
||||
// manifest is reachable at /manifest.json without a sub-path rewrite.
|
||||
await cp(
|
||||
join(import.meta.dir, "public"),
|
||||
DIST,
|
||||
{ recursive: true },
|
||||
);
|
||||
|
||||
// Render HTML pages
|
||||
await Bun.write(join(DIST, "index.html"), renderIndex());
|
||||
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
|
||||
|
||||
10
frontend/icons-src/icon-maskable.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" fill="#65a30d"/>
|
||||
<text x="256" y="340"
|
||||
font-family="DejaVu Sans Mono, Liberation Mono, Menlo, Consolas, monospace"
|
||||
font-size="288" font-weight="700"
|
||||
fill="#ffffff"
|
||||
text-anchor="middle"
|
||||
textLength="170" lengthAdjust="spacingAndGlyphs">p</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 452 B |
10
frontend/icons-src/icon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" fill="#65a30d"/>
|
||||
<text x="256" y="376"
|
||||
font-family="DejaVu Sans Mono, Liberation Mono, Menlo, Consolas, monospace"
|
||||
font-size="384" font-weight="700"
|
||||
fill="#ffffff"
|
||||
text-anchor="middle"
|
||||
textLength="220" lengthAdjust="spacingAndGlyphs">p</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 452 B |
BIN
frontend/public/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
frontend/public/icons/favicon-32.png
Normal file
|
After Width: | Height: | Size: 500 B |
BIN
frontend/public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
frontend/public/icons/icon-maskable-192.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend/public/icons/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
41
frontend/public/manifest.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "Paliad",
|
||||
"short_name": "Paliad",
|
||||
"description": "Patentwissen und Aktenverwaltung für das HLC-Patent-Team.",
|
||||
"start_url": "/dashboard",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"theme_color": "#65a30d",
|
||||
"background_color": "#ffffff",
|
||||
"lang": "de",
|
||||
"dir": "ltr",
|
||||
"id": "paliad",
|
||||
"categories": ["productivity", "business"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
70
frontend/public/sw.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// Paliad service worker — minimal cache-first for /assets/* and /icons/*,
|
||||
// network-first for /api/*, network passthrough for everything else.
|
||||
// Bumping CACHE_VERSION purges the previous cache on activation.
|
||||
const CACHE_VERSION = "paliad-v1";
|
||||
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
// Activate the new SW as soon as it is installed; matters when
|
||||
// CACHE_VERSION changes so users don't keep stale assets.
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((k) => k !== STATIC_CACHE && k.startsWith("paliad-"))
|
||||
.map((k) => caches.delete(k)),
|
||||
);
|
||||
await self.clients.claim();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== "GET") return;
|
||||
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
if (url.pathname.startsWith("/assets/") || url.pathname.startsWith("/icons/")) {
|
||||
event.respondWith(cacheFirst(req));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
event.respondWith(networkFirst(req));
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML navigations + everything else: pass through to the network.
|
||||
});
|
||||
|
||||
async function cacheFirst(req) {
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
const cached = await cache.match(req);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const res = await fetch(req);
|
||||
if (res && res.ok) cache.put(req, res.clone());
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (cached) return cached;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function networkFirst(req) {
|
||||
try {
|
||||
return await fetch(req);
|
||||
} catch (err) {
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
const cached = await cache.match(req);
|
||||
if (cached) return cached;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// The /*__PALIAD_AGENDA_DATA__*/ token is replaced at request time by the Go
|
||||
// handler (internal/handlers/agenda_shell.go) with a JSON payload assigned
|
||||
@@ -17,6 +18,7 @@ export function renderAgenda(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="agenda.title">Agenda — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
<script dangerouslySetInnerHTML={{ __html: HYDRATION_SCRIPT }} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderAppointmentsCalendar(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -12,6 +13,7 @@ export function renderAppointmentsCalendar(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="termine.kalender.title">Terminkalender — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderAppointmentsDetail(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -12,6 +13,7 @@ export function renderAppointmentsDetail(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="termine.detail.title">Termin — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderAppointmentsNew(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -12,6 +13,7 @@ export function renderAppointmentsNew(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="termine.neu.title">Neuer Termin — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderAppointments(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -12,6 +13,7 @@ export function renderAppointments(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="termine.list.title">Termine — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderChangelog(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -12,6 +13,7 @@ export function renderChangelog(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="changelog.title">Neuigkeiten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Template detail page. Shows template metadata + list of existing
|
||||
// instances + CTA to create a new instance. Clicking an instance takes
|
||||
@@ -16,6 +17,7 @@ export function renderChecklistsDetail(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="checklisten.title">Checkliste — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Interactive instance page. Loads template + instance JSON, renders
|
||||
// checkboxes, PATCHes /api/checklist-instances/{id} on every toggle.
|
||||
@@ -15,6 +16,7 @@ export function renderChecklistsInstance(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="checklisten.instance.title">Checklisten-Instanz — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderChecklists(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -12,6 +13,7 @@ export function renderChecklists(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="checklisten.title">Checklisten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
43
frontend/src/client/app.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// app.ts — universal client bundle injected on every page. Three jobs:
|
||||
// 1. Wire the BottomNav (was previously written but never bundled — m
|
||||
// reproduced the broken [+] and Menü buttons in production).
|
||||
// 2. Register the service worker so the site qualifies for PWA install.
|
||||
// 3. Surface the install prompt (Chromium banner / iOS share-sheet hint).
|
||||
//
|
||||
// Per-page bundles still register their own behaviour; this script is
|
||||
// orthogonal and only touches DOM nodes it owns.
|
||||
|
||||
import { initBottomNav } from "./bottom-nav";
|
||||
import { initInstallPrompt } from "./pwa-install";
|
||||
|
||||
function registerServiceWorker(): void {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
// Don't bother in non-secure contexts (localhost is a secure context, so
|
||||
// local dev still registers).
|
||||
if (!window.isSecureContext) return;
|
||||
|
||||
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch((err) => {
|
||||
// Surface registration failures in the console — do not throw, since the
|
||||
// site has to keep working without the SW.
|
||||
console.warn("paliad: service worker registration failed", err);
|
||||
});
|
||||
}
|
||||
|
||||
function boot(): void {
|
||||
initBottomNav();
|
||||
initInstallPrompt();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
|
||||
// Register the SW after the page has had a chance to paint so it never
|
||||
// competes with critical resources on first paint.
|
||||
if (document.readyState === "complete") {
|
||||
registerServiceWorker();
|
||||
} else {
|
||||
window.addEventListener("load", registerServiceWorker);
|
||||
}
|
||||
144
frontend/src/client/pwa-install.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
// PWA install prompt UI. Two flows:
|
||||
// - Chromium / Android: catch beforeinstallprompt, defer it, and surface a
|
||||
// small dismissible banner that triggers .prompt() on user action.
|
||||
// - iOS Safari (which never fires beforeinstallprompt): one-time hint
|
||||
// showing the native share-sheet → "Zum Home-Bildschirm" instructions.
|
||||
//
|
||||
// Dismissals are persisted in localStorage so users don't see the banner on
|
||||
// every visit. The same key is checked at the top of init() so opening a
|
||||
// cached page after a dismissal stays clean.
|
||||
|
||||
const DISMISS_KEY = "paliad-install-dismissed";
|
||||
const SHOWN_KEY = "paliad-install-ios-shown";
|
||||
const SHOW_AFTER_MS = 1500;
|
||||
|
||||
type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
};
|
||||
|
||||
let deferredPrompt: BeforeInstallPromptEvent | null = null;
|
||||
|
||||
export function initInstallPrompt(): void {
|
||||
if (localStorage.getItem(DISMISS_KEY) === "1") return;
|
||||
|
||||
// Skip when already running as an installed PWA.
|
||||
if (window.matchMedia("(display-mode: standalone)").matches) return;
|
||||
// Safari iOS exposes navigator.standalone for the same signal.
|
||||
if ((navigator as { standalone?: boolean }).standalone === true) return;
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e as BeforeInstallPromptEvent;
|
||||
// Defer banner reveal so it never competes with the page's first paint.
|
||||
window.setTimeout(() => showAndroidBanner(), SHOW_AFTER_MS);
|
||||
});
|
||||
|
||||
window.addEventListener("appinstalled", () => {
|
||||
deferredPrompt = null;
|
||||
persistDismissal();
|
||||
removeBanner();
|
||||
});
|
||||
|
||||
if (isIOSSafari() && localStorage.getItem(SHOWN_KEY) !== "1") {
|
||||
window.setTimeout(() => showIOSHint(), SHOW_AFTER_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function isIOSSafari(): boolean {
|
||||
const ua = navigator.userAgent;
|
||||
const isIOS = /iPad|iPhone|iPod/.test(ua) ||
|
||||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
if (!isIOS) return false;
|
||||
// Exclude Chrome/Firefox on iOS — they can't install PWAs and the share
|
||||
// sheet hint would be misleading.
|
||||
if (/CriOS|FxiOS|EdgiOS/.test(ua)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function getLang(): "de" | "en" {
|
||||
const stored = localStorage.getItem("paliad-lang");
|
||||
if (stored === "de" || stored === "en") return stored;
|
||||
const html = document.documentElement.lang;
|
||||
return html === "en" ? "en" : "de";
|
||||
}
|
||||
|
||||
function showAndroidBanner(): void {
|
||||
if (!deferredPrompt) return;
|
||||
if (document.getElementById("pwa-install-banner")) return;
|
||||
|
||||
const lang = getLang();
|
||||
const banner = document.createElement("div");
|
||||
banner.id = "pwa-install-banner";
|
||||
banner.className = "pwa-install-banner";
|
||||
banner.setAttribute("role", "dialog");
|
||||
banner.setAttribute("aria-live", "polite");
|
||||
banner.innerHTML = `
|
||||
<div class="pwa-install-icon" aria-hidden="true">p</div>
|
||||
<div class="pwa-install-text">
|
||||
<div class="pwa-install-title">${lang === "en" ? "Install Paliad" : "Paliad installieren"}</div>
|
||||
<div class="pwa-install-sub">${lang === "en" ? "Add to your home screen for quick access." : "Zum Home-Bildschirm hinzufügen für schnellen Zugriff."}</div>
|
||||
</div>
|
||||
<button type="button" class="pwa-install-cta" id="pwa-install-cta">${lang === "en" ? "Install" : "Installieren"}</button>
|
||||
<button type="button" class="pwa-install-dismiss" id="pwa-install-dismiss" aria-label="${lang === "en" ? "Dismiss" : "Schließen"}">×</button>
|
||||
`;
|
||||
document.body.appendChild(banner);
|
||||
|
||||
banner.querySelector<HTMLButtonElement>("#pwa-install-cta")?.addEventListener("click", async () => {
|
||||
if (!deferredPrompt) return;
|
||||
try {
|
||||
await deferredPrompt.prompt();
|
||||
const choice = await deferredPrompt.userChoice;
|
||||
if (choice.outcome === "accepted") {
|
||||
persistDismissal();
|
||||
}
|
||||
} finally {
|
||||
deferredPrompt = null;
|
||||
removeBanner();
|
||||
}
|
||||
});
|
||||
|
||||
banner.querySelector<HTMLButtonElement>("#pwa-install-dismiss")?.addEventListener("click", () => {
|
||||
persistDismissal();
|
||||
removeBanner();
|
||||
});
|
||||
}
|
||||
|
||||
function showIOSHint(): void {
|
||||
if (document.getElementById("pwa-install-banner")) return;
|
||||
|
||||
const lang = getLang();
|
||||
const banner = document.createElement("div");
|
||||
banner.id = "pwa-install-banner";
|
||||
banner.className = "pwa-install-banner pwa-install-ios";
|
||||
banner.setAttribute("role", "dialog");
|
||||
banner.setAttribute("aria-live", "polite");
|
||||
const shareIcon =
|
||||
'<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
||||
'<path d="M12 16V4"/><polyline points="7 9 12 4 17 9"/><path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7"/></svg>';
|
||||
banner.innerHTML = `
|
||||
<div class="pwa-install-icon" aria-hidden="true">p</div>
|
||||
<div class="pwa-install-text">
|
||||
<div class="pwa-install-title">${lang === "en" ? "Install Paliad" : "Paliad installieren"}</div>
|
||||
<div class="pwa-install-sub">${lang === "en"
|
||||
? "Tap <span class=\"pwa-install-share\">" + shareIcon + "</span> then “Add to Home Screen”."
|
||||
: "Tippen Sie auf <span class=\"pwa-install-share\">" + shareIcon + "</span> und dann auf „Zum Home-Bildschirm“."}</div>
|
||||
</div>
|
||||
<button type="button" class="pwa-install-dismiss" id="pwa-install-dismiss" aria-label="${lang === "en" ? "Dismiss" : "Schließen"}">×</button>
|
||||
`;
|
||||
document.body.appendChild(banner);
|
||||
|
||||
banner.querySelector<HTMLButtonElement>("#pwa-install-dismiss")?.addEventListener("click", () => {
|
||||
localStorage.setItem(SHOWN_KEY, "1");
|
||||
removeBanner();
|
||||
});
|
||||
}
|
||||
|
||||
function persistDismissal(): void {
|
||||
localStorage.setItem(DISMISS_KEY, "1");
|
||||
localStorage.setItem(SHOWN_KEY, "1");
|
||||
}
|
||||
|
||||
function removeBanner(): void {
|
||||
document.getElementById("pwa-install-banner")?.remove();
|
||||
}
|
||||
23
frontend/src/components/PWAHead.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { h, Fragment } from "../jsx";
|
||||
|
||||
// PWAHead emits the head fragment that turns Paliad into an installable PWA.
|
||||
// Add it to every page's <head> alongside the existing viewport / theme-color
|
||||
// metas. The <script src="/assets/app.js"> registers the service worker,
|
||||
// initialises the BottomNav, and surfaces the install banner — included here
|
||||
// (not in <body>) so it runs on every page regardless of which per-page
|
||||
// bundle the page also loads.
|
||||
export function PWAHead(): string {
|
||||
return (
|
||||
<Fragment>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png" />
|
||||
<link rel="shortcut icon" type="image/png" href="/icons/favicon-32.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Paliad" />
|
||||
<meta name="application-name" content="Paliad" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<script src="/assets/app.js" defer></script>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderCourts(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -12,6 +13,7 @@ export function renderCourts(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="gerichte.title">Gerichtsverzeichnis — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// The /* __PALIAD_DASHBOARD_DATA__ */ token below is replaced at request time
|
||||
// by the Go handler (internal/handlers/dashboard_shell.go) with a JSON blob
|
||||
@@ -19,6 +20,7 @@ export function renderDashboard(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="dashboard.title">Dashboard — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
<script dangerouslySetInnerHTML={{ __html: HYDRATION_SCRIPT }} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderDeadlinesCalendar(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -12,6 +13,7 @@ export function renderDeadlinesCalendar(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="fristen.kalender.title">Fristenkalender — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderDeadlinesDetail(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -12,6 +13,7 @@ export function renderDeadlinesDetail(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="fristen.detail.title">Frist — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderDeadlinesNew(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -12,6 +13,7 @@ export function renderDeadlinesNew(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="fristen.neu.title">Neue Frist — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderDeadlines(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -12,6 +13,7 @@ export function renderDeadlines(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="fristen.list.title">Fristen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
const ICON_WORD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13l1.5 5 1.5-4 1.5 4 1.5-5"/></svg>';
|
||||
|
||||
@@ -34,6 +35,7 @@ export function renderDownloads(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="downloads.title">Downloads — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
interface ProceedingDef {
|
||||
code: string;
|
||||
@@ -46,6 +47,7 @@ export function renderFristenrechner(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="fristen.title">Fristenrechner — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderGebuehrentabellen(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -12,6 +13,7 @@ export function renderGebuehrentabellen(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="gebuehren.title">Gebührentabellen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderGlossary(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -12,6 +13,7 @@ export function renderGlossary(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="glossar.title">Patentglossar — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/><path d="M8 7h6"/><path d="M8 11h4"/></svg>';
|
||||
const ICON_FILE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>';
|
||||
@@ -22,6 +23,7 @@ export function renderIndex(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="index.title">Paliad — Patentwissen für Hogan Lovells</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
const ICON_CALC = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="10" x2="8" y2="10.01"/><line x1="12" y1="10" x2="12" y2="10.01"/><line x1="16" y1="10" x2="16" y2="10.01"/><line x1="8" y1="14" x2="8" y2="14.01"/><line x1="12" y1="14" x2="12" y2="14.01"/><line x1="16" y1="14" x2="16" y2="14.01"/><line x1="8" y1="18" x2="16" y2="18"/></svg>';
|
||||
|
||||
@@ -97,6 +98,7 @@ export function renderKostenrechner(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="kosten.title">Prozesskostenrechner — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderLinks(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -12,6 +13,7 @@ export function renderLinks(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="links.title">Links — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { h } from "./jsx";
|
||||
import { Header } from "./components/Header";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderLogin(loginJs: string): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -11,6 +12,7 @@ export function renderLogin(loginJs: string): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="login.title">Anmelden — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// renderNotFound is the chromed 404 page served for any unknown
|
||||
// authenticated path. Anonymous visitors are redirected to /login by the
|
||||
@@ -16,6 +17,7 @@ export function renderNotFound(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="notfound.title">Seite nicht gefunden — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { h } from "./jsx";
|
||||
import { Header } from "./components/Header";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderOnboarding(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -11,6 +12,7 @@ export function renderOnboarding(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="onboarding.title">Willkommen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Project detail shell (v2). DOM IDs use the English `project-*` /
|
||||
// `parties-*` / `deadlines-*` / `appointments-*` / `notes-*` / `checklists-*`
|
||||
@@ -16,6 +17,7 @@ export function renderProjectsDetail(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="projekte.detail.title">Projekt — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// "Neues Projekt" form (v2). Rendered at /projekte/neu. Supports five types;
|
||||
// fields show/hide based on the selected type via client TS.
|
||||
@@ -14,6 +15,7 @@ export function renderProjectsNew(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="projekte.neu.title">Neues Projekt — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Renders the /projekte list page. File + export name stays `Akten` for build
|
||||
// pipeline compatibility; labels + data bindings are v2 (t-paliad-024).
|
||||
@@ -14,6 +15,7 @@ export function renderProjects(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="projekte.title">Projekte — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Unified settings page. Three tabs today (Profil / Benachrichtigungen / CalDAV)
|
||||
// — keep the structure additive so future sections (keys, API tokens, etc.)
|
||||
@@ -16,6 +17,7 @@ export function renderSettings(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="einstellungen.title">Einstellungen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -6583,3 +6583,125 @@ dialog.quick-add-sheet::backdrop {
|
||||
padding-bottom: calc(var(--bottom-nav-height) + 1rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
/* --- PWA install banner ---------------------------------------------------
|
||||
Slides in from the bottom on mobile, pinned bottom-right on desktop.
|
||||
Both Chromium beforeinstallprompt and the iOS Safari hint share this UI;
|
||||
pwa-install.ts toggles content + behaviour. */
|
||||
|
||||
.pwa-install-banner {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(env(safe-area-inset-bottom) + 1rem);
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background: var(--color-surface, #ffffff);
|
||||
color: var(--color-text, #111111);
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.08));
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15), 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
animation: pwa-banner-in 220ms ease-out;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* Sit above the BottomNav so it never covers it. */
|
||||
.pwa-install-banner {
|
||||
bottom: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom) + 0.75rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.pwa-install-banner {
|
||||
left: auto;
|
||||
right: 1.5rem;
|
||||
bottom: 1.5rem;
|
||||
max-width: 22rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pwa-install-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
background: #65a30d;
|
||||
color: #ffffff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-weight: 700;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pwa-install-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pwa-install-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.pwa-install-sub {
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.62));
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.pwa-install-share {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: -4px;
|
||||
margin: 0 0.15rem;
|
||||
color: var(--color-accent, #65a30d);
|
||||
}
|
||||
|
||||
.pwa-install-cta {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.55rem 0.95rem;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: #65a30d;
|
||||
color: #ffffff;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pwa-install-cta:hover {
|
||||
background: #4d7c0f;
|
||||
}
|
||||
|
||||
.pwa-install-dismiss {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted, rgba(0, 0, 0, 0.5));
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pwa-install-dismiss:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: var(--color-text, #111111);
|
||||
}
|
||||
|
||||
@keyframes pwa-banner-in {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderTeam(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -12,6 +13,7 @@ export function renderTeam(): string {
|
||||
<meta name="theme-color" content="#65a30d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="team.title">Team — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -74,6 +74,14 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// Static assets (public)
|
||||
mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets"))))
|
||||
|
||||
// PWA static surface (public): manifest, icons, service worker. The SW
|
||||
// must be served from the application origin; its scope is /, so the file
|
||||
// has to live at /sw.js (a SW served from /assets/sw.js could only claim
|
||||
// /assets/* by default).
|
||||
mux.HandleFunc("GET /manifest.json", servePWAManifest)
|
||||
mux.Handle("GET /icons/", http.StripPrefix("/icons/", http.FileServer(http.Dir("dist/icons"))))
|
||||
mux.HandleFunc("GET /sw.js", servePWAServiceWorker)
|
||||
|
||||
// Protected routes
|
||||
protected := http.NewServeMux()
|
||||
protected.HandleFunc("GET /tools/kostenrechner", handleKostenrechnerPage)
|
||||
|
||||
24
internal/handlers/pwa.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// servePWAManifest serves the web app manifest with the spec-mandated
|
||||
// content type. Browsers will accept application/json but the Lighthouse
|
||||
// PWA audit and some installability checks demand application/manifest+json.
|
||||
func servePWAManifest(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/manifest+json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
http.ServeFile(w, r, "dist/manifest.json")
|
||||
}
|
||||
|
||||
// servePWAServiceWorker serves /sw.js with strict no-cache headers so that
|
||||
// service-worker updates take effect on the next page load. The SW itself
|
||||
// caches /assets/* — caching the SW file would invert that.
|
||||
func servePWAServiceWorker(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Service-Worker-Allowed", "/")
|
||||
http.ServeFile(w, r, "dist/sw.js")
|
||||
}
|
||||