Squashed 'CTFd/themes/core-beta/' changes from 9126d77d..5ce3003b

5ce3003b Merge pull request #47 from aCursedComrade/patch-1
c9887cb1 Fix team template

git-subtree-dir: CTFd/themes/core-beta
git-subtree-split: 5ce3003b4d68352e629ee2d390bc999e7d6b071e
This commit is contained in:
Kevin Chung
2023-06-11 15:56:28 -04:00
parent 692c4b086c
commit a64e7d51ef
815 changed files with 683 additions and 101218 deletions

BIN
assets/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

253
assets/js/challenges.js Normal file
View File

@@ -0,0 +1,253 @@
import Alpine from "alpinejs";
import dayjs from "dayjs";
import CTFd from "./index";
import { Modal, Tab } from "bootstrap";
import highlight from "./theme/highlight";
function addTargetBlank(html) {
let dom = new DOMParser();
let view = dom.parseFromString(html, "text/html");
let links = view.querySelectorAll('a[href*="://"]');
links.forEach(link => {
link.setAttribute("target", "_blank");
});
return view.documentElement.outerHTML;
}
window.Alpine = Alpine;
Alpine.store("challenge", {
data: {
view: "",
},
});
Alpine.data("Hint", () => ({
id: null,
html: null,
async showHint(event) {
if (event.target.open) {
let response = await CTFd.pages.challenge.loadHint(this.id);
let hint = response.data;
if (hint.content) {
this.html = addTargetBlank(hint.html);
} else {
let answer = await CTFd.pages.challenge.displayUnlock(this.id);
if (answer) {
let unlock = await CTFd.pages.challenge.loadUnlock(this.id);
if (unlock.success) {
let response = await CTFd.pages.challenge.loadHint(this.id);
let hint = response.data;
this.html = addTargetBlank(hint.html);
} else {
event.target.open = false;
CTFd._functions.challenge.displayUnlockError(unlock);
}
} else {
event.target.open = false;
}
}
}
},
}));
Alpine.data("Challenge", () => ({
id: null,
next_id: null,
submission: "",
tab: null,
solves: [],
response: null,
async init() {
highlight();
},
getStyles() {
let styles = {
"modal-dialog": true,
};
try {
let size = CTFd.config.themeSettings.challenge_window_size;
switch (size) {
case "sm":
styles["modal-sm"] = true;
break;
case "lg":
styles["modal-lg"] = true;
break;
case "xl":
styles["modal-xl"] = true;
break;
default:
break;
}
} catch (error) {
// Ignore errors with challenge window size
console.log("Error processing challenge_window_size");
console.log(error);
}
return styles;
},
async init() {
highlight();
},
async showChallenge() {
new Tab(this.$el).show();
},
async showSolves() {
this.solves = await CTFd.pages.challenge.loadSolves(this.id);
this.solves.forEach(solve => {
solve.date = dayjs(solve.date).format("MMMM Do, h:mm:ss A");
return solve;
});
new Tab(this.$el).show();
},
getNextId() {
let data = Alpine.store("challenge").data;
return data.next_id;
},
async nextChallenge() {
let modal = Modal.getOrCreateInstance("[x-ref='challengeWindow']");
// TODO: Get rid of this private attribute access
// See https://github.com/twbs/bootstrap/issues/31266
modal._element.addEventListener(
"hidden.bs.modal",
event => {
// Dispatch load-challenge event to call loadChallenge in the ChallengeBoard
Alpine.nextTick(() => {
this.$dispatch("load-challenge", this.getNextId());
});
},
{ once: true }
);
modal.hide();
},
async submitChallenge() {
this.response = await CTFd.pages.challenge.submitChallenge(
this.id,
this.submission
);
await this.renderSubmissionResponse();
},
async renderSubmissionResponse() {
if (this.response.data.status === "correct") {
this.submission = "";
}
// Dispatch load-challenges event to call loadChallenges in the ChallengeBoard
this.$dispatch("load-challenges");
},
}));
Alpine.data("ChallengeBoard", () => ({
loaded: false,
challenges: [],
challenge: null,
async init() {
this.challenges = await CTFd.pages.challenges.getChallenges();
this.loaded = true;
if (window.location.hash) {
let chalHash = decodeURIComponent(window.location.hash.substring(1));
let idx = chalHash.lastIndexOf("-");
if (idx >= 0) {
let pieces = [chalHash.slice(0, idx), chalHash.slice(idx + 1)];
let id = pieces[1];
await this.loadChallenge(id);
}
}
},
getCategories() {
const categories = [];
this.challenges.forEach(challenge => {
const { category } = challenge;
if (!categories.includes(category)) {
categories.push(category);
}
});
try {
const f = CTFd.config.themeSettings.challenge_category_order;
if (f) {
const getSort = new Function(`return (${f})`);
categories.sort(getSort());
}
} catch (error) {
// Ignore errors with theme category sorting
console.log("Error running challenge_category_order function");
console.log(error);
}
return categories;
},
getChallenges(category) {
let challenges = this.challenges;
if (category) {
challenges = this.challenges.filter(challenge => challenge.category === category);
}
try {
const f = CTFd.config.themeSettings.challenge_order;
if (f) {
const getSort = new Function(`return (${f})`);
challenges.sort(getSort());
}
} catch (error) {
// Ignore errors with theme challenge sorting
console.log("Error running challenge_order function");
console.log(error);
}
return challenges;
},
async loadChallenges() {
this.challenges = await CTFd.pages.challenges.getChallenges();
},
async loadChallenge(challengeId) {
await CTFd.pages.challenge.displayChallenge(challengeId, challenge => {
challenge.data.view = addTargetBlank(challenge.data.view);
Alpine.store("challenge").data = challenge.data;
// nextTick is required here because we're working in a callback
Alpine.nextTick(() => {
let modal = Modal.getOrCreateInstance("[x-ref='challengeWindow']");
// TODO: Get rid of this private attribute access
// See https://github.com/twbs/bootstrap/issues/31266
modal._element.addEventListener(
"hidden.bs.modal",
event => {
// Remove location hash
history.replaceState(null, null, " ");
},
{ once: true }
);
modal.show();
history.replaceState(null, null, `#${challenge.data.name}-${challengeId}`);
});
});
},
}));
Alpine.start();

35
assets/js/index.js Normal file
View File

@@ -0,0 +1,35 @@
import CTFd from "@ctfdio/ctfd-js";
import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
import times from "./theme/times";
import styles from "./theme/styles";
import highlight from "./theme/highlight";
import alerts from "./utils/alerts";
import tooltips from "./utils/tooltips";
import collapse from "./utils/collapse";
import eventAlerts from "./utils/notifications/alerts";
import eventToasts from "./utils/notifications/toasts";
import eventRead from "./utils/notifications/read";
dayjs.extend(advancedFormat);
CTFd.init(window.init);
(() => {
styles();
times();
highlight();
alerts();
tooltips();
collapse();
eventRead();
eventAlerts();
eventToasts();
})();
export default CTFd;

View File

@@ -0,0 +1,33 @@
import Alpine from "alpinejs";
import CTFd from "./index";
window.CTFd = CTFd;
window.Alpine = Alpine;
// Get unread notifications from server
let lastId = CTFd.events.counter.read.getLast();
CTFd.fetch(`/api/v1/notifications?since_id=${lastId}`)
.then(response => {
return response.json();
})
.then(response => {
// Get notifications from server and mark them as read
let notifications = response.data;
let read = CTFd.events.counter.read.getAll();
notifications.forEach(n => {
read.push(n.id);
});
CTFd.events.counter.read.setAll(read);
// Mark all unread as read
CTFd.events.counter.unread.readAll();
// Broadcast our new count (which should be 0)
let count = CTFd.events.counter.unread.getAll().length;
CTFd.events.controller.broadcast("counter", {
count: count,
});
Alpine.store("unread_count", count);
});
Alpine.start();

7
assets/js/page.js Normal file
View File

