Browse Source

Render some pages on the server

Clément Wolf 7 tháng trước cách đây
mục cha
commit
8cc974b9a1
17 tập tin đã thay đổi với 539 bổ sung395 xóa
  1. 3 1
      package.json
  2. 61 0
      public/base.js
  3. 20 223
      public/extra.js
  4. 10 88
      public/index.html
  5. 12 0
      public/stylesheet.css
  6. 1 1
      tailwind.config.cjs
  7. 2 2
      util/helpers.js
  8. 28 0
      views/command_list.eta
  9. 40 0
      views/faq.eta
  10. 34 0
      views/leaderboard.eta
  11. 3 35
      views/main.eta
  12. 32 0
      views/pagination.eta
  13. 97 0
      views/user.eta
  14. 2 44
      website.js
  15. 1 1
      website_api.js
  16. 138 0
      website_ssr.js
  17. 55 0
      yarn.lock

+ 3 - 1
package.json

@@ -20,11 +20,13 @@
     "dayjs": "^1.10.7",
     "discord-api-types": "^0.23.1",
     "discord.js": "=13.14.0",
+    "eta": "^3.1.1",
     "express": "^4.17.1",
     "morgan": "^1.10.0",
     "node-fetch": "^3.1.0",
     "progress": "^2.0.3",
-    "rosu-pp": "^0.9.1"
+    "rosu-pp": "^0.9.1",
+    "twemoji": "^14.0.2"
   },
   "devDependencies": {
     "eslint": "^7.32.0",

+ 61 - 0
public/base.js

@@ -0,0 +1,61 @@
+// Grab info from cookies
+const cookies = document.cookie.split(';');
+for (const cookie of cookies) {
+  const foo = decodeURIComponent(cookie).trim().split('=');
+  const key = foo[0];
+  if (key == '') break;
+
+  let value = foo[1];
+  if (value.substr(0, 2) == 's:') {
+    value = value.slice(2, value.lastIndexOf('.'));
+  }
+
+  if (key == 'user') {
+    window.logged_user_id = parseInt(value, 10);
+  } else if (key == 'last_match') {
+    window.last_match_id = parseInt(value, 10);
+  } else if (key == 'login_to' && value != '') {
+    document.cookie = `login_to=; path=/; domain=${domain_name}; secure; SameSite=Strict; Max-Age=0`;
+    document.location = value;
+  }
+}
+
+// Update login button
+const a = document.querySelector('.login-btn');
+if (window.logged_user_id) {
+  a.href = `/u/${window.logged_user_id}/`;
+  a.querySelector('img').src = `https://s.ppy.sh/a/${window.logged_user_id}`;
+} else {
+  a.href = '/me/';
+  a.querySelector('img').src = `/images/login.png`;
+}
+
+// Get current ruleset from hostname (eg. 'mania.kiwec.net')
+const rulesets = ['osu', 'taiko', 'catch', 'mania'];
+const subdomain = location.hostname.split('.')[0];
+let selected_ruleset = rulesets.indexOf(subdomain);
+if (selected_ruleset == -1) {
+  console.warn('No ruleset found for subdomain "' + subdomain + '"! Defaulting to standard.');
+  selected_ruleset = 0;
+}
+document.querySelector('#toggle-rulesets-dropdown-btn img').src = `/images/mode-${rulesets[selected_ruleset]}.png`;
+
+// Activate ruleset dropdown
+document.addEventListener('click', (event) => {
+  const open_ruleset_dropdown_btn = document.querySelector('#toggle-rulesets-dropdown-btn');
+  const ruleset_dropdown = document.querySelector('#rulesets-dropdown');
+  if (open_ruleset_dropdown_btn.contains(event.target)) {
+    ruleset_dropdown.classList.toggle('hidden');
+  } else {
+    ruleset_dropdown.classList.add('hidden');
+  }
+});
+document.querySelectorAll('.choose-ruleset').forEach((btn) => {
+  btn.addEventListener('click', function(event) {
+    event.preventDefault();
+
+    const subdomains = location.hostname.split('.');
+    subdomains[0] = this.dataset.ruleset;
+    location.hostname = subdomains.join('.');
+  });
+});

+ 20 - 223
public/scripts.js → public/extra.js

@@ -3,51 +3,17 @@ const domain_name = 'kiwec.net'; // TODO set this in build step?
 // Migrate from old authentication method
 const legacy_token = localStorage.getItem('token');
 if (legacy_token != null) {
-  const last_match_id = localStorage.getItem('last_match_id') || '';
+  const window.last_match_id = localStorage.getItem('window.last_match_id') || '';
 
   localStorage.removeItem('token');
   localStorage.removeItem('user_id');
-  localStorage.removeItem('last_match_id');
+  localStorage.removeItem('window.last_match_id');
 
   const login_to = encodeURIComponent(location.href);
   document.cookie = `login_to=${login_to}; path=/; domain=${domain_name}; secure; SameSite=Strict; Max-Age=30000`;
-  document.location = '/auth_migrate?token=' + legacy_token + '&last_match_id=' + last_match_id;
+  document.location = '/auth_migrate?token=' + legacy_token + '&window.last_match_id=' + window.last_match_id;
 }
 
-// Grab info from cookies
-let logged_user_id = null;
-let last_match_id = null;
-const cookies = document.cookie.split(';');
-for (const cookie of cookies) {
-  const foo = decodeURIComponent(cookie).trim().split('=');
-  const key = foo[0];
-  if (key == '') break;
-
-  let value = foo[1];
-  if (value.substr(0, 2) == 's:') {
-    value = value.slice(2, value.lastIndexOf('.'));
-  }
-
-  if (key == 'user') {
-    logged_user_id = parseInt(value, 10);
-  } else if (key == 'last_match') {
-    last_match_id = parseInt(value, 10);
-  } else if (key == 'login_to' && value != '') {
-    document.cookie = `login_to=; path=/; domain=${domain_name}; secure; SameSite=Strict; Max-Age=0`;
-    document.location = value;
-  }
-}
-
-// Get current ruleset from hostname (eg. 'mania.kiwec.net')
-const rulesets = ['osu', 'taiko', 'catch', 'mania'];
-const subdomain = location.hostname.split('.')[0];
-let selected_ruleset = rulesets.indexOf(subdomain);
-if (selected_ruleset == -1) {
-  console.warn('No ruleset found for subdomain "' + subdomain + '"! Defaulting to standard.');
-  selected_ruleset = 0;
-}
-document.querySelector('#toggle-rulesets-dropdown-btn img').src = `/images/mode-${rulesets[selected_ruleset]}.png`;
-
 function update_header_highlights() {
   const header_links = document.querySelectorAll('header a');
   for (const link of header_links) {
@@ -59,17 +25,6 @@ function update_header_highlights() {
   }
 }
 
-function update_header_profile() {
-  const a = document.querySelector('.login-btn');
-  if (logged_user_id) {
-    a.href = `/u/${logged_user_id}/`;
-    a.querySelector('img').src = `https://s.ppy.sh/a/${logged_user_id}`;
-  } else {
-    a.href = '/me/';
-    a.querySelector('img').src = `/images/login.png`;
-  }
-}
-
 // Returns the color of a given star rating, matching osu!web's color scheme.
 function stars_to_color(sr) {
   if (sr <= 0.1) {
@@ -94,32 +49,13 @@ function stars_to_color(sr) {
   }
 }
 
-
-document.addEventListener('click', (event) => {
-  const open_ruleset_dropdown_btn = document.querySelector('#toggle-rulesets-dropdown-btn');
-  const ruleset_dropdown = document.querySelector('#rulesets-dropdown');
-  if (open_ruleset_dropdown_btn.contains(event.target)) {
-    ruleset_dropdown.classList.toggle('hidden');
-  } else {
-    ruleset_dropdown.classList.add('hidden');
-  }
-});
-
-document.querySelectorAll('.choose-ruleset').forEach((btn) => {
-  btn.addEventListener('click', function(event) {
-    event.preventDefault();
-
-    const subdomains = location.hostname.split('.');
-    subdomains[0] = this.dataset.ruleset;
-    location.hostname = subdomains.join('.');
-  });
-});
-
-
 function click_listener(evt) {
-  if (this.pathname == '/me/') {
-    document.location = '/me/';
-    return;
+  const static_routes = ['/faq/', '/me/', '/u/', '/leaderboard/'];
+  for (const route of static_routes) {
+    if (this.pathname.startsWith(route)) {
+      document.location = this.pathname;
+      return;
+    }
   }
 
   // Intercept clicks that don't lead to an external domain
@@ -281,7 +217,7 @@ function render_closed_lobby(lobby, user_has_lobby_open) {
   lobby_div.querySelector('.go-to-create-lobby').addEventListener('click', (evt) => {
     evt.preventDefault();
     evt.stopPropagation();
-    if (logged_user_id) {
+    if (window.logged_user_id) {
       window.history.pushState({}, '', `/reopen-lobby/${lobby.match_id}`);
       route(`/reopen-lobby/${lobby.match_id}`);
     } else {
@@ -324,7 +260,7 @@ function render_open_lobby(lobby) {
     evt.preventDefault();
     evt.stopPropagation();
 
-    if (!logged_user_id) {
+    if (!window.logged_user_id) {
       const login_to = encodeURIComponent(`${location.origin}/lobbies/`);
       document.cookie = `login_to=${login_to}; path=/; domain=${domain_name}; secure; SameSite=Strict; Max-Age=30000`;
       document.location = '/osu_login';
@@ -354,14 +290,6 @@ function render_open_lobby(lobby) {
   return lobby_div;
 }
 
-async function render_faq() {
-  document.title = 'FAQ - o!RL';
-  const template = document.querySelector('#FAQ-template').content.cloneNode(true);
-  const commands_template = document.querySelector('#command-list-template').content.cloneNode(true);
-  template.querySelector('.command-list').appendChild(commands_template);
-  document.querySelector('main').appendChild(template);
-}
-
 async function render_lobbies() {
   document.title = 'Lobbies - o!RL';
   const template = document.querySelector('#lobbies-template').content.cloneNode(true);
@@ -378,7 +306,7 @@ async function render_lobbies() {
 
   let user_has_lobby_open = false;
   for (const lobby of json.open) {
-    if (lobby.creator_id == logged_user_id) {
+    if (lobby.creator_id == window.logged_user_id) {
       // User already created a lobby: hide the "Create lobby" button
       template.querySelector('.lobby-creation-banner').hidden = true;
       user_has_lobby_open = true;
@@ -402,7 +330,7 @@ async function render_lobbies() {
     evt.preventDefault();
     evt.stopPropagation();
 
-    if (logged_user_id) {
+    if (window.logged_user_id) {
       window.history.pushState({}, '', '/create-lobby/');
       route('/create-lobby/');
     } else {
@@ -433,118 +361,9 @@ function get_country_flag_html(country_code) {
   return twemoji.parse(emoji);
 }
 
-async function render_leaderboard(page_num) {
-  document.title = 'Leaderboard - o!RL';
-  const template = document.querySelector('#leaderboard-template').content.cloneNode(true);
-
-  document.querySelector('main').appendChild(document.querySelector('#loading-template').content.cloneNode(true));
-  const json = await get(`/api/leaderboard/${page_num}`);
-  document.querySelector('main .loading-placeholder').remove();
-
-  const lboard = template.querySelector('.leaderboard tbody');
-  for (const player of json.players) {
-    lboard.innerHTML += `
-      <tr class="inline-flex justify-between">
-        <td class="border border-transparent border-r-zinc-700 p-1.5 pr-3 w-10 text-right">${player.ranking}</td>
-        <td class="pl-3 p-1.5 ${player.ranking == 1 ? 'the-one': ''}"><a href="/u/${player.user_id}/">${get_country_flag_html(player.country_code)} ${player.username}</a></td>
-        <td class="p-1.5 ml-auto">${fancy_elo(player.elo)}</td>
-        <td class="p-1.5 text-orange-600">ELO</td>
-      </tr>`;
-  }
-
-  const pagi_div = template.querySelector('.pagination');
-  render_pagination(pagi_div, json.page, json.max_pages, (num) => `/leaderboard/page-${num}/`);
-
-  document.querySelector('main').appendChild(template);
-}
-
-
-async function render_user(user_id, page_num) {
-  let json = null;
-  try {
-    document.querySelector('main').appendChild(document.querySelector('#loading-template').content.cloneNode(true));
-    json = await get('/api/user/' + user_id);
-    document.querySelector('main .loading-placeholder').remove();
-  } catch (err) {
-    console.error(err);
-    return;
-  }
-
-  const user_info = json.ranks[selected_ruleset];
-  document.title = `${json.username} - o!RL`;
-
-  const division_to_class = {
-    'Unranked': 'unranked',
-    'Cardboard': 'cardboard',
-    'Wood': 'wood',
-    'Wood+': 'wood',
-    'Bronze': 'bronze',
-    'Bronze+': 'bronze',
-    'Silver': 'silver',
-    'Silver+': 'silver',
-    'Gold': 'gold',
-    'Gold+': 'gold',
-    'Platinum': 'platinum',
-    'Platinum+': 'platinum',
-    'Diamond': 'diamond',
-    'Diamond+': 'diamond',
-    'Legendary': 'legendary',
-    'The One': 'the-one',
-  };
-
-  const template = document.querySelector('#user-template').content.cloneNode(true);
-  template.querySelector('.heading-left img').src = `https://s.ppy.sh/a/${json.user_id}`;
-  template.querySelector('.heading-right h1').innerHTML = get_country_flag_html(json.country_code) + ' ' + json.username;
-  template.querySelector('.heading-right h1').classList.add(division_to_class[user_info.text]);
-  template.querySelector('.heading-right .subheading').href = `https://osu.ppy.sh/users/${json.user_id}`;
-
-  const blocks = template.querySelectorAll('.user-focus-block');
-  if (user_info.is_ranked) {
-    blocks[0].innerHTML = `<span class="text-3xl ${division_to_class[user_info.text]}">${user_info.text}</span><span class="text-xl p-1">Rank #${user_info.rank_nb}</span>`;
-    blocks[1].innerHTML = `<span class="text-orange-600 text-3xl">${user_info.total_scores}</span><span class="text-xl p-1">Games Played</span>`;
-    blocks[2].innerHTML = `<span class="text-orange-600 text-3xl">${fancy_elo(user_info.elo)}</span><span class="text-xl p-1">Elo</span>`;
-  } else {
-    blocks[0].innerHTML = `<span class="text-3xl">Unranked</span><span class="text-xl p-1">Rank #???</span>`;
-    blocks[1].innerHTML = `<span class="text-orange-600 text-3xl">${user_info.total_scores}</span><span class="text-xl p-1">Games Played</span>`;
-    blocks[2].remove();
-  }
-  document.querySelector('main').appendChild(template);
-
-  document.querySelector('main').appendChild(document.querySelector('#loading-template').content.cloneNode(true));
-  const matches_json = await get(`/api/user/${user_id}/matches/${page_num}`);
-  document.querySelector('main .loading-placeholder').remove();
-
-  const tbody = document.querySelector('.match-history tbody');
-  const osu_rulesets = ['osu', 'taiko', 'fruits', 'mania'];
-  for (const match of matches_json.matches) {
-    const row = document.createElement('tr');
-    row.classList.add('inline-flex', 'justify-between');
-
-    let fancy_elo = Math.round(match.elo_diff);
-    if (fancy_elo > 0) fancy_elo = '+' + fancy_elo;
-
-    row.innerHTML = `
-      <td class="map grow w-40 p-1.5 text-center border border-transparent border-t-zinc-700">
-        <a href="https://osu.ppy.sh/beatmapsets/${match.map.set_id}#${osu_rulesets[selected_ruleset]}/${match.map.map_id}"></a>
-      </td>
-      <td class="w-40 p-1.5 text-center border border-transparent border-t-zinc-700">
-        ${match.placement}/${match.nb_players}
-      </td>
-      <td class="w-40 p-1.5 text-center border border-transparent border-t-zinc-700 ${match.elo_diff >= 0 ? 'text-green-500' : 'text-red-500'}">${fancy_elo}</td>
-      <td class="w-40 p-1.5 text-center border border-transparent border-t-zinc-700" data-tms="${match.tms}">${match.time}</td>`;
-    row.querySelector('.map a').innerText = match.map.name;
-    tbody.appendChild(row);
-  }
-
-  const pagi_div = document.querySelector('.pagination');
-  render_pagination(pagi_div, matches_json.page, matches_json.max_pages, (num) => `/u/${user_id}/page-${num}/`);
-}
-
-
 async function route(new_url) {
   console.info('Loading ' + new_url);
   update_header_highlights();
-  update_header_profile();
 
   let m;
   if (m = new_url.match(/\/create-lobby\//)) {
@@ -697,7 +516,7 @@ async function route(new_url) {
         document.querySelector('.lobby-creation-success .info').appendChild(info_template);
         document.querySelector('.lobby-creation-success').hidden = false;
 
-        last_match_id = json_res.lobby.tournament_id;
+        window.last_match_id = json_res.lobby.tournament_id;
       } catch (err) {
         document.querySelector('.lobby-creation-error .error-msg').innerText = err.message;
         document.querySelector('.lobby-creation-spinner').hidden = true;
@@ -705,7 +524,7 @@ async function route(new_url) {
       }
     }
 
-    if (last_match_id) {
+    if (window.last_match_id) {
       const recreate_lobby_div = document.createElement('div');
       recreate_lobby_div.className = 'border-2 border-orange-600 rounded-lg p-3 text-center mx-2 mt-4';
       recreate_lobby_div.innerHTML = `
@@ -717,11 +536,11 @@ async function route(new_url) {
       document.querySelector('.reopen-lobby').addEventListener('click', (evt) => {
         evt.preventDefault();
         evt.stopPropagation();
-        if (logged_user_id) {
-          window.history.pushState({}, '', `/reopen-lobby/${last_match_id}`);
-          route(`/reopen-lobby/${last_match_id}`);
+        if (window.logged_user_id) {
+          window.history.pushState({}, '', `/reopen-lobby/${window.last_match_id}`);
+          route(`/reopen-lobby/${window.last_match_id}`);
         } else {
-          const login_to = encodeURIComponent(`${location.origin}/reopen-lobby/${last_match_id}`);
+          const login_to = encodeURIComponent(`${location.origin}/reopen-lobby/${window.last_match_id}`);
           document.cookie = `login_to=${login_to}; path=/; domain=${domain_name}; secure; SameSite=Strict; Max-Age=30000`;
           document.location = '/osu_login';
         }
@@ -828,7 +647,7 @@ async function route(new_url) {
         document.querySelector('.lobby-creation-success .info').appendChild(info_template);
         document.querySelector('.lobby-creation-success').hidden = false;
 
-        last_match_id = json_res.lobby.tournament_id;
+        window.last_match_id = json_res.lobby.tournament_id;
       } catch (err) {
         document.querySelector('.lobby-creation-error .error-msg').innerText = err.message;
         document.querySelector('.lobby-creation-spinner').hidden = true;
@@ -855,30 +674,9 @@ async function route(new_url) {
     });
 
     create_lobby_plz(null);
-  } else if (m = new_url.match(/\/faq\//)) {
-    document.querySelector('main').innerHTML = '';
-    await render_faq();
   } else if (m = new_url.match(/\/lobbies\//)) {
     document.querySelector('main').innerHTML = '';
     await render_lobbies();
-  } else if (m = new_url.match(/\/leaderboard\/(page-(\d+)\/)?/)) {
-    const page_num = m[2] || 1;
-    document.querySelector('main').innerHTML = '';
-    await render_leaderboard(page_num);
-  } else if (m = new_url.match(/\/u\/(\d+)\/page-(\d+)\/?/)) {
-    const user_id = m[1];
-    const page_num = m[2] || 1;
-    document.querySelector('main').innerHTML = '';
-    await render_user(user_id, page_num);
-  } else if (m = new_url.match(/\/u\/(\d+)\/?/)) {
-    const user_id = m[1];
-    document.querySelector('main').innerHTML = '';
-    await render_user(user_id, 1);
-  } else {
-    const main = document.querySelector('main');
-    if (main.innerHTML.indexOf('{{ error }}') != -1) {
-      main.innerHTML = 'Page not found.';
-    }
   }
 
   const links = document.querySelectorAll('a');
@@ -897,5 +695,4 @@ async function route(new_url) {
 
 
 // Load pages and hijack browser browsing
-update_header_profile();
 route(location.pathname);

+ 10 - 88
public/index.html

@@ -10,7 +10,9 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
     <link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
     <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
-    <script src="https://unpkg.com/twemoji@latest/dist/twemoji.min.js" crossorigin="anonymous"></script>
+    <script defer src="https://unpkg.com/twemoji@latest/dist/twemoji.min.js" crossorigin="anonymous"></script>
+    <script defer src="/base.js?v=1" type="module"></script>
+    <script defer src="/extra.js?v=1" type="module"></script>
   </head>
   <body class="bg-zinc-800">
     <header class="h-16 bg-zinc-900">
@@ -65,8 +67,13 @@
     </header>
 
     <main class="p-8 max-sm:pt-16">
-      {{ error }}
-      <noscript>Sorry, but to reduce server load, pages are rendered client-side, with JavaScript :(</noscript>
+      <div class="loading-placeholder text-center mt-24">
+        <p>Loading...</p>
+        <svg class="animate-spin m-auto my-4 h-10 w-10 text-orange-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
+          <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+          <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+        </svg>
+      </div>
     </main>
 
     <template id="loading-template">
@@ -79,47 +86,6 @@
       </div>
     </template>
 
-    <template id="FAQ-template">
-      <div class="max-w-5xl m-auto">
-        <h2 class="mb-2 underline">Frequently Asked Questions</h2>
-        <h3 class="mt-2">When do I get a rank?</h3>
-        You need to play enough games for your rank to be accurate. To have it visible in this Discord server, you need to link your account in #welcome.
-        <h3 class="mt-2">What are the ranks?</h3>
-        <table class="mt-1 mb-2">
-          <thead>
-            <tr><th class="border border-zinc-700 p-1.5">Division</th><th class="border border-zinc-700 p-1.5">Placement</th></tr>
-          </thead>
-          <tbody>
-            <tr><td class="border border-zinc-700 p-1.5 the-one">The One</td><td class="border border-zinc-700 p-1.5">#1</td></tr>
-            <tr><td class="border border-zinc-700 p-1.5 legendary pr-3">Legendary</td><td class="border border-zinc-700 p-1.5">Top 2.5%</td></tr>
-            <tr><td class="border border-zinc-700 p-1.5 diamond">Diamond</td><td class="border border-zinc-700 p-1.5">Top 10.1%</td></tr>
-            <tr><td class="border border-zinc-700 p-1.5 platinum">Platinum</td><td class="border border-zinc-700 p-1.5">Top 22.3%</td></tr>
-            <tr><td class="border border-zinc-700 p-1.5 gold">Gold</td><td class="border border-zinc-700 p-1.5">Top 38.3%</td></tr>
-            <tr><td class="border border-zinc-700 p-1.5 silver">Silver</td><td class="border border-zinc-700 p-1.5">Top 56.8%</td></tr>
-            <tr><td class="border border-zinc-700 p-1.5 bronze">Bronze</td><td class="border border-zinc-700 p-1.5">Top 75.5%</td></tr>
-            <tr><td class="border border-zinc-700 p-1.5 wood">Wood</td><td class="border border-zinc-700 p-1.5">Top 91.3%</td></tr>
-            <tr><td class="border border-zinc-700 p-1.5 cardboard">Cardboard</td><td class="border border-zinc-700 p-1.5">Bottom 8.6%</td></tr>
-          </tbody>
-        </table>
-        <h3 class="mt-2">How do the ranks work?</h3>
-        The ranks are calculated based on how well you do against other players in the lobby. A player getting the first place in every game will rank up very fast. There is no difference between getting 1 and 10000000 more points than the next in line.
-        <h3 class="mt-2">What mods are allowed?</h3>
-        Every mod is allowed, however they won't help you to rank up faster. They affect your score according to vanilla osu! rules, so it might give you an edge, but consistency is key. Playing HDHR is more of a flex than anything. Looking at you, the 4 digits stuck in Plat.
-        <h3 class="mt-2">How are the maps chosen?</h3>
-        That depends on the lobby settings. Every lobby creator can customize their lobby differently, but in most cases it is based on lobby pp, which means the bot will try to get maps close to your profile.
-        <h3 class="mt-2"> What's the map pool?</h3>
-        The default map pool includes every osu! map with a leaderboard, but lobby creators can also use osu!collector collections, which can also contain graveyarded maps.
-        <h3 class="mt-2">Why isn't the game starting when all players are ready?</h3>
-        That happens the last person that wasn't ready leaves. Anyone can unready and re-ready to start the game immediately. (I can't fix this bug, it comes from BanchoBot itself.)'
-        <h3 class="mt-2">Can I see the source code?</h3>
-        Yes: <a href="https://github.com/kiwec/osu-ranked-lobbies" target="_blank">https://github.com/kiwec/osu-ranked-lobbies</a>
-
-        <h2 class="mb-2 mt-8 underline">Command list</h3>
-        <div class="command-list"></div>
-        <div>
-      </div>
-    </template>
-
     <template id="lobbies-template">
       <div class="max-w-5xl m-auto">
         <div class="lobby-creation-banner border-2 border-orange-600 rounded-lg p-3 text-center mx-2 mb-2">
@@ -131,48 +97,6 @@
       </div>
     </template>
 
-    <template id="leaderboard-template">
-      <div class="max-w-5xl m-auto">
-        <div class="leaderboard-section">
-          <table class="leaderboard table-auto block">
-            <tbody class="flex flex-col">
-            </tbody>
-          </table>
-          <div class="pagination"></div>
-        </div>
-      </div>
-    </template>
-
-    <template id="user-template">
-      <div class="flex max-w-5xl m-auto">
-        <div class="max-sm:pt-4 heading-left"><img class="max-sm:h-16 h-24 rounded-full" /></div>
-        <div class="heading-right ml-8">
-          <h1 class="ml-2 mt-1" style="line-height:1.5em"></h1>
-          <a class="subheading ml-4" target="_blank">osu! profile</a>
-        </div>
-      </div>
-      <div class="user-section max-w-5xl m-auto">
-        <div class="user-focus flex flex-wrap my-4">
-          <div class="user-focus-block border-2 border-orange-600 rounded-lg inline-flex flex-col flex-grow p-4 m-4 text-center"></div>
-          <div class="user-focus-block border-2 border-orange-600 rounded-lg inline-flex flex-col flex-grow p-4 m-4 text-center"></div>
-          <div class="user-focus-block border-2 border-orange-600 rounded-lg inline-flex flex-col flex-grow p-4 m-4 text-center"></div>
-        </div>
-        <h2>Match History</h2>
-        <table class="match-history block my-4">
-          <thead class="flex flex-col">
-            <tr class="inline-flex justify-between">
-              <td class="map grow w-40 p-1.5 text-center font-bold">Map</td>
-              <td class="w-40 p-1.5 text-center font-bold">Placement</td>
-              <td class="w-40 p-1.5 text-center font-bold">Elo change</td>
-              <td class="w-40 p-1.5 text-center font-bold">Time</td>
-            </tr>
-          </thead>
-          <tbody class="flex flex-col"></tbody>
-        </table>
-        <div class="pagination"></div>
-      </div>
-    </template>
-
     <template id="command-list-template">
       <div class="command-list">
         <ul>
@@ -493,7 +417,5 @@
         </div>
       </div>
     </template>
-
-    <script src="/scripts.js?v=3.3" type="module"></script>
   </body>
 </html>

+ 12 - 0
public/stylesheet.css

@@ -121,4 +121,16 @@
     .default-btn {
         @apply text-white bg-orange-600 hover:bg-orange-500 rounded-lg py-1.5 px-3;
     }
+
+    .map-list td {
+        @apply w-40 p-1.5 text-center border border-transparent border-t-zinc-700;
+    }
+
+    .pagination .page {
+        @apply text-xl px-3 py-2 border-transparent border-2 text-zinc-400 hover:text-zinc-50 hover:border-b-orange-400;
+    }
+
+    .pagination .current {
+        @apply text-zinc-50 border-b-orange-600;
+    }
 }

+ 1 - 1
tailwind.config.cjs

@@ -1,6 +1,6 @@
 /** @type {import('tailwindcss').Config} */
 module.exports = {
-  content: ["public/*.{html,js}"],
+  content: ["public/*.{html,js}", "views/*.eta"],
   theme: {
     extend: {},
   },

+ 2 - 2
util/helpers.js

@@ -23,7 +23,7 @@ export function random_from(arr) {
   return arr[Math.floor((Math.random() * arr.length))];
 }
 
-const base = fs.readFileSync('views/main.html', 'utf-8');
+const base = fs.readFileSync('views/main.eta', 'utf-8');
 export const render_error = (error) => {
-  return base.replace('{{ content }}', error);
+  return base.replace('<%~ it.body %>', error);
 };

+ 28 - 0
views/command_list.eta

@@ -0,0 +1,28 @@
+<div class="command-list">
+  <ul>
+    <li>
+      !info or !discord - Display some information for new players.
+    </li>
+    <li>
+      !start -  Count down 30 seconds then start the map. Useful when some players are AFK or forget to ready up. Anybody can use this command.
+    </li>
+    <li>
+      !wait - Cancel !start. Use it when you're not done downloading.
+    </li>
+    <li>
+      !skip - Skip current map. You must have played 5 games in the lobby to unlock the command.
+    </li>
+    <li>
+      !abort - Vote to abort the match. At least 1/4 of the lobby must vote to abort for a match to get aborted.
+    </li>
+    <li>
+      !kick (player) - Vote to kick a player. You should probably use the in-game ignoring and reporting features instead.
+    </li>
+    <li>
+      !rank (player) - Display your rank or the rank of another player.
+    </li>
+    <li>
+      !close - Close the lobby. Only the lobby creator can use this command.
+    </li>
+  </ul>
+</div>

+ 40 - 0
views/faq.eta

@@ -0,0 +1,40 @@
+<% layout('main.eta') %>
+
+<div class="max-w-5xl m-auto">
+  <h2 class="mb-2 underline">Frequently Asked Questions</h2>
+  <h3 class="mt-2">When do I get a rank?</h3>
+  You need to play enough games for your rank to be accurate. To have it visible in this Discord server, you need to link your account in #welcome.
+  <h3 class="mt-2">What are the ranks?</h3>
+  <table class="mt-1 mb-2">
+    <thead>
+      <tr><th class="border border-zinc-700 p-1.5">Division</th><th class="border border-zinc-700 p-1.5">Placement</th></tr>
+    </thead>
+    <tbody>
+      <tr><td class="border border-zinc-700 p-1.5 the-one">The One</td><td class="border border-zinc-700 p-1.5">#1</td></tr>
+      <tr><td class="border border-zinc-700 p-1.5 legendary pr-3">Legendary</td><td class="border border-zinc-700 p-1.5">Top 2.5%</td></tr>
+      <tr><td class="border border-zinc-700 p-1.5 diamond">Diamond</td><td class="border border-zinc-700 p-1.5">Top 10.1%</td></tr>
+      <tr><td class="border border-zinc-700 p-1.5 platinum">Platinum</td><td class="border border-zinc-700 p-1.5">Top 22.3%</td></tr>
+      <tr><td class="border border-zinc-700 p-1.5 gold">Gold</td><td class="border border-zinc-700 p-1.5">Top 38.3%</td></tr>
+      <tr><td class="border border-zinc-700 p-1.5 silver">Silver</td><td class="border border-zinc-700 p-1.5">Top 56.8%</td></tr>
+      <tr><td class="border border-zinc-700 p-1.5 bronze">Bronze</td><td class="border border-zinc-700 p-1.5">Top 75.5%</td></tr>
+      <tr><td class="border border-zinc-700 p-1.5 wood">Wood</td><td class="border border-zinc-700 p-1.5">Top 91.3%</td></tr>
+      <tr><td class="border border-zinc-700 p-1.5 cardboard">Cardboard</td><td class="border border-zinc-700 p-1.5">Bottom 8.6%</td></tr>
+    </tbody>
+  </table>
+  <h3 class="mt-2">How do the ranks work?</h3>
+  The ranks are calculated based on how well you do against other players in the lobby. A player getting the first place in every game will rank up very fast. There is no difference between getting 1 and 10000000 more points than the next in line.
+  <h3 class="mt-2">What mods are allowed?</h3>
+  Every mod is allowed, however they won't help you to rank up faster. They affect your score according to vanilla osu! rules, so it might give you an edge, but consistency is key. Playing HDHR is more of a flex than anything. Looking at you, the 4 digits stuck in Plat.
+  <h3 class="mt-2">How are the maps chosen?</h3>
+  That depends on the lobby settings. Every lobby creator can customize their lobby differently, but in most cases it is based on lobby pp, which means the bot will try to get maps close to your profile.
+  <h3 class="mt-2"> What's the map pool?</h3>
+  The default map pool includes every osu! map with a leaderboard, but lobby creators can also use osu!collector collections, which can also contain graveyarded maps.
+  <h3 class="mt-2">Why isn't the game starting when all players are ready?</h3>
+  That happens the last person that wasn't ready leaves. Anyone can unready and re-ready to start the game immediately. (I can't fix this bug, it comes from BanchoBot itself.)'
+  <h3 class="mt-2">Can I see the source code?</h3>
+  Yes: <a href="https://github.com/kiwec/osu-ranked-lobbies" target="_blank">https://github.com/kiwec/osu-ranked-lobbies</a>
+
+  <h2 class="mb-2 mt-8 underline">Command list</h3>
+  <div class="command-list"></div>
+  <%~ include('command_list.eta') %>
+</div>

+ 34 - 0
views/leaderboard.eta

@@ -0,0 +1,34 @@
+<% layout('main.eta') %>
+
+<%
+  function fancy_elo(elo) {
+    if (elo == '???') {
+      return '???';
+    } else {
+      return Math.round(elo);
+    }
+  }
+%>
+
+<div class="max-w-5xl m-auto">
+  <table class="leaderboard table-auto block">
+    <tbody class="flex flex-col">
+      <% for (const player of it.players) { %>
+      <tr class="inline-flex justify-between">
+        <td class="border border-transparent border-r-zinc-700 p-1.5 pr-3 w-10 text-right"><%= player.ranking %></td>
+        <td class="pl-3 p-1.5 <%= player.ranking == 1 ? 'the-one': '' %>"><a href="/u/<%= player.user_id %>/"><%~ player.flag %><%= ' ' + player.username %></a></td>
+        <td class="p-1.5 ml-auto"><%= fancy_elo(player.elo) %></td>
+        <td class="p-1.5 text-orange-600">ELO</td>
+      </tr>
+      <% } %>
+    </tbody>
+  </table>
+
+  <%~
+    include('pagination.eta', {
+      page_num: it.page,
+      max_pages: it.max_pages,
+      url_formatter: (num) => `/leaderboard/page-${num}/`
+    })
+  %>
+</div>

+ 3 - 35
views/main.html → views/main.eta

@@ -10,7 +10,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
     <link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
     <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
-    <script src="https://unpkg.com/twemoji@latest/dist/twemoji.min.js" crossorigin="anonymous"></script>
+    <script defer src="https://unpkg.com/twemoji@latest/dist/twemoji.min.js" crossorigin="anonymous"></script>
+    <script defer src="/base.js?v=1"></script>
   </head>
   <body class="bg-zinc-800">
     <header class="h-16 bg-zinc-900">
@@ -65,40 +66,7 @@
     </header>
 
     <main class="p-8 max-sm:pt-16">
-      {{ content }}
+      <%~ it.body %>
     </main>
-
-    <script type="text/javascript">
-      // Grab info from cookies
-      let logged_user_id = null;
-      const cookies = document.cookie.split(';');
-      for (const cookie of cookies) {
-        const foo = decodeURIComponent(cookie).trim().split('=');
-        const key = foo[0];
-        if (key == '') break;
-
-        let value = foo[1];
-        if (value.substr(0, 2) == 's:') {
-          value = value.slice(2, value.lastIndexOf('.'));
-        }
-
-        if (key == 'user') {
-          logged_user_id = parseInt(value, 10);
-        } else if (key == 'login_to' && value != '') {
-          document.cookie = `login_to=; path=/; domain=${domain_name}; secure; SameSite=Strict; Max-Age=0`;
-          document.location = value;
-        }
-      }
-
-      // Update login button
-      const a = document.querySelector('.login-btn');
-      if (logged_user_id) {
-        a.href = `/u/${logged_user_id}/`;
-        a.querySelector('img').src = `https://s.ppy.sh/a/${logged_user_id}`;
-      } else {
-        a.href = '/me/';
-        a.querySelector('img').src = `/images/login.png`;
-      }
-    </script>
   </body>
 </html>

+ 32 - 0
views/pagination.eta

@@ -0,0 +1,32 @@
+<%
+  const MAX_PAGINATED_PAGES = Math.min(it.max_pages, 9);
+  let pagination_min = it.page_num;
+  let pagination_max = it.page_num;
+  let nb_paginated_pages = 1;
+
+  while (nb_paginated_pages < MAX_PAGINATED_PAGES) {
+    if (pagination_min > 1) {
+      pagination_min--;
+      nb_paginated_pages++;
+    }
+    if (pagination_max < it.max_pages) {
+      pagination_max++;
+      nb_paginated_pages++;
+    }
+  }
+
+  const previous = Math.max(it.page_num - 1, 1);
+  const next = Math.min(it.page_num + 1, it.max_pages);
+%>
+
+<div class="pagination">
+  <div class="flex justify-between m-5">
+    <a class="text-xl text-zinc-400 hover:text-zinc-50" href="<%= it.url_formatter(previous) %>"><span class="text-2xl text-orange-600 mr-2">‹</span>Previous</a>
+    <div class="number-nav leading-10">
+      <% for (let i = pagination_min; i <= pagination_max; i++) { %>
+      <a class="<%= i == it.page_num ? 'current ' : '' %> page" href="<%= it.url_formatter(i) %>"><%= i %></a>
+      <% } %>
+    </div>
+    <a class="text-xl text-zinc-400 hover:text-zinc-50" href="<%= it.url_formatter(next) %>">Next<span class="text-2xl text-orange-600 ml-2">›</span></a>
+  </div>
+</div>

+ 97 - 0
views/user.eta

@@ -0,0 +1,97 @@
+<% layout('main.eta') %>
+
+<%
+  const division_to_class = {
+    'Unranked': 'unranked',
+    'Cardboard': 'cardboard',
+    'Wood': 'wood',
+    'Wood+': 'wood',
+    'Bronze': 'bronze',
+    'Bronze+': 'bronze',
+    'Silver': 'silver',
+    'Silver+': 'silver',
+    'Gold': 'gold',
+    'Gold+': 'gold',
+    'Platinum': 'platinum',
+    'Platinum+': 'platinum',
+    'Diamond': 'diamond',
+    'Diamond+': 'diamond',
+    'Legendary': 'legendary',
+    'The One': 'the-one',
+  };
+
+  function fancy_elo(elo) {
+    if (elo == '???') {
+      return '???';
+    } else {
+      return Math.round(elo);
+    }
+  }
+%>
+
+<div class="flex max-w-5xl m-auto">
+  <div class="max-sm:pt-4 heading-left">
+    <img class="max-sm:h-16 h-24 rounded-full" src="https://s.ppy.sh/a/<%= it.user.user_id %>" />
+  </div>
+  <div class="heading-right ml-8">
+    <h1 class="ml-2 mt-1 <%= division_to_class[it.rank.text] %>" style="line-height:1.5em"><%~ it.flag %><%= ' ' + it.user.username %></h1>
+    <a class="subheading ml-4" target="_blank" href="https://osu.ppy.sh/users/<%= it.user.user_id %>">osu! profile</a>
+  </div>
+</div>
+<div class="user-section max-w-5xl m-auto">
+  <div class="user-focus flex flex-wrap my-4">
+
+  <% if(it.rank.is_ranked) { %>
+    <div class="user-focus-block border-2 border-orange-600 rounded-lg inline-flex flex-col flex-grow p-4 m-4 text-center">
+      <span class="text-3xl <%= division_to_class[it.rank.text] %>"><%= it.rank.text %></span><span class="text-xl p-1">Rank #<%= it.rank.rank_nb %></span>
+    </div>
+    <div class="user-focus-block border-2 border-orange-600 rounded-lg inline-flex flex-col flex-grow p-4 m-4 text-center">
+      <span class="text-orange-600 text-3xl"><%= it.rank.total_scores %></span><span class="text-xl p-1">Games Played</span>
+    </div>
+    <div class="user-focus-block border-2 border-orange-600 rounded-lg inline-flex flex-col flex-grow p-4 m-4 text-center">
+      <span class="text-orange-600 text-3xl"><%= fancy_elo(it.rank.elo) %></span><span class="text-xl p-1">Elo</span>
+    </div>
+  <% } else { %>
+    <div class="user-focus-block border-2 border-orange-600 rounded-lg inline-flex flex-col flex-grow p-4 m-4 text-center">
+      <span class="text-3xl">Unranked</span><span class="text-xl p-1">Rank #???</span>
+    </div>
+    <div class="user-focus-block border-2 border-orange-600 rounded-lg inline-flex flex-col flex-grow p-4 m-4 text-center">
+      <span class="text-orange-600 text-3xl"><%= it.rank.total_scores %></span><span class="text-xl p-1">Games Played</span>
+    </div>
+  <% } %>
+  </div>
+  <h2>Match History</h2>
+  <table class="match-history block my-4">
+    <thead class="flex flex-col">
+      <tr class="inline-flex justify-between">
+        <td class="map grow w-40 p-1.5 text-center font-bold">Map</td>
+        <td class="w-40 p-1.5 text-center font-bold">Placement</td>
+        <td class="w-40 p-1.5 text-center font-bold">Elo change</td>
+        <td class="w-40 p-1.5 text-center font-bold">Time</td>
+      </tr>
+    </thead>
+    <tbody class="flex flex-col map-list">
+      <% it.matches.matches.forEach((match) => {
+        let match_elo = Math.round(match.elo_diff);
+        if(match_elo > 0) match_elo = '+' + match_elo;
+      %>
+      <tr class="inline-flex justify-between">
+        <td class="map grow">
+          <a href="https://osu.ppy.sh/beatmapsets/<%= match.map.set_id %>#<%= it.osu_ruleset %>/<%= match.map.map_id %>"><%= match.map.name %></a>
+        </td>
+        <td><%= match.placement %>/<%= match.nb_players %></td>
+        <td class="<%= match.elo_diff >= 0 ? 'text-green-500' : 'text-red-500' %>"><%= match_elo %></td>
+        <td data-tms="<%= match.tms %>"><%= match.time %></td>
+      </tr>
+      <% }) %>
+    </tbody>
+  </table>
+
+  <%~
+    include('pagination.eta', {
+      page_num: it.matches.page,
+      max_pages: it.matches.max_pages,
+      url_formatter: (num) => `/u/${it.user.user_id}/page-${num}/`
+    })
+  %>
+</div>

+ 2 - 44
website.js

@@ -12,6 +12,7 @@ import {get_user_by_id} from './user.js';
 import Config from './util/config.js';
 import {render_error, gen_url} from './util/helpers.js';
 import {register_routes as register_api_routes} from './website_api.js';
+import {register_routes as register_static_routes} from './website_ssr.js';
 
 
 const auth_page = gen_url(0, '/auth');
@@ -245,50 +246,7 @@ async function listen() {
     return res.redirect(`${req.protocol}://${req.params.ruleset}.${Config.domain_name}${new_path}`);
   });
 
-  // Dirty hack to handle Discord embeds nicely
-  app.get('/u/:userId', async (req, http_res, next) => {
-    if (req.get('User-Agent').indexOf('Discordbot') != -1) {
-      const user = await get_user_by_id(req.params.userId, false);
-      if (!user) {
-        http_res.status(404).send();
-        return http_res.end();
-      }
-
-      function get_mode(req) {
-        const ruleset = req.subdomains[0];
-        if (ruleset == 'osu') {
-          return 0;
-        } else if (ruleset == 'taiko') {
-          return 1;
-        } else if (ruleset == 'catch') {
-          return 2;
-        } else if (ruleset == 'mania') {
-          return 3;
-        } else {
-          throw RULESET_NOT_FOUND;
-        }
-      }
-
-      const rank = get_user_rank(user.user_id, get_mode(req));
-      if (!rank) {
-        http_res.status(404).send();
-        return http_res.end();
-      } else {
-        http_res.send(`<html>
-          <head>
-            <meta content="${user.username} - o!RL" property="og:title" />
-            <meta content="#${rank.rank_nb} - ${rank.text}" property="og:description" />
-            <meta content="https://${req.hostname}/u/${user.user_id}" property="og:url" />
-            <meta content="https://s.ppy.sh/a/${user.user_id}" property="og:image" />
-          </head>
-          <body>hi :)</body>
-        </html>`);
-        return http_res.end();
-      }
-    }
-
-    return next();
-  });
+  await register_static_routes(app);
 
   if (Config.ENABLE_SENTRY) {
     app.use(Sentry.Handlers.errorHandler());

+ 1 - 1
website_api.js

@@ -525,5 +525,5 @@ async function register_routes(app) {
 }
 
 export {
-  register_routes,
+  register_routes, get_user_matches, get_leaderboard_page,
 };

+ 138 - 0
website_ssr.js

@@ -0,0 +1,138 @@
+import fs from 'fs';
+import {Eta} from 'eta';
+import twemoji from 'twemoji';
+
+import Config from './util/config.js';
+import {get_user_by_id} from './user.js';
+import {get_user_rank} from './elo.js';
+import {get_user_matches, get_leaderboard_page} from './website_api.js';
+
+
+const eta = new Eta({
+  autoTrim: ['nl', 'slurp'],
+  cache: true,
+  debug: !Config.IS_PRODUCTION,
+  views: './views',
+});
+
+const base = fs.readFileSync('views/main.eta', 'utf-8');
+const not_found_page = base.replace('<%~ it.body %>', 'Page not found.'); // TODO better looking page
+const user_not_found_page = base.replace('<%~ it.body %>', 'User not found.'); // TODO better looking page
+const faq_page = eta.render('faq.eta');
+
+
+function get_mode(req) {
+  const ruleset = req.subdomains[0];
+  if (ruleset == 'osu') {
+    return 0;
+  } else if (ruleset == 'taiko') {
+    return 1;
+  } else if (ruleset == 'catch') {
+    return 2;
+  } else if (ruleset == 'mania') {
+    return 3;
+  } else {
+    throw RULESET_NOT_FOUND;
+  }
+}
+
+function country_code_to_flag_html(country_code) {
+  // Unknown country
+  if (country_code?.length != 2) {
+    return `<img class="emoji" draggable="false" alt="?" src="/unknown_flag.png" />`;
+  }
+
+  const codepoints = country_code.split('').map((char) => 127397 + char.charCodeAt(0));
+  const emoji = String.fromCodePoint(...codepoints);
+  return twemoji.parse(emoji);
+}
+
+async function render_user_profile(req, res, page_num) {
+  const mode = get_mode(req);
+  const user = await get_user_by_id(req.params.userId, false);
+  if (!user) {
+    return res.status(404).end(user_not_found_page);
+  }
+
+  const rank = get_user_rank(user.user_id, mode);
+  if (!rank) {
+    return res.status(404).end(user_not_found_page);
+  }
+
+  // Dirty hack to make Discord embeds work
+  if (req.get('User-Agent').indexOf('Discordbot') != -1) {
+    return res.end(`<html>
+      <head>
+        <meta content="${user.username} - o!RL" property="og:title" />
+        <meta content="#${rank.rank_nb} - ${rank.text}" property="og:description" />
+        <meta content="https://${req.hostname}/u/${user.user_id}" property="og:url" />
+        <meta content="https://s.ppy.sh/a/${user.user_id}" property="og:image" />
+      </head>
+      <body>hi :)</body>
+    </html>`);
+  }
+
+  const osu_rulesets = ['osu', 'taiko', 'fruits', 'mania'];
+  const matches = await get_user_matches(user.user_id, mode, page_num);
+  return res.end(eta.render('user.eta', {
+    user, rank, matches,
+    flag: country_code_to_flag_html(user.country_code),
+    osu_ruleset: osu_rulesets[mode],
+  }));
+}
+
+async function render_leaderboard(req, res, page_num) {
+  const data = await get_leaderboard_page(get_mode(req), page_num);
+  for (const player of data.players) {
+    player.flag = country_code_to_flag_html(player.country_code);
+  }
+
+  return res.end(eta.render('leaderboard.eta', data));
+}
+
+async function register_routes(app) {
+  // TODO: add cache headers to every page
+
+  app.get('/faq/', (req, res) => {
+    res.end(faq_page);
+  });
+
+  app.get('/u/:userId/:pageStr/', (req, res) => {
+    let page_num = 1;
+    if (req.params.pageStr.indexOf('page-') == 0) {
+      page_num = parseInt(req.params.pageStr.split('-')[1], 10);
+      if (page_num <= 0 || isNaN(page_num)) {
+        page_num = 1;
+      }
+    }
+
+    return render_user_profile(req, res, page_num);
+  });
+  app.get('/u/:userId', (req, res) => {
+    return render_user_profile(req, res, 1);
+  });
+
+  app.get('/leaderboard/:pageStr/', (req, res) => {
+    let page_num = 1;
+    if (req.params.pageStr.indexOf('page-') == 0) {
+      page_num = parseInt(req.params.pageStr.split('-')[1], 10);
+      if (page_num <= 0 || isNaN(page_num)) {
+        page_num = 1;
+      }
+    }
+
+    return render_leaderboard(req, res, page_num);
+  });
+  app.get('/leaderboard/', (req, res) => {
+    return render_leaderboard(req, res, 1);
+  });
+
+  // Dirty hack to handle Discord embeds nicely
+  app.get('/u/:userId', async (req, http_res, next) => {
+    return next();
+  });
+}
+
+export {
+  register_routes,
+};

+ 55 - 0
yarn.lock

@@ -898,6 +898,11 @@ esutils@^2.0.2:
   resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
   integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
 
+eta@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/eta/-/eta-3.1.1.tgz#f96574befd26cff585081954bec24b1ed7ec4853"
+  integrity sha512-GVKq8BhYjvGiwKAnvPOnTwAHach3uHglvW0nG9gjEmo8ZIe8HR1aCLdQ97jlxXPcCWhB6E3rDWOk2fahFKG5Cw==
+
 etag@~1.8.1:
   version "1.8.1"
   resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
@@ -1076,6 +1081,15 @@ fs-constants@^1.0.0:
   resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
   integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
 
+fs-extra@^8.0.1:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
+  integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==
+  dependencies:
+    graceful-fs "^4.2.0"
+    jsonfile "^4.0.0"
+    universalify "^0.1.0"
+
 fs.realpath@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
@@ -1143,6 +1157,11 @@ globals@^13.6.0, globals@^13.9.0:
   dependencies:
     type-fest "^0.20.2"
 
+graceful-fs@^4.1.6, graceful-fs@^4.2.0:
+  version "4.2.11"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
+  integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
+
 has-flag@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
@@ -1311,6 +1330,22 @@ json-stable-stringify-without-jsonify@^1.0.1:
   resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
   integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==
 
+jsonfile@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb"
+  integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==
+  optionalDependencies:
+    graceful-fs "^4.1.6"
+
+jsonfile@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-5.0.0.tgz#e6b718f73da420d612823996fdf14a03f6ff6922"
+  integrity sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==
+  dependencies:
+    universalify "^0.1.2"
+  optionalDependencies:
+    graceful-fs "^4.1.6"
+
 levn@^0.4.1:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
@@ -2098,6 +2133,21 @@ tunnel-agent@^0.6.0:
   dependencies:
     safe-buffer "^5.0.1"
 
[email protected]:
+  version "14.0.0"
+  resolved "https://registry.yarnpkg.com/twemoji-parser/-/twemoji-parser-14.0.0.tgz#13dabcb6d3a261d9efbf58a1666b182033bf2b62"
+  integrity sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==
+
+twemoji@^14.0.2:
+  version "14.0.2"
+  resolved "https://registry.yarnpkg.com/twemoji/-/twemoji-14.0.2.tgz#c53adb01dab22bf4870f648ca8cc347ce99ee37e"
+  integrity sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==
+  dependencies:
+    fs-extra "^8.0.1"
+    jsonfile "^5.0.0"
+    twemoji-parser "14.0.0"
+    universalify "^0.1.2"
+
 type-check@^0.4.0, type-check@~0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
@@ -2123,6 +2173,11 @@ type-is@~1.6.18:
     media-typer "0.3.0"
     mime-types "~2.1.24"
 
+universalify@^0.1.0, universalify@^0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
+  integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
+
 [email protected], unpipe@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"