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).
This commit is contained in:
m
2026-04-26 10:48:27 +02:00
parent 69efafeb33
commit 8921830f43
48 changed files with 572 additions and 0 deletions

4
.gitignore vendored
View File

@@ -17,3 +17,7 @@ frontend/dist/
# mai worker state
.m/
# Playwright MCP scratch (screenshots + console logs from local verification)
/.playwright-mcp/
/paliad-*.png

View File

@@ -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"));

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View 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
View 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;
}
}

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
<script dangerouslySetInnerHTML={{ __html: HYDRATION_SCRIPT }} />

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View 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);
}

View 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"}">&times;</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"}">&times;</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();
}

View 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>
);
}

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
<script dangerouslySetInnerHTML={{ __html: HYDRATION_SCRIPT }} />

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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&uuml;hrentabellen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Patentwissen f&uuml;r Hogan Lovells</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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; }
}

View File

@@ -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 &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>

View File

@@ -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
View 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")
}