@@ -0,0 +1,7 @@
import Alpine from "alpinejs";
import CTFd from "./index";
window.CTFd = CTFd;
window.Alpine = Alpine;
Alpine.start();

20
assets/js/scoreboard.js Normal file
View File

@@ -0,0 +1,20 @@
import Alpine from "alpinejs";
import CTFd from "./index";
import { getOption } from "./utils/graphs/echarts/scoreboard";
import { embed } from "./utils/graphs/echarts";
window.Alpine = Alpine;
window.CTFd = CTFd;
Alpine.data("ScoreboardDetail", () => ({
data: null,
async init() {
this.data = await CTFd.pages.scoreboard.getScoreboardDetail(10);
let option = getOption(CTFd.config.userMode, this.data);
embed(this.$refs.scoregraph, option);
},
}));
Alpine.start();

100
assets/js/settings.js Normal file
View File

@@ -0,0 +1,100 @@
import Alpine from "alpinejs";
import { Modal } from "bootstrap";
import { serializeJSON } from "@ctfdio/ctfd-js/forms";
import CTFd from "./index";
import { copyToClipboard } from "./utils/clipboard";
window.Alpine = Alpine;
Alpine.data("SettingsForm", () => ({
success: null,
error: null,
initial: null,
errors: [],
init() {
this.initial = serializeJSON(this.$el);
},
async updateProfile() {
this.success = null;
this.error = null;
this.errors = [];
let data = serializeJSON(this.$el, this.initial, true);
// Process fields[id] to fields: {}
data.fields = [];
for (const property in data) {
if (property.match(/fields\[\d+\]/)) {
let field = {};
let id = parseInt(property.slice(7, -1));
field["field_id"] = id;
field["value"] = data[property];
data.fields.push(field);
delete data[property];
}
}
// Send API request
const response = await CTFd.pages.settings.updateSettings(data);
if (response.success) {
this.success = true;
this.error = false;
setTimeout(() => {
this.success = null;
this.error = null;
}, 3000);
} else {
this.success = false;
this.error = true;
Object.keys(response.errors).map(error => {
const error_msg = response.errors[error];
this.errors.push(error_msg);
});
}
},
}));
Alpine.data("TokensForm", () => ({
token: null,
async generateToken() {
const data = serializeJSON(this.$el);
if (!data.expiration) {
delete data.expiration;
}
const response = await CTFd.pages.settings.generateToken(data);
this.token = response.data.value;
new Modal(this.$refs.tokenModal).show();
},
copyToken() {
copyToClipboard(this.$refs.token);
},
}));
Alpine.data("Tokens", () => ({
selectedTokenId: null,
async deleteTokenModal(tokenId) {
this.selectedTokenId = tokenId;
new Modal(this.$refs.confirmModal).show();
},
async deleteSelectedToken() {
await CTFd.pages.settings.deleteToken(this.selectedTokenId);
const $token = this.$refs[`token-${this.selectedTokenId}`];
if ($token) {
$token.remove();
}
},
}));
Alpine.start();

145
assets/js/setup.js Normal file
View File

@@ -0,0 +1,145 @@
import Alpine from "alpinejs";
import dayjs from "dayjs";
import { Tab } from "bootstrap";
import CTFd from "./index";
window.Alpine = Alpine;
Alpine.data("SetupForm", () => ({
init() {
// Bind Enter on any input to clicking the Next button
this.$root.querySelectorAll("input").forEach(i =>
i.addEventListener("keypress", e => {
if (e.key == "Enter") {
e.preventDefault();
e.target.closest(".tab-pane").querySelector("button[data-href]").click();
}
})
);
// Register storage listener for MLC integration
window.addEventListener("storage", function (event) {
if (event.key == "integrations" && event.newValue) {
let integration = JSON.parse(event.newValue);
if (integration["name"] == "mlc") {
$("#integration-mlc").text("Already Configured").attr("disabled", true);
window.focus();
localStorage.removeItem("integrations");
}
}
});
},
validateFileSize(e, limit) {
if (e.target.files[0].size > limit) {
if (
!confirm(
`This image file is larger than ${
limit / 1000
}KB which may result in increased load times. Are you sure you'd like to use this file?`
)
) {
e.target.value = "";
}
}
},
switchTab(e) {
// Handle tab validation
let valid_tab = true;
let inputs = e.target
.closest('[role="tabpanel"]')
.querySelectorAll("input,textarea");
inputs.forEach(e => {
if (e.checkValidity() === false) {
valid_tab = false;
}
});
if (valid_tab == false) {
return;
}
let target = e.target.dataset.href;
let tab = this.$root.querySelector(`[data-bs-target="${target}"]`);
Tab.getOrCreateInstance(tab).show();
},
setThemeColor(e) {
document.querySelector("#config-color-input").value = e.target.value;
},
resetThemeColor(_e) {
document.querySelector("#config-color-input").value = "";
document.querySelector("#config-color-picker").value = "";
},
processDateTime(datetime) {
return function (_event) {
let date_picker = document.querySelector(`#${datetime}-date`);
let time_picker = document.querySelector(`#${datetime}-time`);
let unix_time = dayjs(
`${date_picker.value} ${time_picker.value}`,
"YYYY-MM-DD HH:mm"
).unix();
if (isNaN(unix_time)) {
document.querySelector(`#${datetime}-preview`).value = "";
} else {
document.querySelector(`#${datetime}-preview`).value = unix_time;
}
};
},
mlcSetup() {
let q = document.querySelector;
let r = CTFd.config.urlRoot;
let params = {
name: q("#ctf_name").value,
type: "jeopardy",
description: q("#ctf_description").value,
user_mode: q("#user_mode").value,
event_url: window.location.origin + r,
redirect_url: window.location.origin + r + "/redirect",
integration_setup_url: window.location.origin + r + "/setup/integrations",
start: q("#start-preview").value,
end: q("#end-preview").value,
platform: "CTFd",
state: window.STATE,
};
const ret = [];
for (let p in params) {
ret.push(encodeURIComponent(p) + "=" + encodeURIComponent(params[p]));
}
window.open(
"https://www.majorleaguecyber.org/events/new?" + ret.join("&"),
"_blank"
);
},
submitSetup(e) {
if (document.querySelector("#newsletter-checkbox").checked) {
let email = e.target.querySelector("input[name=email]").value;
let params = {
email: email,
b_38e27f7d496889133d2214208_d7c3ed71f9: "",
c: "jsonp_callback_" + Math.round(10000 * Math.random()),
};
const ret = [];
for (let p in params) {
ret.push(encodeURIComponent(p) + "=" + encodeURIComponent(params[p]));
}
var script = document.createElement("script");
script.src =
"https://newsletters.ctfd.io/lists/ot889gr1sa0e1/subscribe/post-json?" +
ret.join("&");
document.head.appendChild(script);
}
},
}));
Alpine.start();

7
assets/js/teams/list.js Normal file
View File

@@ -0,0 +1,7 @@
import Alpine from "alpinejs";
import CTFd from "../index";
window.CTFd = CTFd;
window.Alpine = Alpine;
Alpine.start();

201
assets/js/teams/private.js Normal file
View File

