diff --git a/frontend/package.json b/frontend/package.json
index b8965ab54..e27f444d3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
diff --git a/frontend/src/html/pages/settings.html b/frontend/src/html/pages/settings.html
index 70666a05c..1007a195f 100644
--- a/frontend/src/html/pages/settings.html
+++ b/frontend/src/html/pages/settings.html
@@ -1307,11 +1307,47 @@
- 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.
+
+
+ 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.
+
+
+
+ remove local image
+
+
+
+
+
+ use local image
+
+
+
+
+
-
-
-
-
-
-
-
-
cover
diff --git a/frontend/src/styles/settings.scss b/frontend/src/styles/settings.scss
index db93648cc..6a64d9821 100644
--- a/frontend/src/styles/settings.scss
+++ b/frontend/src/styles/settings.scss
@@ -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;
diff --git a/frontend/src/ts/controllers/theme-controller.ts b/frontend/src/ts/controllers/theme-controller.ts
index 4ebaa11b5..bd3c00beb 100644
--- a/frontend/src/ts/controllers/theme-controller.ts
+++ b/frontend/src/ts/controllers/theme-controller.ts
@@ -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 {
// $(".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) {
diff --git a/frontend/src/ts/elements/input-validation.ts b/frontend/src/ts/elements/input-validation.ts
index db71dde3e..9bfa49cb4 100644
--- a/frontend/src/ts/elements/input-validation.ts
+++ b/frontend/src/ts/elements/input-validation.ts
@@ -32,6 +32,9 @@ export type Validation = {
/** 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({
}
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"));
diff --git a/frontend/src/ts/elements/settings/custom-background-picker.ts b/frontend/src/ts/elements/settings/custom-background-picker.ts
new file mode 100644
index 000000000..6cd3132ff
--- /dev/null
+++ b/frontend/src/ts/elements/settings/custom-background-picker.ts
@@ -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 {
+ 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 {
+ 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 = "";
+ });
diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts
index 9a3bc2b45..a92a65790 100644
--- a/frontend/src/ts/modals/simple-modals.ts
+++ b/frontend/src/ts/modals/simple-modals.ts
@@ -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 => {
await UpdateConfig.reset();
+ await FileStorage.deleteFile("LocalBackgroundFile");
return {
status: 1,
message: "Settings reset",
diff --git a/frontend/src/ts/pages/settings.ts b/frontend/src/ts/pages/settings.ts
index beebd1df2..39522354d 100644
--- a/frontend/src/ts/pages/settings.ts
+++ b/frontend/src/ts/pages/settings.ts
@@ -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 {
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", () => {
diff --git a/frontend/src/ts/utils/file-storage.ts b/frontend/src/ts/utils/file-storage.ts
new file mode 100644
index 000000000..70b5404fa
--- /dev/null
+++ b/frontend/src/ts/utils/file-storage.ts
@@ -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>;
+
+ constructor(dbName = "file-storage-db") {
+ this.dbPromise = openDB(dbName, 1, {
+ upgrade(db) {
+ if (!db.objectStoreNames.contains("files")) {
+ db.createObjectStore("files");
+ }
+ },
+ });
+ }
+
+ async storeFile(filename: Filename, dataUrl: string): Promise {
+ const db = await this.dbPromise;
+ await db.put("files", dataUrl, filename);
+ }
+
+ async getFile(filename: Filename): Promise {
+ const db = await this.dbPromise;
+ return db.get("files", filename);
+ }
+
+ async deleteFile(filename: Filename): Promise {
+ const db = await this.dbPromise;
+ await db.delete("files", filename);
+ }
+
+ async listFilenames(): Promise {
+ const db = await this.dbPromise;
+ return db.getAllKeys("files") as Promise;
+ }
+
+ async hasFile(filename: Filename): Promise {
+ return (await this.getFile(filename)) !== undefined;
+ }
+}
+
+export default new FileStorage();
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f30a491b2..7eba2b632 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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: {}