Make the app an installable PWA with phone layout and Web Push

Add a generated manifest, icon set, offline page and service worker that never caches authenticated HTML, plus install and update UI. Turn the rail into a drawer below md with container-query internals, dvh and safe-area handling, and reconnect the socket after visibility/network changes. Add Web Push subscriptions, presence-aware delivery, notification settings and tests.
This commit is contained in:
2026-10-19 17:12:16 +01:00
parent 61098a114c
commit dbfe48796b
100 changed files with 3573 additions and 117 deletions
+35 -2
View File
@@ -1,14 +1,21 @@
import { useContext } from "react";
"use client";
import { useContext, useState } from "react";
import Paper from "@moduletrace/twui/elements/Paper";
import Divider from "@moduletrace/twui/layout/Divider";
import { Download } from "lucide-react";
import { RailUserMenuItems } from "../(dict)/rail-user-menu-dict";
import RailUserMenuEntry from "./rail-user-menu-entry";
import RailUserMenuAction from "./rail-user-menu-action";
import InstallAppModal from "./install-app-modal";
import { AppContext } from "@/src/pages/__root";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import useInstallPrompt from "@/src/hooks/use-install-prompt";
/**
* 220px popover: the only element in the shell with elevation. Super
* admin items show to super admins only.
* admin items show to super admins only. Install app shows while the
* browser can install it; Safari gets the Share menu steps instead.
*/
export default function RailUserMenu() {
const { pageProps } = useContext(AppContext);
@@ -17,6 +24,12 @@ export default function RailUserMenu() {
const items = shown.filter((item) => !item.destructive);
const destructive = shown.filter((item) => item.destructive);
const { can_install, needs_ios_steps, installing, install } =
useInstallPrompt();
const [ios_modal_open, setIosModalOpen] = useState(false);
const show_install = can_install || needs_ios_steps;
return (
<Paper
role="menu"
@@ -25,10 +38,30 @@ export default function RailUserMenu() {
{items.map((item) => (
<RailUserMenuEntry key={item.title} {...item} />
))}
{show_install ? (
<RailUserMenuAction
title={needs_ios_steps ? "How to install" : "Install app"}
Icon={Download}
disabled={installing}
onClick={() => {
if (needs_ios_steps) {
setIosModalOpen(true);
return;
}
install();
}}
/>
) : null}
<Divider className="my-1 mx-3 w-auto border-hairline dark:border-hairline" />
{destructive.map((item) => (
<RailUserMenuEntry key={item.title} {...item} />
))}
{needs_ios_steps ? (
<InstallAppModal
open={ios_modal_open}
setOpen={setIosModalOpen}
/>
) : null}
</Paper>
);
}