@@ -0,0 +1,201 @@
import Alpine from "alpinejs";
import CTFd from "../index";
import { Modal } from "bootstrap";
import { serializeJSON } from "@ctfdio/ctfd-js/forms";
import { copyToClipboard } from "../utils/clipboard";
import { colorHash } from "@ctfdio/ctfd-js/ui";
import { getOption as getUserScoreOption } from "../utils/graphs/echarts/userscore";
import { embed } from "../utils/graphs/echarts";
window.Alpine = Alpine;
window.CTFd = CTFd;
Alpine.store("inviteToken", "");
Alpine.data("TeamEditModal", () => ({
success: null,
error: null,
initial: null,
errors: [],
init() {
this.initial = serializeJSON(this.$el.querySelector("form"));
},
async updateProfile() {
let data = serializeJSON(this.$el, this.initial, true);
data.fields = [];
for (const property in data) {
if (property.match(/fields\[\d+\]/)) {
let field = {};
let id = parseInt(property.slice(7, -1));
field["field_id"] = id;
field["value"] = data[property];
data.fields.push(field);
delete data[property];
}
}
let response = await CTFd.pages.teams.updateTeamSettings(data);
if (response.success) {
this.success = true;
this.error = false;
setTimeout(() => {
this.success = null;
this.error = null;
}, 3000);
} else {
this.success = false;
this.error = true;
Object.keys(response.errors).map(error => {
const error_msg = response.errors[error];
this.errors.push(error_msg);
});
}
},
}));
Alpine.data("TeamCaptainModal", () => ({
success: null,
error: null,
errors: [],
async updateCaptain() {
let data = serializeJSON(this.$el, null, true);
let response = await CTFd.pages.teams.updateTeamSettings(data);
if (response.success) {
window.location.reload();
} else {
this.success = false;
this.error = true;
Object.keys(response.errors).map(error => {
const error_msg = response.errors[error];
this.errors.push(error_msg);
});
}
},
}));
Alpine.data("TeamInviteModal", () => ({
copy() {
copyToClipboard(this.$refs.link);
},
}));
Alpine.data("TeamDisbandModal", () => ({
errors: [],
async disbandTeam() {
let response = await CTFd.pages.teams.disbandTeam();
if (response.success) {
window.location.reload();
} else {
this.errors = response.errors[""];
}
},
}));
Alpine.data("CaptainMenu", () => ({
captain: false,
editTeam() {
this.teamEditModal = new Modal(document.getElementById("team-edit-modal"));
this.teamEditModal.show();
},
chooseCaptain() {
this.teamCaptainModal = new Modal(document.getElementById("team-captain-modal"));
this.teamCaptainModal.show();
},
async inviteMembers() {
const response = await CTFd.pages.teams.getInviteToken();
if (response.success) {
const code = response.data.code;
const url = `${window.location.origin}${CTFd.config.urlRoot}/teams/invite?code=${code}`;
document.querySelector("#team-invite-modal input[name=link]").value = url;
this.$store.inviteToken = url;
this.teamInviteModal = new Modal(document.getElementById("team-invite-modal"));
this.teamInviteModal.show();
}
},
disbandTeam() {
this.teamDisbandModal = new Modal(document.getElementById("team-disband-modal"));
this.teamDisbandModal.show();
},
}));
Alpine.data("TeamGraphs", () => ({
solves: null,
fails: null,
awards: null,
solveCount: 0,
failCount: 0,
awardCount: 0,
getSolvePercentage() {
return ((this.solveCount / (this.solveCount + this.failCount)) * 100).toFixed(2);
},
getFailPercentage() {
return ((this.failCount / (this.solveCount + this.failCount)) * 100).toFixed(2);
},
getCategoryBreakdown() {
const categories = [];
const breakdown = {};
this.solves.data.map(solve => {
categories.push(solve.challenge.category);
});
categories.forEach(category => {
if (category in breakdown) {
breakdown[category] += 1;
} else {
breakdown[category] = 1;
}
});
const data = [];
for (const property in breakdown) {
data.push({
name: property,
count: breakdown[property],
percent: (breakdown[property] / categories.length) * 100,
color: colorHash(property),
});
}
return data;
},
async init() {
this.solves = await CTFd.pages.teams.teamSolves("me");
this.fails = await CTFd.pages.teams.teamFails("me");
this.awards = await CTFd.pages.teams.teamAwards("me");
this.solveCount = this.solves.meta.count;
this.failCount = this.fails.meta.count;
this.awardCount = this.awards.meta.count;
embed(
this.$refs.scoregraph,
getUserScoreOption(
CTFd.team.id,
CTFd.team.name,
this.solves.data,
this.awards.data
)
);
},
}));
Alpine.start();

76
assets/js/teams/public.js Normal file
View File

@@ -0,0 +1,76 @@
import CTFd from "../index";
import Alpine from "alpinejs";
import { colorHash } from "@ctfdio/ctfd-js/ui";
import { getOption as getUserScoreOption } from "../utils/graphs/echarts/userscore";
import { embed } from "../utils/graphs/echarts";
window.Alpine = Alpine;
Alpine.data("TeamGraphs", () => ({
solves: null,
fails: null,
awards: null,
solveCount: 0,
failCount: 0,
awardCount: 0,
getSolvePercentage() {
return ((this.solveCount / (this.solveCount + this.failCount)) * 100).toFixed(2);
},
getFailPercentage() {
return ((this.failCount / (this.solveCount + this.failCount)) * 100).toFixed(2);
},
getCategoryBreakdown() {
const categories = [];
const breakdown = {};
this.solves.data.map(solve => {
categories.push(solve.challenge.category);
});
categories.forEach(category => {
if (category in breakdown) {
breakdown[category] += 1;
} else {
breakdown[category] = 1;
}
});
const data = [];
for (const property in breakdown) {
data.push({
name: property,
count: breakdown[property],
percent: ((breakdown[property] / categories.length) * 100).toFixed(2),
color: colorHash(property),
});
}
return data;
},
async init() {
this.solves = await CTFd.pages.teams.teamSolves(window.TEAM.id);
this.fails = await CTFd.pages.teams.teamFails(window.TEAM.id);
this.awards = await CTFd.pages.teams.teamAwards(window.TEAM.id);
this.solveCount = this.solves.meta.count;
this.failCount = this.fails.meta.count;
this.awardCount = this.awards.meta.count;
embed(
this.$refs.scoregraph,
getUserScoreOption(
window.TEAM.id,
window.TEAM.name,
this.solves.data,
this.awards.data
)
);
},
}));
Alpine.start();

View File

@@ -0,0 +1,12 @@
import CTFd from "../index";
import lolight from "lolight";
export default () => {
if (
// default to true if config is not defined yet
!CTFd.config.themeSettings.hasOwnProperty("use_builtin_code_highlighter") ||
CTFd.config.themeSettings.use_builtin_code_highlighter === true
) {
lolight("pre code");
}
};

27
assets/js/theme/styles.js Normal file
View File

@@ -0,0 +1,27 @@
export default () => {
document.querySelectorAll(".form-control").forEach($el => {
$el.addEventListener("onfocus", () => {
$el.classList.remove("input-filled-invalid");
$el.classList.add("input-filled-valid");
});
$el.addEventListener("onblur", () => {
if ($el.nodeValue === "") {
$el.classList.remove("input-filled-valid");
$el.classList.remove("input-filled-invalid");
}
});
if ($el.nodeValue) {
$el.classList.add("input-filled-valid");
}
});
document.querySelectorAll(".page-select").forEach($el => {
if ($el.nodeValue) {
const url = new URL(window.location);
url.searchParams.set("page", $el.nodeValue);
window.location.href = url.toString();
}
});
};

12
assets/js/theme/times.js Normal file
View File

@@ -0,0 +1,12 @@
import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
dayjs.extend(advancedFormat);
export default () => {
document.querySelectorAll("[data-time]").forEach($el => {
const time = $el.getAttribute("data-time");
const format = $el.getAttribute("data-time-format") || "MMMM Do, h:mm:ss A";
$el.innerText = dayjs(time).format(format);
});
};

7
assets/js/users/list.js Normal file
View File

@@ -0,0 +1,7 @@
import Alpine from "alpinejs";
import CTFd from "../index";
window.CTFd = CTFd;
window.Alpine = Alpine;
Alpine.start();

View File

