rhombus 0.2.21

Next generation extendable CTF framework with batteries included
Documentation
{% extends "layout.html" %}
{% import "icons.html" as icons %}
{% import "card.html" as card %}

{% block content %}
  <div class="container mb-6">
    <div class="w-full mt-4 relative" style="height: 600px">
      <div class="size-full absolute" id="chart" hx-preserve="true"></div>
      <div
        id="empty-banner"
        style="display: none;"
        class="absolute z-10 size-full items-center justify-center"
      >
        <span class="text-2xl font-semibold">No historical data yet!</span>
      </div>
    </div>

    <div class="flex gap-6 mt-6 flex-col lg:flex-row">
      {% if divisions | length > 1 %}
        <div class="lg:max-w-96">
          {% call card.root() %}
            {% call card.header() %}
              {% call card.title() %}
                Division
              {% endcall %}
              {% call card.description() %}
                Select the division for the scoreboard
              {% endcall %}
            {% endcall %}
            {% call card.content() %}
              <div class="flex gap-2 flex-wrap">
                {% for division_id, division in divisions | items %}
                  <a
                    hx-boost="true"
                    hx-select="#screen"
                    hx-target="#screen"
                    hx-swap="outerHTML"
                    href="/scoreboard/{{ division_id }}"
                    class="relative"
                  >
                    <div
                      class="flex items-center px-3 py-2 rounded-md border border-input text-sm hover:bg-secondary"
                    >
                      {{ division.name }}
                    </div>
                    {% if selected_division_id == division_id %}
                      <div
                        class="size-3 rounded-full cursor-pointer bg-primary absolute -top-1 -right-1"
                      ></div>
                    {% endif %}
                  </a>
                {% endfor %}
              </div>
              {% if user.is_admin %}
                <div class="flex mt-4">
                  <button
                    class="px-4 py-2 gap-2 border border-input bg-background hover:bg-accent hover:text-accent-foreground inline-flex items-center justify-center rounded-md text-sm font-medium whitespace-nowrap ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
                    onclick="copyDivisionCTFtimeScoreboardFeed()"
                    title="Copy CTFtime scoreboard feed to clipboard"
                  >
                    <span>CTFtime Scoreboard Feed</span> {{ icons.copy() }}
                  </button>
                  <script>
                    function copyDivisionCTFtimeScoreboardFeed() {
                      navigator.clipboard.writeText(`${location}/ctftime`).then(
                        () => {
                          rhombus.toast.success(
                            "Copied CTFtime scoreboard feed to clipboard",
                          );
                        },
                        () => {
                          rhombus.toast.error(
                            "Failed to copy CTFtime scoreboard feed to clipboard",
                          );
                        },
                      );
                    }
                  </script>
                </div>
              {% endif %}
            {% endcall %}
          {% endcall %}
        </div>
      {% endif %}
      <div class="grow">
        {% call card.root() %}
          {% call card.header() %}
            {% call card.title() %}
              Leaderboard
            {% endcall %}
            {% call card.description() %}
              Current standing of all teams in the division
            {% endcall %}
          {% endcall %}
          {% call card.content() %}
            {% if leaderboard.entries | length > 0 %}
              <div class="w-full flex">
                <table class="table-fixed grow">
                  {% for entry in leaderboard.entries %}
                    <tr class="even:bg-secondary *:p-2">
                      <td>{{ entry.rank }}</td>
                      <td class="w-2/3">
                        <a
                          hx-boost="true"
                          hx-select="#screen"
                          hx-target="#screen"
                          hx-swap="outerHTML"
                          href="/team/{{ entry.team_id }}"
                          >{{ entry.team_name }}</a
                        >
                      </td>
                      <td>{{ entry.score }} points</td>
                    </tr>
                  {% endfor %}
                </table>
              </div>
              {% if leaderboard.num_pages > 1 %}
                <div class="flex justify-center">
                  <div>
                    {% for i in range(leaderboard.num_pages) %}
                      {% if i == 0 and page_num != 0 %}
                        <a
                          hx-boost="true"
                          hx-select="#screen"
                          hx-target="#screen"
                          hx-swap="outerHTML"
                          class="underline"
                          href="/scoreboard/{{ selected_division_id }}"
                          >1</a
                        >
                      {% elif i != page_num %}
                        <a
                          hx-boost="true"
                          hx-select="#screen"
                          hx-target="#screen"
                          hx-swap="outerHTML"
                          class="underline"
                          href="/scoreboard/{{ selected_division_id }}?page={{ i + 1 }}"
                          >{{ i + 1 }}</a
                        >
                      {% else %}
                        <span>{{ i + 1 }}</span>
                      {% endif %}
                    {% endfor %}
                  </div>
                </div>
              {% endif %}
            {% else %}
              <p>No teams in this division have solved a challenge yet!</p>
            {% endif %}
          {% endcall %}
        {% endcall %}
      </div>
    </div>
  </div>
  <div class="hidden" id="initial-scoreboard-json">
    {{- scoreboard | tojson -}}
  </div>
  <script>
    (function () {
      const chartElement = document.getElementById("chart");

      const setScoreboardTheme = () => {
        const isDark = document.documentElement.classList.contains("dark");
        if (window.scoreboardChart) {
          window.scoreboardChart.dispose();
        }
        if (isDark) {
          window.scoreboardChart = echarts.init(
            chartElement,
            "dark",
            undefined,
          );
        } else {
          window.scoreboardChart = echarts.init(chartElement, null, undefined);
        }
        if (window.mostRecentOptions)
          window.scoreboardChart.setOption(window.mostRecentOptions);
        window.scoreboardChart.resize();
      };

      setScoreboardTheme();
      const observer = new MutationObserver(() => setScoreboardTheme());
      observer.observe(document.documentElement, {
        attributes: true,
        attributeFilter: ["class"],
      });

      const refetchHandler = async () => {
        const scoreboard_data = await (
          await fetch(window.location.pathname + ".json", {
            headers: { accept: "application/json" },
          })
        ).json();
        render(scoreboard_data);
      };

      const resizeHandler = () => {
        window.scoreboardChart.resize();
      };

      window.addEventListener("resize", resizeHandler);
      window.addEventListener("focus", refetchHandler);
      const periodicRefetch = setInterval(refetchHandler, 1000 * 60 * 2);

      window.deregister = () => {
        window.removeEventListener("resize", resizeHandler);
        window.removeEventListener("focus", refetchHandler);
        clearInterval(periodicRefetch);
      };

      render(
        JSON.parse(
          document.getElementById("initial-scoreboard-json").innerHTML,
        ),
      );

      function render(scoreboard_data) {
        if (Object.values(scoreboard_data).length === 0) {
          document.getElementById("empty-banner").style.display = "flex";
        }

        const seriesCommon = {
          type: "line",
          emphasis: {
            focus: "series",
          },
          lineStyle: {
            width: 4,
            // cap: "round",
          },
          // symbolSize: 10,
        };

        const optionsCommon = {
          tooltip: {
            trigger: "axis",
          },
          grid: {
            top: "70",
            left: "3%",
            right: "3%",
            bottom: "60",
            containLabel: true,
          },
          toolbox: {
            feature: {
              saveAsImage: {},
              dataZoom: {},
              myFullscreen: {
                show: true,
                title: "Fullscreen",
                icon: "path://M432.45,595.444c0,2.177-4.661,6.82-11.305,6.82c-6.475,0-11.306-4.567-11.306-6.82s4.852-6.812,11.306-6.812C427.841,588.632,432.452,593.191,432.45,595.444L432.45,595.444z M421.155,589.876c-3.009,0-5.448,2.495-5.448,5.572s2.439,5.572,5.448,5.572c3.01,0,5.449-2.495,5.449-5.572C426.604,592.371,424.165,589.876,421.155,589.876L421.155,589.876z M421.146,591.891c-1.916,0-3.47,1.589-3.47,3.549c0,1.959,1.554,3.548,3.47,3.548s3.469-1.589,3.469-3.548C424.614,593.479,423.062,591.891,421.146,591.891L421.146,591.891zM421.146,591.891",
                onclick() {
                  document.exitFullscreen().catch(() => {});
                  chartElement.requestFullscreen();
                },
              },
            },
          },
          xAxis: {
            type: "time",
            minInterval: 1000 * 60 * 60,
          },
          dataZoom: [
            {
              type: "slider",
              height: 20,
              top: 35,
            },
          ],
          animationDuration: 0,
          backgroundColor: "transparent",
          yAxis: {
            type: "value",
            min: 0,
            minInterval: 100,
          },
        };

        const timestampOffset = new Date().getTimezoneOffset() * 60;

        const series = Object.values(scoreboard_data).map((team) => ({
          name: team.team_name,
          data: team.series.map((d) => ({
            value: [d.timestamp * 1000 + timestampOffset, d.total_score],
          })),
          ...seriesCommon,
        }));

        const options = {
          ...optionsCommon,
          legend: {
            data: series.map((s) => s.name),
            type: "scroll",
            orient: "horizontal",
            align: "left",
            bottom: 20,
          },
          series,
        };

        window.mostRecentOptions = options;
        window.scoreboardChart.setOption(options, true);
      }
    })();
  </script>
{% endblock %}