mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-10-06 05:26:54 +08:00
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:
parent
b024e8ea46
commit
f2b34a541f
10 changed files with 255 additions and 85 deletions
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"));
|
||||
|
|
|
@ -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 = "";
|
||||
});
|
|
@ -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",
|
||||
|
|
|
@ -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", () => {
|
||||
|
|
50
frontend/src/ts/utils/file-storage.ts
Normal file
50
frontend/src/ts/utils/file-storage.ts
Normal 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
8
pnpm-lock.yaml
generated
|
@ -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: {}
|
||||
|
|
Loading…
Add table
Reference in a new issue