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.
+
+ +
+
+ + + +
+
+
+ or +
+
- - - -
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: {}