Name each install, fix the notification badge, switch to Archivo

- Settings > General: an App name (super admin) for the installed app.
  The manifest's name/short_name, the iOS head tags and the loading
  page's title use it, so two installs on one phone can be told apart.
- Notifications get a real badge (white stone silhouette): Android masks
  the badge by alpha, so the opaque app icon showed as a square.
- The loading and offline pages load the app's font.
- The font is now Archivo (variable, one request for every weight),
  matching the marketing site.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 10:17:40 +01:00
co-authored by Claude Opus 5.5
parent 89c4e26d06
commit 3b7c05bfa0
23 changed files with 440 additions and 16 deletions
@@ -0,0 +1,101 @@
import { useState } from "react";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import { PwaInstallNameMaxLength } from "@/src/data/pwa-data";
import { SiteData } from "@/src/data/site-data";
import SettingsSaveBar from "../../(partials)/settings-save-bar";
type Props = {
install_name?: string | null;
};
/**
* The name the install goes by as an installed app, saved on its own
* without a reload. Empty puts the site name back.
*/
export default function InstallNameForm({ install_name }: Props) {
const [saved, setSaved] = useState(install_name || "");
const [value, setValue] = useState(install_name || "");
const [dirty, setDirty] = useState(false);
/**
* The input only reads `defaultValue` when it mounts, so discarding or
* saving remounts it with the saved name.
*/
const [form_key, setFormKey] = useState(0);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<{
msg?: string;
error?: boolean;
success?: boolean;
}>();
const save = async () => {
if (!window.confirm("Save the install name?")) return;
setLoading(true);
setStatus(undefined);
const res = await fetchApi<{}, APIResponseObject<{ install_name: string }>>(
"/api/admin/settings/install-name",
{ method: "POST", body: { install_name: value } },
).catch(() => null);
setLoading(false);
if (!res?.success || !res.singleRes) {
setStatus({ error: true, msg: res?.msg || `Couldn't save the name.` });
return;
}
setSaved(res.singleRes.install_name);
setValue(res.singleRes.install_name);
setFormKey((key) => key + 1);
setDirty(false);
setStatus({
success: true,
msg: res.singleRes.install_name
? `Saved. Reinstall the app on a device to see "${res.singleRes.install_name}".`
: `Removed. The app is called ${SiteData["SiteName"]} again.`,
});
};
const discard = () => {
setValue(saved);
setFormKey((key) => key + 1);
setDirty(false);
setStatus(undefined);
};
return (
<Form
onSubmit={(event) => {
event.preventDefault();
save().catch(() => {});
}}
className="w-full gap-3! items-stretch"
>
<Input
key={`install-name-${form_key}`}
label="Install name"
placeholder={SiteData["SiteName"]}
maxLength={PwaInstallNameMaxLength}
defaultValue={value}
onChange={(event) => {
setValue(event.target.value);
setDirty(true);
setStatus(undefined);
}}
info={`Shown under the app's icon on phones and computers, e.g. "Acme". Empty uses ${SiteData["SiteName"]}. A device that already installed the app keeps the old name until it's installed again.`}
/>
<SettingsSaveBar
dirty={dirty}
loading={loading}
status={status}
save_label="Save name"
onDiscard={discard}
/>
</Form>
);
}
@@ -0,0 +1,25 @@
import { useContext } from "react";
import { AppContext } from "@/src/pages/__root";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import SettingsGroup from "../../(partials)/settings-group";
import InstallNameForm from "../(partials)/install-name-form";
/**
* What this install is called once installed as an app, so installs for
* different teams can sit side by side on one device. Super admin only.
*/
export default function InstallNameSection() {
const { pageProps } = useContext(AppContext);
if (!isPassThroughUser({ user_types: pageProps?.user_types })) return null;
return (
<SettingsGroup
title="App name"
description="The name this install goes by when it's added to a home screen or app list."
id="install-name"
>
<InstallNameForm install_name={pageProps?.install_name} />
</SettingsGroup>
);
}
@@ -1,4 +1,5 @@
import AppearanceSection from "./(sections)/appearance-section";
import InstallNameSection from "./(sections)/install-name-section";
import NotificationsSection from "./(sections)/notifications-section";
import PromptSection from "./(sections)/prompt-section";
import RunLimitsSection from "./(sections)/run-limits-section";
@@ -15,6 +16,7 @@ export default function AdminSettingsGeneralPage() {
description="How the app looks and behaves."
>
<AppearanceSection />
<InstallNameSection />
<NotificationsSection />
<PromptSection />
<RunLimitsSection />