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
+41
View File
@@ -0,0 +1,41 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabPushOverview from "@/src/functions/backend/db/push/grab-push-overview";
import { ensureVapidKeys } from "@/src/push/vapid";
import AppError from "@/src/utils/app-error";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
import type { PushOverview } from "@/src/types";
/**
* The Notifications page refreshes this user's devices after a change
* without a reload.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<PushOverview>
> = async ({ req }) => {
try {
if (req.method != "GET") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
return {
success: true,
singleRes: await grabPushOverview({
user_id: user.id,
public_key: (await ensureVapidKeys().catch(() => null))
?.public_key,
}),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+43
View File
@@ -0,0 +1,43 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import deletePushSubscriptionById from "@/src/functions/backend/db/push/delete-push-subscription-by-id";
import { PushSubscriptionIdRequestSchema } from "@/src/schemas/push-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Removes one of the session user's devices from the settings list. Its
* browser keeps its own subscription until it turns notifications off
* there, which also removes it again on the next subscribe/unsubscribe.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const { id } = parseWithSchema({
schema: PushSubscriptionIdRequestSchema,
data: body,
});
await deletePushSubscriptionById({ user_id: user.id, id });
return { success: true, msg: "Device removed." };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+49
View File
@@ -0,0 +1,49 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import savePushSubscription from "@/src/functions/backend/db/push/save-push-subscription";
import { SavePushSubscriptionSchema } from "@/src/schemas/push-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Saves this device's push subscription for the session user. The
* endpoint is unique, so subscribing again only refreshes the keys.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const data = parseWithSchema({
schema: SavePushSubscriptionSchema,
data: body,
});
await savePushSubscription({
user_id: user.id,
endpoint: data.endpoint,
p256dh: data.keys.p256dh,
auth: data.keys.auth,
user_agent: data.user_agent,
platform: data.platform,
});
return { success: true, msg: "Notifications are on for this device." };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+54
View File
@@ -0,0 +1,54 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabPushSubscriptions from "@/src/functions/backend/db/push/grab-push-subscriptions";
import deliverToUser from "@/src/push/deliver-to-user";
import AppError from "@/src/utils/app-error";
import safeErrorMessage from "@/src/utils/safe-error-message";
import { SiteData } from "@/src/data/site-data";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Sends one notification to every device this user has subscribed, so
* they can check that notifications arrive without waiting for a bot.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const subscriptions = await grabPushSubscriptions({
user_id: user.id,
});
if (!subscriptions.length) {
throw new AppError(
`Turn notifications on for this device first, then try again.`,
);
}
await deliverToUser({
user_id: user.id,
payload: {
title: `${SiteData["SiteName"]} notifications are on`,
body: "This is what a bot's message will look like.",
url: "/admin/settings/notifications",
tag: "cobble-test",
},
});
return { success: true, msg: "Test sent." };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+42
View File
@@ -0,0 +1,42 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import deletePushSubscription from "@/src/functions/backend/db/push/delete-push-subscription";
import { PushEndpointRequestSchema } from "@/src/schemas/push-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Forgets this device. The browser also drops its own subscription first,
* so the two never disagree for long.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const { endpoint } = parseWithSchema({
schema: PushEndpointRequestSchema,
data: body,
});
await deletePushSubscription({ user_id: user.id, endpoint });
return { success: true, msg: "Notifications are off for this device." };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};