feat: allow user to use local file as background (@fehmer, @byseif21, @miodec) (#6663)

Allow the user to use a local file as custom background without
uploading it to the server.

Based on @byseif21 work in #6630, thanks!

---------

Co-authored-by: Miodec <jack@monkeytype.com>
Co-authored-by: Lukas <dev@mardybum.de>
Co-authored-by: Seif Soliman <byseif21@gmail.com>
This commit is contained in:
Christian Fehmer 2025-07-28 12:52:07 +02:00 committed by GitHub
parent b024e8ea46
commit f2b34a541f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 255 additions and 85 deletions

View file

@ -100,6 +100,7 @@
"hangul-js": "0.2.6",
"howler": "2.2.3",
"html2canvas": "1.4.1",
"idb": "8.0.3",
"jquery": "3.7.1",
"jquery-color": "2.2.0",
"jquery.easing": "1.4.1",

View file

@ -1307,11 +1307,47 @@
</button>
</div>
<div class="text">
Set an image url to be a custom background image. Cover fits the image
to cover the screen. Contain fits the image to be fully visible. Max
fits the image corner to corner.
Set an image url or local image to be a custom background image. Local
image always take priority over the image url. Cover fits the image to
cover the screen. Contain fits the image to be fully visible. Max fits
the image corner to corner.
<br />
<br />
Note: The local image is stored in your browser's local storage and will
not be uploaded to the server. This means that if you clear your
browser's local storage or use a different browser, the local image will
be lost.
</div>
<div class="inputs">
<div class="usingLocalImage">
<button class="no-auto-handle">
<i class="fas fa-trash fa-fw"></i>
remove local image
</button>
</div>
<div class="uploadContainer">
<label
for="customBackgroundUpload"
class="button"
aria-label="Select custom background image"
data-balloon-pos="left"
>
<i class="fas fa-file-import fa-fw"></i>
use local image
</label>
<input
type="file"
id="customBackgroundUpload"
accept="image/png,image/jpeg,image/jpg,image/gif"
style="display: none"
/>
</div>
<div class="separator">
<div class="line"></div>
or
<div class="line"></div>
</div>
<div class="inputAndButton">
<input
type="text"
@ -1320,14 +1356,6 @@
tabindex="0"
onClick="this.select();"
/>
<span>
<button class="hidden remove no-auto-handle">
<i class="fas fa-trash fa-fw"></i>
</button>
<button class="save no-auto-handle">
<i class="fas fa-save fa-fw"></i>
</button>
</span>
</div>
<div class="buttons">
<button data-config-value="cover">cover</button>

View file

@ -110,12 +110,19 @@
}
.statusIndicator {
opacity: 0;
visibility: hidden;
}
input {
padding-right: 0.5em !important;
}
&:has(input:focus),
&:has([data-indicator-status="failed"]) {
.statusIndicator {
opacity: 1;
visibility: visible;
}
input {
padding-right: 2.2em !important;
}
}
}
@ -144,6 +151,41 @@
}
}
&[data-config-name="customBackgroundSize"] {
.uploadContainer {
grid-column: span 2;
margin-bottom: 0.5em;
margin-top: 0.5em;
}
label.button {
width: 100%;
}
.separator {
margin-bottom: 0.5rem;
grid-column: span 2;
// color: var(--sub-color);
display: grid;
gap: 1em;
grid-template-columns: 1fr auto 1fr;
place-items: center;
}
.line {
width: 100%;
height: 0.25em;
border-radius: 0.25em;
background: var(--sub-alt-color);
}
.usingLocalImage {
display: grid;
grid-template-columns: 1fr;
place-items: center;
margin-bottom: 0.5em;
button {
width: 100%;
}
}
}
&[data-config-name="customBackgroundFilter"] {
.groups {
grid-area: buttons;

View file

@ -12,6 +12,7 @@ import * as Loader from "../elements/loader";
import { debounce } from "throttle-debounce";
import { ThemeName } from "@monkeytype/schemas/configs";
import { ThemesList } from "../constants/themes";
import fileStorage from "../utils/file-storage";
export let randomTheme: ThemeName | string | null = null;
let isPreviewingTheme = false;
@ -376,12 +377,22 @@ function applyCustomBackgroundSize(): void {
}
}
function applyCustomBackground(): void {
export async function applyCustomBackground(): Promise<void> {
// $(".customBackground").css({
// backgroundImage: `url(${Config.customBackground})`,
// backgroundAttachment: "fixed",
// });
if (Config.customBackground === "") {
let backgroundUrl = Config.customBackground;
//if there is a localBackgroundFile available, use it.
const localBackgroundFile = await fileStorage.getFile("LocalBackgroundFile");
if (localBackgroundFile !== undefined) {
backgroundUrl = localBackgroundFile;
}
if (backgroundUrl === "") {
$("#words").removeClass("noErrorBorder");
$("#resultWordsHistory").removeClass("noErrorBorder");
$(".customBackground img").remove();
@ -392,7 +403,8 @@ function applyCustomBackground(): void {
//use setAttribute for possible unsafe customBackground value
const container = document.querySelector(".customBackground");
const img = document.createElement("img");
img.setAttribute("src", Config.customBackground);
img.setAttribute("src", backgroundUrl);
img.setAttribute(
"onError",
"javascript:this.style.display='none'; window.dispatchEvent(new Event('customBackgroundFailed'))"
@ -439,7 +451,7 @@ ConfigEvent.subscribe(async (eventKey, eventValue, nosave) => {
await set(Config.theme);
}
}
applyCustomBackground();
await applyCustomBackground();
}
// this is here to prevent calling set / preview multiple times during a full config loading
@ -461,7 +473,8 @@ ConfigEvent.subscribe(async (eventKey, eventValue, nosave) => {
await set(eventValue as string);
}
if (eventKey === "randomTheme" && eventValue === "off") await clearRandom();
if (eventKey === "customBackground") applyCustomBackground();
if (eventKey === "customBackground") await applyCustomBackground();
if (eventKey === "customBackgroundSize") applyCustomBackgroundSize();
if (eventKey === "autoSwitchTheme") {
if (eventValue as boolean) {

View file

@ -32,6 +32,9 @@ export type Validation<T> = {
/** custom debounce delay for `isValid` call. defaults to 100 */
debounceDelay?: number;
/** Resets the value to the current config if empty */
resetIfEmpty?: false;
};
/**
* Create input handler for validated input element.
@ -213,7 +216,7 @@ export function handleConfigInput<T extends ConfigKey>({
}
const handleStore = (): void => {
if (input.value === "") {
if (input.value === "" && (validation?.resetIfEmpty ?? true)) {
//use last config value, clear validation
input.value = new String(Config[configName]).toString();
input.dispatchEvent(new Event("input"));

View file

@ -0,0 +1,68 @@
import FileStorage from "../../utils/file-storage";
import * as Notifications from "../notifications";
import { applyCustomBackground } from "../../controllers/theme-controller";
const parentEl = document.querySelector(
".pageSettings .section[data-config-name='customBackgroundSize']"
);
const usingLocalImageEl = parentEl?.querySelector(".usingLocalImage");
const separatorEl = parentEl?.querySelector(".separator");
const uploadContainerEl = parentEl?.querySelector(".uploadContainer");
const inputAndButtonEl = parentEl?.querySelector(".inputAndButton");
async function readFileAsDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export async function updateUI(): Promise<void> {
if (await FileStorage.hasFile("LocalBackgroundFile")) {
usingLocalImageEl?.classList.remove("hidden");
separatorEl?.classList.add("hidden");
uploadContainerEl?.classList.add("hidden");
inputAndButtonEl?.classList.add("hidden");
} else {
usingLocalImageEl?.classList.add("hidden");
separatorEl?.classList.remove("hidden");
uploadContainerEl?.classList.remove("hidden");
inputAndButtonEl?.classList.remove("hidden");
}
}
usingLocalImageEl
?.querySelector("button")
?.addEventListener("click", async () => {
await FileStorage.deleteFile("LocalBackgroundFile");
await updateUI();
await applyCustomBackground();
});
uploadContainerEl
?.querySelector("input[type='file']")
?.addEventListener("change", async (e) => {
const fileInput = e.target as HTMLInputElement;
const file = fileInput.files?.[0];
if (!file) {
return;
}
// check type
if (!file.type.match(/image\/(jpeg|jpg|png|gif)/)) {
Notifications.add("Unsupported image format", 0);
fileInput.value = "";
return;
}
const dataUrl = await readFileAsDataURL(file);
await FileStorage.storeFile("LocalBackgroundFile", dataUrl);
await updateUI();
await applyCustomBackground();
fileInput.value = "";
});

View file

@ -37,6 +37,7 @@ import { ShowOptions } from "../utils/animated-modal";
import { GenerateDataRequest } from "@monkeytype/contracts/dev";
import { UserEmailSchema, UserNameSchema } from "@monkeytype/contracts/users";
import { goToPage } from "../pages/leaderboards";
import FileStorage from "../utils/file-storage";
type PopupKey =
| "updateEmail"
@ -784,6 +785,7 @@ list.resetAccount = new SimpleModal({
Notifications.add("Resetting settings...", 0);
await UpdateConfig.reset();
await FileStorage.deleteFile("LocalBackgroundFile");
Notifications.add("Resetting account...", 0);
const response = await Ape.users.reset();
@ -939,6 +941,7 @@ list.resetSettings = new SimpleModal({
onlineOnly: true,
execFn: async (): Promise<ExecReturn> => {
await UpdateConfig.reset();
await FileStorage.deleteFile("LocalBackgroundFile");
return {
status: 1,
message: "Settings reset",

View file

@ -19,7 +19,6 @@ import SlimSelect from "slim-select";
import * as Skeleton from "../utils/skeleton";
import * as CustomBackgroundFilter from "../elements/custom-background-filter";
import {
CustomBackgroundSchema,
ThemeName,
CustomLayoutFluid,
FunboxName,
@ -36,9 +35,11 @@ import { areSortedArraysEqual, areUnsortedArraysEqual } from "../utils/arrays";
import { LayoutName } from "@monkeytype/schemas/layouts";
import { LanguageGroupNames, LanguageGroups } from "../constants/languages";
import { Language } from "@monkeytype/schemas/languages";
import FileStorage from "../utils/file-storage";
import { z } from "zod";
import { handleConfigInput } from "../elements/input-validation";
import { Fonts } from "../constants/fonts";
import * as CustomBackgroundPicker from "../elements/settings/custom-background-picker";
let settingsInitialized = false;
@ -683,6 +684,18 @@ async function fillSettingsPage(): Promise<void> {
inputValueConvert: Number,
},
});
handleConfigInput({
input: document.querySelector(
".pageSettings .section[data-config-name='customBackgroundSize'] input[type='text']"
),
configName: "customBackground",
validation: {
schema: true,
resetIfEmpty: false,
},
});
setEventDisabled(true);
await initGroups();
@ -818,6 +831,7 @@ export async function update(
await Misc.sleep(0);
ThemePicker.updateActiveTab();
ThemePicker.setCustomInputs(true);
await CustomBackgroundPicker.updateUI();
const setInputValue = (
key: ConfigKey,
@ -873,7 +887,10 @@ export async function update(
).addClass("hidden");
}
if (Config.customBackground !== "") {
if (
Config.customBackground !== "" ||
(await FileStorage.hasFile("LocalBackgroundFile"))
) {
$(
".pageSettings .section[data-config-name='customBackgroundFilter']"
).removeClass("hidden");
@ -882,7 +899,6 @@ export async function update(
".pageSettings .section[data-config-name='customBackgroundFilter']"
).addClass("hidden");
}
updateCustomBackgroundRemoveButtonVisibility();
setInputValue(
"fontSize",
@ -910,7 +926,7 @@ export async function update(
setInputValue(
"customBackground",
".pageSettings .section[data-config-name='customBackgroundSize'] input",
".pageSettings .section[data-config-name='customBackgroundSize'] input[type='text']",
Config.customBackground
);
@ -973,20 +989,6 @@ function toggleSettingsGroup(groupName: string): void {
}
}
function updateCustomBackgroundRemoveButtonVisibility(): void {
const button = $(
".pageSettings .section[data-config-name='customBackgroundSize'] button.remove"
);
if (
Config.customBackground !== undefined &&
Config.customBackground.length > 0
) {
button.removeClass("hidden");
} else {
button.addClass("hidden");
}
}
//funbox
$(".pageSettings .section[data-config-name='funbox'] .buttons").on(
"click",
@ -1041,54 +1043,6 @@ $(".pageSettings .sectionGroupTitle").on("click", (e) => {
toggleSettingsGroup($(e.currentTarget).attr("group") as string);
});
$(
".pageSettings .section[data-config-name='customBackgroundSize'] .inputAndButton button.save"
).on("click", () => {
const newVal = $(
".pageSettings .section[data-config-name='customBackgroundSize'] .inputAndButton input"
).val() as string;
const parsed = CustomBackgroundSchema.safeParse(newVal);
if (!parsed.success) {
Notifications.add(
`Invalid custom background URL (${parsed.error.issues[0]?.message})`,
0
);
return;
}
UpdateConfig.setCustomBackground(newVal);
});
$(
".pageSettings .section[data-config-name='customBackgroundSize'] .inputAndButton button.remove"
).on("click", () => {
UpdateConfig.setCustomBackground("");
});
$(
".pageSettings .section[data-config-name='customBackgroundSize'] .inputAndButton input"
).on("keypress", (e) => {
if (e.key === "Enter") {
const newVal = $(
".pageSettings .section[data-config-name='customBackgroundSize'] .inputAndButton input"
).val() as string;
const parsed = CustomBackgroundSchema.safeParse(newVal);
if (!parsed.success) {
Notifications.add(
`Invalid custom background URL (${parsed.error.issues[0]?.message})`,
0
);
return;
}
UpdateConfig.setCustomBackground(newVal);
}
});
$(
".pageSettings .section[data-config-name='keymapSize'] .inputAndButton button.save"
).on("click", () => {

View file

@ -0,0 +1,50 @@
import { openDB, DBSchema, IDBPDatabase } from "idb";
type FileDB = DBSchema & {
files: {
key: string; // filename
value: string; // the data url
};
};
type Filename = "LocalBackgroundFile";
class FileStorage {
private dbPromise: Promise<IDBPDatabase<FileDB>>;
constructor(dbName = "file-storage-db") {
this.dbPromise = openDB<FileDB>(dbName, 1, {
upgrade(db) {
if (!db.objectStoreNames.contains("files")) {
db.createObjectStore("files");
}
},
});
}
async storeFile(filename: Filename, dataUrl: string): Promise<void> {
const db = await this.dbPromise;
await db.put("files", dataUrl, filename);
}
async getFile(filename: Filename): Promise<string | undefined> {
const db = await this.dbPromise;
return db.get("files", filename);
}
async deleteFile(filename: Filename): Promise<void> {
const db = await this.dbPromise;
await db.delete("files", filename);
}
async listFilenames(): Promise<Filename[]> {
const db = await this.dbPromise;
return db.getAllKeys("files") as Promise<Filename[]>;
}
async hasFile(filename: Filename): Promise<boolean> {
return (await this.getFile(filename)) !== undefined;
}
}
export default new FileStorage();

8
pnpm-lock.yaml generated
View file

@ -342,6 +342,9 @@ importers:
html2canvas:
specifier: 1.4.1
version: 1.4.1
idb:
specifier: 8.0.3
version: 8.0.3
jquery:
specifier: 3.7.1
version: 3.7.1
@ -5939,6 +5942,9 @@ packages:
idb@7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
idb@8.0.3:
resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==}
identity-function@1.0.0:
resolution: {integrity: sha512-kNrgUK0qI+9qLTBidsH85HjDLpZfrrS0ElquKKe/fJFdB3D7VeKdXXEvOPDUHSHOzdZKCAAaQIWWyp0l2yq6pw==}
@ -16920,6 +16926,8 @@ snapshots:
idb@7.1.1: {}
idb@8.0.3: {}
identity-function@1.0.0: {}
ieee754@1.2.1: {}