@@ -0,0 +1,79 @@
import Alpine from "alpinejs";
import CTFd from "../index";
import { colorHash } from "@ctfdio/ctfd-js/ui";
import { getOption as getUserScoreOption } from "../utils/graphs/echarts/userscore";
import { embed } from "../utils/graphs/echarts";
window.Alpine = Alpine;
Alpine.data("UserGraphs", () => ({
solves: null,
fails: null,
awards: null,
solveCount: 0,
failCount: 0,
awardCount: 0,
getSolvePercentage() {
return ((this.solveCount / (this.solveCount + this.failCount)) * 100).toFixed(2);
},
getFailPercentage() {
return ((this.failCount / (this.solveCount + this.failCount)) * 100).toFixed(2);
},
getCategoryBreakdown() {
const categories = [];
const breakdown = {};
this.solves.data.map(solve => {
categories.push(solve.challenge.category);
});
categories.forEach(category => {
if (category in breakdown) {
breakdown[category] += 1;
} else {
breakdown[category] = 1;
}
});
const data = [];
for (const property in breakdown) {
const percent = Number((breakdown[property] / categories.length) * 100).toFixed(
2
);
data.push({
name: property,
count: breakdown[property],
color: colorHash(property),
percent,
});
}
return data;
},
async init() {
this.solves = await CTFd.pages.users.userSolves("me");
this.fails = await CTFd.pages.users.userFails("me");
this.awards = await CTFd.pages.users.userAwards("me");
this.solveCount = this.solves.meta.count;
this.failCount = this.fails.meta.count;
this.awardCount = this.awards.meta.count;
embed(
this.$refs.scoregraph,
getUserScoreOption(
CTFd.user.id,
CTFd.user.name,
this.solves.data,
this.awards.data
)
);
},
}));
Alpine.start();

79
assets/js/users/public.js Normal file
View File

@@ -0,0 +1,79 @@
import CTFd from "../index";
import Alpine from "alpinejs";
import { colorHash } from "@ctfdio/ctfd-js/ui";
import { getOption as getUserScoreOption } from "../utils/graphs/echarts/userscore";
import { embed } from "../utils/graphs/echarts";
window.Alpine = Alpine;
Alpine.data("UserGraphs", () => ({
solves: null,
fails: null,
awards: null,
solveCount: 0,
failCount: 0,
awardCount: 0,
getSolvePercentage() {
return ((this.solveCount / (this.solveCount + this.failCount)) * 100).toFixed(2);
},
getFailPercentage() {
return ((this.failCount / (this.solveCount + this.failCount)) * 100).toFixed(2);
},
getCategoryBreakdown() {
const categories = [];
const breakdown = {};
this.solves.data.map(solve => {
categories.push(solve.challenge.category);
});
categories.forEach(category => {
if (category in breakdown) {
breakdown[category] += 1;
} else {
breakdown[category] = 1;
}
});
const data = [];
for (const property in breakdown) {
const percent = Number((breakdown[property] / categories.length) * 100).toFixed(
2
);
data.push({
name: property,
count: breakdown[property],
color: colorHash(property),
percent,
});
}
return data;
},
async init() {
this.solves = await CTFd.pages.users.userSolves(window.USER.id);
this.fails = await CTFd.pages.users.userFails(window.USER.id);
this.awards = await CTFd.pages.users.userAwards(window.USER.id);
this.solveCount = this.solves.meta.count;
this.failCount = this.fails.meta.count;
this.awardCount = this.awards.meta.count;
embed(
this.$refs.scoregraph,
getUserScoreOption(
window.USER.id,
window.USER.name,
this.solves.data,
this.awards.data
)
);
},
}));
Alpine.start();

View File

@@ -0,0 +1,8 @@
import { Alert } from "bootstrap";
export default () => {
const alertList = [].slice.call(document.querySelectorAll(".alert"));
alertList.map(function (element) {
return new Alert(element);
});
};

View File

@@ -0,0 +1,15 @@
import { Tooltip } from "bootstrap";
export function copyToClipboard($input) {
const tooltip = new Tooltip($input, {
title: "Copied!",
trigger: "manual",
});
navigator.clipboard.writeText($input.value).then(() => {
tooltip.show();
setTimeout(() => {
tooltip.hide();
}, 1500);
});
}

View File

@@ -0,0 +1,8 @@
import { Collapse } from "bootstrap";
export default () => {
const collapseList = [].slice.call(document.querySelectorAll(".collapse"));
collapseList.map(element => {
return new Collapse(element, { toggle: false });
});
};

View File

@@ -0,0 +1,103 @@
import { colorHash } from "@ctfdio/ctfd-js/ui";
export function getOption(solves) {
let option = {
title: {
left: "center",
text: "Category Breakdown",
},
tooltip: {
trigger: "item",
},
toolbox: {
show: true,
feature: {
saveAsImage: {},
},
},
legend: {
type: "scroll",
orient: "vertical",
top: "middle",
right: 0,
data: [],
},
series: [
{
name: "Category Breakdown",
type: "pie",
radius: ["30%", "50%"],
avoidLabelOverlap: false,
label: {
show: false,
position: "center",
},
itemStyle: {
normal: {
label: {
show: true,
formatter: function (data) {
return `${data.percent}% (${data.value})`;
},
},
labelLine: {
show: true,
},
},
emphasis: {
label: {
show: true,
position: "center",
textStyle: {
fontSize: "14",
fontWeight: "normal",
},
},
},
},
emphasis: {
label: {
show: true,
fontSize: "30",
fontWeight: "bold",
},
},
labelLine: {
show: false,
},
data: [],
},
],
};
const categories = [];
for (let i = 0; i < solves.length; i++) {
categories.push(solves[i].challenge.category);
}
const keys = categories.filter((elem, pos) => {
return categories.indexOf(elem) == pos;
});
const counts = [];
for (let i = 0; i < keys.length; i++) {
let count = 0;
for (let x = 0; x < categories.length; x++) {
if (categories[x] == keys[i]) {
count++;
}
}
counts.push(count);
}
keys.forEach((category, index) => {
option.legend.data.push(category);
option.series[0].data.push({
value: counts[index],
name: category,
itemStyle: { color: colorHash(category) },
});
});
return option;
}

View File

@@ -0,0 +1,44 @@
import * as echarts from "echarts/core";
import { LineChart } from "echarts/charts";
import {
TitleComponent,
TooltipComponent,
GridComponent,
DatasetComponent,
TransformComponent,
LegendComponent,
ToolboxComponent,
DataZoomComponent,
} from "echarts/components";
// Features like Universal Transition and Label Layout
import { LabelLayout, UniversalTransition } from "echarts/features";
// Import the Canvas renderer
// Note that introducing the CanvasRenderer or SVGRenderer is a required step
import { CanvasRenderer } from "echarts/renderers";
// Register the required components
echarts.use([
LineChart,
TitleComponent,
TooltipComponent,
GridComponent,
DatasetComponent,
TransformComponent,
LegendComponent,
ToolboxComponent,
DataZoomComponent,
LabelLayout,
UniversalTransition,
CanvasRenderer,
]);
export function embed(target, option) {
let chart = echarts.init(target);
chart.setOption(option);
window.addEventListener("resize", () => {
if (chart) {
chart.resize();
}
});
}

View File

@@ -0,0 +1,106 @@
import { colorHash } from "@ctfdio/ctfd-js/ui";
import dayjs from "dayjs";
export function cumulativeSum(arr) {
let result = arr.concat();
for (let i = 0; i < arr.length; i++) {
result[i] = arr.slice(0, i + 1).reduce(function (p, i) {
return p + i;
});
}
return result;
}
export function getOption(mode, places) {
let option = {
title: {
left: "center",
text: "Top 10 " + (mode === "teams" ? "Teams" : "Users"),
},
tooltip: {
trigger: "axis",
axisPointer: {
type: "cross",
},
},
legend: {
type: "scroll",
orient: "horizontal",
align: "left",
bottom: 35,
data: [],
},
toolbox: {
feature: {
dataZoom: {
yAxisIndex: "none",
},
saveAsImage: {},
},
},
grid: {
containLabel: true,
},
xAxis: [
{
type: "time",
boundaryGap: false,
data: [],
},
],
yAxis: [
{
type: "value",
},
],
dataZoom: [
{
id: "dataZoomX",
type: "slider",
xAxisIndex: [0],
filterMode: "filter",
height: 20,
top: 35,
fillerColor: "rgba(233, 236, 241, 0.4)",
},
],
series: [],
};
const teams = Object.keys(places);
for (let i = 0; i < teams.length; i++) {
const team_score = [];
const times = [];
for (let j = 0; j < places[teams[i]]["solves"].length; j++) {
team_score.push(places[teams[i]]["solves"][j].value);
const date = dayjs(places[teams[i]]["solves"][j].date);
times.push(date.toDate());
}
const total_scores = cumulativeSum(team_score);
let scores = times.map(function (e, i) {
return [e, total_scores[i]];
});
option.legend.data.push(places[teams[i]]["name"]);
const data = {
name: places[teams[i]]["name"],
type: "line",
label: {
normal: {
position: "top",
},
},
itemStyle: {
normal: {
color: colorHash(places[teams[i]]["name"] + places[teams[i]]["id"]),
},
},
data: scores,
};
option.series.push(data);
}
return option;
}

