// Firebase
import {
createUserWithEmailAndPassword,
onAuthStateChanged,
signOut,
} from "firebase/auth";
import { auth, db } from "./firebase.js";
import { doc, getDoc, setDoc } from "firebase/firestore";
// DOM libs
import "../css/tailwind.css";
import $ from "jquery";
// Code Editor stuff
import { basicSetup } from "codemirror";
import { javascript } from "@codemirror/lang-javascript";
import { basicDark } from "@fsegurai/codemirror-theme-bundle";
import { indentWithTab } from "@codemirror/commands";
import { keymap, EditorView } from "@codemirror/view";
import { Prec } from "@codemirror/state";
import { initMobileMenu } from "./mobile-menu.js";
//for mobile view
initMobileMenu();
// Load JSON data
const courses = (await (await fetch("/data/courses.json")).json()) || {};
/**
* Creates a new user object
*
* @param {string} uid - User's unique id
* @returns {User} The new User object
*
* @example
* const user = new User("nouser")
* const user = new User("ejnf24h24ufn2")
*/
export class User {
constructor(uid) {
this.uid = uid;
}
/**
* Get the users public data from firebase
*
* @returns {object} The data returned in the form of a dict
*
* @example
* const data = user.get() // returns {} if user.uid == "nouser" or data not found
* const data = user.get() // returns data if found
*/
async get() {
if (this.uid == "nouser") {
return {};
}
const r = await getDoc(doc(db, `public/${this.uid}`));
if (r.exists()) {
return r.data();
} else {
return {};
}
}
/**
* Check if a user is an admin or not
*
* @returns {boolean} True if admin, false if not
*
* @example
* const admin = user.admin() // true or false
*/
async admin() {
if (this.uid == "nouser") {
return false;
}
const r = await getDoc(doc(db, `adminOnly/${this.uid}`));
if (r.exists()) {
return r.data().admin;
}
return false;
}
/**
* Add data to users public directory
*
* @param {object} data - Data to add users public dir
*
* @returns {null}
*
* @example
* user.update({"name": "test"}) // adds "name": "test" to public
*/
async update(data) {
if (this.uid == "nouser") {
return;
}
await setDoc(doc(db, `public/${this.uid}`), data, { merge: true });
}
/**
* Creates a new user
*
* @param {string} email - Users email
* @param {string} password - Users password
*
* @returns {null}
*
* @example
* User.create("sample@syntaxforge.dev", "1234") // Creates user
*/
static async create(email, password) {
createUserWithEmailAndPassword(auth, email, password)
.then((userCredential) => {
// Signed up
const user = userCredential.user;
if (user) {
setDoc(doc(db, `public/${user.uid}`), {
uid: user.uid,
// username: username
});
setDoc(doc(db, `private/${user.uid}`), {
email: email,
});
}
// ...
})
.catch((error) => {
const errorMessage = error.message;
console.log(errorMessage);
});
}
}
/**
* Creates a new Lesson object
*
* @param {Section} parent - The parent Section this lesson belongs to
* @param {number} index - The index of this lesson within the section
*
* @returns {Lesson} - Lesson object created
*
* @example
* const lesson = new Lesson(section, 0)
*/
export class Lesson {
constructor(parent, index) {
this.parent = parent;
this.index = index;
}
/**
* Get lesson data from its parent Section
*
* @returns {object} - Lesson data
*
* @example
* lesson.get() // returns the lesson object
*/
get() {
return this.parent.get().lessons[this.index];
}
}
/**
* Creates a new Section object
*
* @param {Course} parent - The parent Course this section belongs to
* @param {number} index - The index of this section within the course
*
* @returns {Section} - Section object created
*
* @example
* const section = new Section(course, 1)
*/
export class Section {
constructor(parent, index) {
this.parent = parent;
this.index = index;
}
/**
* Get all lessons belonging to this section
*
* @returns {Lesson[]} - List of lessons
*
* @example
* section.getLessons() // [Lesson1, Lesson2]
*/
getLessons() {
const l = [];
for (const index in this.get().lessons) {
l.push(new Lesson(this, index));
}
return l;
}
/**
* Get a single lesson by index
*
* @param {number} index - The index of the lesson
*
* @returns {Lesson} - The requested lesson object
*
* @example
* section.getLesson(0) // returns Lesson object
*/
getLesson(index) {
return new Lesson(this, index);
}
/**
* Returns section data from the parent Course
*
* @returns {object} - Section data
*
* @example
* section.get() // returns section data
*/
get() {
return this.parent.get().sections[this.index];
}
}
/**
* Creates a new course object
*
* @param {string} id - Course id (from courses.json)
*
* @returns {Course} - Course object created
*
* @example
* const course = new Course("js101")
*/
export class Course {
constructor(id) {
this.id = id;
}
/**
* Get all sections with this course as a parent
*
* @returns {Section[]} - Sorted list of lessons by id in ascending order
*
* @example
* course.getSections() // [Lesson1, Lesson2]
*/
getSections() {
const data = this.get();
const l = [];
for (const index in data.sections) {
l.push(new Section(this, index));
}
return l;
}
getSection(index) {
return new Section(this, index);
}
/**
* Get all courses in courses.json
*
* @returns {Course[]} - List of courses
*
* @example
* Course.getAll() // [Course1, Course2]
*/
static getAll() {
return Object.keys(courses).map((id) => {
return new Course(id);
});
}
/**
* Returns Course data
*
* @returns {object} - Course data or {}
*
* @example
* const courseData = course.get()
*/
get() {
return courses[this.id] || {};
}
/**
* Displays course info on /courses
*
* @param {string} id - Id of the user to check for where to display courses
*
* @returns {object} - Course data or {}
*
* @example
* Course.display("SOMEUSERID")
* Course.display() // for a user logged out
*/
async display(id = "nouser") {
const userData = (await new User(id).get())["courses"];
const data = this.get();
const link = $("<a/>")
.addClass("card")
.attr("href", `/course/${this.id}`);
const name = $("<h3/>").text(data.name);
const desc = $("<p/>").text(data.desc);
const progress = $("<progress/>").attr("max", 100).val(0);
let num = 0;
let percent = 0;
const l = this.getSections();
let total = 0;
for (const section of l) {
for (const lesson of section.getLessons()) {
if (
userData &&
userData[this.id] &&
userData[this.id][section.index] &&
userData[this.id][section.index][lesson.index]
) {
const lessonData =
userData[this.id][section.index][lesson.index];
if (lessonData.finished) {
num += 1;
}
}
total += 1;
}
}
percent = Math.round((num / total) * 100) || 0;
progress.val(percent);
link.append(name, desc, progress);
let on = $("#yours");
if (percent == 0) {
on = $("#avail");
}
$(on).append(link);
}
}
/**
* Creates a new editor object
*
* @param {JQuery.<HTMLElement>} [parent = $("#editor")] - Element to place editor on
* @param {string} [defaultCode = ""] - Element to place editor on
*
* @returns {Editor} - New editor object
*
* @example
* const editor = new Editor()
* const editor = new Editor($("#SOMEELEMENT")) // with parent
* const editor = new Editor($("#SOMEELEMENT"), SOMECODEVAR) // with parent & default code
*/
export class Editor {
constructor(parent = $("#editor"), defaultCode = "") {
this.wrapper = $("<div>").addClass("editor gap-0");
this.col = $("<div/>").addClass(
"col h-full w-full md:w-3/4 gap-0 place-content-start place-items-start"
);
this.buttons = $("<div/>").addClass(
"row w-full bg-forge-surface p-2 place-content-end border-t-4 border-t-forge-accent text-sm"
);
this.buttonsBack = $("<div/>").addClass("row mr-auto");
this.buttons.append(this.buttonsBack);
this.terminal = $("<div/>")
.addClass("terminal order-last md:order-none min-w-1/5")
.text("SyntaxForge Terminal v1.0.0");
this.wrapper.append(this.col, this.terminal);
this.view = new EditorView({
extensions: [
basicSetup,
javascript(),
basicDark,
Prec.highest(
keymap.of([
indentWithTab,
{
key: "Mod-Enter",
run: () => {
console.log("Mod-Enter triggered");
this.runButton.trigger("click");
return true;
},
},
])
),
],
parent: this.col[0],
});
this.fontSize = $("<select/>")
.html(
`
<option>10px</option>
<option>12px</option>
<option>14px</option>
<option selected>16px</option>
<option>18px</option>
<option>20px</option>`
)
.addClass("muted");
const editorSize = localStorage.getItem("editorSize") || "16px";
this.fontSize.val(editorSize);
this.fontSize.on("change", () => {
$(".cm-editor").css("font-size", this.fontSize.val());
localStorage.setItem("editorSize", this.fontSize.val());
});
this.runButton = $("<button/>").text("Run Code");
this.runButton.on("click", () => {
this.terminal.text("");
for (const log of this.safeEval(this.getContent()).logs) {
this.terminal.append($("<span/>").text(log));
}
this.terminal.scrollTop(this.terminal.get(0).scrollHeight);
});
this.addCustomButton(this.runButton);
this.addCustomButton(this.fontSize, true);
this.col.append(this.buttons);
this.setContent(defaultCode);
parent.append(this.wrapper);
$(".cm-editor").css("font-size", editorSize);
$(document).on("keypress", (e) => {
if (e.ctrlKey && e.key == "Enter") {
this.runButton.trigger("click");
}
});
}
/**
* Disables the editors terminal
*
* @example
* editor.disableTerminal() // Terminal gets hidden
*/
disableTerminal() {
this.terminal.addClass("hidden");
}
/**
* Enables the editors terminal
*
* @example
* editor.enableTerminal() // Terminal gets unhidden
*/
enableTerminal() {
this.terminal.removeClass("hidden");
}
verticalMode() {
this.wrapper.addClass("flex flex-col h-full");
this.col.addClass("flex-1 overflow-auto");
this.col.removeClass("max-w-4/5 md:w-3/4").addClass("w-full");
this.terminal.css({
minWidth: "100%",
height: "250px",
flexShrink: 0,
});
}
/**
* Adds an element to the editors buttons
*
* @param {JQuery.<HTMLElement>} e - Element to add
* @param {boolean} [back = false] - Place on the backside or frontside
*
* @example
* editor.addCustomButton($("#SOMEELMT"))
* editor.addCustomButton($("#SOMEELMT"), true) // adds to other side
*/
addCustomButton(e, back = false) {
if (back) {
this.buttonsBack.append(e);
} else {
this.buttons.append(e);
}
}
/**
* Safely evaluate users js on the frontend with a test case added if wanted
*
* @param {string} input - Code to test
* @param {string} [test = null] - Test case or null for none
*
* @example
* editor.saveEval(editor.getContent()) // tests current content
* editor.saveEval(editor.getContent(), ";test == true") // tests current content with test case
*/
safeEval(input, test = null) {
var logs = [];
var console = {
log: function (text) {
logs.push(text);
},
};
var window = function () {};
var document = function () {};
var editor = function () {};
var print = function () {};
const a = function () {
try {
return eval(input + (test || ""));
} catch (error) {
logs.push(error.toString());
}
};
// Return the eval'd result
return { res: a(), logs: logs };
}
/**
* Gets the editors current content
*
* @returns {string} - Editors current content
*
* @example
* editor.getContent() // "Some string"
*/
getContent() {
return this.view.state.doc.toString();
}
/**
* Sets editor content to a piece of code
*
* @param {code} input - Code to set content to
*
* @example
* editor.setContent("// This is some codes")
*/
setContent(code) {
this.view.dispatch({
changes: {
from: 0,
to: this.view.state.doc.length,
insert: code,
},
});
}
}
onAuthStateChanged(auth, (user) => {
if (user) {
// Hide the login button
$("#login").addClass("hidden");
// Create avatar container
const avatarContainer = $("<div/>")
.attr("id", "user-avatar")
.addClass("relative cursor-pointer");
// Function to generate a consistent color from string (like how Google does)
const stringToColor = (str) => {
const colors = [
"#F44336",
"#E91E63",
"#9C27B0",
"#673AB7",
"#3F51B5",
"#2196F3",
"#03A9F4",
"#00BCD4",
"#009688",
"#4CAF50",
"#8BC34A",
"#CDDC39",
"#FF9800",
"#FF5722",
"#795548",
"#607D8B",
];
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
};
// Function to get the first letter
const getInitial = (name, email) => {
if (name) {
return name.charAt(0).toUpperCase();
}
return email ? email.charAt(0).toUpperCase() : "U";
};
// Function to create default avatar
const createDefaultAvatar = (size, fontSize) => {
const initial = getInitial(user.displayName, user.email);
const bgColor = stringToColor(user.email);
return $("<div/>")
.text(initial)
.addClass(
`${size} rounded-full border-2 border-forge-accent text-white font-medium ${fontSize}`
)
.css({
backgroundColor: bgColor,
display: "flex",
alignItems: "center",
justifyContent: "center",
});
};
// Create avatar - either image or default
let avatar;
if (user.photoURL) {
avatar = $("<img/>")
.attr("src", user.photoURL)
.attr("alt", "User Avatar")
.addClass(
"w-12 h-12 rounded-full border-2 border-forge-accent object-cover"
)
.on("error", function () {
$(this).replaceWith(
createDefaultAvatar("w-8 h-8", "text-lg")
);
});
} else {
avatar = createDefaultAvatar("w-8 h-8", "text-lg");
}
// Create popup menu (hidden by default)
const popup = $("<div/>")
.attr("id", "avatar-popup")
.addClass(
"hidden absolute right-0 top-12 bg-forge-surface border-2 border-forge-accent rounded-lg p-4 shadow-lg min-w-[200px] z-100"
);
// Popup avatar
let popupAvatar;
if (user.photoURL) {
popupAvatar = $("<img/>")
.attr("src", user.photoURL)
.attr("alt", "User Avatar")
.addClass("w-16 h-16 rounded-full mx-auto mb-2 object-cover")
.on("error", function () {
$(this).replaceWith(
createDefaultAvatar(
"w-16 h-16 mx-auto mb-2",
"text-3xl"
)
);
});
} else {
popupAvatar = createDefaultAvatar(
"w-16 h-16 mx-auto mb-2",
"text-3xl"
);
}
const userName = $("<p/>")
.text(user.displayName || user.email.split("@")[0])
.addClass("text-center font-semibold mb-2");
const userEmail = $("<p/>")
.text(user.email)
.addClass("text-center text-sm text-forge-subtext mb-4");
const logoutButton = $("<button/>")
.text("Logout")
.addClass(
"w-full bg-forge-accent text-md text-white py-2 rounded-2xl hover:opacity-80"
);
// Logout functionality
logoutButton.on("click", async () => {
try {
await signOut(auth);
window.location.reload();
} catch (error) {
console.error("Error signing out:", error);
}
});
// Assemble popup
popup.append(popupAvatar, userName, userEmail, logoutButton);
// Toggle popup on avatar click
avatarContainer.on("click", (e) => {
e.stopPropagation();
popup.toggleClass("hidden");
});
// Close popup when clicking outside
$(document).on("click", (e) => {
if (!$(e.target).closest("#user-avatar").length) {
popup.addClass("hidden");
}
});
// Assemble avatar container
avatarContainer.append(avatar, popup);
// Add to header (replace the #back div content)
$("#back").append(avatarContainer);
} else {
// User is signed out
$("#login").removeClass("hidden");
$("#user-avatar").remove();
}
});