mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-12-29 11:26:13 +08:00
Merge branch 'master' into newtribemerge
This commit is contained in:
commit
aa75bdf051
65 changed files with 3461 additions and 689 deletions
|
|
@ -7,6 +7,7 @@
|
|||
"endOfLine": "lf",
|
||||
"trailingComma": "all",
|
||||
"ignorePatterns": [
|
||||
"pnpm-lock.yaml",
|
||||
"node_modules",
|
||||
".turbo",
|
||||
"dist",
|
||||
|
|
|
|||
|
|
@ -31,7 +31,14 @@ export async function setup(): Promise<void> {
|
|||
process.env["REDIS_URI"] = redisUrl;
|
||||
}
|
||||
|
||||
export async function teardown(): Promise<void> {
|
||||
async function stopContainers(): Promise<void> {
|
||||
await startedMongoContainer?.stop();
|
||||
await startedRedisContainer?.stop();
|
||||
}
|
||||
|
||||
export async function teardown(): Promise<void> {
|
||||
await stopContainers();
|
||||
}
|
||||
|
||||
process.on("SIGTERM", stopContainers);
|
||||
process.on("SIGINT", stopContainers);
|
||||
|
|
|
|||
|
|
@ -77,17 +77,17 @@
|
|||
"@types/swagger-stats": "0.95.11",
|
||||
"@types/ua-parser-js": "0.7.36",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@vitest/coverage-v8": "4.0.8",
|
||||
"@vitest/coverage-v8": "4.0.15",
|
||||
"concurrently": "8.2.2",
|
||||
"openapi3-ts": "2.0.2",
|
||||
"oxlint": "1.33.0",
|
||||
"oxlint-tsgolint": "0.9.0",
|
||||
"oxlint": "1.34.0",
|
||||
"oxlint-tsgolint": "0.9.2",
|
||||
"readline-sync": "1.4.10",
|
||||
"supertest": "7.1.4",
|
||||
"testcontainers": "11.4.0",
|
||||
"testcontainers": "11.10.0",
|
||||
"tsx": "4.16.2",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.8"
|
||||
"vitest": "4.0.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": "24.11.0 || 22.21.0"
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@
|
|||
"@types/seedrandom": "3.0.2",
|
||||
"@types/subset-font": "1.4.3",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@vitest/coverage-v8": "4.0.8",
|
||||
"@vitest/coverage-v8": "4.0.15",
|
||||
"autoprefixer": "10.4.20",
|
||||
"concurrently": "8.2.2",
|
||||
"eslint": "9.39.1",
|
||||
|
|
@ -93,8 +93,8 @@
|
|||
"madge": "8.0.0",
|
||||
"magic-string": "0.30.17",
|
||||
"normalize.css": "8.0.1",
|
||||
"oxlint": "1.33.0",
|
||||
"oxlint-tsgolint": "0.9.0",
|
||||
"oxlint": "1.34.0",
|
||||
"oxlint-tsgolint": "0.9.2",
|
||||
"postcss": "8.4.31",
|
||||
"sass": "1.70.0",
|
||||
"subset-font": "2.3.0",
|
||||
|
|
@ -103,13 +103,12 @@
|
|||
"unplugin-inject-preload": "3.0.0",
|
||||
"vite": "7.1.12",
|
||||
"vite-bundle-visualizer": "1.2.1",
|
||||
"vite-plugin-checker": "0.11.0",
|
||||
"vite-plugin-filter-replace": "0.1.14",
|
||||
"vite-plugin-html-inject": "1.1.2",
|
||||
"vite-plugin-inspect": "11.3.3",
|
||||
"vite-plugin-minify": "2.1.0",
|
||||
"vite-plugin-pwa": "1.1.0",
|
||||
"vitest": "4.0.8"
|
||||
"vitest": "4.0.15"
|
||||
},
|
||||
"browserslist": [
|
||||
"defaults",
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@
|
|||
<div class="buttons">
|
||||
<button data-config-value="false">off</button>
|
||||
<button data-config-value="true">
|
||||
⠀
|
||||
 
|
||||
<!-- On is missing on purpose. -->
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -403,6 +403,11 @@ key {
|
|||
}
|
||||
}
|
||||
|
||||
.userIcon {
|
||||
display: grid;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
font-size: 0.8em;
|
||||
line-height: 0.8em;
|
||||
|
|
|
|||
|
|
@ -2209,8 +2209,7 @@ body.darkMode {
|
|||
}
|
||||
}
|
||||
.notificationHistory .list .item {
|
||||
grid-template-areas: "indicator title" "indicator body";
|
||||
grid-template-columns: 0.25rem calc(100% - 0.25rem);
|
||||
grid-template-areas: "indicator title buttons" "indicator body buttons";
|
||||
.title {
|
||||
font-size: 0.75rem;
|
||||
color: var(--sub-color);
|
||||
|
|
@ -2218,6 +2217,9 @@ body.darkMode {
|
|||
.body {
|
||||
opacity: 1;
|
||||
}
|
||||
.highlight {
|
||||
color: var(--main-color) !important;
|
||||
}
|
||||
}
|
||||
.accountAlerts {
|
||||
.title {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@
|
|||
.settingsGroup {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
overflow: hidden;
|
||||
&.quickNav {
|
||||
justify-content: center;
|
||||
.links {
|
||||
|
|
|
|||
|
|
@ -45,14 +45,11 @@ async function sendVerificationEmail(): Promise<void> {
|
|||
|
||||
Loader.show();
|
||||
qs(".sendVerificationEmail")?.disable();
|
||||
const result = await Ape.users.verificationEmail();
|
||||
const response = await Ape.users.verificationEmail();
|
||||
qs(".sendVerificationEmail")?.enable();
|
||||
if (result.status !== 200) {
|
||||
if (response.status !== 200) {
|
||||
Loader.hide();
|
||||
Notifications.add(
|
||||
"Failed to request verification email: " + result.body.message,
|
||||
-1,
|
||||
);
|
||||
Notifications.add("Failed to request verification email", -1, { response });
|
||||
} else {
|
||||
Loader.hide();
|
||||
Notifications.add("Verification email sent", 1);
|
||||
|
|
|
|||
|
|
@ -188,7 +188,12 @@ export const LanguageGroups: Record<string, Language[]> = {
|
|||
swahili: ["swahili_1k"],
|
||||
maori: ["maori_1k"],
|
||||
catalan: ["catalan", "catalan_1k"],
|
||||
bulgarian: ["bulgarian", "bulgarian_latin"],
|
||||
bulgarian: [
|
||||
"bulgarian",
|
||||
"bulgarian_1k",
|
||||
"bulgarian_latin",
|
||||
"bulgarian_latin_1k",
|
||||
],
|
||||
bosnian: ["bosnian", "bosnian_4k"],
|
||||
esperanto: [
|
||||
"esperanto",
|
||||
|
|
|
|||
|
|
@ -299,7 +299,7 @@ export async function getUserResults(offset?: number): Promise<boolean> {
|
|||
const response = await Ape.results.get({ query: { offset } });
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Error getting results: " + response.body.message, -1);
|
||||
Notifications.add("Error getting results", -1, { response });
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -357,10 +357,7 @@ export async function addCustomTheme(
|
|||
|
||||
const response = await Ape.users.addCustomTheme({ body: { ...theme } });
|
||||
if (response.status !== 200) {
|
||||
Notifications.add(
|
||||
"Error adding custom theme: " + response.body.message,
|
||||
-1,
|
||||
);
|
||||
Notifications.add("Error adding custom theme", -1, { response });
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -400,10 +397,7 @@ export async function editCustomTheme(
|
|||
body: { themeId, theme: newTheme },
|
||||
});
|
||||
if (response.status !== 200) {
|
||||
Notifications.add(
|
||||
"Error editing custom theme: " + response.body.message,
|
||||
-1,
|
||||
);
|
||||
Notifications.add("Error editing custom theme", -1, { response });
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -427,10 +421,7 @@ export async function deleteCustomTheme(themeId: string): Promise<boolean> {
|
|||
|
||||
const response = await Ape.users.deleteCustomTheme({ body: { themeId } });
|
||||
if (response.status !== 200) {
|
||||
Notifications.add(
|
||||
"Error deleting custom theme: " + response.body.message,
|
||||
-1,
|
||||
);
|
||||
Notifications.add("Error deleting custom theme", -1, { response });
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -923,7 +914,7 @@ export async function saveConfig(config: Partial<Config>): Promise<void> {
|
|||
if (isAuthenticated()) {
|
||||
const response = await Ape.configs.save({ body: config });
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Failed to save config: " + response.body.message, -1);
|
||||
Notifications.add("Failed to save config", -1, { response });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -932,7 +923,7 @@ export async function resetConfig(): Promise<void> {
|
|||
if (isAuthenticated()) {
|
||||
const response = await Ape.configs.delete();
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Failed to reset config: " + response.body.message, -1);
|
||||
Notifications.add("Failed to reset config", -1, { response });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1055,10 +1046,7 @@ export async function getTestActivityCalendar(
|
|||
Loader.show();
|
||||
const response = await Ape.users.getTestActivity();
|
||||
if (response.status !== 200) {
|
||||
Notifications.add(
|
||||
"Error getting test activities: " + response.body.message,
|
||||
-1,
|
||||
);
|
||||
Notifications.add("Error getting test activities", -1, { response });
|
||||
Loader.hide();
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ const editApeKey = new SimpleModal({
|
|||
if (response.status !== 200) {
|
||||
return {
|
||||
status: -1,
|
||||
message: "Failed to update key: " + response.body.message,
|
||||
message: "Failed to update key",
|
||||
notificationOptions: { response },
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
|
@ -53,7 +54,8 @@ const deleteApeKeyModal = new SimpleModal({
|
|||
if (response.status !== 200) {
|
||||
return {
|
||||
status: -1,
|
||||
message: "Failed to delete key: " + response.body.message,
|
||||
message: "Failed to delete key",
|
||||
notificationOptions: { response },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +130,8 @@ const generateApeKey = new SimpleModal({
|
|||
if (response.status !== 200) {
|
||||
return {
|
||||
status: -1,
|
||||
message: "Failed to generate key: " + response.body.message,
|
||||
message: "Failed to generate key",
|
||||
notificationOptions: { response },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -174,7 +177,7 @@ async function getData(): Promise<boolean> {
|
|||
void update();
|
||||
return false;
|
||||
}
|
||||
Notifications.add("Error getting ape keys: " + response.body.message, -1);
|
||||
Notifications.add("Error getting ape keys", -1, { response });
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -261,7 +264,7 @@ async function toggleActiveKey(keyId: string): Promise<void> {
|
|||
});
|
||||
Loader.hide();
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Failed to update key: " + response.body.message, -1);
|
||||
Notifications.add("Failed to update key", -1, { response });
|
||||
return;
|
||||
}
|
||||
key.enabled = !key.enabled;
|
||||
|
|
|
|||
|
|
@ -24,10 +24,7 @@ async function getData(): Promise<boolean> {
|
|||
|
||||
if (response.status !== 200) {
|
||||
blockedUsers = [];
|
||||
Notifications.add(
|
||||
"Error getting blocked users: " + response.body.message,
|
||||
-1,
|
||||
);
|
||||
Notifications.add("Error getting blocked users", -1, { response });
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ import * as NotificationEvent from "../observables/notification-event";
|
|||
import * as BadgeController from "../controllers/badge-controller";
|
||||
import * as Notifications from "../elements/notifications";
|
||||
import * as ConnectionState from "../states/connection";
|
||||
import { escapeHTML } from "../utils/misc";
|
||||
import {
|
||||
applyReducedMotion,
|
||||
createErrorMessage,
|
||||
escapeHTML,
|
||||
promiseAnimate,
|
||||
} from "../utils/misc";
|
||||
import AnimatedModal from "../utils/animated-modal";
|
||||
import { updateXp as accountPageUpdateProfile } from "./profile";
|
||||
import { MonkeyMail } from "@monkeytype/schemas/users";
|
||||
|
|
@ -29,10 +34,17 @@ let mailToMarkRead: string[] = [];
|
|||
let mailToDelete: string[] = [];
|
||||
|
||||
type State = {
|
||||
notifications: { message: string; level: number; customTitle?: string }[];
|
||||
notifications: {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
level: number;
|
||||
details?: string | object;
|
||||
}[];
|
||||
psas: { message: string; level: number }[];
|
||||
};
|
||||
|
||||
let notificationId = 0;
|
||||
const state: State = {
|
||||
notifications: [],
|
||||
psas: [],
|
||||
|
|
@ -289,28 +301,29 @@ function fillNotifications(): void {
|
|||
} else {
|
||||
notificationHistoryListEl.empty();
|
||||
for (const n of state.notifications) {
|
||||
const { message, level, customTitle } = n;
|
||||
let title = "Notice";
|
||||
const { message, level, title } = n;
|
||||
|
||||
let levelClass = "sub";
|
||||
if (level === -1) {
|
||||
levelClass = "error";
|
||||
title = "Error";
|
||||
} else if (level === 1) {
|
||||
levelClass = "main";
|
||||
title = "Success";
|
||||
}
|
||||
|
||||
if (customTitle !== undefined) {
|
||||
title = customTitle;
|
||||
}
|
||||
|
||||
notificationHistoryListEl.prependHtml(`
|
||||
<div class="item">
|
||||
<div class="item" data-id="${n.id}">
|
||||
<div class="indicator ${levelClass}"></div>
|
||||
<div class="title">${title}</div>
|
||||
<div class="body">
|
||||
${escapeHTML(message)}
|
||||
</div>
|
||||
<div class="buttons">
|
||||
${
|
||||
n.details !== undefined
|
||||
? `<button class="copyNotification textButton" aria-label="Copy details to clipboard" data-balloon-pos="left"><i class="fas fa-fw fa-clipboard"></i></button>`
|
||||
: ``
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
|
@ -396,15 +409,89 @@ function updateClaimDeleteAllButton(): void {
|
|||
}
|
||||
}
|
||||
|
||||
async function copyNotificationToClipboard(target: HTMLElement): Promise<void> {
|
||||
const id = (target as HTMLElement | null)
|
||||
?.closest(".item")
|
||||
?.getAttribute("data-id")
|
||||
?.toString();
|
||||
|
||||
if (id === undefined) {
|
||||
throw new Error("Notification ID is undefined");
|
||||
}
|
||||
const notification = state.notifications.find((it) => it.id === id);
|
||||
if (notification === undefined) return;
|
||||
|
||||
const icon = target.querySelector("i") as HTMLElement;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(
|
||||
JSON.stringify(
|
||||
{
|
||||
title: notification.title,
|
||||
message: notification.message,
|
||||
details: notification.details,
|
||||
},
|
||||
null,
|
||||
4,
|
||||
),
|
||||
);
|
||||
|
||||
const duration = applyReducedMotion(100);
|
||||
|
||||
await promiseAnimate(icon, {
|
||||
scale: [1, 0.8],
|
||||
opacity: [1, 0],
|
||||
duration,
|
||||
});
|
||||
icon.classList.remove("fa-clipboard");
|
||||
icon.classList.add("fa-check", "highlight");
|
||||
await promiseAnimate(icon, {
|
||||
scale: [0.8, 1],
|
||||
opacity: [0, 1],
|
||||
duration,
|
||||
});
|
||||
|
||||
await promiseAnimate(icon, {
|
||||
scale: [1, 0.8],
|
||||
opacity: [1, 0],
|
||||
delay: 3000,
|
||||
duration,
|
||||
});
|
||||
icon.classList.remove("fa-check", "highlight");
|
||||
icon.classList.add("fa-clipboard");
|
||||
|
||||
await promiseAnimate(icon, {
|
||||
scale: [0.8, 1],
|
||||
opacity: [0, 1],
|
||||
duration,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const msg = createErrorMessage(e, "Could not copy to clipboard");
|
||||
Notifications.add(msg, -1);
|
||||
}
|
||||
}
|
||||
|
||||
qs("header nav .showAlerts")?.on("click", () => {
|
||||
void show();
|
||||
});
|
||||
|
||||
NotificationEvent.subscribe((message, level, customTitle) => {
|
||||
NotificationEvent.subscribe((message, level, options) => {
|
||||
let title = "Notice";
|
||||
if (level === -1) {
|
||||
title = "Error";
|
||||
} else if (level === 1) {
|
||||
title = "Success";
|
||||
}
|
||||
if (options.customTitle !== undefined) {
|
||||
title = options.customTitle;
|
||||
}
|
||||
|
||||
state.notifications.push({
|
||||
id: (notificationId++).toString(),
|
||||
title,
|
||||
message,
|
||||
level,
|
||||
customTitle,
|
||||
details: options.details,
|
||||
});
|
||||
if (state.notifications.length > 25) {
|
||||
state.notifications.shift();
|
||||
|
|
@ -495,5 +582,11 @@ const modal = new AnimatedModal({
|
|||
|
||||
markReadAlert(id);
|
||||
});
|
||||
|
||||
alertsPopupEl
|
||||
.qs(".notificationHistory .list")
|
||||
?.onChild("click", ".item .buttons .copyNotification", (e) => {
|
||||
void copyNotificationToClipboard(e.target as HTMLElement);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
} from "@monkeytype/schemas/configs";
|
||||
import Config, { setConfig } from "../config";
|
||||
import * as Notifications from "../elements/notifications";
|
||||
import { ElementWithUtils } from "../utils/dom";
|
||||
import { DomUtilsEvent, ElementWithUtils } from "../utils/dom";
|
||||
|
||||
export type ValidationResult = {
|
||||
status: "checking" | "success" | "failed" | "warning";
|
||||
|
|
@ -60,7 +60,7 @@ export function createInputEventHandler<T>(
|
|||
callback: (result: ValidationResult) => void,
|
||||
validation: Validation<T>,
|
||||
inputValueConvert?: (val: string) => T,
|
||||
): (e: Event) => Promise<void> {
|
||||
): (e: DomUtilsEvent) => Promise<void> {
|
||||
let callIsValid =
|
||||
validation.isValid !== undefined
|
||||
? debounceIfNeeded(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import * as NotificationEvent from "../observables/notification-event";
|
|||
import { convertRemToPixels } from "../utils/numbers";
|
||||
import { animate } from "animejs";
|
||||
import { qsr } from "../utils/dom";
|
||||
import { CommonResponsesType } from "@monkeytype/contracts/util/api";
|
||||
|
||||
const notificationCenter = qsr("#notificationCenter");
|
||||
const notificationCenterHistory = notificationCenter.qsr(".history");
|
||||
|
|
@ -107,6 +108,7 @@ class Notification {
|
|||
visibleStickyNotifications++;
|
||||
updateClearAllButton();
|
||||
}
|
||||
|
||||
notificationCenterHistory.prependHtml(`
|
||||
<div class="notif ${cls}" id=${this.id} style="opacity: 0;">
|
||||
<div class="message"><div class="title"><div class="icon">${icon}</div>${title}</div>${this.message}</div>
|
||||
|
|
@ -270,6 +272,8 @@ export type AddNotificationOptions = {
|
|||
customIcon?: string;
|
||||
closeCallback?: () => void;
|
||||
allowHTML?: boolean;
|
||||
details?: object | string;
|
||||
response?: CommonResponsesType;
|
||||
};
|
||||
|
||||
export function add(
|
||||
|
|
@ -277,7 +281,24 @@ export function add(
|
|||
level = 0,
|
||||
options: AddNotificationOptions = {},
|
||||
): void {
|
||||
NotificationEvent.dispatch(message, level, options.customTitle);
|
||||
let details = options.details;
|
||||
|
||||
if (options.response !== undefined) {
|
||||
details = {
|
||||
status: options.response.status,
|
||||
additionalDetails: options.details,
|
||||
validationErrors:
|
||||
options.response.status === 422
|
||||
? options.response.body.validationErrors
|
||||
: undefined,
|
||||
};
|
||||
message = message + ": " + options.response.body.message;
|
||||
}
|
||||
|
||||
NotificationEvent.dispatch(message, level, {
|
||||
customTitle: options.customTitle,
|
||||
details,
|
||||
});
|
||||
|
||||
new Notification(
|
||||
"notification",
|
||||
|
|
|
|||
|
|
@ -1,27 +1,30 @@
|
|||
import * as ActivePage from "../states/active-page";
|
||||
import { prefersReducedMotion } from "../utils/misc";
|
||||
import { qsr } from "../utils/dom";
|
||||
|
||||
let visible = false;
|
||||
|
||||
const button = qsr(".scrollToTopButton");
|
||||
|
||||
export function hide(): void {
|
||||
$(".scrollToTopButton").addClass("invisible");
|
||||
button.addClass("invisible");
|
||||
visible = false;
|
||||
}
|
||||
|
||||
function show(): void {
|
||||
$(".scrollToTopButton").removeClass("invisible");
|
||||
button.removeClass("invisible");
|
||||
visible = true;
|
||||
}
|
||||
|
||||
$(document).on("click", ".scrollToTopButton", () => {
|
||||
$(".scrollToTopButton").addClass("invisible");
|
||||
button.on("click", () => {
|
||||
button.addClass("invisible");
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: prefersReducedMotion() ? "instant" : "smooth",
|
||||
});
|
||||
});
|
||||
|
||||
$(window).on("scroll", () => {
|
||||
window.addEventListener("scroll", () => {
|
||||
const page = ActivePage.get();
|
||||
if (page === "test") return;
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ async function setup(modalEl: HTMLElement): Promise<void> {
|
|||
});
|
||||
Notifications.add("This is a test", -1, {
|
||||
duration: 0,
|
||||
details: { test: true, error: "Example error message" },
|
||||
});
|
||||
void modal.hide();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export function show(action: string, id?: string, name?: string): void {
|
|||
$("#editPresetModal .modal .text").addClass("hidden");
|
||||
addCheckBoxes();
|
||||
presetNameEl ??= new ValidatedHtmlInputElement(
|
||||
qsr("#editPresetModal .modal input"),
|
||||
qsr("#editPresetModal .modal input[type=text]"),
|
||||
{
|
||||
schema: PresetNameSchema,
|
||||
},
|
||||
|
|
@ -284,7 +284,7 @@ async function apply(): Promise<void> {
|
|||
|
||||
if (response.status !== 200 || response.body.data === null) {
|
||||
Notifications.add(
|
||||
"Failed to add preset: " +
|
||||
"Failed to add preset" +
|
||||
response.body.message.replace(presetName, propPresetName),
|
||||
-1,
|
||||
);
|
||||
|
|
@ -325,7 +325,7 @@ async function apply(): Promise<void> {
|
|||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Failed to edit preset: " + response.body.message, -1);
|
||||
Notifications.add("Failed to edit preset", -1, { response });
|
||||
} else {
|
||||
Notifications.add("Preset updated", 1);
|
||||
|
||||
|
|
@ -344,10 +344,7 @@ async function apply(): Promise<void> {
|
|||
const response = await Ape.presets.delete({ params: { presetId } });
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add(
|
||||
"Failed to remove preset: " + response.body.message,
|
||||
-1,
|
||||
);
|
||||
Notifications.add("Failed to remove preset", -1, { response });
|
||||
} else {
|
||||
Notifications.add("Preset removed", 1);
|
||||
snapshotPresets.forEach((preset: SnapshotPreset, index: number) => {
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ async function updateProfile(): Promise<void> {
|
|||
Loader.hide();
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Failed to update profile: " + response.body.message, -1);
|
||||
Notifications.add("Failed to update profile", -1, { response });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,10 +121,7 @@ async function save(): Promise<void> {
|
|||
state.tags = state.tags.filter((el) => el !== undefined);
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add(
|
||||
"Failed to update result tags: " + response.body.message,
|
||||
-1,
|
||||
);
|
||||
Notifications.add("Failed to update result tags", -1, { response });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ const actionModals: Record<Action, SimpleModal> = {
|
|||
message:
|
||||
"Failed to add tag: " +
|
||||
response.body.message.replace(tagName, propTagName),
|
||||
notificationOptions: { response },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -83,7 +84,8 @@ const actionModals: Record<Action, SimpleModal> = {
|
|||
if (response.status !== 200) {
|
||||
return {
|
||||
status: -1,
|
||||
message: "Failed to edit tag: " + response.body.message,
|
||||
message: "Failed to edit tag",
|
||||
notificationOptions: { response },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +115,8 @@ const actionModals: Record<Action, SimpleModal> = {
|
|||
if (response.status !== 200) {
|
||||
return {
|
||||
status: -1,
|
||||
message: "Failed to remove tag: " + response.body.message,
|
||||
message: "Failed to remove tag",
|
||||
notificationOptions: { response },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +146,8 @@ const actionModals: Record<Action, SimpleModal> = {
|
|||
if (response.status !== 200) {
|
||||
return {
|
||||
status: -1,
|
||||
message: "Failed to clear tag pb: " + response.body.message,
|
||||
message: "Failed to clear tag pb",
|
||||
notificationOptions: { response },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ async function getQuotes(): Promise<void> {
|
|||
Loader.hide();
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Failed to get new quotes: " + response.body.message, -1);
|
||||
Notifications.add("Failed to get new quotes", -1, { response });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -160,7 +160,7 @@ async function approveQuote(index: number, dbid: string): Promise<void> {
|
|||
if (response.status !== 200) {
|
||||
resetButtons(index);
|
||||
quote.find("textarea, input").prop("disabled", false);
|
||||
Notifications.add("Failed to approve quote: " + response.body.message, -1);
|
||||
Notifications.add("Failed to approve quote", -1, { response });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -184,7 +184,7 @@ async function refuseQuote(index: number, dbid: string): Promise<void> {
|
|||
if (response.status !== 200) {
|
||||
resetButtons(index);
|
||||
quote.find("textarea, input").prop("disabled", false);
|
||||
Notifications.add("Failed to refuse quote: " + response.body.message, -1);
|
||||
Notifications.add("Failed to refuse quote", -1, { response });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -218,7 +218,7 @@ async function editQuote(index: number, dbid: string): Promise<void> {
|
|||
if (response.status !== 200) {
|
||||
resetButtons(index);
|
||||
quote.find("textarea, input").prop("disabled", false);
|
||||
Notifications.add("Failed to approve quote: " + response.body.message, -1);
|
||||
Notifications.add("Failed to approve quote", -1, { response });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,10 +60,7 @@ export async function getQuoteStats(
|
|||
Loader.hide();
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add(
|
||||
"Failed to get quote ratings: " + response.body.message,
|
||||
-1,
|
||||
);
|
||||
Notifications.add("Failed to get quote ratings", -1, { response });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -156,10 +153,7 @@ async function submit(): Promise<void> {
|
|||
Loader.hide();
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add(
|
||||
"Failed to submit quote rating: " + response.body.message,
|
||||
-1,
|
||||
);
|
||||
Notifications.add("Failed to submit quote rating", -1, { response });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ async function submitReport(): Promise<void> {
|
|||
Loader.hide();
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Failed to report quote: " + response.body.message, -1);
|
||||
Notifications.add("Failed to report quote", -1, { response });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ const searchServiceCache: Record<string, SearchService<Quote>> = {};
|
|||
const pageSize = 100;
|
||||
let currentPageNumber = 1;
|
||||
let usingCustomLength = true;
|
||||
let quotes: Quote[];
|
||||
|
||||
async function updateQuotes(): Promise<void> {
|
||||
({ quotes } = await QuotesController.getQuotes(Config.language));
|
||||
}
|
||||
|
||||
function getSearchService<T>(
|
||||
language: string,
|
||||
|
|
@ -188,10 +193,61 @@ function buildQuoteSearchResult(
|
|||
`;
|
||||
}
|
||||
|
||||
function exactSearch(quotes: Quote[], captured: RegExp[]): [Quote[], string[]] {
|
||||
const matches: Quote[] = [];
|
||||
const exactSearchQueryTerms: Set<string> = new Set<string>();
|
||||
|
||||
for (const quote of quotes) {
|
||||
const textAndSource = quote.text + quote.source;
|
||||
const currentMatches = [];
|
||||
let noMatch = false;
|
||||
|
||||
for (const regex of captured) {
|
||||
const match = textAndSource.match(regex);
|
||||
|
||||
if (!match) {
|
||||
noMatch = true;
|
||||
break;
|
||||
}
|
||||
|
||||
currentMatches.push(match[0]);
|
||||
}
|
||||
|
||||
if (!noMatch) {
|
||||
currentMatches.forEach((match) => exactSearchQueryTerms.add(match));
|
||||
matches.push(quote);
|
||||
}
|
||||
}
|
||||
|
||||
return [matches, Array.from(exactSearchQueryTerms)];
|
||||
}
|
||||
|
||||
async function updateResults(searchText: string): Promise<void> {
|
||||
if (!modal.isOpen()) return;
|
||||
|
||||
const { quotes } = await QuotesController.getQuotes(Config.language);
|
||||
if (quotes === undefined) {
|
||||
({ quotes } = await QuotesController.getQuotes(Config.language));
|
||||
}
|
||||
|
||||
let matches: Quote[] = [];
|
||||
let matchedQueryTerms: string[] = [];
|
||||
let exactSearchMatches: Quote[] = [];
|
||||
let exactSearchMatchedQueryTerms: string[] = [];
|
||||
|
||||
const quotationsRegex = /"(.*?)"/g;
|
||||
const exactSearchQueries = Array.from(searchText.matchAll(quotationsRegex));
|
||||
const removedSearchText = searchText.replaceAll(quotationsRegex, "");
|
||||
|
||||
if (exactSearchQueries[0]) {
|
||||
const searchQueriesRaw = exactSearchQueries.map(
|
||||
(query) => new RegExp(query[1] ?? "", "i"),
|
||||
);
|
||||
|
||||
[exactSearchMatches, exactSearchMatchedQueryTerms] = exactSearch(
|
||||
quotes,
|
||||
searchQueriesRaw,
|
||||
);
|
||||
}
|
||||
|
||||
const quoteSearchService = getSearchService<Quote>(
|
||||
Config.language,
|
||||
|
|
@ -200,8 +256,21 @@ async function updateResults(searchText: string): Promise<void> {
|
|||
return `${quote.text} ${quote.id} ${quote.source}`;
|
||||
},
|
||||
);
|
||||
const { results: matches, matchedQueryTerms } =
|
||||
quoteSearchService.query(searchText);
|
||||
|
||||
if (exactSearchMatches.length > 0 || removedSearchText === searchText) {
|
||||
const ids = exactSearchMatches.map((match) => match.id);
|
||||
|
||||
({ results: matches, matchedQueryTerms } = quoteSearchService.query(
|
||||
removedSearchText,
|
||||
ids,
|
||||
));
|
||||
|
||||
exactSearchMatches.forEach((match) => {
|
||||
if (!matches.includes(match)) matches.push(match);
|
||||
});
|
||||
|
||||
matchedQueryTerms = [...exactSearchMatchedQueryTerms, ...matchedQueryTerms];
|
||||
}
|
||||
|
||||
const quotesToShow = applyQuoteLengthFilter(
|
||||
applyQuoteFavFilter(searchText === "" ? quotes : matches),
|
||||
|
|
@ -340,12 +409,7 @@ export async function show(showOptions?: ShowOptions): Promise<void> {
|
|||
});
|
||||
},
|
||||
afterAnimation: async () => {
|
||||
const quoteSearchInputValue = $(
|
||||
"#quoteSearchModal input",
|
||||
).val() as string;
|
||||
currentPageNumber = 1;
|
||||
|
||||
void updateResults(quoteSearchInputValue);
|
||||
void updateQuotes();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ async function submitQuote(): Promise<void> {
|
|||
Loader.hide();
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Failed to submit quote: " + response.body.message, -1);
|
||||
Notifications.add("Failed to submit quote", -1, { response });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -288,7 +288,8 @@ list.updateEmail = new SimpleModal({
|
|||
if (response.status !== 200) {
|
||||
return {
|
||||
status: -1,
|
||||
message: "Failed to update email: " + response.body.message,
|
||||
message: "Failed to update email",
|
||||
notificationOptions: { response },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -499,13 +500,14 @@ list.updateName = new SimpleModal({
|
|||
};
|
||||
}
|
||||
|
||||
const updateNameResponse = await Ape.users.updateName({
|
||||
const response = await Ape.users.updateName({
|
||||
body: { name: newName },
|
||||
});
|
||||
if (updateNameResponse.status !== 200) {
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
status: -1,
|
||||
message: "Failed to update name: " + updateNameResponse.body.message,
|
||||
message: "Failed to update name",
|
||||
notificationOptions: { response },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -598,7 +600,8 @@ list.updatePassword = new SimpleModal({
|
|||
if (response.status !== 200) {
|
||||
return {
|
||||
status: -1,
|
||||
message: "Failed to update password: " + response.body.message,
|
||||
message: "Failed to update password",
|
||||
notificationOptions: { response },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -699,8 +702,8 @@ list.addPasswordAuth = new SimpleModal({
|
|||
return {
|
||||
status: -1,
|
||||
message:
|
||||
"Password authentication added but updating the database email failed. This shouldn't happen, please contact support. Error: " +
|
||||
response.body.message,
|
||||
"Password authentication added but updating the database email failed. This shouldn't happen, please contact support. Error",
|
||||
notificationOptions: { response },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -735,12 +738,13 @@ list.deleteAccount = new SimpleModal({
|
|||
}
|
||||
|
||||
Notifications.add("Deleting all data...", 0);
|
||||
const usersResponse = await Ape.users.delete();
|
||||
const response = await Ape.users.delete();
|
||||
|
||||
if (usersResponse.status !== 200) {
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
status: -1,
|
||||
message: "Failed to delete user data: " + usersResponse.body.message,
|
||||
message: "Failed to delete user data",
|
||||
notificationOptions: { response },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -791,7 +795,8 @@ list.resetAccount = new SimpleModal({
|
|||
if (response.status !== 200) {
|
||||
return {
|
||||
status: -1,
|
||||
message: "Failed to reset account: " + response.body.message,
|
||||
message: "Failed to reset account",
|
||||
notificationOptions: { response },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -837,7 +842,8 @@ list.optOutOfLeaderboards = new SimpleModal({
|
|||
if (response.status !== 200) {
|
||||
return {
|
||||
status: -1,
|
||||
message: "Failed to opt out: " + response.body.message,
|
||||
message: "Failed to opt out",
|
||||
notificationOptions: { response },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -898,7 +904,8 @@ list.resetPersonalBests = new SimpleModal({
|
|||
if (response.status !== 200) {
|
||||
return {
|
||||
status: -1,
|
||||
message: "Failed to reset personal bests: " + response.body.message,
|
||||
message: "Failed to reset personal bests",
|
||||
notificationOptions: { response },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -974,7 +981,8 @@ list.revokeAllTokens = new SimpleModal({
|
|||
if (response.status !== 200) {
|
||||
return {
|
||||
status: -1,
|
||||
message: "Failed to revoke tokens: " + response.body.message,
|
||||
message: "Failed to revoke tokens",
|
||||
notificationOptions: { response },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1015,7 +1023,8 @@ list.unlinkDiscord = new SimpleModal({
|
|||
if (response.status !== 200) {
|
||||
return {
|
||||
status: -1,
|
||||
message: "Failed to unlink Discord: " + response.body.message,
|
||||
message: "Failed to unlink Discord",
|
||||
notificationOptions: { response },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1203,7 +1212,7 @@ list.devGenerateData = new SimpleModal({
|
|||
const span = document.querySelector(
|
||||
"#devGenerateData_1 + span",
|
||||
) as HTMLInputElement;
|
||||
span.innerHTML = `if checked, user will be created with ${target.value}@example.com and password: password`;
|
||||
span.innerText = `if checked, user will be created with ${target.value}@example.com and password: password`;
|
||||
return;
|
||||
},
|
||||
validation: {
|
||||
|
|
|
|||
|
|
@ -88,10 +88,7 @@ async function apply(): Promise<void> {
|
|||
});
|
||||
Loader.hide();
|
||||
if (response.status !== 200) {
|
||||
Notifications.add(
|
||||
"Failed to set streak hour offset: " + response.body.message,
|
||||
-1,
|
||||
);
|
||||
Notifications.add("Failed to set streak hour offset", -1, { response });
|
||||
} else {
|
||||
Notifications.add("Streak hour offset set", 1);
|
||||
const snap = getSnapshot() as Snapshot;
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ async function submitReport(): Promise<void> {
|
|||
Loader.hide();
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Failed to report user: " + response.body.message, -1);
|
||||
Notifications.add("Failed to report user", -1, { response });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
type SubscribeFunction = (
|
||||
export type NotificationOptions = {
|
||||
customTitle?: string;
|
||||
details?: object | string;
|
||||
};
|
||||
|
||||
export type SubscribeFunction = (
|
||||
message: string,
|
||||
level: number,
|
||||
customTitle?: string,
|
||||
options: NotificationOptions,
|
||||
) => void;
|
||||
|
||||
const subscribers: SubscribeFunction[] = [];
|
||||
|
|
@ -13,11 +18,11 @@ export function subscribe(fn: SubscribeFunction): void {
|
|||
export function dispatch(
|
||||
message: string,
|
||||
level: number,
|
||||
customTitle?: string,
|
||||
options: NotificationOptions,
|
||||
): void {
|
||||
subscribers.forEach((fn) => {
|
||||
try {
|
||||
fn(message, level, customTitle);
|
||||
fn(message, level, options);
|
||||
} catch (e) {
|
||||
console.error("Notification event subscriber threw an error");
|
||||
console.error(e);
|
||||
|
|
|
|||
|
|
@ -1107,7 +1107,7 @@ $(".pageAccount").on("click", ".miniResultChartButton", async (event) => {
|
|||
target.removeClass("loading");
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Error fetching result: " + response.body.message, -1);
|
||||
Notifications.add("Error fetching result", -1, { response });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ import * as CustomBackgroundPicker from "../elements/settings/custom-background-
|
|||
import * as CustomFontPicker from "../elements/settings/custom-font-picker";
|
||||
import * as AuthEvent from "../observables/auth-event";
|
||||
import * as FpsLimitSection from "../elements/settings/fps-limit-section";
|
||||
import { qsr } from "../utils/dom";
|
||||
import { qs, qsr } from "../utils/dom";
|
||||
|
||||
let settingsInitialized = false;
|
||||
|
||||
|
|
@ -763,13 +763,28 @@ function toggleSettingsGroup(groupName: string): void {
|
|||
//The highlight is repeated/broken when toggling the group
|
||||
handleHighlightSection(undefined);
|
||||
|
||||
const groupEl = $(`.pageSettings .settingsGroup.${groupName}`);
|
||||
groupEl.stop(true, true).slideToggle(250).toggleClass("slideup");
|
||||
if (groupEl.hasClass("slideup")) {
|
||||
const groupEl = qs(`.pageSettings .settingsGroup.${groupName}`);
|
||||
if (!groupEl?.hasClass("slideup")) {
|
||||
groupEl?.animate({
|
||||
height: 0,
|
||||
duration: 250,
|
||||
onComplete: () => {
|
||||
groupEl?.hide();
|
||||
},
|
||||
});
|
||||
groupEl?.addClass("slideup");
|
||||
$(`.pageSettings .sectionGroupTitle[group=${groupName}]`).addClass(
|
||||
"rotateIcon",
|
||||
);
|
||||
} else {
|
||||
groupEl?.show();
|
||||
groupEl?.setStyle({ height: "" });
|
||||
const height = groupEl.getOffsetHeight();
|
||||
groupEl?.animate({
|
||||
height: [0, height],
|
||||
duration: 250,
|
||||
});
|
||||
groupEl?.removeClass("slideup");
|
||||
$(`.pageSettings .sectionGroupTitle[group=${groupName}]`).removeClass(
|
||||
"rotateIcon",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
import { z } from "zod";
|
||||
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
|
||||
|
||||
const ls = new LocalStorageWithSchema({
|
||||
key: "prefersArabicLazyMode",
|
||||
schema: z.boolean(),
|
||||
fallback: true,
|
||||
});
|
||||
|
||||
export function get(): boolean {
|
||||
return ls.get();
|
||||
}
|
||||
|
||||
export function set(value: boolean): void {
|
||||
ls.set(value);
|
||||
}
|
||||
30
frontend/src/ts/states/remember-lazy-mode.ts
Normal file
30
frontend/src/ts/states/remember-lazy-mode.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { z } from "zod";
|
||||
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
|
||||
|
||||
const rememberLazyModeLS = new LocalStorageWithSchema({
|
||||
key: "rememberLazyMode",
|
||||
schema: z.boolean(),
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
const arabicLazyModeLS = new LocalStorageWithSchema({
|
||||
key: "prefersArabicLazyMode",
|
||||
schema: z.boolean(),
|
||||
fallback: true,
|
||||
});
|
||||
|
||||
export function getRemember(): boolean {
|
||||
return rememberLazyModeLS.get();
|
||||
}
|
||||
|
||||
export function setRemember(value: boolean): void {
|
||||
rememberLazyModeLS.set(value);
|
||||
}
|
||||
|
||||
export function getArabicPref(): boolean {
|
||||
return arabicLazyModeLS.get();
|
||||
}
|
||||
|
||||
export function setArabicPref(value: boolean): void {
|
||||
arabicLazyModeLS.set(value);
|
||||
}
|
||||
|
|
@ -4,8 +4,8 @@ export function get(): number {
|
|||
return time;
|
||||
}
|
||||
|
||||
export function set(active: number): void {
|
||||
time = active;
|
||||
export function set(number: number): void {
|
||||
time = number;
|
||||
}
|
||||
|
||||
export function increment(): void {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export let leftState = false;
|
||||
export let rightState = false;
|
||||
|
||||
$(document).on("keydown", (e) => {
|
||||
document.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.code === "AltLeft") {
|
||||
leftState = true;
|
||||
} else if (e.code === "AltRight") {
|
||||
|
|
@ -9,7 +9,7 @@ $(document).on("keydown", (e) => {
|
|||
}
|
||||
});
|
||||
|
||||
$(document).on("keyup", (e) => {
|
||||
document.addEventListener("keyup", (e: KeyboardEvent) => {
|
||||
if (e.code === "AltLeft") {
|
||||
leftState = false;
|
||||
} else if (e.code === "AltRight") {
|
||||
|
|
|
|||
|
|
@ -652,6 +652,33 @@ const replacementRules: BritishEnglishReplacements = {
|
|||
moisturizing: "moisturising",
|
||||
favoring: "favouring",
|
||||
marvelous: "marvellous",
|
||||
hematuria: "haematuria",
|
||||
hemoptysis: "haemoptysis",
|
||||
hemorrhoid: "haemorrhoid",
|
||||
hemorrhagic: "haemorrhagic",
|
||||
hypercalcemia: "hypercalcaemia",
|
||||
hyperglycemia: "hyperglycaemia",
|
||||
hypoglycemia: "hypoglycaemia",
|
||||
toxemia: "toxaemia",
|
||||
hypoxemia: "hypoxaemia",
|
||||
bacteremia: "bacteraemia",
|
||||
hypernatremia: "hypernatraemia",
|
||||
hyponatremia: "hyponatraemia",
|
||||
leukocytosis: "leucocytosis",
|
||||
leukocyte: "leucocyte",
|
||||
leukopenia: "leucopenia",
|
||||
apnea: "apnoea",
|
||||
bradypnea: "bradypnoea",
|
||||
tachypnea: "tachypnoea",
|
||||
orthopnea: "orthopnoea",
|
||||
ileocecal: "ileocaecal",
|
||||
metastasize: "metastasise",
|
||||
lymphedema: "lymphoedema",
|
||||
neuron: "neurone",
|
||||
hemianopsia: "hemianopia",
|
||||
galactorrhea: "galactorrhoea",
|
||||
nebulizer: "nebuliser",
|
||||
paresthesia: "paraesthesia",
|
||||
};
|
||||
|
||||
export async function replace(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import Config from "../config";
|
||||
import * as Misc from "../utils/misc";
|
||||
import { qsr } from "../utils/dom";
|
||||
|
||||
const el = document.querySelector("#capsWarning") as HTMLElement;
|
||||
const el = qsr("#capsWarning");
|
||||
|
||||
export let capsState = false;
|
||||
|
||||
|
|
@ -9,23 +10,23 @@ let visible = false;
|
|||
|
||||
function show(): void {
|
||||
if (!visible) {
|
||||
el?.classList.remove("hidden");
|
||||
el.removeClass("hidden");
|
||||
visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
function hide(): void {
|
||||
if (visible) {
|
||||
el?.classList.add("hidden");
|
||||
el.addClass("hidden");
|
||||
visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
function update(event: JQuery.KeyDownEvent | JQuery.KeyUpEvent): void {
|
||||
if (event?.originalEvent?.key === "CapsLock" && capsState !== null) {
|
||||
function update(event: KeyboardEvent): void {
|
||||
if (event.key === "CapsLock" && capsState !== null) {
|
||||
capsState = !capsState;
|
||||
} else {
|
||||
const modState = event?.originalEvent?.getModifierState?.("CapsLock");
|
||||
const modState = event.getModifierState?.("CapsLock");
|
||||
if (modState !== undefined) {
|
||||
capsState = modState;
|
||||
}
|
||||
|
|
@ -40,8 +41,8 @@ function update(event: JQuery.KeyDownEvent | JQuery.KeyUpEvent): void {
|
|||
} catch {}
|
||||
}
|
||||
|
||||
$(document).on("keyup", update);
|
||||
document.addEventListener("keyup", update);
|
||||
|
||||
$(document).on("keydown", (event) => {
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (Misc.isMac()) update(event);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import * as KeyConverter from "../utils/key-converter";
|
|||
export let leftState = false;
|
||||
export let rightState = false;
|
||||
|
||||
$(document).on("keydown", (e) => {
|
||||
document.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.code === "ShiftLeft") {
|
||||
leftState = true;
|
||||
rightState = false;
|
||||
|
|
@ -14,7 +14,7 @@ $(document).on("keydown", (e) => {
|
|||
}
|
||||
});
|
||||
|
||||
$(document).on("keyup", (e) => {
|
||||
document.addEventListener("keyup", (e: KeyboardEvent) => {
|
||||
if (e.code === "ShiftLeft" || e.code === "ShiftRight") {
|
||||
leftState = false;
|
||||
rightState = false;
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ import * as Tribe from "../tribe/tribe";
|
|||
import * as TribeTypes from "../tribe/types";
|
||||
import * as ConnectionState from "../states/connection";
|
||||
import * as KeymapEvent from "../observables/keymap-event";
|
||||
import * as ArabicLazyMode from "../states/arabic-lazy-mode";
|
||||
import * as LazyModeState from "../states/remember-lazy-mode";
|
||||
import Format from "../utils/format";
|
||||
import { QuoteLength, QuoteLengthConfig } from "@monkeytype/schemas/configs";
|
||||
import { Mode } from "@monkeytype/schemas/shared";
|
||||
|
|
@ -75,6 +75,7 @@ import { animate } from "animejs";
|
|||
import { setInputElementValue } from "../input/input-element";
|
||||
import { debounce } from "throttle-debounce";
|
||||
import tribeSocket from "../tribe/tribe-socket";
|
||||
import * as Time from "../states/time";
|
||||
|
||||
let resolve: TribeTypes.ResultResolve = {};
|
||||
let failReason = "";
|
||||
|
|
@ -108,6 +109,7 @@ export function startTest(now: number): boolean {
|
|||
Replay.startReplayRecording();
|
||||
Replay.replayGetWordsList(TestWords.words.list);
|
||||
TestInput.resetKeypressTimings();
|
||||
Time.set(0);
|
||||
TestTimer.clear();
|
||||
|
||||
for (const fb of getActiveFunboxesWithFunction("start")) {
|
||||
|
|
@ -356,7 +358,6 @@ export function restart(options = {} as RestartOptions): void {
|
|||
}
|
||||
|
||||
let lastInitError: Error | null = null;
|
||||
let rememberLazyMode: boolean;
|
||||
let showedLazyModeNotification: boolean = false;
|
||||
let testReinitCount = 0;
|
||||
|
||||
|
|
@ -440,39 +441,43 @@ async function init(): Promise<boolean> {
|
|||
.some((lang) => !lang.noLazyMode);
|
||||
|
||||
if (Config.lazyMode && !anySupportsLazyMode) {
|
||||
rememberLazyMode = true;
|
||||
Notifications.add(
|
||||
"None of the selected polyglot languages support lazy mode.",
|
||||
0,
|
||||
{
|
||||
important: true,
|
||||
},
|
||||
);
|
||||
LazyModeState.setRemember(true);
|
||||
if (!showedLazyModeNotification) {
|
||||
Notifications.add(
|
||||
"None of the selected polyglot languages support lazy mode.",
|
||||
0,
|
||||
{
|
||||
important: true,
|
||||
},
|
||||
);
|
||||
showedLazyModeNotification = true;
|
||||
}
|
||||
setConfig("lazyMode", false);
|
||||
} else if (rememberLazyMode && anySupportsLazyMode) {
|
||||
setConfig("lazyMode", true, {
|
||||
nosave: true,
|
||||
});
|
||||
} else if (LazyModeState.getRemember() && anySupportsLazyMode) {
|
||||
setConfig("lazyMode", true);
|
||||
LazyModeState.setRemember(false);
|
||||
showedLazyModeNotification = false;
|
||||
}
|
||||
} else {
|
||||
// normal mode
|
||||
if (Config.lazyMode && !allowLazyMode) {
|
||||
rememberLazyMode = true;
|
||||
showedLazyModeNotification = true;
|
||||
Notifications.add("This language does not support lazy mode.", 0, {
|
||||
important: true,
|
||||
});
|
||||
|
||||
LazyModeState.setRemember(true);
|
||||
if (!showedLazyModeNotification) {
|
||||
Notifications.add("This language does not support lazy mode.", 0, {
|
||||
important: true,
|
||||
});
|
||||
showedLazyModeNotification = true;
|
||||
}
|
||||
setConfig("lazyMode", false);
|
||||
} else if (rememberLazyMode && !language.noLazyMode) {
|
||||
setConfig("lazyMode", true, {
|
||||
nosave: true,
|
||||
});
|
||||
} else if (LazyModeState.getRemember() && allowLazyMode) {
|
||||
setConfig("lazyMode", true);
|
||||
LazyModeState.setRemember(false);
|
||||
showedLazyModeNotification = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Config.lazyMode && !language.noLazyMode) {
|
||||
rememberLazyMode = false;
|
||||
LazyModeState.setRemember(false);
|
||||
}
|
||||
|
||||
if (Config.mode === "custom") {
|
||||
|
|
@ -1413,7 +1418,7 @@ async function saveResult(
|
|||
response.body.message =
|
||||
"Looks like your result data is using an incorrect schema. Please refresh the page to download the new update. If the problem persists, please contact support.";
|
||||
}
|
||||
Notifications.add("Failed to save result: " + response.body.message, -1);
|
||||
Notifications.add("Failed to save result", -1, { response });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1725,7 +1730,10 @@ ConfigEvent.subscribe(({ key, newValue, nosave }) => {
|
|||
if (ActivePage.get() === "test") {
|
||||
if (key === "language") {
|
||||
//automatically enable lazy mode for arabic
|
||||
if ((newValue as string)?.startsWith("arabic") && ArabicLazyMode.get()) {
|
||||
if (
|
||||
(newValue as string)?.startsWith("arabic") &&
|
||||
LazyModeState.getArabicPref()
|
||||
) {
|
||||
setConfig("lazyMode", true, {
|
||||
nosave: true,
|
||||
});
|
||||
|
|
@ -1758,13 +1766,7 @@ ConfigEvent.subscribe(({ key, newValue, nosave }) => {
|
|||
}
|
||||
if (key === "lazyMode" && !nosave) {
|
||||
if (Config.language.startsWith("arabic")) {
|
||||
ArabicLazyMode.set(newValue);
|
||||
}
|
||||
if (newValue) {
|
||||
if (!showedLazyModeNotification) {
|
||||
rememberLazyMode = false;
|
||||
}
|
||||
showedLazyModeNotification = false;
|
||||
LazyModeState.setArabicPref(newValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -62,7 +62,6 @@ export function enableTimerDebug(): void {
|
|||
|
||||
export function clear(): void {
|
||||
clearLowFpsMode();
|
||||
Time.set(0);
|
||||
newTimer.reset();
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -300,7 +300,7 @@ export class Caret {
|
|||
if (options.letterIndex >= letters.length) {
|
||||
side = "afterLetter";
|
||||
|
||||
if (Config.blindMode) {
|
||||
if (Config.blindMode || Config.hideExtraLetters) {
|
||||
options.letterIndex = wordText?.length - 1;
|
||||
} else {
|
||||
options.letterIndex = letters.length - 1;
|
||||
|
|
@ -450,6 +450,9 @@ export class Caret {
|
|||
let left = 0;
|
||||
let top = 0;
|
||||
|
||||
const tapeOffset =
|
||||
wordsWrapperCache.getOffsetWidth() * (Config.tapeMargin / 100);
|
||||
|
||||
// yes, this is all super verbose, but its easier to maintain and understand
|
||||
if (isWordRTL) {
|
||||
let afterLetterCorrection = 0;
|
||||
|
|
@ -475,8 +478,7 @@ export class Caret {
|
|||
left += options.letter.getOffsetLeft();
|
||||
left += afterLetterCorrection;
|
||||
if (this.isMainCaret && lockedMainCaretInTape) {
|
||||
left +=
|
||||
wordsWrapperCache.getOffsetWidth() * (Config.tapeMargin / 100);
|
||||
left += wordsWrapperCache.getOffsetWidth() - tapeOffset;
|
||||
} else {
|
||||
left += options.word.getOffsetLeft();
|
||||
left += options.word.getOffsetWidth();
|
||||
|
|
@ -486,8 +488,7 @@ export class Caret {
|
|||
left += width * -1;
|
||||
}
|
||||
if (this.isMainCaret && lockedMainCaretInTape) {
|
||||
left +=
|
||||
wordsWrapperCache.getOffsetWidth() * (Config.tapeMargin / 100);
|
||||
left += wordsWrapperCache.getOffsetWidth() - tapeOffset;
|
||||
} else {
|
||||
left += options.letter.getOffsetLeft();
|
||||
left += options.word.getOffsetLeft();
|
||||
|
|
@ -508,15 +509,13 @@ export class Caret {
|
|||
left += options.letter.getOffsetLeft();
|
||||
left += afterLetterCorrection;
|
||||
if (this.isMainCaret && lockedMainCaretInTape) {
|
||||
left +=
|
||||
wordsWrapperCache.getOffsetWidth() * (Config.tapeMargin / 100);
|
||||
left += tapeOffset;
|
||||
} else {
|
||||
left += options.word.getOffsetLeft();
|
||||
}
|
||||
} else if (Config.tapeMode === "letter") {
|
||||
if (this.isMainCaret && lockedMainCaretInTape) {
|
||||
left +=
|
||||
wordsWrapperCache.getOffsetWidth() * (Config.tapeMargin / 100);
|
||||
left += tapeOffset;
|
||||
} else {
|
||||
left += options.letter.getOffsetLeft();
|
||||
left += options.word.getOffsetLeft();
|
||||
|
|
|
|||
|
|
@ -105,6 +105,12 @@ type ElementWithValue =
|
|||
| HTMLTextAreaElement
|
||||
| HTMLSelectElement;
|
||||
|
||||
export type DomUtilsEvent<T extends Event = Event> = Omit<T, "currentTarget">;
|
||||
|
||||
type DomUtilsEventListenerOrEventListenerObject =
|
||||
| { (evt: DomUtilsEvent): void }
|
||||
| { handleEvent(object: DomUtilsEvent): void };
|
||||
|
||||
export class ElementWithUtils<T extends HTMLElement = HTMLElement> {
|
||||
/**
|
||||
* The native dom element
|
||||
|
|
@ -238,19 +244,19 @@ export class ElementWithUtils<T extends HTMLElement = HTMLElement> {
|
|||
*/
|
||||
on<K extends keyof HTMLElementEventMap>(
|
||||
event: K,
|
||||
handler: (this: T, ev: HTMLElementEventMap[K]) => void,
|
||||
handler: (this: T, ev: DomUtilsEvent<HTMLElementEventMap[K]>) => void,
|
||||
): this;
|
||||
on(event: string, handler: EventListenerOrEventListenerObject): this;
|
||||
on(event: string, handler: DomUtilsEventListenerOrEventListenerObject): this;
|
||||
on(
|
||||
event: keyof HTMLElementEventMap | string,
|
||||
handler:
|
||||
| EventListenerOrEventListenerObject
|
||||
| ((this: T, ev: Event) => void),
|
||||
| DomUtilsEventListenerOrEventListenerObject
|
||||
| ((this: T, ev: DomUtilsEvent) => void),
|
||||
): this {
|
||||
// this type was some AI magic but if it works it works
|
||||
this.native.addEventListener(
|
||||
event,
|
||||
handler as EventListenerOrEventListenerObject,
|
||||
handler as DomUtilsEventListenerOrEventListenerObject,
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
|
@ -262,19 +268,22 @@ export class ElementWithUtils<T extends HTMLElement = HTMLElement> {
|
|||
onChild<K extends keyof HTMLElementEventMap>(
|
||||
event: K,
|
||||
query: string,
|
||||
handler: (this: HTMLElement, ev: HTMLElementEventMap[K]) => void,
|
||||
handler: (
|
||||
this: HTMLElement,
|
||||
ev: DomUtilsEvent<HTMLElementEventMap[K]>,
|
||||
) => void,
|
||||
): this;
|
||||
onChild(
|
||||
event: string,
|
||||
query: string,
|
||||
handler: EventListenerOrEventListenerObject,
|
||||
handler: DomUtilsEventListenerOrEventListenerObject,
|
||||
): this;
|
||||
onChild(
|
||||
event: keyof HTMLElementEventMap | string,
|
||||
query: string,
|
||||
handler:
|
||||
| EventListenerOrEventListenerObject
|
||||
| ((this: HTMLElement, ev: Event) => void),
|
||||
| DomUtilsEventListenerOrEventListenerObject
|
||||
| ((this: HTMLElement, ev: DomUtilsEvent) => void),
|
||||
): this {
|
||||
// this type was some AI magic but if it works it works
|
||||
this.native.addEventListener(event, (e) => {
|
||||
|
|
@ -671,14 +680,14 @@ export class ElementsWithUtils<
|
|||
*/
|
||||
on<K extends keyof HTMLElementEventMap>(
|
||||
event: K,
|
||||
handler: (this: T, ev: HTMLElementEventMap[K]) => void,
|
||||
handler: (this: T, ev: DomUtilsEvent<HTMLElementEventMap[K]>) => void,
|
||||
): this;
|
||||
on(event: string, handler: EventListenerOrEventListenerObject): this;
|
||||
on(event: string, handler: DomUtilsEventListenerOrEventListenerObject): this;
|
||||
on(
|
||||
event: keyof HTMLElementEventMap | string,
|
||||
handler:
|
||||
| EventListenerOrEventListenerObject
|
||||
| ((this: T, ev: Event) => void),
|
||||
| DomUtilsEventListenerOrEventListenerObject
|
||||
| ((this: T, ev: DomUtilsEvent) => void),
|
||||
): this {
|
||||
for (const item of this) {
|
||||
item.on(event, handler);
|
||||
|
|
|
|||
|
|
@ -14,10 +14,7 @@ export async function syncNotSignedInLastResult(uid: string): Promise<void> {
|
|||
body: { result: notSignedInLastResult },
|
||||
});
|
||||
if (response.status !== 200) {
|
||||
Notifications.add(
|
||||
"Failed to save last result: " + response.body.message,
|
||||
-1,
|
||||
);
|
||||
Notifications.add("Failed to save last result", -1, { response });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { stemmer } from "stemmer";
|
|||
import levenshtein from "damerau-levenshtein";
|
||||
|
||||
export type SearchService<T> = {
|
||||
query: (query: string) => SearchResult<T>;
|
||||
query: (query: string, ids: number[]) => SearchResult<T>;
|
||||
};
|
||||
|
||||
type SearchServiceOptions = {
|
||||
|
|
@ -110,7 +110,7 @@ export const buildSearchService = <T>(
|
|||
|
||||
const tokenSet = Object.keys(reverseIndex);
|
||||
|
||||
const query = (searchQuery: string): SearchResult<T> => {
|
||||
const query = (searchQuery: string, ids: number[]): SearchResult<T> => {
|
||||
const searchResult: SearchResult<T> = {
|
||||
results: [],
|
||||
matchedQueryTerms: [],
|
||||
|
|
@ -155,7 +155,13 @@ export const buildSearchService = <T>(
|
|||
|
||||
const scoreForToken = score * idf * termFrequency;
|
||||
|
||||
results.set(document.id, currentScore + scoreForToken);
|
||||
const quote = documents[document.id] as InternalDocument;
|
||||
if (
|
||||
ids.length === 0 ||
|
||||
(quote !== null && quote !== undefined && ids.includes(quote.id))
|
||||
) {
|
||||
results.set(document.id, currentScore + scoreForToken);
|
||||
}
|
||||
});
|
||||
|
||||
normalizedTokenToOriginal[token]?.forEach((originalToken) => {
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export async function linkDiscord(hashOverride: string): Promise<void> {
|
|||
Loader.hide();
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Failed to link Discord: " + response.body.message, -1);
|
||||
Notifications.add("Failed to link Discord", -1, { response });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
{
|
||||
"name": "bulgarian",
|
||||
"rightToLeft": false,
|
||||
"ligatures": false,
|
||||
"orderedByFrequency": false,
|
||||
"bcp47": "bg",
|
||||
"noLazyMode": true,
|
||||
"words": [
|
||||
"а",
|
||||
|
|
|
|||
1020
frontend/static/languages/bulgarian_1k.json
Normal file
1020
frontend/static/languages/bulgarian_1k.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,9 @@
|
|||
{
|
||||
"name": "bulgarian_latin",
|
||||
"rightToLeft": false,
|
||||
"ligatures": false,
|
||||
"orderedByFrequency": false,
|
||||
"bcp47": "bg",
|
||||
"noLazyMode": true,
|
||||
"words": [
|
||||
"a",
|
||||
|
|
|
|||
1017
frontend/static/languages/bulgarian_latin_1k.json
Normal file
1017
frontend/static/languages/bulgarian_latin_1k.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -39111,6 +39111,36 @@
|
|||
"source": "George Orwell, 1984",
|
||||
"id": 7731,
|
||||
"length": 109
|
||||
},
|
||||
{
|
||||
"text": "Commander! You may want to instruct your men to exercise restraint when using explosives... while certainly effective at killing aliens, they also destroy the artifacts we're hoping to recover from the bodies. Just something to consider.",
|
||||
"source": "XCOM: Enemy Unknown",
|
||||
"id": 7732,
|
||||
"length": 237
|
||||
},
|
||||
{
|
||||
"text": "This is unlike anything else we've previously identified. Based on its physical appearance, I would assume this alien doesn't rely on brute strength. I recommend the troops exercise extreme caution, Commander.",
|
||||
"source": "XCOM: Enemy Unknown",
|
||||
"id": 7733,
|
||||
"length": 209
|
||||
},
|
||||
{
|
||||
"text": "Ever since mankind first looked up at the stars, we have wondered what lies beyond. So very few have dared to look inward... The depths of the human mind hold more secrets than we can possibly imagine. How ironic that the means to defeat our enemy comes not through weapons or machines of war, but from within. And if we have succeeded... we will have gained a glimpse of what we are to become. We will have created something... extraordinary.",
|
||||
"source": "XCOM: Enemy Unknown",
|
||||
"id": 7734,
|
||||
"length": 443
|
||||
},
|
||||
{
|
||||
"text": "Incredible! That alien seems to have... taken control of that soldier somehow. All of the advances we've made so far... they would be useless against this type of power.",
|
||||
"source": "XCOM: Enemy Unknown",
|
||||
"id": 7735,
|
||||
"length": 169
|
||||
},
|
||||
{
|
||||
"text": "I have difficulty understanding how such an advanced species could show so little empathy for the lives of other sentient beings... It goes against everything we have ever imagined. The technology is there, but with it comes a callousness we would never have expected. What could have brought them to this...",
|
||||
"source": "XCOM: Enemy Unknown",
|
||||
"id": 7736,
|
||||
"length": 308
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
312
frontend/vite-plugins/oxlint-checker.ts
Normal file
312
frontend/vite-plugins/oxlint-checker.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { Plugin, ViteDevServer, normalizePath } from "vite";
|
||||
import { spawn, execSync, ChildProcess } from "child_process";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
export type OxlintCheckerOptions = {
|
||||
/** Debounce delay in milliseconds before running lint after file changes. @default 125 */
|
||||
debounceDelay?: number;
|
||||
/** Run type-aware checks (slower but more thorough). @default true */
|
||||
typeAware?: boolean;
|
||||
/** Show browser overlay with lint status. @default true */
|
||||
overlay?: boolean;
|
||||
/** File extensions to watch for changes. @default ['.ts', '.tsx', '.js', '.jsx'] */
|
||||
extensions?: string[];
|
||||
};
|
||||
|
||||
type LintResult = {
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
running: boolean;
|
||||
hadIssues: boolean;
|
||||
typeAware?: boolean;
|
||||
};
|
||||
|
||||
const OXLINT_SUMMARY_REGEX = /Found (\d+) warnings? and (\d+) errors?/;
|
||||
|
||||
export function oxlintChecker(options: OxlintCheckerOptions = {}): Plugin {
|
||||
const {
|
||||
debounceDelay = 125,
|
||||
typeAware = true,
|
||||
overlay = true,
|
||||
extensions = [".ts", ".tsx", ".js", ".jsx"],
|
||||
} = options;
|
||||
|
||||
let currentProcess: ChildProcess | null = null;
|
||||
let debounceTimer: NodeJS.Timeout | null = null;
|
||||
let server: ViteDevServer | null = null;
|
||||
let isProduction = false;
|
||||
let currentRunId = 0;
|
||||
let lastLintResult: LintResult = {
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
running: false,
|
||||
hadIssues: false,
|
||||
};
|
||||
|
||||
const killCurrentProcess = (): boolean => {
|
||||
if ((currentProcess && !currentProcess.killed) || currentProcess !== null) {
|
||||
currentProcess.kill();
|
||||
currentProcess = null;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const clearDebounceTimer = (): void => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseLintOutput = (
|
||||
output: string,
|
||||
): Pick<LintResult, "errorCount" | "warningCount"> => {
|
||||
const summaryMatch = output.match(OXLINT_SUMMARY_REGEX);
|
||||
if (summaryMatch?.[1] !== undefined && summaryMatch?.[2] !== undefined) {
|
||||
return {
|
||||
warningCount: parseInt(summaryMatch[1], 10),
|
||||
errorCount: parseInt(summaryMatch[2], 10),
|
||||
};
|
||||
}
|
||||
return { errorCount: 0, warningCount: 0 };
|
||||
};
|
||||
|
||||
const sendLintResult = (result: Partial<LintResult>): void => {
|
||||
const previousHadIssues = lastLintResult.hadIssues;
|
||||
|
||||
const payload: LintResult = {
|
||||
errorCount: result.errorCount ?? lastLintResult.errorCount,
|
||||
warningCount: result.warningCount ?? lastLintResult.warningCount,
|
||||
running: result.running ?? false,
|
||||
hadIssues: previousHadIssues,
|
||||
typeAware: result.typeAware,
|
||||
};
|
||||
|
||||
// Only update hadIssues when we have actual lint results (not just running status)
|
||||
if (result.running === false) {
|
||||
const currentHasIssues =
|
||||
(result.errorCount ?? 0) > 0 || (result.warningCount ?? 0) > 0;
|
||||
lastLintResult = { ...payload, hadIssues: currentHasIssues };
|
||||
} else {
|
||||
// Keep hadIssues unchanged when just updating running status
|
||||
lastLintResult = { ...payload, hadIssues: previousHadIssues };
|
||||
}
|
||||
|
||||
if (server) {
|
||||
server.ws.send("vite-plugin-oxlint", payload);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs an oxlint process with the given arguments and captures its combined output.
|
||||
*
|
||||
* This function is responsible for managing the lifecycle of the current lint process:
|
||||
* - It spawns a new child process via `npx oxlint . ...args`.
|
||||
* - It assigns the spawned process to the shared {@link currentProcess} variable so that
|
||||
* other parts of the plugin can cancel or track the active lint run.
|
||||
* - On process termination (either "error" or "close"), it clears {@link currentProcess}
|
||||
* if it still refers to this child, avoiding interference with any newer process that
|
||||
* may have started in the meantime.
|
||||
*
|
||||
* @param args Additional command-line arguments to pass to `oxlint`.
|
||||
* @returns A promise that resolves with the process exit code (or `null` if
|
||||
* the process exited due to a signal) and the full stdout/stderr output
|
||||
* produced by the lint run.
|
||||
*/
|
||||
const runLintProcess = async (
|
||||
args: string[],
|
||||
): Promise<{ code: number | null; output: string }> => {
|
||||
return new Promise((resolve) => {
|
||||
const childProcess = spawn("npx", ["oxlint", ".", ...args], {
|
||||
cwd: process.cwd(),
|
||||
shell: true,
|
||||
env: { ...process.env, FORCE_COLOR: "3" },
|
||||
});
|
||||
|
||||
currentProcess = childProcess;
|
||||
let output = "";
|
||||
|
||||
childProcess.stdout?.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
childProcess.stderr?.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
childProcess.on("error", (error: Error) => {
|
||||
output += `\nError: ${error.message}`;
|
||||
if (currentProcess === childProcess) {
|
||||
currentProcess = null;
|
||||
}
|
||||
resolve({ code: 1, output });
|
||||
});
|
||||
|
||||
childProcess.on("close", (code: number | null) => {
|
||||
if (currentProcess === childProcess) {
|
||||
currentProcess = null;
|
||||
}
|
||||
resolve({ code, output });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const runOxlint = async (): Promise<void> => {
|
||||
const wasKilled = killCurrentProcess();
|
||||
const runId = ++currentRunId;
|
||||
|
||||
console.log(
|
||||
wasKilled
|
||||
? "\x1b[36mRunning oxlint...\x1b[0m \x1b[90m(killed previous process)\x1b[0m"
|
||||
: "\x1b[36mRunning oxlint...\x1b[0m",
|
||||
);
|
||||
|
||||
sendLintResult({ running: true });
|
||||
|
||||
// First pass: fast oxlint without type checking
|
||||
const { code, output } = await runLintProcess([]);
|
||||
|
||||
// Check if we were superseded by a newer run
|
||||
if (runId !== currentRunId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (output) {
|
||||
console.log(output);
|
||||
}
|
||||
|
||||
// If first pass had errors, send them immediately (fast-fail)
|
||||
if (code !== 0) {
|
||||
const counts = parseLintOutput(output);
|
||||
if (counts.errorCount > 0 || counts.warningCount > 0) {
|
||||
sendLintResult({ ...counts, running: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// First pass clean - run type-aware check if enabled
|
||||
if (!typeAware) {
|
||||
sendLintResult({ errorCount: 0, warningCount: 0, running: false });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\x1b[36mRunning type-aware checks...\x1b[0m");
|
||||
sendLintResult({ running: true, typeAware: true });
|
||||
const typeResult = await runLintProcess(["--type-check", "--type-aware"]);
|
||||
|
||||
// Check if we were superseded by a newer run
|
||||
if (runId !== currentRunId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeResult.output) {
|
||||
console.log(typeResult.output);
|
||||
}
|
||||
|
||||
const counts =
|
||||
typeResult.code !== 0
|
||||
? parseLintOutput(typeResult.output)
|
||||
: { errorCount: 0, warningCount: 0 };
|
||||
sendLintResult({ ...counts, running: false });
|
||||
};
|
||||
|
||||
const debouncedLint = (): void => {
|
||||
clearDebounceTimer();
|
||||
sendLintResult({ running: true });
|
||||
debounceTimer = setTimeout(() => void runOxlint(), debounceDelay);
|
||||
};
|
||||
|
||||
return {
|
||||
name: "vite-plugin-oxlint-checker",
|
||||
|
||||
config(_, { command }) {
|
||||
isProduction = command === "build";
|
||||
},
|
||||
|
||||
configureServer(devServer: ViteDevServer) {
|
||||
server = devServer;
|
||||
|
||||
// Send current lint status to new clients on connection
|
||||
devServer.ws.on("connection", () => {
|
||||
devServer.ws.send("vite-plugin-oxlint", lastLintResult);
|
||||
});
|
||||
|
||||
// Run initial lint
|
||||
void runOxlint();
|
||||
|
||||
// Listen for file changes
|
||||
devServer.watcher.on("change", (file: string) => {
|
||||
// Only lint on relevant file changes
|
||||
if (extensions.some((ext) => file.endsWith(ext))) {
|
||||
debouncedLint();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
transformIndexHtml() {
|
||||
if (!overlay) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Inject import to the overlay module (actual .ts file processed by Vite)
|
||||
const overlayPath = normalizePath(
|
||||
fileURLToPath(new URL("./oxlint-overlay.ts", import.meta.url)),
|
||||
);
|
||||
return [
|
||||
{
|
||||
tag: "script",
|
||||
attrs: {
|
||||
type: "module",
|
||||
src: `/@fs${overlayPath}`,
|
||||
},
|
||||
injectTo: "body-prepend",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
buildStart() {
|
||||
// Only run during production builds, not dev server startup
|
||||
if (!isProduction) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Run oxlint synchronously during build
|
||||
console.log("\n\x1b[1mRunning oxlint...\x1b[0m");
|
||||
|
||||
try {
|
||||
const output = execSync(
|
||||
"npx oxlint . && npx oxlint . --type-aware --type-check",
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, FORCE_COLOR: "3" },
|
||||
},
|
||||
);
|
||||
|
||||
if (output) {
|
||||
console.log(output);
|
||||
}
|
||||
console.log(` \x1b[32m✓ No linting issues found\x1b[0m\n`);
|
||||
} catch (error) {
|
||||
// execSync throws on non-zero exit code (linting errors found)
|
||||
if (error instanceof Error && "stdout" in error) {
|
||||
const execError = error as Error & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
if (execError.stdout !== undefined) console.log(execError.stdout);
|
||||
if (execError.stderr !== undefined) console.error(execError.stderr);
|
||||
}
|
||||
console.error("\n\x1b[31mBuild aborted due to linting errors\x1b[0m\n");
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
|
||||
closeBundle() {
|
||||
// Cleanup on server close
|
||||
killCurrentProcess();
|
||||
clearDebounceTimer();
|
||||
},
|
||||
};
|
||||
}
|
||||
126
frontend/vite-plugins/oxlint-overlay.ts
Normal file
126
frontend/vite-plugins/oxlint-overlay.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// Oxlint overlay client-side code
|
||||
let overlay: HTMLDivElement | null = null;
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function createOverlay(): HTMLDivElement {
|
||||
if (overlay) return overlay;
|
||||
|
||||
overlay = document.createElement("div");
|
||||
overlay.id = "oxlint-error-overlay";
|
||||
overlay.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
background: #323437;
|
||||
color: #e4dec8ff;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 10000;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
`;
|
||||
|
||||
overlay.addEventListener("mouseenter", () => {
|
||||
if (overlay) overlay.style.opacity = "0.5";
|
||||
});
|
||||
|
||||
overlay.addEventListener("mouseleave", () => {
|
||||
if (overlay) overlay.style.opacity = "1";
|
||||
});
|
||||
|
||||
overlay.addEventListener("click", () => {
|
||||
if (overlay) overlay.style.display = "none";
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function updateOverlay(data: {
|
||||
errorCount?: number;
|
||||
warningCount?: number;
|
||||
running?: boolean;
|
||||
hadIssues?: boolean;
|
||||
typeAware?: boolean;
|
||||
}): void {
|
||||
const overlayEl = createOverlay();
|
||||
|
||||
// Clear any pending hide timeout
|
||||
if (hideTimeout !== null) {
|
||||
clearTimeout(hideTimeout);
|
||||
hideTimeout = null;
|
||||
}
|
||||
|
||||
// Show running icon if linting is running and there were issues before
|
||||
if (data.running) {
|
||||
if (data.hadIssues) {
|
||||
const message = data.typeAware ? "checking type aware..." : "checking...";
|
||||
overlayEl.innerHTML = `
|
||||
<span style="font-size: 18px;">⏳</span>
|
||||
<span>oxlint: ${message}</span>
|
||||
`;
|
||||
overlayEl.style.display = "flex";
|
||||
overlayEl.style.color = "#e4dec8ff";
|
||||
} else {
|
||||
overlayEl.style.display = "none";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { errorCount = 0, warningCount = 0, hadIssues = false } = data;
|
||||
const total = errorCount + warningCount;
|
||||
|
||||
if (total > 0) {
|
||||
overlayEl.innerHTML = `
|
||||
<span style="font-size: 18px;">🚨</span>
|
||||
<span>oxlint: ${errorCount} error${errorCount !== 1 ? "s" : ""}, ${warningCount} warning${warningCount !== 1 ? "s" : ""}</span>
|
||||
`;
|
||||
overlayEl.style.display = "flex";
|
||||
|
||||
if (errorCount > 0) {
|
||||
overlayEl.style.color = "#e4dec8ff";
|
||||
}
|
||||
} else {
|
||||
// Only show success if the previous lint had issues
|
||||
if (hadIssues) {
|
||||
overlayEl.innerHTML = `
|
||||
<span style="font-size: 18px;">✅</span>
|
||||
<span>oxlint: ok</span>
|
||||
`;
|
||||
overlayEl.style.display = "flex";
|
||||
overlayEl.style.color = "#e4dec8ff";
|
||||
|
||||
// Hide after 3 seconds
|
||||
hideTimeout = setTimeout(() => {
|
||||
overlayEl.style.display = "none";
|
||||
hideTimeout = null;
|
||||
}, 3000);
|
||||
} else {
|
||||
// Two good lints in a row - don't show anything
|
||||
overlayEl.style.display = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize overlay on load
|
||||
createOverlay();
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.on(
|
||||
"vite-plugin-oxlint",
|
||||
(data: {
|
||||
errorCount?: number;
|
||||
warningCount?: number;
|
||||
running?: boolean;
|
||||
hadIssues?: boolean;
|
||||
}) => {
|
||||
updateOverlay(data);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ import { languageHashes } from "./vite-plugins/language-hashes";
|
|||
import { minifyJson } from "./vite-plugins/minify-json";
|
||||
import { versionFile } from "./vite-plugins/version-file";
|
||||
import { jqueryInject } from "./vite-plugins/jquery-inject";
|
||||
import { checker } from "vite-plugin-checker";
|
||||
import { oxlintChecker } from "./vite-plugins/oxlint-checker";
|
||||
import Inspect from "vite-plugin-inspect";
|
||||
import { ViteMinifyPlugin } from "vite-plugin-minify";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
|
@ -81,13 +81,10 @@ function getPlugins({
|
|||
const plugins: PluginOption[] = [
|
||||
envConfig({ isDevelopment, clientVersion, env }),
|
||||
languageHashes({ skip: isDevelopment }),
|
||||
checker({
|
||||
oxlint: {
|
||||
lintCommand: "oxlint . --type-aware --type-check",
|
||||
},
|
||||
overlay: {
|
||||
initialIsOpen: false,
|
||||
},
|
||||
oxlintChecker({
|
||||
debounceDelay: 125,
|
||||
typeAware: true,
|
||||
overlay: true,
|
||||
}),
|
||||
jqueryInject(),
|
||||
injectHTML(),
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -67,18 +67,18 @@
|
|||
"@commitlint/cli": "17.7.1",
|
||||
"@commitlint/config-conventional": "19.2.2",
|
||||
"@monkeytype/release": "workspace:*",
|
||||
"@vitest/coverage-v8": "4.0.8",
|
||||
"@vitest/coverage-v8": "4.0.15",
|
||||
"conventional-changelog": "6.0.0",
|
||||
"husky": "8.0.1",
|
||||
"knip": "2.19.2",
|
||||
"lint-staged": "13.2.3",
|
||||
"only-allow": "1.2.1",
|
||||
"oxfmt": "0.18.0",
|
||||
"oxlint": "1.33.0",
|
||||
"oxlint-tsgolint": "0.9.0",
|
||||
"oxfmt": "0.19.0",
|
||||
"oxlint": "1.34.0",
|
||||
"oxlint-tsgolint": "0.9.2",
|
||||
"prettier": "3.7.1",
|
||||
"turbo": "2.5.6",
|
||||
"vitest": "4.0.8"
|
||||
"vitest": "4.0.15"
|
||||
},
|
||||
"packageManager": "pnpm@9.6.0",
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@
|
|||
"@monkeytype/tsup-config": "workspace:*",
|
||||
"@monkeytype/typescript-config": "workspace:*",
|
||||
"madge": "8.0.0",
|
||||
"oxlint": "1.33.0",
|
||||
"oxlint-tsgolint": "0.9.0",
|
||||
"oxlint": "1.34.0",
|
||||
"oxlint-tsgolint": "0.9.2",
|
||||
"tsup": "8.4.0",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.8"
|
||||
"vitest": "4.0.15"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ts-rest/core": "3.52.1",
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ export const MonkeyValidationErrorSchema = MonkeyResponseSchema.extend({
|
|||
export type MonkeyValidationError = z.infer<typeof MonkeyValidationErrorSchema>;
|
||||
|
||||
export const MonkeyClientError = MonkeyResponseSchema;
|
||||
export type MonkeyClientErrorType = z.infer<typeof MonkeyClientError>;
|
||||
|
||||
export const MonkeyServerError = MonkeyClientError.extend({
|
||||
errorId: z.string(),
|
||||
uid: z.string().optional(),
|
||||
|
|
@ -130,3 +132,17 @@ export const CommonResponses = {
|
|||
"Endpoint disabled or server is under maintenance",
|
||||
),
|
||||
};
|
||||
|
||||
export type CommonResponsesType =
|
||||
| {
|
||||
status: 400 | 401 | 403 | 429 | 470 | 471 | 472 | 479;
|
||||
body: MonkeyClientErrorType;
|
||||
}
|
||||
| {
|
||||
status: 422;
|
||||
body: MonkeyValidationError;
|
||||
}
|
||||
| {
|
||||
status: 500 | 503;
|
||||
body: MonkeyServerErrorType;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,10 +25,10 @@
|
|||
"@monkeytype/tsup-config": "workspace:*",
|
||||
"@monkeytype/typescript-config": "workspace:*",
|
||||
"madge": "8.0.0",
|
||||
"oxlint": "1.33.0",
|
||||
"oxlint-tsgolint": "0.9.0",
|
||||
"oxlint": "1.34.0",
|
||||
"oxlint-tsgolint": "0.9.2",
|
||||
"tsup": "8.4.0",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.8"
|
||||
"vitest": "4.0.15"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "3.1.4",
|
||||
"oxlint": "1.33.0",
|
||||
"oxlint-tsgolint": "0.9.0"
|
||||
"oxlint": "1.34.0",
|
||||
"oxlint-tsgolint": "0.9.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,11 +26,11 @@
|
|||
"@monkeytype/tsup-config": "workspace:*",
|
||||
"@monkeytype/typescript-config": "workspace:*",
|
||||
"madge": "8.0.0",
|
||||
"oxlint": "1.33.0",
|
||||
"oxlint-tsgolint": "0.9.0",
|
||||
"oxlint": "1.34.0",
|
||||
"oxlint-tsgolint": "0.9.2",
|
||||
"tsup": "8.4.0",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.8"
|
||||
"vitest": "4.0.15"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "3.23.8"
|
||||
|
|
|
|||
|
|
@ -235,7 +235,9 @@ export const LanguageSchema = z.enum(
|
|||
"lithuanian_1k",
|
||||
"lithuanian_3k",
|
||||
"bulgarian",
|
||||
"bulgarian_1k",
|
||||
"bulgarian_latin",
|
||||
"bulgarian_latin_1k",
|
||||
"bangla",
|
||||
"bangla_letters",
|
||||
"bangla_10k",
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@monkeytype/typescript-config": "workspace:*",
|
||||
"oxlint": "1.33.0",
|
||||
"oxlint-tsgolint": "0.9.0",
|
||||
"oxlint": "1.34.0",
|
||||
"oxlint-tsgolint": "0.9.2",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@
|
|||
"@monkeytype/tsup-config": "workspace:*",
|
||||
"@monkeytype/typescript-config": "workspace:*",
|
||||
"madge": "8.0.0",
|
||||
"oxlint": "1.33.0",
|
||||
"oxlint-tsgolint": "0.9.0",
|
||||
"oxlint": "1.34.0",
|
||||
"oxlint-tsgolint": "0.9.2",
|
||||
"tsup": "8.4.0",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.8",
|
||||
"vitest": "4.0.15",
|
||||
"zod": "3.23.8"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
839
pnpm-lock.yaml
generated
839
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue