Let each install pick its app icon

Settings > General gets an App icon (super admin): one of the seven
stones, or an uploaded image with the picked stone as a top-left corner
mark (inside a round launcher's crop, clear of the browser's badge),
previewed in both launcher crops before saving.

Uploads are decoded and cropped to a stored square (no SVG); the set is
drawn into DATA_DIR/install-icon/<version>/ and served publicly and
immutable. The manifest, head icon links and push notifications point at
it; the ember stone keeps the stock icons. The generator shares the icon
renderer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 10:38:25 +01:00
co-authored by Claude Opus 5.5
parent 5a603bf61f
commit e462806438
36 changed files with 1190 additions and 68 deletions
@@ -0,0 +1,25 @@
import { Gem, ImageIcon } from "lucide-react";
import type { PwaInstallIconMode } from "@/src/data/pwa-data";
/**
* The two kinds of app icon an install can have (Settings > General).
*/
export const InstallIconModes: {
value: PwaInstallIconMode;
title: string;
description: string;
Icon: typeof Gem;
}[] = [
{
value: "stone",
title: "Stone",
description: "One of the stones on paper.",
Icon: Gem,
},
{
value: "image",
title: "Image",
description: "Your own image, with a stone in the corner.",
Icon: ImageIcon,
},
];
@@ -0,0 +1,195 @@
import { useState } from "react";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import Checkbox from "@moduletrace/twui/form/Checkbox";
import Form from "@moduletrace/twui/form/Form";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import ImagePickField from "@/src/components/general/image-pick-field";
import {
PwaDefaultInstallIcon,
PwaInstallIconMinSide,
type PwaInstallIcon,
} from "@/src/data/pwa-data";
import { BotHues, type BotHue } from "@/src/dict/bot-dict";
import type { SaveInstallIconRequest } from "@/src/schemas/install-schema";
import installIconBase from "@/src/utils/install-icon-base";
import SettingsSaveBar from "../../(partials)/settings-save-bar";
import { InstallIconModes } from "../(data)/install-icon-modes";
import InstallIconModeOption from "./install-icon-mode-option";
import InstallIconPreview from "./install-icon-preview";
import InstallIconShape from "./install-icon-shape";
import InstallIconStoneOption from "./install-icon-stone-option";
type Props = {
install_icon?: PwaInstallIcon | null;
};
/**
* The app icon: a stone, or an uploaded image with the picked stone in its
* top-left corner. Previewed as launchers crop it, saved on its own
* without a reload.
*/
export default function InstallIconForm({ install_icon }: Props) {
const [saved, setSaved] = useState(install_icon || PwaDefaultInstallIcon);
const [mode, setMode] = useState(saved.mode);
const [stone, setStone] = useState<BotHue>(saved.stone);
const [mark, setMark] = useState(saved.mark);
/**
* A newly picked image, sent with the next save.
*/
const [data_url, setDataUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<{
msg?: string;
error?: boolean;
success?: boolean;
}>();
const saved_base = installIconBase({ icon: saved });
const image_src =
data_url || (saved.mode == "image" && saved_base ? `${saved_base}/plain-512.png` : null);
const dirty =
Boolean(data_url) || mode != saved.mode || stone != saved.stone || mark != saved.mark;
const change = () => setStatus(undefined);
const save = async () => {
if (mode == "image" && !image_src) {
setStatus({ error: true, msg: `Upload an image first.` });
return;
}
const question =
mode == "stone" && saved.mode == "image"
? "Switch the app icon to a stone? The uploaded image is removed."
: "Save the app icon?";
if (!window.confirm(question)) return;
setLoading(true);
setStatus(undefined);
const res = await fetchApi<SaveInstallIconRequest, APIResponseObject<PwaInstallIcon>>(
"/api/admin/settings/install-icon",
{ method: "POST", body: { mode, stone, mark, data_url } },
).catch(() => null);
setLoading(false);
if (!res?.success || !res.singleRes) {
setStatus({ error: true, msg: res?.msg || `Couldn't save the icon.` });
return;
}
setSaved(res.singleRes);
setMode(res.singleRes.mode);
setStone(res.singleRes.stone);
setMark(res.singleRes.mark);
setDataUrl(null);
setStatus({
success: true,
msg: `Saved. Reinstall the app on a device to see the new icon.`,
});
};
const discard = () => {
setMode(saved.mode);
setStone(saved.stone);
setMark(saved.mark);
setDataUrl(null);
setStatus(undefined);
};
return (
<Form
onSubmit={(event) => {
event.preventDefault();
save().catch(() => {});
}}
className="w-full gap-4! items-stretch"
>
<Row
role="radiogroup"
aria-label="Icon"
className="w-full grid grid-cols-1 sm:grid-cols-2 gap-3!"
>
{InstallIconModes.map((option) => (
<InstallIconModeOption
key={option.value}
{...option}
selected={mode == option.value}
onSelect={() => {
setMode(option.value);
change();
}}
/>
))}
</Row>
{mode == "image" ? (
<ImagePickField
preview={
<InstallIconShape
shape="rounded"
mode="image"
stone={stone}
mark={false}
image_src={image_src}
className="size-16"
/>
}
has_image={Boolean(image_src)}
onPick={({ data_url }) => {
setDataUrl(data_url);
change();
}}
onRemove={() => {
setDataUrl(null);
setMode("stone");
change();
}}
loading={loading}
note={`PNG, JPG or WebP, at least ${PwaInstallIconMinSide} pixels on each side. It's cropped to a square from the centre.`}
/>
) : null}
<Stack className="w-full gap-2! items-start">
<Span className="text-sm font-bold leading-5 text-ink">
{mode == "image" ? "Corner stone" : "Stone"}
</Span>
<Row role="radiogroup" aria-label="Stone" className="gap-2!">
{BotHues.map((hue) => (
<InstallIconStoneOption
key={hue.value}
{...hue}
selected={stone == hue.value}
onSelect={() => {
setStone(hue.value);
change();
}}
/>
))}
</Row>
</Stack>
{mode == "image" ? (
<Checkbox
title="Show the stone in the top-left corner"
label="Show the stone in the top-left corner"
checked={mark}
changeHandler={(checked: boolean) => {
setMark(checked);
change();
}}
/>
) : null}
<InstallIconPreview mode={mode} stone={stone} mark={mark} image_src={image_src} />
<SettingsSaveBar
dirty={dirty}
loading={loading}
status={status}
save_label="Save icon"
onDiscard={discard}
/>
</Form>
);
}
@@ -0,0 +1,49 @@
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { Check } from "lucide-react";
import { twMerge } from "tailwind-merge";
import type { InstallIconModes } from "../(data)/install-icon-modes";
type Props = (typeof InstallIconModes)[number] & {
selected: boolean;
onSelect: () => void;
};
/**
* One radio tile, like the theme picker's: a strong border plus a check
* when selected.
*/
export default function InstallIconModeOption({ title, description, Icon, selected, onSelect }: Props) {
return (
<Button
title={title}
type="button"
role="radio"
aria-checked={selected}
variant="ghost"
color="gray"
onClick={onSelect}
className={twMerge(
"relative w-full min-w-0 h-auto p-4! pr-10! justify-start! rounded-default text-left",
"bg-surface dark:bg-surface hover:bg-sunken hover:dark:bg-sunken text-ink dark:text-ink",
"border border-solid border-hairline transition-colors duration-[var(--m-fast)]",
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
selected && "border-ink dark:border-ink bg-ink/5 dark:bg-ink/5",
)}
>
<Stack className="w-full min-w-0 gap-3! items-start">
<Icon size={20} aria-hidden />
<Stack className="w-full min-w-0 gap-0! items-start">
<Span className="text-sm font-bold leading-5 text-ink">{title}</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">{description}</Span>
</Stack>
</Stack>
{selected ? (
<Span className="absolute top-3 right-3 size-5 rounded-full bg-ink text-on-ink flex items-center justify-center">
<Check size={12} strokeWidth={3} aria-hidden />
</Span>
) : null}
</Button>
);
}
@@ -0,0 +1,28 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import type { ComponentProps } from "react";
import InstallIconShape from "./install-icon-shape";
type Props = Omit<ComponentProps<typeof InstallIconShape>, "shape" | "className">;
/**
* The icon in the two crops launchers use, before it's saved.
*/
export default function InstallIconPreview(props: Props) {
return (
<Stack className="w-full gap-2! items-start">
<Span className="text-sm font-bold leading-5 text-ink">Preview</Span>
<Row className="gap-6! items-end">
<Stack className="gap-2! items-center">
<InstallIconShape shape="circle" {...props} />
<Span className="text-[13px] leading-[18px] text-ink-secondary">Round</Span>
</Stack>
<Stack className="gap-2! items-center">
<InstallIconShape shape="rounded" {...props} />
<Span className="text-[13px] leading-[18px] text-ink-secondary">Rounded square</Span>
</Stack>
</Row>
</Stack>
);
}
@@ -0,0 +1,61 @@
import Img from "@moduletrace/twui/layout/Img";
import Span from "@moduletrace/twui/layout/Span";
import { twMerge } from "tailwind-merge";
import type { PwaInstallIconMode } from "@/src/data/pwa-data";
type Props = {
/**
* `circle` is a round launcher, which keeps only the centre 80% of
* the maskable icon (so the content is drawn 125% and cropped);
* `rounded` is the plain icon with rounded corners.
*/
shape: "circle" | "rounded";
mode: PwaInstallIconMode;
stone: string;
mark: boolean;
image_src?: string | null;
className?: string;
};
/**
* The app icon as a launcher would show it, laid out like the generated
* files (`renderInstallIcons`): a stone at 52% (maskable) or 80% on paper,
* or the image with the stone 20% in from the top-left at 22% of the side.
*/
export default function InstallIconShape({ shape, mode, stone, mark, image_src, className }: Props) {
const stone_src = `/brand/icons/${stone}.svg`;
return (
<Span
className={twMerge(
"relative block size-24 shrink-0 overflow-hidden bg-paper border border-solid border-hairline",
shape == "circle" ? "rounded-full" : "rounded-[22%]",
className,
)}
>
<Span className={twMerge("absolute inset-0 block", shape == "circle" && "scale-125")}>
{mode == "image" && image_src ? (
<>
<Img src={image_src} alt="" className="absolute inset-0 size-full object-cover" />
{mark ? (
<Img
src={stone_src}
alt=""
className="absolute left-[20%] top-[20%] size-[22%]"
/>
) : null}
</>
) : (
<Img
src={stone_src}
alt=""
className={twMerge(
"absolute inset-0 m-auto",
shape == "circle" ? "size-[52%]" : "size-[80%]",
)}
/>
)}
</Span>
</Span>
);
}
@@ -0,0 +1,37 @@
import Button from "@moduletrace/twui/layout/Button";
import Img from "@moduletrace/twui/layout/Img";
import { twMerge } from "tailwind-merge";
import type { BotHues } from "@/src/dict/bot-dict";
type Props = (typeof BotHues)[number] & {
selected: boolean;
onSelect: () => void;
};
/**
* One stone to pick, drawn from its brand icon. Selected is a strong
* border, never a colour change.
*/
export default function InstallIconStoneOption({ title, value, selected, onSelect }: Props) {
return (
<Button
title={title}
type="button"
role="radio"
aria-checked={selected}
aria-label={title}
variant="ghost"
color="gray"
onClick={onSelect}
className={twMerge(
"size-12 p-2! rounded-default",
"bg-surface dark:bg-surface hover:bg-sunken hover:dark:bg-sunken",
"border border-solid border-hairline transition-colors duration-[var(--m-fast)]",
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
selected && "border-ink dark:border-ink bg-ink/5 dark:bg-ink/5",
)}
>
<Img src={`/brand/icons/${value}.svg`} alt="" className="size-full" />
</Button>
);
}
@@ -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 InstallIconForm from "../(partials)/install-icon-form";
/**
* The icon this install goes by once installed as an app, beside its name.
* Super admin only.
*/
export default function InstallIconSection() {
const { pageProps } = useContext(AppContext);
if (!isPassThroughUser({ user_types: pageProps?.user_types })) return null;
return (
<SettingsGroup
title="App icon"
description="The icon this install shows on home screens, app lists and notifications. A device that already installed the app keeps the old icon until it's installed again."
id="install-icon"
>
<InstallIconForm install_icon={pageProps?.install_icon} />
</SettingsGroup>
);
}
+3 -1
View File
@@ -1,4 +1,5 @@
import AppearanceSection from "./(sections)/appearance-section";
import InstallIconSection from "./(sections)/install-icon-section";
import InstallNameSection from "./(sections)/install-name-section";
import NotificationsSection from "./(sections)/notifications-section";
import PromptSection from "./(sections)/prompt-section";
@@ -15,8 +16,9 @@ export default function AdminSettingsGeneralPage() {
title="General"
description="How the app looks and behaves."
>
<AppearanceSection />
<InstallNameSection />
<InstallIconSection />
<AppearanceSection />
<NotificationsSection />
<PromptSection />
<RunLimitsSection />