View File

@@ -0,0 +1,81 @@
export function getOption(solves, fails) {
let option = {
title: {
left: "center",
text: "Solve Percentages",
},
tooltip: {
trigger: "item",
},
toolbox: {
show: true,
feature: {
saveAsImage: {},
},
},
legend: {
orient: "vertical",
top: "middle",
right: 0,
data: ["Fails", "Solves"],
},
series: [
{
name: "Solve Percentages",
type: "pie",
radius: ["30%", "50%"],
avoidLabelOverlap: false,
label: {
show: false,
position: "center",
},
itemStyle: {
normal: {
label: {
show: true,
formatter: function (data) {
return `${data.name} - ${data.value} (${data.percent}%)`;
},
},
labelLine: {
show: true,
},
},
emphasis: {
label: {
show: true,
position: "center",
textStyle: {
fontSize: "14",
fontWeight: "normal",
},
},
},
},
emphasis: {
label: {
show: true,
fontSize: "30",
fontWeight: "bold",
},
},
labelLine: {
show: false,
},
data: [
{
value: fails,
name: "Fails",
itemStyle: { color: "rgb(207, 38, 0)" },
},
{
value: solves,
name: "Solves",
itemStyle: { color: "rgb(0, 209, 64)" },
},
],
},
],
};
return option;
}

View File

@@ -0,0 +1,102 @@
import { colorHash } from "@ctfdio/ctfd-js/ui";
import { cumulativeSum } from "./scoreboard";
import dayjs from "dayjs";
export function getOption(id, name, solves, awards) {
let option = {
title: {
left: "center",
text: "Score over Time",
},
tooltip: {
trigger: "axis",
axisPointer: {
type: "cross",
},
},
legend: {
type: "scroll",
orient: "horizontal",
align: "left",
bottom: 0,
data: [name],
},
toolbox: {
feature: {
saveAsImage: {},
},
},
grid: {
containLabel: true,
},
xAxis: [
{
type: "category",
boundaryGap: false,
data: [],
},
],
yAxis: [
{
type: "value",
},
],
dataZoom: [
{
id: "dataZoomX",
type: "slider",
xAxisIndex: [0],
filterMode: "filter",
height: 20,
top: 35,
fillerColor: "rgba(233, 236, 241, 0.4)",
},
],
series: [],
};
const times = [];
const scores = [];
const total = solves.concat(awards);
total.sort((a, b) => {
return new Date(a.date) - new Date(b.date);
});
for (let i = 0; i < total.length; i++) {
const date = dayjs(total[i].date);
times.push(date.toDate());
try {
scores.push(total[i].challenge.value);
} catch (e) {
scores.push(total[i].value);
}
}
times.forEach(time => {
option.xAxis[0].data.push(time);
});
option.series.push({
name: name,
type: "line",
label: {
normal: {
show: true,
position: "top",
},
},
areaStyle: {
normal: {
color: colorHash(name + id),
},
},
itemStyle: {
normal: {
color: colorHash(name + id),
},
},
data: cumulativeSum(scores),
});
return option;
}

View File

@@ -0,0 +1,105 @@
export function getSpec(description, values) {
return {
$schema: "https://vega.github.io/schema/vega-lite/v5.json",
description: description,
data: {
values: values,
},
width: "container",
layer: [
{
params: [
{
name: "category",
select: {
type: "point",
fields: ["category"],
},
bind: "legend",
},
],
mark: {
type: "arc",
innerRadius: 50,
outerRadius: 95,
stroke: "#fff",
},
encoding: {
opacity: {
condition: {
param: "category",
value: 1,
},
value: 0.2,
},
},
},
{
mark: {
type: "text",
radius: 105,
},
encoding: {
text: {
field: "value",
type: "quantitative",
},
},
},
],
encoding: {
theta: {
field: "value",
type: "quantitative",
stack: true,
},
color: {
field: "category",
type: "nominal",
// scale: {
// domain: ["Solves", "Fails"],
// range: ["#00d13f", "#cf2600"],
// },
legend: {
orient: "bottom",
},
},
},
};
}
export function getValues(solves) {
const solvesData = solves.data;
const categories = [];
for (let i = 0; i < solvesData.length; i++) {
categories.push(solvesData[i].challenge.category);
}
const keys = categories.filter((elem, pos) => {
return categories.indexOf(elem) == pos;
});
const counts = [];
for (let i = 0; i < keys.length; i++) {
let count = 0;
for (let x = 0; x < categories.length; x++) {
if (categories[x] == keys[i]) {
count++;
}
}
counts.push(count);
}
let values = [];
keys.forEach((category, index) => {
values.push({
category: category,
value: counts[index],
});
});
return values;
}

View File

@@ -0,0 +1,53 @@
import { cumulativeSum } from "../math";
export function getSpec(description, values) {
let spec = {
$schema: "https://vega.github.io/schema/vega-lite/v5.json",
description: description,
data: { values: values },
mark: "line",
width: "container",
encoding: {
x: { field: "date", type: "temporal" },
y: { field: "score", type: "quantitative" },
color: {
field: "name",
type: "nominal",
legend: {
orient: "bottom",
},
},
},
};
return spec;
}
export function getValues(scoreboardDetail) {
const teams = Object.keys(scoreboardDetail);
let values = [];
for (let i = 0; i < teams.length; i++) {
const team = scoreboardDetail[teams[i]];
const team_score = [];
const times = [];
for (let j = 0; j < team["solves"].length; j++) {
team_score.push(team["solves"][j].value);
times.push(team["solves"][j].date);
// const date = dayjs(team["solves"][j].date);
// times.push(date.toDate());
}
const total_scores = cumulativeSum(team_score);
const team_name = team["name"];
let scores = times.map(function (e, i) {
return {
name: team_name,
score: total_scores[i],
date: e,
};
});
values = values.concat(scores);
}
return values;
}

View File

@@ -0,0 +1,83 @@
export function getSpec(description, values) {
return {
$schema: "https://vega.github.io/schema/vega-lite/v5.json",
description: description,
data: {
values: values,
},
width: "container",
layer: [
{
params: [
{
name: "category",
select: {
type: "point",
fields: ["category"],
},
bind: "legend",
},
],
mark: {
type: "arc",
innerRadius: 50,
outerRadius: 95,
stroke: "#fff",
},
encoding: {
opacity: {
condition: {
param: "category",
value: 1,
},
value: 0.2,
},
},
},
{
mark: {
type: "text",
radius: 105,
},
encoding: {
text: {
field: "value",
type: "quantitative",
},
},
},
],
encoding: {
theta: {
field: "value",
type: "quantitative",
stack: true,
},
color: {
field: "category",
type: "nominal",
scale: {
domain: ["Solves", "Fails"],
range: ["#00d13f", "#cf2600"],
},
legend: {
orient: "bottom",
},
},
},
};
}
export function getValues(solves, fails) {
return [
{
category: "Solves",
value: solves.meta.count,
},
{
category: "Fails",
value: fails.meta.count,
},
];
}

View File

