mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-12-09 21:06:37 +08:00
parent
57a6fd9bd5
commit
b4ea7f119f
21 changed files with 996 additions and 136 deletions
388
backend/src/api/controllers/dev.ts
Normal file
388
backend/src/api/controllers/dev.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
38
backend/src/api/routes/dev.ts
Normal file
38
backend/src/api/routes/dev.ts
Normal file
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) &&
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ export async function getUser(
|
|||
return user;
|
||||
}
|
||||
|
||||
async function findByName(
|
||||
export async function findByName(
|
||||
name: string
|
||||
): Promise<MonkeyTypes.DBUser | undefined> {
|
||||
return (
|
||||
|
|
|
|||
51
frontend/__tests__/utils/tag-builder.spec.ts
Normal file
51
frontend/__tests__/utils/tag-builder.spec.ts
Normal file
|
|
@ -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>'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -273,4 +273,8 @@
|
|||
aspect-ratio: 1;
|
||||
}
|
||||
}
|
||||
.popupWrapper .modal .inputs.withLabel,
|
||||
.modalWrapper .modal .inputs.withLabel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
15
frontend/src/ts/ape/endpoints/dev.ts
Normal file
15
frontend/src/ts/ape/endpoints/dev.ts
Normal file
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
14
frontend/src/ts/ape/types/dev.d.ts
vendored
Normal file
14
frontend/src/ts/ape/types/dev.d.ts
vendored
Normal file
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
34
frontend/src/ts/modals/dev-options.ts
Normal file
34
frontend/src/ts/modals/dev-options.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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"
|
||||
>
|
||||
`);
|
||||
});
|
||||
} 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 {
|
||||
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"
|
||||
>
|
||||
`);
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
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[],
|
||||
|
|
|
|||
|
|
@ -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>`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
31
frontend/src/ts/utils/tag-builder.ts
Normal file
31
frontend/src/ts/utils/tag-builder.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue