From 187999eecd4b3a0c06ce5ea2e30f9f340cce2048 Mon Sep 17 00:00:00 2001 From: Patrick Feng Date: Thu, 7 May 2026 16:55:50 -0700 Subject: [PATCH 1/3] tailwind v2 migration: settings - Add views/private/settingsV2.ejs using base.ejs layout: profile/personalization/account/delete-account forms with Tailwind grid layouts and native input styling - Replace jQuery unsaved-changes detector with Alpine.js (already loaded by V2 head); replace Bootstrap delete-confirmation modal with an Alpine-driven custom modal - Replace Bootstrap form-group/form-control classes with V2 input styling and grid; preserve all field names/IDs/POST endpoints - Wire /settings route to render settingsV2.ejs with expressLayouts Part of the broader Tailwind V2 migration tracked in #609. Legacy settings.ejs remains in tree until the final cleanup pass. Co-Authored-By: Claude Opus 4.7 --- routes/settings.js | 7 +- views/private/settingsV2.ejs | 175 +++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 views/private/settingsV2.ejs diff --git a/routes/settings.js b/routes/settings.js index 6837af12..e7a58d57 100644 --- a/routes/settings.js +++ b/routes/settings.js @@ -2,6 +2,8 @@ const { genPassword, validPassword } = require('../utils/functions/password'); const emailValidation = require('../utils/functions/emailValidation'); +const expressLayouts = require('express-ejs-layouts'); + const VIEWS = __dirname + '/../views/'; module.exports = (app, mongo) => { @@ -286,10 +288,11 @@ module.exports = (app, mongo) => { } }); - app.get('/settings', (req, res) => { - res.render(VIEWS + 'private/settings.ejs', { + app.get('/settings', expressLayouts, (req, res) => { + res.render(VIEWS + 'private/settingsV2.ejs', { user: req.user, pageName: 'Settings', + layout: 'layouts/base.ejs', }); }); }; diff --git a/views/private/settingsV2.ejs b/views/private/settingsV2.ejs new file mode 100644 index 00000000..80472ff6 --- /dev/null +++ b/views/private/settingsV2.ejs @@ -0,0 +1,175 @@ +<%- contentFor('main') %> +
+

Settings

+ +
+ Careful! You have unsaved changes! +
+ + <%# Profile %> +
+

Profile

+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + <%# Personalization %> +
+

Personalization

+ + + + + +
+ + <%# Account %> +
+

Account

+ +
+ + +
+
+ + +
+ +
+ + + + + + +

+ A valid password must have a minimum of 7 characters which includes at least 1 letter and at least 1 number. +

+
+ + +
+ + <%# Delete Account %> +
+

Delete Account

+ +
+ + +
+ + +
+ + <%# Confirm-delete modal %> +
+
+
Confirm Delete Account
+
Are you sure you want to delete your account?
+
+ + +
+
+
+
From a8fc5ff5df1c2577d9506fb8b239dbf61e24dbde Mon Sep 17 00:00:00 2001 From: Patrick Feng Date: Thu, 7 May 2026 16:57:03 -0700 Subject: [PATCH 2/3] tailwind v2 migration: leaderboard - Add views/private/leaderboardV2.ejs using base.ejs layout: rating leaderboard 4-col grid (Physics/Chemistry/Biology/USABO), Rush leaderboard, Experience leaderboard - Consolidate the four near-identical rating tables into a single forEach over [title, list, valueGetter] tuples; preserves row colors (1st/2nd/3rd/4th+) and medal SVGs - Wire /leaderboard route to render leaderboardV2.ejs with expressLayouts Part of the broader Tailwind V2 migration tracked in #609. Co-Authored-By: Claude Opus 4.7 --- routes/stats.js | 5 +- views/private/leaderboardV2.ejs | 138 ++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 views/private/leaderboardV2.ejs diff --git a/routes/stats.js b/routes/stats.js index d27dd036..4a07f236 100644 --- a/routes/stats.js +++ b/routes/stats.js @@ -24,11 +24,12 @@ module.exports = (app, mongo) => { } }); - app.get('/leaderboard', async (req, res) => { + app.get('/leaderboard', expressLayouts, async (req, res) => { const leaderboard = await generateLeaderboard(mongo.User, 10); - res.render(VIEWS + 'private/leaderboard.ejs', { + res.render(VIEWS + 'private/leaderboardV2.ejs', { rankings: leaderboard, pageName: 'Leaderboard', + layout: 'layouts/base.ejs', }); }); diff --git a/views/private/leaderboardV2.ejs b/views/private/leaderboardV2.ejs new file mode 100644 index 00000000..b1e81e24 --- /dev/null +++ b/views/private/leaderboardV2.ejs @@ -0,0 +1,138 @@ +<% + const rankBg = (r) => { + if (r === 1) return 'bg-sky-500 text-white'; + if (r === 2) return 'bg-yellow-400'; + if (r === 3) return 'bg-red-500 text-white'; + return 'bg-green-500 text-white'; + }; + const medalSrc = (r) => { + if (r === 1) return 'https://cdn.mutorials.org/images/icons/Leaderboard_First.svg'; + if (r === 2) return 'https://cdn.mutorials.org/images/icons/Leaderboard_Second.svg'; + if (r === 3) return 'https://cdn.mutorials.org/images/icons/Leaderboard_Third.svg'; + return null; + }; + const truncate = (s, n) => s.length <= n ? s : s.substring(0, n - 3) + '...'; +%> + +<%- contentFor('main') %> +
+ +

Rating Leaderboard

+ +
+ <% [ + ['Physics', rankings.physics, (u) => u.rating.physics], + ['Chemistry', rankings.chem, (u) => u.rating.chemistry], + ['Biology', rankings.bio, (u) => u.rating.biology], + ['USABO', rankings.usabo, (u) => u.rating.usabo], + ].forEach(([title, list, getValue]) => { %> +
+

<%= title %>

+
+ + + + + + + + + + <% let rank = 1; %> + <% list.forEach((user) => { %> + + + + + + <% rank++; %> + <% }) %> + +
RankUsernameRating
+ <% const medal = medalSrc(rank); %> + <% if (medal) { %> + + <% } else { %> + <%= rank %> + <% } %> + <%= truncate(user.ign, 15) %><%= getValue(user) %>
+
+
+ <% }) %> +
+ +

Problem Rush Leaderboard

+ +
+

Highest Rush Scores

+
+ + + + + + + + + + + <% let rankRush = 1; %> + <% rankings.rush.forEach((user) => { %> + + + + + + + <% rankRush++; %> + <% }) %> + +
RankUsernameHigh ScoreAttempts
+ <% const medal = medalSrc(rankRush); %> + <% if (medal) { %> + + <% } else { %> + <%= rankRush %> + <% } %> + <%= truncate(user.ign, 33) %><%= user.stats.rush.highscore %><%= user.stats.rush.attempts %>
+
+
+ +

Experience Leaderboard

+ +
+

Highest Level Mutorials Users

+
+ + + + + + + + + + + <% let rankExp = 1; %> + <% rankings.experience.forEach((user) => { %> + + + + + + + <% rankExp++; %> + <% }) %> + +
RankUsernameLevelExperience
+ <% const medal = medalSrc(rankExp); %> + <% if (medal) { %> + + <% } else { %> + <%= rankExp %> + <% } %> + <%= truncate(user.ign, 33) %><%= user.level.level %><%= Math.round(user.experience) %>
+
+
+ +
From dad3aea188190c20db78529d95c5eaada7c22dbe Mon Sep 17 00:00:00 2001 From: Patrick Feng Date: Thu, 7 May 2026 16:59:29 -0700 Subject: [PATCH 3/3] tailwind v2 migration: stats - Add views/private/statsV2.ejs using base.ejs layout: 5 subject sections (Physics/Chemistry/Biology/ESS/USABO) each with rating+global rank, collected tags, analytics tags (Areas of Mastery / Currently Studying / Recommended Training / Favorite Units), Chart.js rating tracker, and a Change Proficiency button; plus a Performance section with Chart.js correct/wrong pie, site experience bar, and Problem Rush stats - Consolidate 5 near-identical subject sections into a single forEach over a config table; preserves all rating/analytics keys and proficiency URL casing (note: USABO uses uppercase "USABO" in /train/USABO/proficiency) - Wire /stats/:username route (both teacher and self branches) to render statsV2.ejs with expressLayouts Part of the broader Tailwind V2 migration tracked in #609. Largest page in batch 2 per the migration plan. Co-Authored-By: Claude Opus 4.7 --- routes/stats.js | 8 +- views/private/statsV2.ejs | 247 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 views/private/statsV2.ejs diff --git a/routes/stats.js b/routes/stats.js index 4a07f236..6d6b65c9 100644 --- a/routes/stats.js +++ b/routes/stats.js @@ -126,7 +126,7 @@ module.exports = (app, mongo) => { } }); - app.get('/stats/:username', (req, res) => { + app.get('/stats/:username', expressLayouts, (req, res) => { mongo.User.findOne({ ign: req.params.username }, async function (err, obj) { if (obj) { if ( @@ -147,12 +147,13 @@ module.exports = (app, mongo) => { obj.stats.units ? obj.stats.units : {} ); - res.render(VIEWS + 'private/stats.ejs', { + res.render(VIEWS + 'private/statsV2.ejs', { user: obj, totalTags: tags, userLevel, analytics, pageName: obj.ign + "'s Stats", + layout: 'layouts/base.ejs', }); } else { req.flash( @@ -168,12 +169,13 @@ module.exports = (app, mongo) => { ); let analytics = await analyze(obj.stats.units ? obj.stats.units : {}); - res.render(VIEWS + 'private/stats.ejs', { + res.render(VIEWS + 'private/statsV2.ejs', { user: obj, totalTags: tags, userLevel, analytics, pageName: obj.ign + "'s Stats", + layout: 'layouts/base.ejs', }); } } else { diff --git a/views/private/statsV2.ejs b/views/private/statsV2.ejs new file mode 100644 index 00000000..cc516065 --- /dev/null +++ b/views/private/statsV2.ejs @@ -0,0 +1,247 @@ +<% + // [displayName, ratingKey, analyticsKey, proficiencyPathSegment, hasCollectedTags] + const subjects = [ + ['Physics', 'physics', 'physics', 'physics', true], + ['Chemistry', 'chemistry', 'chemistry', 'chemistry', true], + ['Biology', 'biology', 'biology', 'biology', true], + ['ESS', 'ess', 'ess', 'ess', true], + ['USABO', 'usabo', 'usabo', 'USABO', false], + ]; +%> + +<%- contentFor('head') %> + + + + +<%- contentFor('main') %> +
+ +

+ <%= user.ign %>'s Stats +

+ + <% subjects.forEach(([display, ratingKey, akey, profPath, hasTags]) => { %> +
+ +
+ + <%# Left: rating + global rank + tags + analytics tags %> +
+

<%= display %>: + <%= user.rating[ratingKey] != -1 ? user.rating[ratingKey] : 'Unrated' %> +

+

Global Rank: XXX

+ + <% if (hasTags) { %> +

Collected <%= display %> Tags:

+
+ <% let counter = 0; %> + <% user.stats.collectedTags.forEach((tag) => { %> + <% if (Object.keys(totalTags[display]['Units']).includes(tag)) { %> + + <% counter++; %> + <% } else if (Object.keys(totalTags[display]['Concepts']).includes(tag)) { %> + + <% counter++; %> + <% } %> + <% }); %> +
+ <% const totalCount = Object.keys(totalTags[display]['Units']).length + Object.keys(totalTags[display]['Concepts']).length %> + <% if (counter === 0) { %> +

0 out of <%= totalCount %> possible tags

+ <% } else { %> +

<%= counter %>/<%= totalCount %> tags in this subject have been collected

+ <% } %> + <% } %> + + <% [ + ['Areas of Mastery', analytics.strengths[akey]], + ['Currently Studying', analytics.studying[akey]], + ['Recommended Training', analytics.weaknesses[akey]], + ['Favorite Units', analytics.favorites[akey]], + ].forEach(([heading, units]) => { %> + <% if (units && units.length > 0) { %> +

<%= heading %>:

+
+ <% units.forEach((unit) => { %> + <%= unit.split(' ')[2] %> + <% }) %> +
+ <% } %> + <% }) %> +
+ + <%# Right: rating tracker chart %> +
+
+ +
+ +
+ +
+ + Change <%= display %> Proficiency +
+ <% }) %> + + <%# Performance + site experience + rush %> +
+
+ +
+

Performance

+
+ +
+ + +
+ +
+

Site Experience

+

Global Rank: XXX

+ +
+
+
+

+ Level <%= userLevel.level %> + (<%= userLevel.remainder %>/<%= userLevel.totalToNext %> XP to Level <%= userLevel.level + 1 %>) +

+ +
+ +

Problem Rush

+

Global Rank: XXX

+ +
+

Attempts: <%= user.stats.rush.attempts ? user.stats.rush.attempts : 0 %>

+

High Score: <%= user.stats.rush.highscore ? user.stats.rush.highscore : 0 %>

+
+
+
+
+ +