@@ -0,0 +1,60 @@
import { cumulativeSum } from "../math";
export function getSpec(description, values) {
return {
$schema: "https://vega.github.io/schema/vega-lite/v5.json",
description: description,
data: {
values: values,
},
width: "container",
mark: {
type: "area",
line: true,
point: true,
// interpolate: "step-after",
tooltip: { content: "data", nearest: true },
},
encoding: {
x: { field: "time", type: "temporal" },
y: { field: "score", type: "quantitative" },
},
};
}
export function getValues(solves, awards) {
const times = [];
let scores = [];
const solvesData2 = solves.data;
const awardsData = awards.data;
const total = solvesData2.concat(awardsData);
total.sort((a, b) => {
return new Date(a.date) - new Date(b.date);
});
for (let i = 0; i < total.length; i++) {
// const date = dayjs(total[i].date);
// times.push(date.toDate());
const date = total[i].date;
times.push(date);
try {
scores.push(total[i].challenge.value);
} catch (e) {
scores.push(total[i].value);
}
}
scores = cumulativeSum(scores);
let values = [];
times.forEach((time, index) => {
// option.xAxis[0].data.push(time);
values.push({
time: time,
score: scores[index],
});
});
return values;
}

9
assets/js/utils/math.js Normal file
View File

@@ -0,0 +1,9 @@
export function cumulativeSum(arr) {
let result = arr.concat();
for (let i = 0; i < arr.length; i++) {
result[i] = arr.slice(0, i + 1).reduce(function (p, i) {
return p + i;
});
}
return result;
}

View File

@@ -0,0 +1,23 @@
import Alpine from "alpinejs";
import { Modal } from "bootstrap";
import CTFd from "../../index";
export default () => {
Alpine.store("modal", { title: "", html: "" });
CTFd._functions.events.eventAlert = data => {
Alpine.store("modal", data);
let modal = new Modal(document.querySelector("[x-ref='modal']"));
// TODO: Get rid of this private attribute access
// See https://github.com/twbs/bootstrap/issues/31266
modal._element.addEventListener(
"hidden.bs.modal",
event => {
CTFd._functions.events.eventRead(data.id);
},
{ once: true }
);
modal.show();
};
};

View File

@@ -0,0 +1,19 @@
import Alpine from "alpinejs";
import CTFd from "../../index";
export default () => {
CTFd._functions.events.eventCount = count => {
Alpine.store("unread_count", count);
};
CTFd._functions.events.eventRead = eventId => {
CTFd.events.counter.read.add(eventId);
let count = CTFd.events.counter.unread.getAll().length;
CTFd.events.controller.broadcast("counter", { count: count });
Alpine.store("unread_count", count);
};
document.addEventListener("alpine:init", () => {
CTFd._functions.events.eventCount(CTFd.events.counter.unread.getAll().length);
});
};

View File

@@ -0,0 +1,28 @@
import Alpine from "alpinejs";
import { Toast } from "bootstrap";
import CTFd from "../../index";
export default () => {
Alpine.store("toast", { title: "", html: "" });
CTFd._functions.events.eventToast = data => {
Alpine.store("toast", data);
let toast = new Toast(document.querySelector("[x-ref='toast']"));
// TODO: Get rid of this private attribute access
// See https://github.com/twbs/bootstrap/issues/31266
let close = toast._element.querySelector("[data-bs-dismiss='toast']");
let handler = event => {
CTFd._functions.events.eventRead(data.id);
};
close.addEventListener("click", handler, { once: true });
toast._element.addEventListener(
"hidden.bs.toast",
event => {
close.removeEventListener("click", handler);
},
{ once: true }
);
toast.show();
};
};

View File

@@ -0,0 +1,10 @@
import { Tooltip } from "bootstrap";
export default () => {
const tooltipList = [].slice.call(
document.querySelectorAll('[data-bs-toggle="tooltip"]')
);
tooltipList.map(element => {
return new Tooltip(element);
});
};

View File

@@ -0,0 +1,20 @@
/* Challenge styles
-------------------------------------------------- */
.challenge-desc {
overflow-wrap: anywhere;
img {
max-width: 100%;
}
}
.challenge-button {
box-shadow: 3px 3px 3px var(--bs-secondary);
}
.challenge-solved {
background-color: #37d63e !important;
opacity: 0.6;
border: none;
}

View File

@@ -0,0 +1,23 @@
/* Graph styles
-------------------------------------------------- */
#score-graph {
min-height: 400px;
display: block;
clear: both;
}
#solves-graph {
display: block;
height: 350px;
}
#keys-pie-graph {
min-height: 400px;
display: block;
}
#categories-pie-graph {
min-height: 400px;
display: block;
}

View File

@@ -0,0 +1,29 @@
/* Jumbotron styles
-------------------------------------------------- */
// Move down content because we have a fixed navbar that is 3.5rem tall
main {
padding-top: 3.5rem;
}
.jumbotron {
padding: 2rem 1rem;
margin-bottom: 2rem;
text-align: center;
background-color: var(--bs-dark);
color: var(--bs-white);
}
@media (min-width: 576px) {
.jumbotron {
padding: 4rem 2rem;
}
}
// @media (min-width: 768px) {
// .jumbotron {
// // I kind of like this, but it could be discussed
// clip-path: polygon(0 0, 100% 0, 100% 90%, 0% 100%);
// }
// }

View File

@@ -0,0 +1,31 @@
/* Sticky footer styles
-------------------------------------------------- */
html {
position: relative;
min-height: 100%;
}
main {
/* Margin bottom by footer height */
margin-bottom: 100px;
}
.footer {
position: absolute;
/* prevent scrollbars from showing on pages that don't use the full page height */
bottom: 1px;
width: 100%;
/* Set the fixed height of the footer here */
height: 60px;
/* Vertically center the text there */
line-height: 60px;
/* Avoid covering things */
z-index: -20;
/*background-color: #f5f5f5;*/
}

View File

@@ -0,0 +1,33 @@
/* Table styles
-------------------------------------------------- */
// Center text in tables, after the first cell
// Override with text-center on cells
.table tr > td:not(:first-of-type) {
text-align: center !important;
}
// Vertically center text in cells
.table td,
th {
vertical-align: middle !important;
}
// Add some default spacing
.table > tbody > tr > td {
padding: 0.8rem 1rem !important;
}
// Remove border line from thead of all tables
.table > tbody {
border-top: none !important;
}
// Add some padding from the edge of table
.table > thead > tr > td:first-of-type {
padding-left: 1rem !important;
}
.table > thead > th {
white-space: nowrap;
}

View File

@@ -0,0 +1,42 @@
.award-icon {
font-family: "Font Awesome 6 Free";
font-weight: 900;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
display: inline-block;
font-style: normal;
font-variant: normal;
text-rendering: auto;
line-height: 1;
}
.award-shield:before {
content: "\f3ed";
}
.award-bug:before {
content: "\f188";
}
.award-crown:before {
content: "\f521";
}
.award-crosshairs:before {
content: "\f05b";
}
.award-ban:before {
content: "\f05e";
}
.award-brain:before {
content: "\f5dc";
}
.award-lightning:before {
content: "\f0e7";
}
.award-code:before {
content: "\f121";
}
.award-cowboy:before {
content: "\f8c0";
}
.award-angry:before {
content: "\f556";
}

View File

