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:
@@ -7,6 +7,7 @@ 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";
|
||||
import grabInstallIcon from "../functions/backend/db/app-settings/grab-install-icon";
|
||||
|
||||
/**
|
||||
* Routing for a local install. There are no public pages:
|
||||
@@ -82,6 +83,7 @@ const server: BunextPageServerFn<PagePropsType> = async ({ req, url }) => {
|
||||
user,
|
||||
user_types,
|
||||
install_name: await grabInstallName().catch(() => ""),
|
||||
install_icon: await grabInstallIcon().catch(() => null),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
+16
-10
@@ -14,6 +14,7 @@ import type {
|
||||
} from "@/src/types";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import installDisplayName from "@/src/utils/install-display-name";
|
||||
import installIconBase from "@/src/utils/install-icon-base";
|
||||
import Layout from "../layouts/main";
|
||||
import AuthLayout from "../layouts/auth";
|
||||
import BotsLayout from "../layouts/bots";
|
||||
@@ -97,9 +98,12 @@ 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,
|
||||
});
|
||||
const root_props = serverRes?.props as PagePropsType | undefined;
|
||||
const app_name = installDisplayName({ install_name: root_props?.install_name });
|
||||
/**
|
||||
* The install's own icon set, when it has one (Settings > General).
|
||||
*/
|
||||
const icon_base = installIconBase({ icon: root_props?.install_icon });
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -126,21 +130,23 @@ export function Head({ serverRes, ctx }: BunextPageHeadFCProps) {
|
||||
href="https://fonts.googleapis.com/css2?family=Archivo:[email protected]&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon.ico"
|
||||
sizes="16x16 24x24 32x32 48x48"
|
||||
/>
|
||||
{icon_base ? null : (
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon.ico"
|
||||
sizes="16x16 24x24 32x32 48x48"
|
||||
/>
|
||||
)}
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/favicon.png"
|
||||
href={icon_base ? `${icon_base}/favicon.png` : "/favicon.png"}
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
href={icon_base ? `${icon_base}/apple-touch-icon.png` : "/apple-touch-icon.png"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import saveInstallIcon from "@/src/functions/backend/install-icon/save-install-icon";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { PwaInstallIcon } from "@/src/data/pwa-data";
|
||||
import type { BotHue } from "@/src/dict/bot-dict";
|
||||
import { SaveInstallIconRequestSchema } from "@/src/schemas/install-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import bufferFromBase64 from "@/src/utils/buffer-from-base-64";
|
||||
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 icon this install goes by as an installed app, a
|
||||
* stone or an uploaded image with the stone in its corner.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<PwaInstallIcon>> = 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 change the app icon.`);
|
||||
|
||||
const { mode, stone, mark, data_url } = parseWithSchema({
|
||||
schema: SaveInstallIconRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
const image = data_url ? (await bufferFromBase64(data_url)).buffer : null;
|
||||
|
||||
if (image && image.length > AppData.ProfileImageMaxBytes) {
|
||||
throw new AppError(
|
||||
`That image is too big. Images can be up to ${Math.round(AppData.ProfileImageMaxBytes / 1024 / 1024)} MB.`,
|
||||
);
|
||||
}
|
||||
|
||||
const icon = await saveInstallIcon({ mode, stone: stone as BotHue, mark, image });
|
||||
|
||||
await logEvent({
|
||||
title: "App icon changed",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} set the app icon to ${mode == "image" ? `an image${mark ? ` with the ${stone} mark` : ""}` : `the ${stone} stone`}.`,
|
||||
metadata: { mode, stone, mark, version: icon.version, new_image: Boolean(image) },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return { success: true, singleRes: icon };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import grabInstallName from "@/src/functions/backend/db/app-settings/grab-instal
|
||||
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 { SaveInstallNameRequestSchema } from "@/src/schemas/install-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
|
||||
Reference in New Issue
Block a user