aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChristian Fehmer <[email protected]>2024-06-17 15:21:55 +0200
committerGitHub <[email protected]>2024-06-17 15:21:55 +0200
commitb4ea7f119f5b9ee68f8e3927c64c856e6a6b0361 (patch)
tree765cd976a49973be231862f8f7a0db16489e0208
parent57a6fd9bd5cd4eb6fb65b387926434b0c0767ff9 (diff)
downloadmonkeytype-b4ea7f119f5b9ee68f8e3927c64c856e6a6b0361.tar.gz
monkeytype-b4ea7f119f5b9ee68f8e3927c64c856e6a6b0361.zip
impr(dev): add endpoint to create test user/data (fehmer) (#5396)
!nuf
-rw-r--r--backend/src/api/controllers/dev.ts388
-rw-r--r--backend/src/api/routes/dev.ts38
-rw-r--r--backend/src/api/routes/index.ts4
-rw-r--r--backend/src/dal/result.ts48
-rw-r--r--backend/src/dal/user.ts2
-rw-r--r--frontend/__tests__/utils/tag-builder.spec.ts51
-rw-r--r--frontend/src/html/popups.html7
-rw-r--r--frontend/src/styles/core.scss11
-rw-r--r--frontend/src/styles/media-queries-purple.scss4
-rw-r--r--frontend/src/styles/popups.scss40
-rw-r--r--frontend/src/ts/ape/endpoints/dev.ts15
-rw-r--r--frontend/src/ts/ape/endpoints/index.ts2
-rw-r--r--frontend/src/ts/ape/index.ts1
-rw-r--r--frontend/src/ts/ape/types/dev.d.ts14
-rw-r--r--frontend/src/ts/controllers/theme-controller.ts6
-rw-r--r--frontend/src/ts/index.ts4
-rw-r--r--frontend/src/ts/modals/dev-options.ts34
-rw-r--r--frontend/src/ts/modals/simple-modals.ts396
-rw-r--r--frontend/src/ts/ready.ts4
-rw-r--r--frontend/src/ts/utils/async-modules.ts28
-rw-r--r--frontend/src/ts/utils/tag-builder.ts31
21 files changed, 994 insertions, 134 deletions
diff --git a/backend/src/api/controllers/dev.ts b/backend/src/api/controllers/dev.ts
new file mode 100644
index 000000000..6c4e854d5
--- /dev/null
+++ b/backend/src/api/controllers/dev.ts
@@ -0,0 +1,388 @@
+import { MonkeyResponse } from "../../utils/monkey-response";
+import * as UserDal from "../../dal/user";
+import FirebaseAdmin from "../../init/firebase-admin";
+import Logger from "../../utils/logger";
+import * as DateUtils from "date-fns";
+import { UTCDate } from "@date-fns/utc";
+import * as ResultDal from "../../dal/result";
+import { roundTo2 } from "../../utils/misc";
+import { ObjectId } from "mongodb";
+import * as LeaderboardDal from "../../dal/leaderboards";
+import { isNumber } from "lodash";
+import MonkeyError from "../../utils/error";
+
+type GenerateDataOptions = {
+ firstTestTimestamp: Date;
+ lastTestTimestamp: Date;
+ minTestsPerDay: number;
+ maxTestsPerDay: number;
+};
+
+const CREATE_RESULT_DEFAULT_OPTIONS: GenerateDataOptions = {
+ firstTestTimestamp: DateUtils.startOfDay(new UTCDate(Date.now())),
+ lastTestTimestamp: DateUtils.endOfDay(new UTCDate(Date.now())),
+ minTestsPerDay: 0,
+ maxTestsPerDay: 50,
+};
+
+export async function createTestData(
+ req: MonkeyTypes.Request
+): Promise<MonkeyResponse> {
+ const { username, createUser } = req.body;
+ const user = await getOrCreateUser(username, "password", createUser);
+
+ const { uid, email } = user;
+
+ await createTestResults(user, req.body);
+ await updateUser(uid);
+ await updateLeaderboard();
+
+ return new MonkeyResponse("test data created", { uid, email }, 200);
+}
+
+async function getOrCreateUser(
+ username: string,
+ password: string,
+ createUser = false
+): Promise<MonkeyTypes.DBUser> {
+ const existingUser = await UserDal.findByName(username);
+
+ if (existingUser !== undefined && existingUser !== null) {
+ return existingUser;
+ } else if (createUser === false) {
+ throw new MonkeyError(404, `User ${username} does not exist.`);
+ }
+
+ const email = username + "@example.com";
+ Logger.success("create user " + username);
+ const { uid } = await FirebaseAdmin().auth().createUser({
+ displayName: username,
+ password: password,
+ email,
+ emailVerified: true,
+ });
+
+ await UserDal.addUser(username, email, uid);
+ return UserDal.getUser(uid, "getOrCreateUser");
+}
+
+async function createTestResults(
+ user: MonkeyTypes.DBUser,
+ configOptions: Partial<GenerateDataOptions>
+): Promise<void> {
+ const config = {
+ ...CREATE_RESULT_DEFAULT_OPTIONS,
+ ...configOptions,
+ };
+ if (isNumber(config.firstTestTimestamp))
+ config.firstTestTimestamp = toDate(config.firstTestTimestamp);
+ if (isNumber(config.lastTestTimestamp))
+ config.lastTestTimestamp = toDate(config.lastTestTimestamp);
+
+ const days = DateUtils.eachDayOfInterval({
+ start: config.firstTestTimestamp,
+ end: config.lastTestTimestamp,
+ }).map((day) => ({
+ timestamp: DateUtils.startOfDay(day),
+ amount: Math.round(random(config.minTestsPerDay, config.maxTestsPerDay)),
+ }));
+
+ for (const day of days) {
+ Logger.success(
+ `User ${user.name} insert ${day.amount} results on ${new Date(
+ day.timestamp
+ )}`
+ );
+ const results = createArray(day.amount, () =>
+ createResult(user, day.timestamp)
+ );
+ if (results.length > 0)
+ await ResultDal.getResultCollection().insertMany(results);
+ }
+}
+
+function toDate(value: number): Date {
+ return new UTCDate(value);
+}
+
+function random(min: number, max: number): number {
+ return roundTo2(Math.random() * (max - min) + min);
+}
+
+function createResult(
+ user: MonkeyTypes.DBUser,
+ timestamp: Date //evil, we modify this value
+): MonkeyTypes.DBResult {
+ const mode: SharedTypes.Config.Mode = randomValue(["time", "words"]);
+ const mode2: number =
+ mode === "time"
+ ? randomValue([15, 30, 60, 120])
+ : randomValue([10, 25, 50, 100]);
+ const testDuration = mode2;
+
+ timestamp = DateUtils.addSeconds(timestamp, testDuration);
+ return {
+ _id: new ObjectId(),
+ uid: user.uid,
+ wpm: random(80, 120),
+ rawWpm: random(80, 120),
+ charStats: [131, 0, 0, 0],
+ acc: random(80, 100),
+ language: "english",
+ mode: mode as SharedTypes.Config.Mode,
+ mode2: mode2 as unknown as never,
+ timestamp: timestamp.valueOf(),
+ testDuration: testDuration,
+ consistency: random(80, 100),
+ keyConsistency: 33.18,
+ chartData: {
+ wpm: createArray(testDuration, () => random(80, 120)),
+ raw: createArray(testDuration, () => random(80, 120)),
+ err: createArray(testDuration, () => (Math.random() < 0.1 ? 1 : 0)),
+ },
+ keySpacingStats: {
+ average: 113.88,
+ sd: 77.3,
+ },
+ keyDurationStats: {
+ average: 107.13,
+ sd: 39.86,
+ },
+ isPb: Math.random() < 0.1,
+ name: user.name,
+ };
+}
+
+async function updateUser(uid: string): Promise<void> {
+ //update timetyping and completedTests
+ const stats = await ResultDal.getResultCollection()
+ .aggregate([
+ {
+ $match: {
+ uid,
+ },
+ },
+ {
+ $group: {
+ _id: {
+ language: "$language",
+ mode: "$mode",
+ mode2: "$mode2",
+ },
+ timeTyping: {
+ $sum: "$testDuration",
+ },
+ completedTests: {
+ $count: {},
+ },
+ },
+ },
+ ])
+ .toArray();
+
+ const timeTyping = stats.reduce((a, c) => a + c["timeTyping"], 0);
+ const completedTests = stats.reduce((a, c) => a + c["completedTests"], 0);
+
+ //update PBs
+ const lbPersonalBests: MonkeyTypes.LbPersonalBests = {
+ time: {
+ 15: {},
+ 60: {},
+ },
+ };
+
+ const personalBests: SharedTypes.PersonalBests = {
+ time: {},
+ custom: {},
+ words: {},
+ zen: {},
+ quote: {},
+ };
+ const modes = stats.map((it) => it["_id"]);
+ for (const mode of modes) {
+ const best = (
+ await ResultDal.getResultCollection()
+ .find({
+ uid,
+ language: mode.language,
+ mode: mode.mode,
+ mode2: mode.mode2,
+ })
+ .sort({ wpm: -1, timestamp: 1 })
+ .limit(1)
+ .toArray()
+ )[0] as MonkeyTypes.DBResult;
+
+ if (personalBests[mode.mode] === undefined) personalBests[mode.mode] = {};
+ if (personalBests[mode.mode][mode.mode2] === undefined)
+ personalBests[mode.mode][mode.mode2] = [];
+
+ const entry = {
+ acc: best.acc,
+ consistency: best.consistency,
+ difficulty: best.difficulty ?? "normal",
+ lazyMode: best.lazyMode,
+ language: mode.language,
+ punctuation: best.punctuation,
+ raw: best.rawWpm,
+ wpm: best.wpm,
+ numbers: best.numbers,
+ timestamp: best.timestamp,
+ } as SharedTypes.PersonalBest;
+
+ personalBests[mode.mode][mode.mode2].push(entry);
+
+ if (mode.mode === "time") {
+ if (lbPersonalBests[mode.mode][mode.mode2] === undefined)
+ lbPersonalBests[mode.mode][mode.mode2] = {};
+
+ lbPersonalBests[mode.mode][mode.mode2][mode.language] = entry;
+ }
+
+ //update testActivity
+ await updateTestActicity(uid);
+ }
+
+ //update the user
+ await UserDal.getUsersCollection().updateOne(
+ { uid },
+ {
+ $set: {
+ timeTyping: timeTyping,
+ completedTests: completedTests,
+ startedTests: Math.round(completedTests * 1.25),
+ personalBests: personalBests as SharedTypes.PersonalBests,
+ lbPersonalBests: lbPersonalBests,
+ },
+ }
+ );
+}
+
+async function updateLeaderboard(): Promise<void> {
+ await LeaderboardDal.update("time", "15", "english");
+ await LeaderboardDal.update("time", "60", "english");
+}
+
+function randomValue<T>(values: T[]): T {
+ const rnd = Math.round(Math.random() * (values.length - 1));
+ return values[rnd] as T;
+}
+
+function createArray<T>(size: number, builder: () => T): T[] {
+ return new Array(size).fill(0).map((it) => builder());
+}
+
+async function updateTestActicity(uid: string): Promise<void> {
+ await ResultDal.getResultCollection()
+ .aggregate(
+ [
+ {
+ $match: {
+ uid,
+ },
+ },
+ {
+ $project: {
+ _id: 0,
+ timestamp: -1,
+ uid: 1,
+ },
+ },
+ {
+ $addFields: {
+ date: {
+ $toDate: "$timestamp",
+ },
+ },
+ },
+ {
+ $replaceWith: {
+ uid: "$uid",
+ year: {
+ $year: "$date",
+ },
+ day: {
+ $dayOfYear: "$date",
+ },
+ },
+ },
+ {
+ $group: {
+ _id: {
+ uid: "$uid",
+ year: "$year",
+ day: "$day",
+ },
+ count: {
+ $sum: 1,
+ },
+ },
+ },
+ {
+ $group: {
+ _id: {
+ uid: "$_id.uid",
+ year: "$_id.year",
+ },
+ days: {
+ $addToSet: {
+ day: "$_id.day",
+ tests: "$count",
+ },
+ },
+ },
+ },
+ {
+ $replaceWith: {
+ uid: "$_id.uid",
+ days: {
+ $function: {
+ lang: "js",
+ args: ["$days", "$_id.year"],
+ body: `function (days, year) {
+ var max = Math.max(
+ ...days.map((it) => it.day)
+ )-1;
+ var arr = new Array(max).fill(null);
+ for (day of days) {
+ arr[day.day-1] = day.tests;
+ }
+ let result = {};
+ result[year] = arr;
+ return result;
+ }`,
+ },
+ },
+ },
+ },
+ {
+ $group: {
+ _id: "$uid",
+ testActivity: {
+ $mergeObjects: "$days",
+ },
+ },
+ },
+ {
+ $addFields: {
+ uid: "$_id",
+ },
+ },
+ {
+ $project: {
+ _id: 0,
+ },
+ },
+ {
+ $merge: {
+ into: "users",
+ on: "uid",
+ whenMatched: "merge",
+ whenNotMatched: "discard",
+ },
+ },
+ ],
+ { allowDiskUse: true }
+ )
+ .toArray();
+}
diff --git a/backend/src/api/routes/dev.ts b/backend/src/api/routes/dev.ts
new file mode 100644
index 000000000..b251f58e2
--- /dev/null
+++ b/backend/src/api/routes/dev.ts
@@ -0,0 +1,38 @@
+import { Router } from "express";
+import { authenticateRequest } from "../../middlewares/auth";
+import {
+ asyncHandler,
+ validateConfiguration,
+ validateRequest,
+} from "../../middlewares/api-utils";
+import joi from "joi";
+import { createTestData } from "../controllers/dev";
+import { isDevEnvironment } from "../../utils/misc";
+
+const router = Router();
+
+router.use(
+ validateConfiguration({
+ criteria: () => {
+ return isDevEnvironment();
+ },
+ invalidMessage: "Development endpoints are only available in DEV mode.",
+ })
+);
+
+router.post(
+ "/generateData",
+ validateRequest({
+ body: {
+ username: joi.string().required(),
+ createUser: joi.boolean().optional(),
+ firstTestTimestamp: joi.number().optional(),
+ lastTestTimestamp: joi.number().optional(),
+ minTestsPerDay: joi.number().optional(),
+ maxTestsPerDay: joi.number().optional(),
+ },
+ }),
+ asyncHandler(createTestData)
+);
+
+export default router;
diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts
index 04ad80b25..44c92c133 100644
--- a/backend/src/api/routes/index.ts
+++ b/backend/src/api/routes/index.ts
@@ -10,6 +10,7 @@ import presets from "./presets";
import apeKeys from "./ape-keys";
import admin from "./admin";
import webhooks from "./webhooks";
+import dev from "./dev";
import configuration from "./configuration";
import { version } from "../../version";
import leaderboards from "./leaderboards";
@@ -67,6 +68,9 @@ function addApiRoutes(app: Application): void {
}
next();
});
+
+ //enable dev edpoints
+ app.use("/dev", dev);
}
// Cannot be added to the route map because it needs to be added before the maintenance handler
diff --git a/backend/src/dal/result.ts b/backend/src/dal/result.ts
index 85128d109..5e19576bb 100644
--- a/backend/src/dal/result.ts
+++ b/backend/src/dal/result.ts
@@ -1,10 +1,17 @@
import _ from "lodash";
-import { DeleteResult, ObjectId, UpdateResult } from "mongodb";
+import { Collection, DeleteResult, ObjectId, UpdateResult } from "mongodb";
import MonkeyError from "../utils/error";
import * as db from "../init/db";
import { getUser, getTags } from "./user";
+type DBResult = MonkeyTypes.WithObjectId<
+ SharedTypes.DBResult<SharedTypes.Config.Mode>
+>;
+
+export const getResultCollection = (): Collection<DBResult> =>
+ db.collection<DBResult>("results");
+
export async function addResult(
uid: string,
result: MonkeyTypes.DBResult
@@ -18,18 +25,14 @@ export async function addResult(
if (!user) throw new MonkeyError(404, "User not found", "add result");
if (result.uid === undefined) result.uid = uid;
// result.ir = true;
- const res = await db
- .collection<MonkeyTypes.DBResult>("results")
- .insertOne(result);
+ const res = await getResultCollection().insertOne(result);
return {
insertedId: res.insertedId,
};
}
export async function deleteAll(uid: string): Promise<DeleteResult> {
- return await db
- .collection<MonkeyTypes.DBResult>("results")
- .deleteMany({ uid });
+ return await getResultCollection().deleteMany({ uid });
}
export async function updateTags(
@@ -37,9 +40,10 @@ export async function updateTags(
resultId: string,
tags: string[]
): Promise<UpdateResult> {
- const result = await db
- .collection<MonkeyTypes.DBResult>("results")
- .findOne({ _id: new ObjectId(resultId), uid });
+ const result = await getResultCollection().findOne({
+ _id: new ObjectId(resultId),
+ uid,
+ });
if (!result) throw new MonkeyError(404, "Result not found");
const userTags = await getTags(uid);
const userTagIds = userTags.map((tag) => tag._id.toString());
@@ -50,18 +54,20 @@ export async function updateTags(
if (!validTags) {
throw new MonkeyError(422, "One of the tag id's is not valid");
}
- return await db
- .collection<MonkeyTypes.DBResult>("results")
- .updateOne({ _id: new ObjectId(resultId), uid }, { $set: { tags } });
+ return await getResultCollection().updateOne(
+ { _id: new ObjectId(resultId), uid },
+ { $set: { tags } }
+ );
}
export async function getResult(
uid: string,
id: string
): Promise<MonkeyTypes.DBResult> {
- const result = await db
- .collection<MonkeyTypes.DBResult>("results")
- .findOne({ _id: new ObjectId(id), uid });
+ const result = await getResultCollection().findOne({
+ _id: new ObjectId(id),
+ uid,
+ });
if (!result) throw new MonkeyError(404, "Result not found");
return result;
}
@@ -69,8 +75,7 @@ export async function getResult(
export async function getLastResult(
uid: string
): Promise<Omit<MonkeyTypes.DBResult, "uid">> {
- const [lastResult] = await db
- .collection<MonkeyTypes.DBResult>("results")
+ const [lastResult] = await getResultCollection()
.find({ uid })
.sort({ timestamp: -1 })
.limit(1)
@@ -83,9 +88,7 @@ export async function getResultByTimestamp(
uid: string,
timestamp
): Promise<MonkeyTypes.DBResult | null> {
- return await db
- .collection<MonkeyTypes.DBResult>("results")
- .findOne({ uid, timestamp });
+ return await getResultCollection().findOne({ uid, timestamp });
}
type GetResultsOpts = {
@@ -99,8 +102,7 @@ export async function getResults(
opts?: GetResultsOpts
): Promise<MonkeyTypes.DBResult[]> {
const { onOrAfterTimestamp, offset, limit } = opts ?? {};
- let query = db
- .collection<MonkeyTypes.DBResult>("results")
+ let query = getResultCollection()
.find({
uid,
...(!_.isNil(onOrAfterTimestamp) &&
diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts
index d70d96453..f3c47eb92 100644
--- a/backend/src/dal/user.ts
+++ b/backend/src/dal/user.ts
@@ -202,7 +202,7 @@ export async function getUser(
return user;
}
-async function findByName(
+export async function findByName(
name: string
): Promise<MonkeyTypes.DBUser | undefined> {
return (
diff --git a/frontend/__tests__/utils/tag-builder.spec.ts b/frontend/__tests__/utils/tag-builder.spec.ts
new file mode 100644
index 000000000..ed7340f29
--- /dev/null
+++ b/frontend/__tests__/utils/tag-builder.spec.ts
@@ -0,0 +1,51 @@
+import { buildTag } from "../../src/ts/utils/tag-builder";
+
+describe("simple-modals", () => {
+ describe("buildTag", () => {
+ it("builds with mandatory", () => {
+ expect(buildTag({ tagname: "input" })).toBe("<input />");
+ });
+ it("builds with classes", () => {
+ expect(buildTag({ tagname: "input", classes: ["hidden", "bold"] })).toBe(
+ '<input class="hidden bold" />'
+ );
+ });
+ it("builds with attributes", () => {
+ expect(
+ buildTag({
+ tagname: "input",
+ attributes: {
+ id: "4711",
+ oninput: "console.log()",
+ required: true,
+ checked: true,
+ missing: undefined,
+ },
+ })
+ ).toBe('<input checked id="4711" oninput="console.log()" required />');
+ });
+
+ it("builds with innerHtml", () => {
+ expect(
+ buildTag({ tagname: "textarea", innerHTML: "<h1>Hello</h1>" })
+ ).toBe("<textarea><h1>Hello</h1></textarea>");
+ });
+ it("builds with everything", () => {
+ expect(
+ buildTag({
+ tagname: "textarea",
+ classes: ["hidden", "bold"],
+ attributes: {
+ id: "4711",
+ oninput: "console.log()",
+ readonly: true,
+ required: true,
+ },
+ innerHTML: "<h1>Hello</h1>",
+ })
+ ).toBe(
+ '<textarea class="hidden bold" id="4711" oninput="console.log()" readonly required><h1>Hello</h1></textarea>'
+ );
+ });
+ });
+});
diff --git a/frontend/src/html/popups.html b/frontend/src/html/popups.html
index 0f073bf09..6b0cdbc3f 100644
--- a/frontend/src/html/popups.html
+++ b/frontend/src/html/popups.html
@@ -4,6 +4,13 @@
</div>
</dialog>
+<dialog id="devOptionsModal" class="modalWrapper hidden">
+ <div class="modal">
+ <div class="title">Dev options</div>
+ <button class="generateData">generate data</button>
+ </div>
+</dialog>
+
<dialog id="alertsPopup" class="modalWrapper hidden">
<div class="modal">
<button class="mobileClose">
diff --git a/frontend/src/styles/core.scss b/frontend/src/styles/core.scss
index 3e88d85c2..f5b104425 100644
--- a/frontend/src/styles/core.scss
+++ b/frontend/src/styles/core.scss
@@ -334,17 +334,22 @@ key {
}
}
-.configureAPI.button {
+#devButtons {
position: fixed;
left: 0;
top: 10rem;
display: grid;
- grid-auto-flow: column;
+ grid-auto-flow: row;
gap: 0.5rem;
text-decoration: none;
z-index: 999999999;
border-radius: 0 1rem 1rem 0;
- padding: 1rem;
+
+ .button {
+ padding: 1rem;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ }
}
.avatar {
diff --git a/frontend/src/styles/media-queries-purple.scss b/frontend/src/styles/media-queries-purple.scss
index 3fe6862bb..2bd4886fb 100644
--- a/frontend/src/styles/media-queries-purple.scss
+++ b/frontend/src/styles/media-queries-purple.scss
@@ -273,4 +273,8 @@
aspect-ratio: 1;
}
}
+ .popupWrapper .modal .inputs.withLabel,
+ .modalWrapper .modal .inputs.withLabel {
+ grid-template-columns: 1fr;
+ }
}
diff --git a/frontend/src/styles/popups.scss b/frontend/src/styles/popups.scss
index 1e46c0fcf..b16446eab 100644
--- a/frontend/src/styles/popups.scss
+++ b/frontend/src/styles/popups.scss
@@ -41,6 +41,37 @@
font-size: 1.5rem;
color: var(--sub-color);
}
+
+ .inputs.withLabel {
+ display: grid;
+ grid-template-columns: max-content auto;
+ grid-auto-flow: row;
+ }
+
+ .inputs {
+ div:has(> input[type="range"]) {
+ display: grid;
+ grid-auto-columns: auto 3rem;
+ grid-auto-flow: column;
+ gap: 0.5rem;
+
+ span {
+ text-align: right;
+ }
+ }
+ }
+ }
+}
+
+body.darkMode {
+ .popupWrapper,
+ .modalWrapper {
+ .modal .inputs {
+ input[type="date"]::-webkit-calendar-picker-indicator,
+ input[type="datetime-local"]::-webkit-calendar-picker-indicator {
+ filter: invert(1);
+ }
+ }
}
}
@@ -486,6 +517,9 @@
opacity: 1;
}
}
+ & [data-popup-id="devGenerateData"] {
+ max-width: 700px;
+ }
}
#mobileTestConfigModal {
@@ -594,6 +628,12 @@
}
}
+#devOptionsModal {
+ .modal {
+ max-width: 400px;
+ }
+}
+
#shareTestSettingsModal {
.modal {
max-width: 600px;
diff --git a/frontend/src/ts/ape/endpoints/dev.ts b/frontend/src/ts/ape/endpoints/dev.ts
new file mode 100644
index 000000000..0128ed376
--- /dev/null
+++ b/frontend/src/ts/ape/endpoints/dev.ts
@@ -0,0 +1,15 @@
+const BASE_PATH = "/dev";
+
+export default class Dev {
+ constructor(private httpClient: Ape.HttpClient) {
+ this.httpClient = httpClient;
+ }
+
+ async generateData(
+ params: Ape.Dev.GenerateData
+ ): Ape.EndpointResponse<Ape.Dev.GenerateDataResponse> {
+ return await this.httpClient.post(BASE_PATH + "/generateData", {
+ payload: params,
+ });
+ }
+}
diff --git a/frontend/src/ts/ape/endpoints/index.ts b/frontend/src/ts/ape/endpoints/index.ts
index b6ee0762c..8656133c4 100644
--- a/frontend/src/ts/ape/endpoints/index.ts
+++ b/frontend/src/ts/ape/endpoints/index.ts
@@ -8,6 +8,7 @@ import Users from "./users";
import ApeKeys from "./ape-keys";
import Public from "./public";
import Configuration from "./configuration";
+import Dev from "./dev";
export default {
Configs,
@@ -20,4 +21,5 @@ export default {
Users,
ApeKeys,
Configuration,
+ Dev,
};
diff --git a/frontend/src/ts/ape/index.ts b/frontend/src/ts/ape/index.ts
index c456f159b..30fa84b3a 100644
--- a/frontend/src/ts/ape/index.ts
+++ b/frontend/src/ts/ape/index.ts
@@ -20,6 +20,7 @@ const Ape = {
publicStats: new endpoints.Public(httpClient),
apeKeys: new endpoints.ApeKeys(httpClient),
configuration: new endpoints.Configuration(httpClient),
+ dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)),
};
export default Ape;
diff --git a/frontend/src/ts/ape/types/dev.d.ts b/frontend/src/ts/ape/types/dev.d.ts
new file mode 100644
index 000000000..9d3755a3b
--- /dev/null
+++ b/frontend/src/ts/ape/types/dev.d.ts
@@ -0,0 +1,14 @@
+declare namespace Ape.Dev {
+ type GenerateData = {
+ username: string;
+ createUser?: boolean;
+ firstTestTimestamp?: number;
+ lastTestTimestamp?: number;
+ minTestsPerDay?: number;
+ maxTestsPerDay?: number;
+ };
+ type GenerateDataResponse = {
+ uid: string;
+ email: string;
+ };
+}
diff --git a/frontend/src/ts/controllers/theme-controller.ts b/frontend/src/ts/controllers/theme-controller.ts
index c16c25124..005374069 100644
--- a/frontend/src/ts/controllers/theme-controller.ts
+++ b/frontend/src/ts/controllers/theme-controller.ts
@@ -181,6 +181,12 @@ async function apply(
$("#metaThemeColor").attr("content", colors.bg);
// }
updateFooterThemeName(isPreview ? themeName : undefined);
+
+ if (isColorDark(await ThemeColors.get("bg"))) {
+ $("body").addClass("darkMode");
+ } else {
+ $("body").removeClass("darkMode");
+ }
}
function updateFooterThemeName(nameOverride?: string): void {
diff --git a/frontend/src/ts/index.ts b/frontend/src/ts/index.ts
index b59c20b2f..73c8060bf 100644
--- a/frontend/src/ts/index.ts
+++ b/frontend/src/ts/index.ts
@@ -42,6 +42,7 @@ import "./controllers/profile-search-controller";
import { isDevEnvironment } from "./utils/misc";
import * as VersionButton from "./elements/version-button";
import * as Focus from "./test/focus";
+import { getDevOptionsModal } from "./utils/async-modules";
function addToGlobal(items: Record<string, unknown>): void {
for (const [name, item] of Object.entries(items)) {
@@ -72,4 +73,7 @@ if (isDevEnvironment()) {
void import("jquery").then((jq) => {
addToGlobal({ $: jq.default });
});
+ void getDevOptionsModal().then((module) => {
+ module.appendButton();
+ });
}
diff --git a/frontend/src/ts/modals/dev-options.ts b/frontend/src/ts/modals/dev-options.ts
new file mode 100644
index 000000000..da055648e
--- /dev/null
+++ b/frontend/src/ts/modals/dev-options.ts
@@ -0,0 +1,34 @@
+import { envConfig } from "../constants/env-config";
+import AnimatedModal from "../utils/animated-modal";
+import { showPopup } from "./simple-modals";
+
+export function show(): void {
+ void modal.show();
+}
+
+async function setup(modalEl: HTMLElement): Promise<void> {
+ modalEl.querySelector(".generateData")?.addEventListener("click", () => {
+ showPopup("devGenerateData");
+ });
+}
+
+const modal = new AnimatedModal({
+ dialogId: "devOptionsModal",
+ setup,
+});
+
+export function appendButton(): void {
+ $("body").prepend(
+ `
+ <div id="devButtons">
+ <a class='button configureAPI' href='${envConfig.backendUrl}/configure/' target='_blank' aria-label="Configure API" data-balloon-pos="right"><i class="fas fa-fw fa-server"></i></a>
+ <button class='button showDevOptionsModal' aria-label="Dev options" data-balloon-pos="right"><i class="fas fa-fw fa-flask"></i></button>
+ <div>
+ `
+ );
+ document
+ .querySelector("#devButtons .button.showDevOptionsModal")
+ ?.addEventListener("click", () => {
+ show();
+ });
+}
diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts
index c224de9ac..5a7772df0 100644
--- a/frontend/src/ts/modals/simple-modals.ts
+++ b/frontend/src/ts/modals/simple-modals.ts
@@ -31,16 +31,62 @@ import AnimatedModal, {
HideOptions,
ShowOptions,
} from "../utils/animated-modal";
+import { format as dateFormat } from "date-fns/format";
+import { Attributes, buildTag } from "../utils/tag-builder";
-type Input = {
+type CommonInput<TType, TValue> = {
+ type: TType;
+ initVal?: TValue;
placeholder?: string;
- type?: string;
- initVal: string;
hidden?: boolean;
disabled?: boolean;
+ optional?: boolean;
label?: string;
+ oninput?: (event: Event) => void;
};
+type TextInput = CommonInput<"text", string>;
+type TextArea = CommonInput<"textarea", string>;
+type PasswordInput = CommonInput<"password", string>;
+type EmailInput = CommonInput<"email", string>;
+
+type RangeInput = {
+ min: number;
+ max: number;
+ step?: number;
+} & CommonInput<"range", number>;
+
+type DateTimeInput = {
+ min?: Date;
+ max?: Date;
+} & CommonInput<"datetime-local", Date>;
+type DateInput = {
+ min?: Date;
+ max?: Date;
+} & CommonInput<"date", Date>;
+
+type CheckboxInput = {
+ label: string;
+ placeholder?: never;
+ description?: string;
+} & CommonInput<"checkbox", boolean>;
+
+type NumberInput = {
+ min?: number;
+ max?: number;
+} & CommonInput<"number", number>;
+
+type CommonInputType =
+ | TextInput
+ | TextArea
+ | PasswordInput
+ | EmailInput
+ | RangeInput
+ | DateTimeInput
+ | DateInput
+ | CheckboxInput
+ | NumberInput;
+
let activePopup: SimpleModal | null = null;
type ExecReturn = {
@@ -78,7 +124,8 @@ type PopupKey =
| "resetProgressCustomTextLong"
| "updateCustomTheme"
| "deleteCustomTheme"
- | "forgotPassword";
+ | "forgotPassword"
+ | "devGenerateData";
const list: Record<PopupKey, SimpleModal | undefined> = {
updateEmail: undefined,
@@ -106,13 +153,13 @@ const list: Record<PopupKey, SimpleModal | undefined> = {
updateCustomTheme: undefined,
deleteCustomTheme: undefined,
forgotPassword: undefined,
+ devGenerateData: undefined,
};
type SimpleModalOptions = {
id: string;
- type: string;
title: string;
- inputs?: Input[];
+ inputs?: CommonInputType[];
text?: string;
buttonText: string;
execFn: (thisPopup: SimpleModal, ...params: string[]) => Promise<ExecReturn>;
@@ -121,6 +168,7 @@ type SimpleModalOptions = {
canClose?: boolean;
onlineOnly?: boolean;
hideCallsExec?: boolean;
+ showLabels?: boolean;
};
const modal = new AnimatedModal({
@@ -144,9 +192,8 @@ class SimpleModal {
wrapper: HTMLElement;
element: HTMLElement;
id: string;
- type: string;
title: string;
- inputs: Input[];
+ inputs: CommonInputType[];
text?: string;
buttonText: string;
execFn: (thisPopup: SimpleModal, ...params: string[]) => Promise<ExecReturn>;
@@ -155,10 +202,10 @@ class SimpleModal {
canClose: boolean;
onlineOnly: boolean;
hideCallsExec: boolean;
+ showLabels: boolean;
constructor(options: SimpleModalOptions) {
this.parameters = [];
this.id = options.id;
- this.type = options.type;
this.execFn = options.execFn;
this.title = options.title;
this.inputs = options.inputs ?? [];
@@ -171,6 +218,7 @@ class SimpleModal {
this.canClose = options.canClose ?? true;
this.onlineOnly = options.onlineOnly ?? false;
this.hideCallsExec = options.hideCallsExec ?? false;
+ this.showLabels = options.showLabels ?? false;
}
reset(): void {
this.element.innerHTML = `
@@ -214,68 +262,138 @@ class SimpleModal {
return;
}
- if (this.type === "number") {
- this.inputs.forEach((input) => {
- el.find(".inputs").append(`
- <input
- type="number"
- min="1"
- value="${input.initVal}"
- placeholder="${input.placeholder}"
- class="${input.hidden ? "hidden" : ""}"
- ${input.hidden ? "" : "required"}
- autocomplete="off"
- >
+ const inputs = el.find(".inputs");
+ if (this.showLabels) inputs.addClass("withLabel");
+
+ this.inputs.forEach((input, index) => {
+ const id = `${this.id}_${index}`;
+
+ if (this.showLabels && !input.hidden) {
+ inputs.append(`<label for="${id}">${input.label ?? ""}</label>`);
+ }
+
+ const tagname = input.type === "textarea" ? "textarea" : "input";
+ const classes = input.hidden ? ["hidden"] : undefined;
+ const attributes: Attributes = {
+ id: id,
+ placeholder: input.placeholder ?? "",
+ autocomplete: "off",
+ };
+
+ if (input.type !== "textarea") {
+ attributes["value"] = input.initVal?.toString() ?? "";
+ attributes["type"] = input.type;
+ }
+ if (!input.hidden && !input.optional === true) {
+ attributes["required"] = true;
+ }
+ if (input.disabled) {
+ attributes["disabled"] = true;
+ }
+
+ if (input.type === "textarea") {
+ inputs.append(
+ buildTag({
+ tagname,
+ classes,
+ attributes,
+ innerHTML: input.initVal,
+ })
+ );
+ } else if (input.type === "checkbox") {
+ let html = `
+ <input
+ id="${id}"
+ type="checkbox"
+ class="${input.hidden ? "hidden" : ""}"
+ ${input.initVal ? 'checked="checked"' : ""}>
+ `;
+ if (input.description !== undefined) {
+ html += `<span>${input.description}</span>`;
+ }
+ if (!this.showLabels) {
+ html = `
+ <label class="checkbox">
+ ${html}
+ <div>${input.label}</div>
+ </label>
+ `;
+ } else {
+ html = `<div>${html}</div>`;
+ }
+ inputs.append(html);
+ } else if (input.type === "range") {
+ inputs.append(`
+ <div>
+ ${buildTag({
+ tagname,
+ classes,
+ attributes: {
+ ...attributes,
+ min: input.min.toString(),
+ max: input.max.toString(),
+ step: input.step?.toString(),
+ oninput: "this.nextElementSibling.innerHTML = this.value",
+ },
+ })}
+ <span>${input.initVal ?? ""}</span>
+ </div>
`);
- });
- } else if (this.type === "text") {
- this.inputs.forEach((input) => {
- if (input.type !== undefined && input.type !== "") {
- if (input.type === "textarea") {
- el.find(".inputs").append(`
- <textarea
- placeholder="${input.placeholder}"
- class="${input.hidden ? "hidden" : ""}"
- ${input.hidden ? "" : "required"}
- ${input.disabled ? "disabled" : ""}
- autocomplete="off"
- >${input.initVal}</textarea>
- `);
- } else if (input.type === "checkbox") {
- el.find(".inputs").append(`
- <label class="checkbox">
- <input type="checkbox" checked="">
- <div>${input.label}</div>
- </label>
- `);
- } else {
- el.find(".inputs").append(`
- <input
- type="${input.type}"
- value="${input.initVal}"
- placeholder="${input.placeholder}"
- class="${input.hidden ? "hidden" : ""}"
- ${input.hidden ? "" : "required"}
- ${input.disabled ? "disabled" : ""}
- autocomplete="off"
- >
- `);
+ } else {
+ switch (input.type) {
+ case "text":
+ case "password":
+ case "email":
+ break;
+
+ case "datetime-local": {
+ if (input.min !== undefined) {
+ attributes["min"] = dateFormat(
+ input.min,
+ "yyyy-MM-dd'T'HH:mm:ss"
+ );
+ }
+ if (input.max !== undefined) {
+ attributes["max"] = dateFormat(
+ input.max,
+ "yyyy-MM-dd'T'HH:mm:ss"
+ );
+ }
+ if (input.initVal !== undefined) {
+ attributes["value"] = dateFormat(
+ input.initVal,
+ "yyyy-MM-dd'T'HH:mm:ss"
+ );
+ }
+ break;
+ }
+ case "date": {
+ if (input.min !== undefined) {
+ attributes["min"] = dateFormat(input.min, "yyyy-MM-dd");
+ }
+ if (input.max !== undefined) {
+ attributes["max"] = dateFormat(input.max, "yyyy-MM-dd");
+ }
+ if (input.initVal !== undefined) {
+ attributes["value"] = dateFormat(input.initVal, "yyyy-MM-dd");
+ }
+ break;
+ }
+ case "number": {
+ attributes["min"] = input.min?.toString();
+ attributes["max"] = input.max?.toString();
+ break;
}
- } else {
- el.find(".inputs").append(`
- <input
- type="text"
- value="${input.initVal}"
- placeholder="${input.placeholder}"
- class="${input.hidden ? "hidden" : ""}"
- ${input.hidden ? "" : "required"}
- ${input.disabled ? "disabled" : ""}
- autocomplete="off"
- >
- `);
}
- });
- }
+ inputs.append(buildTag({ tagname, classes, attributes }));
+ }
+ if (input.oninput !== undefined) {
+ (
+ document.querySelector("#" + attributes["id"]) as HTMLElement
+ ).oninput = input.oninput;
+ }
+ });
+
el.find(".inputs").removeClass("hidden");
}
@@ -290,14 +408,21 @@ class SimpleModal {
}
}
- const inputsWithCurrentValue = [];
+ type CommonInputWithCurrentValue = CommonInputType & {
+ currentValue: string | undefined;
+ };
+
+ const inputsWithCurrentValue: CommonInputWithCurrentValue[] = [];
for (let i = 0; i < this.inputs.length; i++) {
- inputsWithCurrentValue.push({ ...this.inputs[i], currentValue: vals[i] });
+ inputsWithCurrentValue.push({
+ ...(this.inputs[i] as CommonInputType),
+ currentValue: vals[i],
+ });
}
if (
inputsWithCurrentValue
- .filter((i) => !i.hidden)
+ .filter((i) => i.hidden !== true && i.optional !== true)
.some((v) => v.currentValue === undefined || v.currentValue === "")
) {
Notifications.add("Please fill in all fields", 0);
@@ -494,7 +619,6 @@ async function reauthenticate(
list.updateEmail = new SimpleModal({
id: "updateEmail",
- type: "text",
title: "Update email",
inputs: [
{
@@ -503,10 +627,12 @@ list.updateEmail = new SimpleModal({
initVal: "",
},
{
+ type: "text",
placeholder: "New email",
initVal: "",
},
{
+ type: "text",
placeholder: "Confirm new email",
initVal: "",
},
@@ -565,7 +691,6 @@ list.updateEmail = new SimpleModal({
list.removeGoogleAuth = new SimpleModal({
id: "removeGoogleAuth",
- type: "text",
title: "Remove Google authentication",
inputs: [
{
@@ -620,7 +745,6 @@ list.removeGoogleAuth = new SimpleModal({
list.removeGithubAuth = new SimpleModal({
id: "removeGithubAuth",
- type: "text",
title: "Remove GitHub authentication",
inputs: [
{
@@ -675,7 +799,6 @@ list.removeGithubAuth = new SimpleModal({
list.updateName = new SimpleModal({
id: "updateName",
- type: "text",
title: "Update name",
inputs: [
{
@@ -741,7 +864,7 @@ list.updateName = new SimpleModal({
const snapshot = DB.getSnapshot();
if (!snapshot) return;
if (!isUsingPasswordAuthentication()) {
- (thisPopup.inputs[0] as Input).hidden = true;
+ (thisPopup.inputs[0] as PasswordInput).hidden = true;
thisPopup.buttonText = "reauthenticate to update";
}
if (snapshot.needsToChangeName === true) {
@@ -753,7 +876,6 @@ list.updateName = new SimpleModal({
list.updatePassword = new SimpleModal({
id: "updatePassword",
- type: "text",
title: "Update password",
inputs: [
{
@@ -838,7 +960,6 @@ list.updatePassword = new SimpleModal({
list.addPasswordAuth = new SimpleModal({
id: "addPasswordAuth",
- type: "text",
title: "Add password authentication",
inputs: [
{
@@ -930,7 +1051,6 @@ list.addPasswordAuth = new SimpleModal({
list.deleteAccount = new SimpleModal({
id: "deleteAccount",
- type: "text",
title: "Delete account",
inputs: [
{
@@ -979,7 +1099,6 @@ list.deleteAccount = new SimpleModal({
list.resetAccount = new SimpleModal({
id: "resetAccount",
- type: "text",
title: "Reset account",
inputs: [
{
@@ -1030,7 +1149,6 @@ list.resetAccount = new SimpleModal({
list.optOutOfLeaderboards = new SimpleModal({
id: "optOutOfLeaderboards",
- type: "text",
title: "Opt out of leaderboards",
inputs: [
{
@@ -1077,7 +1195,6 @@ list.optOutOfLeaderboards = new SimpleModal({
list.clearTagPb = new SimpleModal({
id: "clearTagPb",
- type: "text",
title: "Clear tag PB",
text: "Are you sure you want to clear this tags PB?",
buttonText: "clear",
@@ -1121,9 +1238,8 @@ list.clearTagPb = new SimpleModal({
list.applyCustomFont = new SimpleModal({
id: "applyCustomFont",
- type: "text",
title: "Custom font",
- inputs: [{ placeholder: "Font name", initVal: "" }],
+ inputs: [{ type: "text", placeholder: "Font name", initVal: "" }],
text: "Make sure you have the font installed on your computer before applying",
buttonText: "apply",
execFn: async (_thisPopup, fontName): Promise<ExecReturn> => {
@@ -1138,7 +1254,6 @@ list.applyCustomFont = new SimpleModal({
list.resetPersonalBests = new SimpleModal({
id: "resetPersonalBests",
- type: "text",
title: "Reset personal bests",
inputs: [
{
@@ -1198,7 +1313,6 @@ list.resetPersonalBests = new SimpleModal({
list.resetSettings = new SimpleModal({
id: "resetSettings",
- type: "text",
title: "Reset settings",
text: "Are you sure you want to reset all your settings?",
buttonText: "reset",
@@ -1214,7 +1328,6 @@ list.resetSettings = new SimpleModal({
list.revokeAllTokens = new SimpleModal({
id: "revokeAllTokens",
- type: "text",
title: "Revoke all tokens",
inputs: [
{
@@ -1255,7 +1368,7 @@ list.revokeAllTokens = new SimpleModal({
const snapshot = DB.getSnapshot();
if (!snapshot) return;
if (!isUsingPasswordAuthentication()) {
- (thisPopup.inputs[0] as Input).hidden = true;
+ (thisPopup.inputs[0] as PasswordInput).hidden = true;
thisPopup.buttonText = "reauthenticate to revoke all tokens";
}
},
@@ -1263,7 +1376,6 @@ list.revokeAllTokens = new SimpleModal({
list.unlinkDiscord = new SimpleModal({
id: "unlinkDiscord",
- type: "text",
title: "Unlink Discord",
text: "Are you sure you want to unlink your Discord account?",
buttonText: "unlink",
@@ -1300,10 +1412,10 @@ list.unlinkDiscord = new SimpleModal({
list.generateApeKey = new SimpleModal({
id: "generateApeKey",
- type: "text",
title: "Generate new Ape key",
inputs: [
{
+ type: "text",
placeholder: "Name",
initVal: "",
},
@@ -1342,7 +1454,6 @@ list.generateApeKey = new SimpleModal({
list.viewApeKey = new SimpleModal({
id: "viewApeKey",
- type: "text",
title: "Ape key",
inputs: [
{
@@ -1366,7 +1477,7 @@ list.viewApeKey = new SimpleModal({
};
},
beforeInitFn: (_thisPopup): void => {
- (_thisPopup.inputs[0] as Input).initVal = _thisPopup
+ (_thisPopup.inputs[0] as TextArea).initVal = _thisPopup
.parameters[0] as string;
},
beforeShowFn: (_thisPopup): void => {
@@ -1382,7 +1493,6 @@ list.viewApeKey = new SimpleModal({
list.deleteApeKey = new SimpleModal({
id: "deleteApeKey",
- type: "text",
title: "Delete Ape key",
text: "Are you sure?",
buttonText: "delete",
@@ -1408,10 +1518,10 @@ list.deleteApeKey = new SimpleModal({
list.editApeKey = new SimpleModal({
id: "editApeKey",
- type: "text",
title: "Edit Ape key",
inputs: [
{
+ type: "text",
placeholder: "name",
initVal: "",
},
@@ -1440,7 +1550,6 @@ list.editApeKey = new SimpleModal({
list.deleteCustomText = new SimpleModal({
id: "deleteCustomText",
- type: "text",
title: "Delete custom text",
text: "Are you sure?",
buttonText: "delete",
@@ -1460,7 +1569,6 @@ list.deleteCustomText = new SimpleModal({
list.deleteCustomTextLong = new SimpleModal({
id: "deleteCustomTextLong",
- type: "text",
title: "Delete custom text",
text: "Are you sure?",
buttonText: "delete",
@@ -1480,7 +1588,6 @@ list.deleteCustomTextLong = new SimpleModal({
list.resetProgressCustomTextLong = new SimpleModal({
id: "resetProgressCustomTextLong",
- type: "text",
title: "Reset progress for custom text",
text: "Are you sure?",
buttonText: "reset",
@@ -1503,7 +1610,6 @@ list.resetProgressCustomTextLong = new SimpleModal({
list.updateCustomTheme = new SimpleModal({
id: "updateCustomTheme",
- type: "text",
title: "Update custom theme",
inputs: [
{
@@ -1513,7 +1619,7 @@ list.updateCustomTheme = new SimpleModal({
},
{
type: "checkbox",
- initVal: "false",
+ initVal: false,
label: "Update custom theme to current colors",
},
],
@@ -1578,13 +1684,12 @@ list.updateCustomTheme = new SimpleModal({
(t) => t._id === _thisPopup.parameters[0]
);
if (!customTheme) return;
- (_thisPopup.inputs[0] as Input).initVal = customTheme.name;
+ (_thisPopup.inputs[0] as TextInput).initVal = customTheme.name;
},
});
list.deleteCustomTheme = new SimpleModal({
id: "deleteCustomTheme",
- type: "text",
title: "Delete custom theme",
text: "Are you sure?",
buttonText: "delete",
@@ -1602,7 +1707,6 @@ list.deleteCustomTheme = new SimpleModal({
list.forgotPassword = new SimpleModal({
id: "forgotPassword",
- type: "text",
title: "Forgot password",
inputs: [
{
@@ -1634,11 +1738,97 @@ list.forgotPassword = new SimpleModal({
`.pageLogin .login input[name="current-email"]`
).val() as string;
if (inputValue) {
- (thisPopup.inputs[0] as Input).initVal = inputValue;
+ (thisPopup.inputs[0] as TextInput).initVal = inputValue;
}
},
});
+list.devGenerateData = new SimpleModal({
+ id: "devGenerateData",
+ title: "Generate data",
+ showLabels: true,
+ inputs: [
+ {
+ type: "text",
+ label: "username",
+ placeholder: "username",
+ oninput: (event): void => {
+ const target = event.target as HTMLInputElement;
+ 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`;
+ return;
+ },
+ },
+ {
+ type: "checkbox",
+ label: "create user",
+ description:
+ "if checked, user will be created with {username}@example.com and password: password",
+ },
+ {
+ type: "date",
+ label: "first test",
+ optional: true,
+ },
+ {
+ type: "date",
+ label: "last test",
+ max: new Date(),
+ optional: true,
+ },
+ {
+ type: "range",
+ label: "min tests per day",
+ initVal: 0,
+ min: 0,
+ max: 200,
+ step: 10,
+ },
+ {
+ type: "range",
+ label: "max tests per day",
+ initVal: 50,
+ min: 0,
+ max: 200,
+ step: 10,
+ },
+ ],
+ buttonText: "generate (might take a while)",
+ execFn: async (
+ _thisPopup,
+ username,
+ createUser,
+ firstTestTimestamp,
+ lastTestTimestamp,
+ minTestsPerDay,
+ maxTestsPerDay
+ ): Promise<ExecReturn> => {
+ const request: Ape.Dev.GenerateData = {
+ username,
+ createUser: createUser === "true",
+ };
+ if (firstTestTimestamp !== undefined && firstTestTimestamp.length > 0)
+ request.firstTestTimestamp = Date.parse(firstTestTimestamp);
+ if (lastTestTimestamp !== undefined && lastTestTimestamp.length > 0)
+ request.lastTestTimestamp = Date.parse(lastTestTimestamp);
+ if (minTestsPerDay !== undefined && minTestsPerDay.length > 0)
+ request.minTestsPerDay = Number.parseInt(minTestsPerDay);
+ if (maxTestsPerDay !== undefined && maxTestsPerDay.length > 0)
+ request.maxTestsPerDay = Number.parseInt(maxTestsPerDay);
+
+ const result = await Ape.dev.generateData(request);
+
+ return {
+ status: result.status === 200 ? 1 : -1,
+ message: result.message,
+ hideOptions: {
+ clearModalChain: true,
+ },
+ };
+ },
+});
export function showPopup(
key: PopupKey,
showParams = [] as string[],
diff --git a/frontend/src/ts/ready.ts b/frontend/src/ts/ready.ts
index fcbdca162..e373d44dc 100644
--- a/frontend/src/ts/ready.ts
+++ b/frontend/src/ts/ready.ts
@@ -8,7 +8,6 @@ import * as ConnectionState from "./states/connection";
import * as FunboxList from "./test/funbox/funbox-list";
//@ts-expect-error
import Konami from "konami";
-import { envConfig } from "./constants/env-config";
import * as ServerConfiguration from "./ape/server-configuration";
$((): void => {
@@ -69,8 +68,5 @@ $((): void => {
void registration.unregister();
}
});
- $("body").prepend(
- `<a class='button configureAPI' href='${envConfig.backendUrl}/configure/' target='_blank' aria-label="Configure API" data-balloon-pos="right"><i class="fas fa-fw fa-server"></i></a>`
- );
}
});
diff --git a/frontend/src/ts/utils/async-modules.ts b/frontend/src/ts/utils/async-modules.ts
index 04894d70a..fa24ff832 100644
--- a/frontend/src/ts/utils/async-modules.ts
+++ b/frontend/src/ts/utils/async-modules.ts
@@ -30,3 +30,31 @@ export async function getCommandline(): Promise<
throw e;
}
}
+
+Skeleton.save("devOptionsModal");
+
+export async function getDevOptionsModal(): Promise<
+ typeof import("../modals/dev-options.js")
+> {
+ try {
+ Loader.show();
+ const module = await import("../modals/dev-options.js");
+ Loader.hide();
+ return module;
+ } catch (e) {
+ Loader.hide();
+ if (
+ e instanceof Error &&
+ e.message.includes("Failed to fetch dynamically imported module")
+ ) {
+ Notifications.add(
+ "Failed to load dev options module: could not fetch",
+ -1
+ );
+ } else {
+ const msg = createErrorMessage(e, "Failed to load dev options module");
+ Notifications.add(msg, -1);
+ }
+ throw e;
+ }
+}
diff --git a/frontend/src/ts/utils/tag-builder.ts b/frontend/src/ts/utils/tag-builder.ts
new file mode 100644
index 000000000..e539a4512
--- /dev/null
+++ b/frontend/src/ts/utils/tag-builder.ts
@@ -0,0 +1,31 @@
+export type Attributes = Record<string, string | true | undefined>;
+type TagOptions = {
+ tagname: string;
+ classes?: string[];
+ attributes?: Attributes;
+ innerHTML?: string;
+};
+
+export function buildTag({
+ tagname,
+ classes,
+ attributes,
+ innerHTML,
+}: TagOptions): string {
+ let html = `<${tagname}`;
+ if (classes !== undefined) html += ` class="${classes.join(" ")}"`;
+
+ if (attributes !== undefined) {
+ html +=
+ " " +
+ Object.entries(attributes)
+ .filter((it) => it[1] !== undefined)
+ .sort((a, b) => a[0].localeCompare(b[0]))
+ .map((it) => (it[1] === true ? `${it[0]}` : `${it[0]}="${it[1]}"`))
+ .join(" ");
+ }
+
+ if (innerHTML !== undefined) html += `>${innerHTML}</${tagname}>`;
+ else html += " />";
+ return html;
+}