@@ -0,0 +1,511 @@
$flags: (
ad: "🇦🇩",
// Andorra
ae: "🇦🇪",
// United Arab Emirates
af: "🇦🇫",
// Afghanistan
ag: "🇦🇬",
// Antigua and Barbuda
ai: "🇦🇮",
// Anguilla
al: "🇦🇱",
// Albania
am: "🇦🇲",
// Armenia
ao: "🇦🇴",
// Angola
aq: "🇦🇶",
// Antarctica
ar: "🇦🇷",
// Argentina
as: "🇦🇸",
// American Samoa
at: "🇦🇹",
// Austria
au: "🇦🇺",
// Australia
aw: "🇦🇼",
// Aruba
ax: "🇦🇽",
// Åland Islands
az: "🇦🇿",
// Azerbaijan
ba: "🇧🇦",
// Bosnia and Herzegovina
bb: "🇧🇧",
// Barbados
bd: "🇧🇩",
// Bangladesh
be: "🇧🇪",
// Belgium
bf: "🇧🇫",
// Burkina Faso
bg: "🇧🇬",
// Bulgaria
bh: "🇧🇭",
// Bahrain
bi: "🇧🇮",
// Burundi
bj: "🇧🇯",
// Benin
bl: "🇧🇱",
// Saint Barthélemy
bm: "🇧🇲",
// Bermuda
bn: "🇧🇳",
// Brunei Darussalam
bo: "🇧🇴",
// Bolivia
bq: "🇧🇶",
// Bonaire, Sint Eustatius and Saba
br: "🇧🇷",
// Brazil
bs: "🇧🇸",
// Bahamas
bt: "🇧🇹",
// Bhutan
bv: "🇧🇻",
// Bouvet Island
bw: "🇧🇼",
// Botswana
by: "🇧🇾",
// Belarus
bz: "🇧🇿",
// Belize
ca: "🇨🇦",
// Canada
cc: "🇨🇨",
// Cocos (Keeling) Islands
cd: "🇨🇩",
// Congo
cf: "🇨🇫",
// Central African Republic
cg: "🇨🇬",
// Congo
ch: "🇨🇭",
// Switzerland
ci: "🇨🇮",
// Côte D'Ivoire
ck: "🇨🇰",
// Cook Islands
cl: "🇨🇱",
// Chile
cm: "🇨🇲",
// Cameroon
cn: "🇨🇳",
// China
co: "🇨🇴",
// Colombia
cr: "🇨🇷",
// Costa Rica
cu: "🇨🇺",
// Cuba
cv: "🇨🇻",
// Cape Verde
cw: "🇨🇼",
// Curaçao
cx: "🇨🇽",
// Christmas Island
cy: "🇨🇾",
// Cyprus
cz: "🇨🇿",
// Czech Republic
de: "🇩🇪",
// Germany
dj: "🇩🇯",
// Djibouti
dk: "🇩🇰",
// Denmark
dm: "🇩🇲",
// Dominica
do: "🇩🇴",
// Dominican Republic
dz: "🇩🇿",
// Algeria
ec: "🇪🇨",
// Ecuador
ee: "🇪🇪",
// Estonia
eg: "🇪🇬",
// Egypt
eh: "🇪🇭",
// Western Sahara
er: "🇪🇷",
// Eritrea
es: "🇪🇸",
// Spain
et: "🇪🇹",
// Ethiopia
fi: "🇫🇮",
// Finland
fj: "🇫🇯",
// Fiji
fk: "🇫🇰",
// Falkland Islands (Malvinas)
fm: "🇫🇲",
// Micronesia
fo: "🇫🇴",
// Faroe Islands
fr: "🇫🇷",
// France
ga: "🇬🇦",
// Gabon
gb: "🇬🇧",
// United Kingdom
gd: "🇬🇩",
// Grenada
ge: "🇬🇪",
// Georgia
gf: "🇬🇫",
// French Guiana
gg: "🇬🇬",
// Guernsey
gh: "🇬🇭",
// Ghana
gi: "🇬🇮",
// Gibraltar
gl: "🇬🇱",
// Greenland
gm: "🇬🇲",
// Gambia
gn: "🇬🇳",
// Guinea
gp: "🇬🇵",
// Guadeloupe
gq: "🇬🇶",
// Equatorial Guinea
gr: "🇬🇷",
// Greece
gs: "🇬🇸",
// South Georgia
gt: "🇬🇹",
// Guatemala
gu: "🇬🇺",
// Guam
gw: "🇬🇼",
// Guinea-Bissau
gy: "🇬🇾",
// Guyana
hk: "🇭🇰",
// Hong Kong
hm: "🇭🇲",
// Heard Island and Mcdonald Islands
hn: "🇭🇳",
// Honduras
hr: "🇭🇷",
// Croatia
ht: "🇭🇹",
// Haiti
hu: "🇭🇺",
// Hungary
id: "🇮🇩",
// Indonesia
ie: "🇮🇪",
// Ireland
il: "🇮🇱",
// Israel
im: "🇮🇲",
// Isle of Man
in: "🇮🇳",
// India
io: "🇮🇴",
// British Indian Ocean Territory
iq: "🇮🇶",
// Iraq
ir: "🇮🇷",
// Iran
is: "🇮🇸",
// Iceland
it: "🇮🇹",
// Italy
je: "🇯🇪",
// Jersey
jm: "🇯🇲",
// Jamaica
jo: "🇯🇴",
// Jordan
jp: "🇯🇵",
// Japan
ke: "🇰🇪",
// Kenya
kg: "🇰🇬",
// Kyrgyzstan
kh: "🇰🇭",
// Cambodia
ki: "🇰🇮",
// Kiribati
km: "🇰🇲",
// Comoros
kn: "🇰🇳",
// Saint Kitts and Nevis
kp: "🇰🇵",
// North Korea
kr: "🇰🇷",
// South Korea
kw: "🇰🇼",
// Kuwait
ky: "🇰🇾",
// Cayman Islands
kz: "🇰🇿",
// Kazakhstan
la: "🇱🇦",
// Lao People's Democratic Republic
lb: "🇱🇧",
// Lebanon
lc: "🇱🇨",
// Saint Lucia
li: "🇱🇮",
// Liechtenstein
lk: "🇱🇰",
// Sri Lanka
lr: "🇱🇷",
// Liberia
ls: "🇱🇸",
// Lesotho
lt: "🇱🇹",
// Lithuania
lu: "🇱🇺",
// Luxembourg
lv: "🇱🇻",
// Latvia
ly: "🇱🇾",
// Libya
ma: "🇲🇦",
// Morocco
mc: "🇲🇨",
// Monaco
md: "🇲🇩",
// Moldova
me: "🇲🇪",
// Montenegro
mf: "🇲🇫",
// Saint Martin (French Part)
mg: "🇲🇬",
// Madagascar
mh: "🇲🇭",
// Marshall Islands
mk: "🇲🇰",
// Macedonia
ml: "🇲🇱",
// Mali
mm: "🇲🇲",
// Myanmar
mn: "🇲🇳",
// Mongolia
mo: "🇲🇴",
// Macao
mp: "🇲🇵",
// Northern Mariana Islands
mq: "🇲🇶",
// Martinique
mr: "🇲🇷",
// Mauritania
ms: "🇲🇸",
// Montserrat
mt: "🇲🇹",
// Malta
mu: "🇲🇺",
// Mauritius
mv: "🇲🇻",
// Maldives
mw: "🇲🇼",
// Malawi
mx: "🇲🇽",
// Mexico
my: "🇲🇾",
// Malaysia
mz: "🇲🇿",
// Mozambique
na: "🇳🇦",
// Namibia
nc: "🇳🇨",
// New Caledonia
ne: "🇳🇪",
// Niger
nf: "🇳🇫",
// Norfolk Island
ng: "🇳🇬",
// Nigeria
ni: "🇳🇮",
// Nicaragua
nl: "🇳🇱",
// Netherlands
no: "🇳🇴",
// Norway
np: "🇳🇵",
// Nepal
nr: "🇳🇷",
// Nauru
nu: "🇳🇺",
// Niue
nz: "🇳🇿",
// New Zealand
om: "🇴🇲",
// Oman
pa: "🇵🇦",
// Panama
pe: "🇵🇪",
// Peru
pf: "🇵🇫",
// French Polynesia
pg: "🇵🇬",
// Papua New Guinea
ph: "🇵🇭",
// Philippines
pk: "🇵🇰",
// Pakistan
pl: "🇵🇱",
// Poland
pm: "🇵🇲",
// Saint Pierre and Miquelon
pn: "🇵🇳",
// Pitcairn
pr: "🇵🇷",
// Puerto Rico
ps: "🇵🇸",
// Palestinian Territory
pt: "🇵🇹",
// Portugal
pw: "🇵🇼",
// Palau
py: "🇵🇾",
// Paraguay
qa: "🇶🇦",
// Qatar
re: "🇷🇪",
// Réunion
ro: "🇷🇴",
// Romania
rs: "🇷🇸",
// Serbia
ru: "🇷🇺",
// Russia
rw: "🇷🇼",
// Rwanda
sa: "🇸🇦",
// Saudi Arabia
sb: "🇸🇧",
// Solomon Islands
sc: "🇸🇨",
// Seychelles
sd: "🇸🇩",
// Sudan
se: "🇸🇪",
// Sweden
sg: "🇸🇬",
// Singapore
sh: "🇸🇭",
// Saint Helena, Ascension and Tristan Da Cunha
si: "🇸🇮",
// Slovenia
sj: "🇸🇯",
// Svalbard and Jan Mayen
sk: "🇸🇰",
// Slovakia
sl: "🇸🇱",
// Sierra Leone
sm: "🇸🇲",
// San Marino
sn: "🇸🇳",
// Senegal
so: "🇸🇴",
// Somalia
sr: "🇸🇷",
// Suriname
ss: "🇸🇸",
// South Sudan
st: "🇸🇹",
// Sao Tome and Principe
sv: "🇸🇻",
// El Salvador
sx: "🇸🇽",
// Sint Maarten (Dutch Part)
sy: "🇸🇾",
// Syrian Arab Republic
sz: "🇸🇿",
// Swaziland
tc: "🇹🇨",
// Turks and Caicos Islands
td: "🇹🇩",
// Chad
tf: "🇹🇫",
// French Southern Territories
tg: "🇹🇬",
// Togo
th: "🇹🇭",
// Thailand
tj: "🇹🇯",
// Tajikistan
tk: "🇹🇰",
// Tokelau
tl: "🇹🇱",
// Timor-Leste
tm: "🇹🇲",
// Turkmenistan
tn: "🇹🇳",
// Tunisia
to: "🇹🇴",
// Tonga
tr: "🇹🇷",
// Turkey
tt: "🇹🇹",
// Trinidad and Tobago
tv: "🇹🇻",
// Tuvalu
tw: "🇹🇼",
// Taiwan
tz: "🇹🇿",
// Tanzania
ua: "🇺🇦",
// Ukraine
ug: "🇺🇬",
// Uganda
um: "🇺🇲",
// United States Minor Outlying Islands
us: "🇺🇸",
// United States
uy: "🇺🇾",
// Uruguay
uz: "🇺🇿",
// Uzbekistan
va: "🇻🇦",
// Vatican City
vc: "🇻🇨",
// Saint Vincent and The Grenadines
ve: "🇻🇪",
// Venezuela
vg: "🇻🇬",
// Virgin Islands, British
vi: "🇻🇮",
// Virgin Islands, U.S.
vn: "🇻🇳",
// Viet Nam
vu: "🇻🇺",
// Vanuatu
wf: "🇼🇫",
// Wallis and Futuna
ws: "🇼🇸",
// Samoa
ye: "🇾🇪",
// Yemen
yt: "🇾🇹",
// Mayotte
za: "🇿🇦",
// South Africa
zm: "🇿🇲",
// Zambia
zw: "🇿🇼",
// Zimbabwe
);
[class^="flag-"] {
font-style: normal;
}
// generate classes
@each $name, $icon in $flags {
.flag-#{$name}:before {
content: $icon;
}
}

