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:
@@ -6,6 +6,7 @@ import isAdminSectionPath from "../utils/is-admin-section-path";
|
||||
import { AdminHomePath } from "../dict/admin-sections-dict";
|
||||
import isUnknownSettingsPath from "../utils/is-unknown-settings-path";
|
||||
import { SettingsDefaultPath } from "../dict/settings-pages-dict";
|
||||
import grabInstallName from "../functions/backend/db/app-settings/grab-install-name";
|
||||
|
||||
/**
|
||||
* Routing for a local install. There are no public pages:
|
||||
@@ -80,6 +81,7 @@ const server: BunextPageServerFn<PagePropsType> = async ({ req, url }) => {
|
||||
},
|
||||
user,
|
||||
user_types,
|
||||
install_name: await grabInstallName().catch(() => ""),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
+11
-3
@@ -13,6 +13,7 @@ import type {
|
||||
PagePropsType,
|
||||
} from "@/src/types";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import installDisplayName from "@/src/utils/install-display-name";
|
||||
import Layout from "../layouts/main";
|
||||
import AuthLayout from "../layouts/auth";
|
||||
import BotsLayout from "../layouts/bots";
|
||||
@@ -93,16 +94,23 @@ export default function App({
|
||||
}
|
||||
|
||||
export function Head({ serverRes, ctx }: BunextPageHeadFCProps) {
|
||||
/**
|
||||
* iOS names a home-screen app from these tags, not the manifest.
|
||||
*/
|
||||
const app_name = installDisplayName({
|
||||
install_name: (serverRes?.props as PagePropsType | undefined)?.install_name,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<ThemeInitScript />
|
||||
<PwaInitScript />
|
||||
<meta name="application-name" content={SiteData["SiteName"]} />
|
||||
<meta name="application-name" content={app_name} />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-title"
|
||||
content={SiteData["SiteName"]}
|
||||
content={app_name}
|
||||
/>
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
@@ -115,7 +123,7 @@ export function Head({ serverRes, ctx }: BunextPageHeadFCProps) {
|
||||
crossOrigin=""
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Lato:wght@400;700;900&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Archivo:wght@400..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import grabInstallName from "@/src/functions/backend/db/app-settings/grab-install-name";
|
||||
import saveAppSetting from "@/src/functions/backend/db/app-settings/save-app-setting";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { PwaInstallNameSettingKey } from "@/src/data/pwa-data";
|
||||
import { SaveInstallNameRequestSchema } 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";
|
||||
|
||||
/**
|
||||
* Super admin only: the name this install goes by when it's installed as
|
||||
* an app. Empty puts `SiteName` back.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<{ install_name: string }>> = async ({
|
||||
req,
|
||||
body,
|
||||
}) => {
|
||||
try {
|
||||
if (req.method != "POST") throw new AppError(`Method not allowed`);
|
||||
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) throw new AppError(`Only a super admin can rename the install.`);
|
||||
|
||||
const { install_name } = parseWithSchema({
|
||||
schema: SaveInstallNameRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
await saveAppSetting({ key: PwaInstallNameSettingKey, value: install_name || null });
|
||||
|
||||
await logEvent({
|
||||
title: "Install renamed",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} set the install name to ${install_name ? `"${install_name}"` : "the default"}.`,
|
||||
metadata: { install_name },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return { success: true, singleRes: { install_name: await grabInstallName() } };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user