View File

@@ -0,0 +1,7 @@
.cursor-pointer {
cursor: pointer;
}
.cursor-help {
cursor: help;
}

View File

@@ -0,0 +1,34 @@
/* Handle offline font loading */
// $fa-font-path: "../../../node_modules/@fortawesome/fontawesome-free/webfonts" !default;
// $fa-font-display: auto !default;
// @import "../../../node_modules/@fortawesome/fontawesome-free/scss/fontawesome.scss";
// @import "../../../node_modules/@fortawesome/fontawesome-free/scss/regular.scss";
// @import "../../../node_modules/@fortawesome/fontawesome-free/scss/solid.scss";
// @import "../../../node_modules/@fortawesome/fontawesome-free/scss/brands.scss";
@use "~/@fontsource/lato/scss/mixins" as Lato;
@use "~/@fontsource/raleway/scss/mixins" as Raleway;
// Include both normal and bold weights
@include Lato.fontFace($fontDir: "../webfonts", $weight: 400);
@include Lato.fontFace($fontDir: "../webfonts", $weight: 700);
@include Raleway.fontFace($fontDir: "../webfonts");
$fa-font-path: "../webfonts";
@import "~/@fortawesome/fontawesome-free/scss/fontawesome.scss";
@import "~/@fortawesome/fontawesome-free/scss/solid.scss";
@import "~/@fortawesome/fontawesome-free/scss/brands.scss";
// @import "~/@fontsource/lato/index.css";
// @import "~/@fontsource/lato/index.css";
html,
body,
.container {
font-family: "Lato", sans-serif;
}
.jumbotron .container {
font-family: "Raleway", sans-serif;
}

View File

@@ -0,0 +1,30 @@
.ll-nam {
/* words */
color: #24292f;
}
.ll-num {
/* numbers */
color: #005cc5;
}
.ll-str {
/* strings */
color: #0a3069;
}
.ll-rex {
/* regular expressions */
color: #032f62;
}
.ll-pct {
/* operators, punctation */
color: #0550ae;
}
.ll-key {
/* keywords */
color: #d73a49;
font-weight: normal;
}
.ll-com {
/* comments */
color: #6a737d;
font-style: italic;
}

View File

@@ -0,0 +1,15 @@
.min-vh-0 {
min-height: 0vh !important;
}
.min-vh-25 {
min-height: 25vh !important;
}
.min-vh-50 {
min-height: 50vh !important;
}
.min-vh-75 {
min-height: 75vh !important;
}
.min-vh-100 {
min-height: 100vh !important;
}

View File

@@ -0,0 +1,15 @@
.opacity-0 {
opacity: 0 !important;
}
.opacity-25 {
opacity: 0.25 !important;
}
.opacity-50 {
opacity: 0.5 !important;
}
.opacity-75 {
opacity: 0.75 !important;
}
.opacity-100 {
opacity: 1 !important;
}

85
assets/scss/main.scss Normal file
View File

@@ -0,0 +1,85 @@
@use "bootstrap/scss/bootstrap" as * with (
$info: #5c728f
);
@use "includes/components/table";
@use "includes/components/jumbotron";
@use "includes/components/challenge";
@use "includes/components/sticky-footer";
@use "includes/components/graphs";
@use "includes/utils/fonts";
@use "includes/utils/opacity";
@use "includes/utils/min-height";
@use "includes/utils/cursors";
@use "includes/utils/lolight";
@use "includes/icons/award-icons";
@use "includes/icons/flag-icons";
h1,
h2 {
font-weight: 500;
letter-spacing: 2px;
}
a {
color: #337ab7;
text-decoration: none !important;
}
blockquote {
border-left: 4px solid $secondary;
padding-left: 15px;
}
.fa-spin.spinner {
text-align: center;
opacity: 0.5;
}
.badge-notification {
vertical-align: top;
margin-left: -1.5em;
font-size: 50%;
}
select.form-control {
height: auto !important;
}
.form-select {
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e")
no-repeat right 0.75rem center/8px 10px !important;
}
// HMM
.input-filled-valid {
background-color: transparent !important;
border-color: #a3d39c;
box-shadow: 0 0 0 0.1rem #a3d39c;
transition: background-color 0.3s, border-color 0.3s;
}
.input-filled-invalid {
background-color: transparent !important;
border-color: #d46767;
box-shadow: 0 0 0 0.1rem #d46767;
transition: background-color 0.3s, border-color 0.3s;
}
.form-control {
padding: 0.8em !important;
background: #f0f0f0;
color: #aaa;
/* Behavior changed in Bootstrap v4.1.3. See https://github.com/twbs/bootstrap/issues/27629 */
height: auto !important;
}
.form-control:focus {
background-color: transparent;
border-color: #a3d39c;
box-shadow: 0 0 0 0.1rem #a3d39c;
transition: background-color 0.3s, border-color 0.3s;
}