aboutsummaryrefslogtreecommitdiff
path: root/174bg/manager/public
diff options
context:
space:
mode:
authorAlex Pooley (@zuedev) <zuedev@gmail.com>2026-07-25 17:27:38 +0100
committerAlex Pooley (@zuedev) <zuedev@gmail.com>2026-07-25 17:27:38 +0100
commita85a54651070765d6254287369835d747be1c276 (patch)
tree6598d2e36bf5a3770c9450e1acceefcfcb092842 /174bg/manager/public
parent0d001f0094f2784033be52fa2a28102c271ffb1e (diff)
downloadunnamed-group-a85a54651070765d6254287369835d747be1c276.tar
unnamed-group-a85a54651070765d6254287369835d747be1c276.tar.gz
unnamed-group-a85a54651070765d6254287369835d747be1c276.tar.bz2
unnamed-group-a85a54651070765d6254287369835d747be1c276.tar.xz
unnamed-group-a85a54651070765d6254287369835d747be1c276.zip
manager is now using react
Diffstat (limited to '174bg/manager/public')
-rw-r--r--174bg/manager/public/index.html1163
-rw-r--r--174bg/manager/public/theme.css423
2 files changed, 0 insertions, 1586 deletions
diff --git a/174bg/manager/public/index.html b/174bg/manager/public/index.html
deleted file mode 100644
index 002417a..0000000
--- a/174bg/manager/public/index.html
+++ /dev/null
@@ -1,1163 +0,0 @@
-<!doctype html>
-<html lang="en">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <link rel="preconnect" href="https://fonts.googleapis.com" />
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
- <link
- href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Rajdhani:wght@400;500;600;700&display=swap"
- rel="stylesheet"
- />
- <link rel="stylesheet" href="theme.css" />
- </head>
- <body>
- <h1>174th Battle Group: Manager</h1>
-
- <section id="anonymous">
- <p>You are not logged in. Please log in to access this site:</p>
- <button id="login">Login with Discord</button>
- </section>
-
- <p id="oauth-status" style="font-size: 0.9rem"></p>
-
- <section
- id="authed"
- style="display: none; flex-direction: column; gap: 1em"
- >
- <div id="welcome">
- <img id="welcome-avatar" src="" alt="" />
- <span id="welcome-text"></span>
- </div>
- <div id="required-info" style="display: flex; flex-direction: column">
- <h2 style="margin-bottom: 0">Required Information</h2>
- <div><b>174BG ID:</b> <span id="174bg-id"></span></div>
- <div>
- <b>UEE Citizen Record ID:</b> <span id="uee-citizen-record-id"></span>
- </div>
- <div><b>RSI Display Name:</b> <span id="rsi-display-name"></span></div>
- <div><b>RSI Handle:</b> <span id="rsi-handle"></span></div>
- <div>
- <b>Joined RSI Organisation:</b> <span id="joined-rsi-org"></span>
- </div>
- <div><b>Email:</b> <span id="user-email"></span></div>
- <div><b>Branch:</b> <span id="174bg-branch"></span></div>
- <div><b>Rank Number:</b> <span id="174bg-rank-number"></span></div>
- <div><b>Rank Name:</b> <span id="174bg-rank-name"></span></div>
- <div><b>Staff Roles:</b> <span id="174bg-staff-roles"></span></div>
- <div id="profile-warning" hidden>
- ⚠️ Your profile is incomplete. Message an officer ASAP!
- </div>
- </div>
- <div id="preferences"></div>
- <div id="oncall"></div>
- <div id="ledger"></div>
- <div>
- <button id="logout">Logout</button>
- </div>
- </section>
- </body>
- <script type="module">
- import PocketBase from "https://esm.sh/pocketbase@0.27.0";
-
- const pb = new PocketBase("https://db.174bg.net");
-
- function populateWelcome() {
- const record = pb.authStore.record ?? {};
- const avatarEl = document.getElementById("welcome-avatar");
- const textEl = document.getElementById("welcome-text");
-
- const name = record["RSI_Handle"] || record["name"] || "Pilot";
- textEl.textContent = `Welcome back, ${name}!`;
-
- // PocketBase avatar URL pattern
- const avatarFile = record["avatar"];
- if (avatarFile) {
- avatarEl.src = `https://db.174bg.net/api/files/${record.collectionId}/${record.id}/${avatarFile}`;
- avatarEl.alt = name;
- avatarEl.hidden = false;
- } else {
- avatarEl.removeAttribute("src");
- avatarEl.alt = "";
- avatarEl.hidden = true;
- }
- }
-
- // ---------------------------------------------------------------------------
- // Required-info field definitions — map span IDs to PocketBase field names
- // ---------------------------------------------------------------------------
- const REQUIRED_INFO_FIELDS = [
- { id: "174bg-id", field: "id" },
- { id: "uee-citizen-record-id", field: "UEE_Citizen_Record_ID" },
- { id: "rsi-display-name", field: "RSI_Display_Name" },
- { id: "rsi-handle", field: "RSI_Handle" },
- { id: "joined-rsi-org", field: "joined_rsi_org" },
- { id: "user-email", field: "email" },
- { id: "174bg-branch", field: "Branch" },
- { id: "174bg-rank-number", field: "Rank_Number" },
- ];
-
- const RANK_NAMES = {
- Naval: [
- "Cadet",
- "Ensign",
- "Lieutenant",
- "Captain",
- "Commodore",
- "Admiral",
- ],
- Marine: [
- "Private",
- "Corporal",
- "Sergeant",
- "Major",
- "Commander",
- "General",
- ],
- Auxiliary: [
- "Trainee",
- "Technician",
- "Specialist",
- "Supervisor",
- "Chief",
- "Marshal",
- ],
- };
-
- function populateRequiredInfo() {
- const record = pb.authStore.record ?? {};
- let anyMissing = false;
-
- for (const { id, field } of REQUIRED_INFO_FIELDS) {
- const span = document.getElementById(id);
- const value = record[field];
- // Treat only null/undefined/empty-string as missing so legitimately
- // falsy values (e.g. Rank_Number 0, joined_rsi_org false) still show.
- if (value != null && value !== "") {
- span.textContent = value;
- span.style.color = "";
- } else {
- span.textContent = "MISSING";
- span.style.color = "red";
- anyMissing = true;
- }
- }
-
- // Derive rank name from branch + rank number
- const rankSpan = document.getElementById("174bg-rank-name");
- const branch = record["Branch"];
- const rankNumber = record["Rank_Number"];
- const rankName = RANK_NAMES[branch]?.[rankNumber];
- if (rankName) {
- rankSpan.textContent = rankName;
- rankSpan.style.color = "";
- } else {
- rankSpan.textContent = "MISSING";
- rankSpan.style.color = "red";
- anyMissing = true;
- }
-
- // Staff roles — optional list; show "none" when empty (not MISSING)
- const staffSpan = document.getElementById("174bg-staff-roles");
- const staffRoles = record["StaffRoles"];
- const staffList = Array.isArray(staffRoles)
- ? staffRoles
- : staffRoles
- ? [staffRoles]
- : [];
- if (staffList.length > 0) {
- staffSpan.textContent = staffList.join(", ");
- staffSpan.style.color = "";
- } else {
- staffSpan.textContent = "none";
- staffSpan.style.color = "";
- }
-
- if (anyMissing) {
- document.getElementById("profile-warning").hidden = false;
- } else {
- document.getElementById("profile-warning").hidden = true;
- }
- }
-
- let ROLES = [];
-
- function fetchRoles() {
- ROLES = [
- {
- branch: "Naval",
- value: "Ship Captain",
- text: "Ship Captain",
- link: "https://handbook.174bg.net/#ship-captain",
- },
- {
- branch: "Naval",
- value: "Pilot",
- text: "Pilot",
- link: "https://handbook.174bg.net/#pilot",
- },
- {
- branch: "Naval",
- value: "Helmsman",
- text: "Helmsman",
- link: "https://handbook.174bg.net/#helmsman",
- },
- {
- branch: "Naval",
- value: "Gunner",
- text: "Gunner",
- link: "https://handbook.174bg.net/#gunner",
- },
- {
- branch: "Marine",
- value: "Tactical Marine",
- text: "Tactical Marine",
- link: "https://handbook.174bg.net/#tactical-marine",
- },
- {
- branch: "Marine",
- value: "Heavy Marine",
- text: "Heavy Marine",
- link: "https://handbook.174bg.net/#heavy-marine",
- },
- {
- branch: "Marine",
- value: "Scout Marine",
- text: "Scout Marine",
- link: "https://handbook.174bg.net/#scout-marine",
- },
- {
- branch: "Marine",
- value: "Combat Medic",
- text: "Combat Medic",
- link: "https://handbook.174bg.net/#combat-medic",
- },
- {
- branch: "Marine",
- value: "Combat Engineer",
- text: "Combat Engineer",
- link: "https://handbook.174bg.net/#combat-engineer",
- },
- {
- branch: "Marine",
- value: "Warden",
- text: "Warden",
- link: "https://handbook.174bg.net/#warden",
- },
- {
- branch: "Auxiliary",
- value: "Engineer",
- text: "Engineer",
- link: "https://handbook.174bg.net/#engineer",
- },
- {
- branch: "Auxiliary",
- value: "Hazardous Materials Specialist",
- text: "Hazardous Materials Specialist",
- link: "https://handbook.174bg.net/#hazardous-materials-specialist",
- },
- {
- branch: "Auxiliary",
- value: "Medical Doctor",
- text: "Medical Doctor",
- link: "https://handbook.174bg.net/#medical-doctor",
- },
- {
- branch: "Auxiliary",
- value: "Quartermaster",
- text: "Quartermaster",
- link: "https://handbook.174bg.net/#quartermaster",
- },
- {
- branch: "Auxiliary",
- value: "Cargo Technician",
- text: "Cargo Technician",
- link: "https://handbook.174bg.net/#cargo-technician",
- },
- {
- branch: "Auxiliary",
- value: "Craftsman",
- text: "Craftsman",
- link: "https://handbook.174bg.net/#craftsman",
- },
- {
- branch: "Auxiliary",
- value: "Mining Technician",
- text: "Mining Technician",
- link: "https://handbook.174bg.net/#mining-technician",
- },
- {
- branch: "Auxiliary",
- value: "Salvage Operator",
- text: "Salvage Operator",
- link: "https://handbook.174bg.net/#salvage-operator",
- },
- {
- branch: "Auxiliary",
- value: "Expeditionary Analyst",
- text: "Expeditionary Analyst",
- link: "https://handbook.174bg.net/#expeditionary-analyst",
- },
- {
- branch: "Auxiliary",
- value: "Intelligence Agent",
- text: "Intelligence Agent",
- link: "https://handbook.174bg.net/#intelligence-agent",
- },
- ];
- }
-
- let rolesEditing = false;
- let rolesTbody;
- let rolesEditBtn;
- let rolesCancelBtn;
- let rolesStatus;
-
- // OtherJsonData may be stored as an object or a JSON string
- function getOtherJsonData() {
- const raw = pb.authStore.record?.OtherJsonData;
- if (!raw) return {};
- if (typeof raw === "string") {
- try {
- return JSON.parse(raw) || {};
- } catch {
- return {};
- }
- }
- return { ...raw };
- }
-
- function getFavouriteRole() {
- return getOtherJsonData().FavouriteRole ?? null;
- }
-
- function renderRoles(saved, favourite, editMode) {
- rolesTbody.querySelectorAll("tr:not([data-separator])").forEach((tr) => {
- const tds = tr.querySelectorAll("td");
- const prefTd = tds[1];
- const favTd = tds[2];
- const value = tr.dataset.roleValue;
- if (editMode) {
- const cb = document.createElement("input");
- cb.type = "checkbox";
- cb.checked = saved.includes(value);
- prefTd.replaceChildren(cb);
-
- const radio = document.createElement("input");
- radio.type = "radio";
- radio.name = "favourite-role";
- radio.value = value;
- radio.checked = favourite === value;
- radio.dataset.wasChecked = radio.checked ? "true" : "false";
- // Clicking an already-selected radio clears the favourite
- radio.addEventListener("click", () => {
- if (radio.dataset.wasChecked === "true") {
- radio.checked = false;
- radio.dataset.wasChecked = "false";
- } else {
- rolesTbody
- .querySelectorAll("input[name=favourite-role]")
- .forEach((r) => (r.dataset.wasChecked = "false"));
- radio.dataset.wasChecked = "true";
- }
- });
- favTd.replaceChildren(radio);
- } else {
- prefTd.textContent = saved.includes(value) ? "✅" : "❌";
- favTd.textContent = favourite === value ? "⭐" : "";
- }
- });
- }
-
- function buildRolesSection() {
- const container = document.getElementById("preferences");
- container.innerHTML = "";
-
- const heading = document.createElement("div");
- const title = document.createElement("b");
- title.textContent = "Role Preferences";
- rolesEditBtn = document.createElement("button");
- rolesEditBtn.type = "button";
- rolesEditBtn.textContent = "✏️ Edit";
- rolesCancelBtn = document.createElement("button");
- rolesCancelBtn.type = "button";
- rolesCancelBtn.textContent = "❌ Cancel";
- rolesCancelBtn.hidden = true;
- heading.append(title, " ", rolesEditBtn, " ", rolesCancelBtn);
-
- const table = document.createElement("table");
- const thead = document.createElement("thead");
- const headerRow = document.createElement("tr");
- for (const text of ["Role", "✅", "⭐"]) {
- const th = document.createElement("th");
- th.textContent = text;
- headerRow.appendChild(th);
- }
- thead.appendChild(headerRow);
-
- rolesTbody = document.createElement("tbody");
- let lastBranch = null;
- for (const role of ROLES) {
- if (role.branch !== lastBranch) {
- lastBranch = role.branch;
- const sep = document.createElement("tr");
- sep.dataset.separator = "true";
- const tdSep = document.createElement("td");
- tdSep.colSpan = 3;
- tdSep.textContent = role.branch ?? "";
- tdSep.style.cssText =
- "font-weight:600; padding-top:0.75rem; padding-bottom:0.25rem; border-bottom:none; opacity:0.6; font-size:0.8rem; text-transform:uppercase; letter-spacing:0.05em";
- sep.appendChild(tdSep);
- rolesTbody.appendChild(sep);
- }
- const tr = document.createElement("tr");
- tr.dataset.roleValue = role.value;
- const tdName = document.createElement("td");
- if (role.link) {
- const a = document.createElement("a");
- a.href = role.link;
- a.textContent = role.text;
- a.target = "_blank";
- a.rel = "noopener noreferrer";
- tdName.appendChild(a);
- } else {
- tdName.textContent = role.text;
- }
- const tdCheck = document.createElement("td");
- const tdFav = document.createElement("td");
- tr.append(tdName, tdCheck, tdFav);
- rolesTbody.appendChild(tr);
- }
-
- table.append(thead, rolesTbody);
- rolesStatus = document.createElement("p");
- rolesStatus.style.cssText = "margin:0; font-size:0.85rem";
- container.append(heading, table, rolesStatus);
- }
-
- function loadRoles() {
- rolesEditing = false;
- rolesEditBtn.textContent = "✏️ Edit";
- rolesCancelBtn.hidden = true;
- renderRoles(
- pb.authStore.record?.RolePreferencesSelect ?? [],
- getFavouriteRole(),
- false,
- );
- }
-
- let oncallEditing = false;
- let oncallTbody;
- let oncallTzContainer;
- let oncallEditBtn;
- let oncallCancelBtn;
- let oncallStatus;
-
- const DAYS_OF_WEEK = [
- "Monday",
- "Tuesday",
- "Wednesday",
- "Thursday",
- "Friday",
- "Saturday",
- "Sunday",
- ];
-
- // Sunday-first, matching Date#getDay()/getUTCDay() and Intl's "weekday"
- const WEEKDAY_NAMES = [
- "Sunday",
- "Monday",
- "Tuesday",
- "Wednesday",
- "Thursday",
- "Friday",
- "Saturday",
- ];
- const WEEKDAY_INDEX = Object.fromEntries(
- WEEKDAY_NAMES.map((d, i) => [d, i]),
- );
-
- function pad2(n) {
- return String(n).padStart(2, "0");
- }
-
- function getBrowserTimeZone() {
- try {
- return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
- } catch {
- return "UTC";
- }
- }
-
- function listTimeZones() {
- if (typeof Intl.supportedValuesOf === "function") {
- try {
- return Intl.supportedValuesOf("timeZone");
- } catch {
- // fall through to the fallback list below
- }
- }
- return [
- "UTC",
- "America/Los_Angeles",
- "America/Denver",
- "America/Chicago",
- "America/New_York",
- "America/Sao_Paulo",
- "Europe/London",
- "Europe/Paris",
- "Europe/Berlin",
- "Europe/Moscow",
- "Africa/Johannesburg",
- "Asia/Dubai",
- "Asia/Kolkata",
- "Asia/Shanghai",
- "Asia/Tokyo",
- "Australia/Sydney",
- "Pacific/Auckland",
- ];
- }
-
- // Offset (ms) of `timeZone` from UTC at the instant `date` represents.
- function tzOffsetMs(date, timeZone) {
- const dtf = new Intl.DateTimeFormat("en-US", {
- timeZone,
- hourCycle: "h23",
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- hour: "2-digit",
- minute: "2-digit",
- second: "2-digit",
- });
- const parts = {};
- for (const { type, value } of dtf.formatToParts(date)) {
- parts[type] = value;
- }
- const asUTC = Date.UTC(
- Number(parts.year),
- Number(parts.month) - 1,
- Number(parts.day),
- parts.hour === "24" ? 0 : Number(parts.hour),
- Number(parts.minute),
- Number(parts.second),
- );
- return asUTC - date.getTime();
- }
-
- // Formats `timeZone`'s UTC offset at `date` as "UTC+HH:MM"/"UTC-HH:MM".
- function formatUtcOffset(timeZone, date = new Date()) {
- const totalMinutes = Math.round(tzOffsetMs(date, timeZone) / 60000);
- const sign = totalMinutes < 0 ? "-" : "+";
- const abs = Math.abs(totalMinutes);
- return `UTC${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`;
- }
-
- // Nearest same-or-later calendar date whose local weekday in `timeZone`
- // is `day` - used as a DST-correct anchor for the conversion below.
- function nextLocalDateFor(day, timeZone) {
- const dtf = new Intl.DateTimeFormat("en-US", {
- timeZone,
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- weekday: "long",
- });
- const parts = {};
- for (const { type, value } of dtf.formatToParts(new Date())) {
- parts[type] = value;
- }
- const diff = (WEEKDAY_INDEX[day] - WEEKDAY_INDEX[parts.weekday] + 7) % 7;
- const base = new Date(
- Date.UTC(
- Number(parts.year),
- Number(parts.month) - 1,
- Number(parts.day),
- ),
- );
- base.setUTCDate(base.getUTCDate() + diff);
- return base;
- }
-
- // Nearest same-or-later UTC calendar date whose UTC weekday is `day`.
- function nextUtcDateFor(day) {
- const now = new Date();
- const diff = (WEEKDAY_INDEX[day] - now.getUTCDay() + 7) % 7;
- const base = new Date(
- Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
- );
- base.setUTCDate(base.getUTCDate() + diff);
- return base;
- }
-
- // Converts a weekday + "HH:MM" wall-clock time in `timeZone` to the
- // equivalent UTC weekday + "HH:MM" (the day may shift due to the offset).
- function localDayTimeToUtc(day, time, timeZone) {
- const [hour, minute] = time.split(":").map(Number);
- const base = nextLocalDateFor(day, timeZone);
- const guess = Date.UTC(
- base.getUTCFullYear(),
- base.getUTCMonth(),
- base.getUTCDate(),
- hour,
- minute,
- );
- const offset = tzOffsetMs(new Date(guess), timeZone);
- const actual = new Date(guess - offset);
- return {
- day: WEEKDAY_NAMES[actual.getUTCDay()],
- time: `${pad2(actual.getUTCHours())}:${pad2(actual.getUTCMinutes())}`,
- };
- }
-
- // Converts a UTC weekday + "HH:MM" back to weekday + "HH:MM" wall-clock
- // time in `timeZone`.
- function utcDayTimeToLocal(day, time, timeZone) {
- const [hour, minute] = time.split(":").map(Number);
- const base = nextUtcDateFor(day);
- const instant = new Date(
- Date.UTC(
- base.getUTCFullYear(),
- base.getUTCMonth(),
- base.getUTCDate(),
- hour,
- minute,
- ),
- );
- const dtf = new Intl.DateTimeFormat("en-US", {
- timeZone,
- hourCycle: "h23",
- weekday: "long",
- hour: "2-digit",
- minute: "2-digit",
- });
- const parts = {};
- for (const { type, value } of dtf.formatToParts(instant)) {
- parts[type] = value;
- }
- return {
- day: parts.weekday,
- time: `${parts.hour === "24" ? "00" : parts.hour}:${parts.minute}`,
- };
- }
-
- // onCallSchedule is stored as JSON, UTC-normalized:
- // { timezone: "IANA/Zone", [utcDay]: { available, start, end } }
- function getOnCallRaw() {
- const raw = pb.authStore.record?.onCallSchedule;
- if (!raw) return {};
- if (typeof raw === "string") {
- try {
- return JSON.parse(raw) || {};
- } catch {
- return {};
- }
- }
- return { ...raw };
- }
-
- function getEffectiveTimezone(raw) {
- return raw.timezone || getBrowserTimeZone();
- }
-
- // Converts the stored UTC schedule to local wall-clock times, keyed by
- // local weekday, for display/editing.
- function utcScheduleToLocal(raw, timeZone) {
- const local = {};
- for (const day of DAYS_OF_WEEK) {
- const entry = raw[day];
- if (!entry?.available) continue;
- const start = utcDayTimeToLocal(day, entry.start, timeZone);
- const end = utcDayTimeToLocal(day, entry.end, timeZone);
- local[start.day] = {
- available: true,
- start: start.time,
- end: end.time,
- };
- }
- return local;
- }
-
- // Converts a local-keyed schedule (from the edit form) to UTC for
- // storage.
- function localScheduleToUtc(local, timeZone) {
- const utc = {};
- for (const day of DAYS_OF_WEEK) {
- const entry = local[day];
- if (!entry?.available) continue;
- const start = localDayTimeToUtc(day, entry.start || "00:00", timeZone);
- const end = localDayTimeToUtc(day, entry.end || "00:00", timeZone);
- utc[start.day] = { available: true, start: start.time, end: end.time };
- }
- return utc;
- }
-
- // Legacy records saved before timezone support has no `timezone` key -
- // those values are already local wall-clock times, so skip conversion.
- function getLocalOnCallSchedule() {
- const raw = getOnCallRaw();
- const timeZone = getEffectiveTimezone(raw);
- const schedule = raw.timezone ? utcScheduleToLocal(raw, timeZone) : raw;
- return { timeZone, schedule };
- }
-
- function renderOnCallTimezone(timeZone, editMode) {
- if (editMode) {
- const zones = listTimeZones();
- const options = zones.includes(timeZone) ? zones : [timeZone, ...zones];
- const now = new Date();
- const select = document.createElement("select");
- for (const tz of options) {
- const option = document.createElement("option");
- option.value = tz;
- option.textContent = `${tz} (${formatUtcOffset(tz, now)})`;
- option.selected = tz === timeZone;
- select.appendChild(option);
- }
- oncallTzContainer.replaceChildren(select);
- } else {
- oncallTzContainer.textContent = `${timeZone} (${formatUtcOffset(timeZone)})`;
- }
- }
-
- function getSelectedTimezone() {
- const select = oncallTzContainer.querySelector("select");
- return select ? select.value : getEffectiveTimezone(getOnCallRaw());
- }
-
- function renderOnCallSchedule(schedule, editMode) {
- oncallTbody.querySelectorAll("tr").forEach((tr) => {
- const day = tr.dataset.day;
- const entry = schedule[day] ?? {};
- const tds = tr.querySelectorAll("td");
- const availTd = tds[1];
- const startTd = tds[2];
- const endTd = tds[3];
-
- if (editMode) {
- const cb = document.createElement("input");
- cb.type = "checkbox";
- cb.checked = !!entry.available;
- availTd.replaceChildren(cb);
-
- const startInput = document.createElement("input");
- startInput.type = "time";
- startInput.value = entry.start ?? "";
- startInput.disabled = !cb.checked;
-
- const endInput = document.createElement("input");
- endInput.type = "time";
- endInput.value = entry.end ?? "";
- endInput.disabled = !cb.checked;
-
- cb.addEventListener("change", () => {
- startInput.disabled = !cb.checked;
- endInput.disabled = !cb.checked;
- });
-
- startTd.replaceChildren(startInput);
- endTd.replaceChildren(endInput);
- } else {
- availTd.textContent = entry.available ? "✅" : "❌";
- startTd.textContent =
- entry.available && entry.start ? entry.start : "—";
- endTd.textContent = entry.available && entry.end ? entry.end : "—";
- }
- });
- }
-
- function buildOnCallSection() {
- const container = document.getElementById("oncall");
- container.innerHTML = "";
-
- const heading = document.createElement("div");
- const title = document.createElement("b");
- title.textContent = "On-Call Schedule";
- oncallEditBtn = document.createElement("button");
- oncallEditBtn.type = "button";
- oncallEditBtn.textContent = "✏️ Edit";
- oncallCancelBtn = document.createElement("button");
- oncallCancelBtn.type = "button";
- oncallCancelBtn.textContent = "❌ Cancel";
- oncallCancelBtn.hidden = true;
- heading.append(title, " ", oncallEditBtn, " ", oncallCancelBtn);
-
- const hint = document.createElement("p");
- hint.style.cssText =
- "margin:0.25rem 0 0.75rem; font-size:0.85rem; color:var(--text-dim)";
- hint.textContent =
- "Let the group know which days and hours you're usually free to join operations.";
-
- const tzRow = document.createElement("div");
- tzRow.style.cssText =
- "display:flex; align-items:center; gap:0.5rem; margin-bottom:0.75rem";
- const tzLabel = document.createElement("b");
- tzLabel.textContent = "Timezone:";
- oncallTzContainer = document.createElement("span");
- tzRow.append(tzLabel, oncallTzContainer);
-
- const table = document.createElement("table");
- const thead = document.createElement("thead");
- const headerRow = document.createElement("tr");
- for (const text of ["Day", "Free", "From", "To"]) {
- const th = document.createElement("th");
- th.textContent = text;
- headerRow.appendChild(th);
- }
- thead.appendChild(headerRow);
-
- oncallTbody = document.createElement("tbody");
- for (const day of DAYS_OF_WEEK) {
- const tr = document.createElement("tr");
- tr.dataset.day = day;
- const tdDay = document.createElement("td");
- tdDay.textContent = day;
- const tdAvail = document.createElement("td");
- const tdStart = document.createElement("td");
- const tdEnd = document.createElement("td");
- tr.append(tdDay, tdAvail, tdStart, tdEnd);
- oncallTbody.appendChild(tr);
- }
-
- table.append(thead, oncallTbody);
- oncallStatus = document.createElement("p");
- oncallStatus.style.cssText = "margin:0.5rem 0 0; font-size:0.85rem";
- container.append(heading, hint, tzRow, table, oncallStatus);
- }
-
- function loadOnCallSchedule() {
- oncallEditing = false;
- oncallEditBtn.textContent = "✏️ Edit";
- oncallCancelBtn.hidden = true;
- const { timeZone, schedule } = getLocalOnCallSchedule();
- renderOnCallTimezone(timeZone, false);
- renderOnCallSchedule(schedule, false);
- }
-
- function memberLabel(record, id) {
- const m = record?.expand?.[id];
- return m ? m.RSI_Handle || m.name || m.id : (record?.[id] ?? "—");
- }
-
- async function loadLedger() {
- const container = document.getElementById("ledger");
- container.innerHTML = "";
-
- const heading = document.createElement("div");
- const title = document.createElement("b");
- title.textContent = "Ledger";
- heading.appendChild(title);
- container.appendChild(heading);
-
- const userId = pb.authStore.record?.id;
- if (!userId) return;
-
- let records;
- try {
- records = await pb.collection("ledger").getFullList({
- expand: "sender,recipient",
- sort: "-created",
- requestKey: null,
- });
- } catch (err) {
- console.error("Failed to load ledger:", err);
- const p = document.createElement("p");
- p.className = "empty";
- p.style.color = "red";
- p.textContent = `Failed to load ledger: ${err?.message ?? String(err)}`;
- container.appendChild(p);
- return;
- }
-
- if (records.length === 0) {
- const p = document.createElement("p");
- p.className = "empty";
- p.textContent = "No ledger entries.";
- container.appendChild(p);
- return;
- }
-
- const table = document.createElement("table");
- const thead = document.createElement("thead");
- const headerRow = document.createElement("tr");
- for (const text of ["Date", "Sender", "Recipient", "UEC", "Note"]) {
- const th = document.createElement("th");
- th.textContent = text;
- headerRow.appendChild(th);
- }
- thead.appendChild(headerRow);
-
- const tbody = document.createElement("tbody");
- for (const rec of records) {
- const tr = document.createElement("tr");
-
- const tdDate = document.createElement("td");
- tdDate.dataset.label = "Date";
- tdDate.textContent = rec.created
- ? new Date(rec.created).toLocaleString()
- : "—";
-
- const tdSender = document.createElement("td");
- tdSender.dataset.label = "Sender";
- tdSender.textContent = memberLabel(rec, "sender");
-
- const tdRecipient = document.createElement("td");
- tdRecipient.dataset.label = "Recipient";
- tdRecipient.textContent = memberLabel(rec, "recipient");
-
- const tdUec = document.createElement("td");
- tdUec.className = "uec";
- tdUec.dataset.label = "UEC";
- tdUec.textContent = Number(rec.uec ?? 0).toLocaleString();
-
- const tdNote = document.createElement("td");
- tdNote.dataset.label = "Note";
- tdNote.textContent = rec.note ?? "";
-
- tr.append(tdDate, tdSender, tdRecipient, tdUec, tdNote);
- tbody.appendChild(tr);
- }
-
- table.append(thead, tbody);
- container.appendChild(table);
- }
-
- const loginBtn = document.getElementById("login");
- const logoutBtn = document.getElementById("logout");
- const anonymousSection = document.getElementById("anonymous");
- const authedSection = document.getElementById("authed");
-
- function initAuthedUI() {
- fetchRoles();
- buildRolesSection();
- buildOnCallSection();
- populateWelcome();
- populateRequiredInfo();
- loadRoles();
- loadOnCallSchedule();
- loadLedger();
- }
-
- function updateAuthUI() {
- const loggedIn = pb.authStore.isValid;
- anonymousSection.style.display = loggedIn ? "none" : "";
- authedSection.style.display = loggedIn ? "flex" : "none";
- }
-
- // Verify the stored session is still valid server-side
- if (pb.authStore.isValid) {
- try {
- await pb.collection("members").authRefresh();
- } catch {
- pb.authStore.clear();
- }
- }
-
- // Complete OAuth redirect if returning from Discord
- const oauthParams = new URLSearchParams(window.location.search);
- const storedProvider = localStorage.getItem("pb_oauth_provider");
- const oauthStatus = document.getElementById("oauth-status");
-
- if (oauthParams.has("code") && oauthParams.has("state")) {
- if (!storedProvider) {
- oauthStatus.textContent =
- "Login error: OAuth state lost (localStorage empty). Please try again.";
- oauthStatus.style.color = "red";
- window.history.replaceState({}, "", window.location.pathname);
- } else {
- oauthStatus.textContent = "Completing login...";
- const provider = JSON.parse(storedProvider);
- localStorage.removeItem("pb_oauth_provider");
- const redirectUrl = window.location.origin + window.location.pathname;
- try {
- await pb
- .collection("members")
- .authWithOAuth2Code(
- provider.name,
- oauthParams.get("code"),
- provider.codeVerifier,
- redirectUrl,
- );
- oauthStatus.style.cssText =
- "color:green; background:#dfd; border:1px solid #080; border-radius:4px; padding:0.5rem";
- oauthStatus.textContent = "✅ Login successful!";
- window.history.replaceState({}, "", window.location.pathname);
- updateAuthUI();
- initAuthedUI();
- } catch (err) {
- console.error("OAuth callback failed:", err);
- const detail = [
- err?.message,
- err?.response ? JSON.stringify(err.response) : null,
- `code=${oauthParams.get("code")?.slice(0, 8)}…`,
- `state=${oauthParams.get("state")?.slice(0, 8)}…`,
- `redirect=${redirectUrl}`,
- `provider=${provider.name}`,
- ]
- .filter(Boolean)
- .join(" | ");
- oauthStatus.style.cssText =
- "color:red; background:#fdd; border:1px solid #c00; border-radius:4px; padding:0.5rem; font-family:monospace; font-size:0.8rem; white-space:pre-wrap; word-break:break-all";
- oauthStatus.textContent = `Login error — send this to your admin:\n${detail}`;
- window.history.replaceState({}, "", window.location.pathname);
- }
- }
- }
-
- if (!oauthParams.has("code") || !oauthParams.has("state")) {
- updateAuthUI();
- if (pb.authStore.isValid) {
- initAuthedUI();
- }
- }
-
- loginBtn.addEventListener("click", async () => {
- loginBtn.disabled = true;
- try {
- const methods = await pb.collection("members").listAuthMethods();
- const provider = methods.oauth2.providers.find(
- (p) => p.name === "discord",
- );
- if (!provider) throw new Error("Discord provider not found");
- const redirectUrl = window.location.origin + window.location.pathname;
- localStorage.setItem("pb_oauth_provider", JSON.stringify(provider));
- window.location.href =
- provider.authUrl + encodeURIComponent(redirectUrl);
- } catch (err) {
- console.error("Login failed:", err);
- loginBtn.disabled = false;
- }
- });
-
- logoutBtn.addEventListener("click", () => {
- pb.authStore.clear();
- updateAuthUI();
- });
-
- rolesEditBtn.addEventListener("click", async () => {
- if (!rolesEditing) {
- rolesEditing = true;
- rolesEditBtn.textContent = "💾 Save";
- rolesCancelBtn.hidden = false;
- renderRoles(
- pb.authStore.record?.RolePreferencesSelect ?? [],
- getFavouriteRole(),
- true,
- );
- } else {
- const selected = [
- ...rolesTbody.querySelectorAll("input[type=checkbox]:checked"),
- ].map((cb) => cb.closest("tr").dataset.roleValue);
- const favRadio = rolesTbody.querySelector(
- "input[name=favourite-role]:checked",
- );
- // Favourite must be one of the selected preferences
- let favourite = favRadio ? favRadio.value : null;
- if (favourite && !selected.includes(favourite)) favourite = null;
- rolesEditBtn.disabled = true;
- rolesCancelBtn.disabled = true;
- try {
- const prefs = selected;
- const other = getOtherJsonData();
- if (favourite) other.FavouriteRole = favourite;
- else delete other.FavouriteRole;
- await pb.collection("members").update(pb.authStore.record.id, {
- RolePreferencesSelect: prefs,
- OtherJsonData: other,
- });
- pb.authStore.record.RolePreferencesSelect = prefs;
- pb.authStore.record.OtherJsonData = other;
- rolesEditing = false;
- rolesEditBtn.textContent = "✏️ Edit";
- rolesCancelBtn.hidden = true;
- rolesStatus.textContent = "";
- renderRoles(selected, favourite, false);
- } catch (err) {
- console.error("Failed to save role preferences:", err);
- const msg = err?.response
- ? JSON.stringify(err.response)
- : (err?.message ?? String(err));
- rolesStatus.style.color = "red";
- rolesStatus.textContent = `Save failed: ${msg}`;
- } finally {
- rolesEditBtn.disabled = false;
- rolesCancelBtn.disabled = false;
- }
- }
- });
-
- rolesCancelBtn.addEventListener("click", () => {
- rolesEditing = false;
- rolesEditBtn.textContent = "✏️ Edit";
- rolesCancelBtn.hidden = true;
- renderRoles(
- pb.authStore.record?.RolePreferencesSelect ?? [],
- getFavouriteRole(),
- false,
- );
- });
-
- oncallEditBtn.addEventListener("click", async () => {
- if (!oncallEditing) {
- oncallEditing = true;
- oncallEditBtn.textContent = "💾 Save";
- oncallCancelBtn.hidden = false;
- const { timeZone, schedule } = getLocalOnCallSchedule();
- renderOnCallTimezone(timeZone, true);
- renderOnCallSchedule(schedule, true);
- } else {
- const timeZone = getSelectedTimezone();
- const local = {};
- oncallTbody.querySelectorAll("tr").forEach((tr) => {
- const day = tr.dataset.day;
- const tds = tr.querySelectorAll("td");
- const cb = tds[1].querySelector("input[type=checkbox]");
- const startInput = tds[2].querySelector("input[type=time]");
- const endInput = tds[3].querySelector("input[type=time]");
- if (cb.checked) {
- local[day] = {
- available: true,
- start: startInput.value || "00:00",
- end: endInput.value || "00:00",
- };
- }
- });
- oncallEditBtn.disabled = true;
- oncallCancelBtn.disabled = true;
- try {
- const utcSchedule = localScheduleToUtc(local, timeZone);
- const payload = { timezone: timeZone, ...utcSchedule };
- await pb.collection("members").update(pb.authStore.record.id, {
- onCallSchedule: payload,
- });
- pb.authStore.record.onCallSchedule = payload;
- oncallEditing = false;
- oncallEditBtn.textContent = "✏️ Edit";
- oncallCancelBtn.hidden = true;
- oncallStatus.textContent = "";
- renderOnCallTimezone(timeZone, false);
- renderOnCallSchedule(local, false);
- } catch (err) {
- console.error("Failed to save on-call schedule:", err);
- const msg = err?.response
- ? JSON.stringify(err.response)
- : (err?.message ?? String(err));
- oncallStatus.style.color = "red";
- oncallStatus.textContent = `Save failed: ${msg}`;
- } finally {
- oncallEditBtn.disabled = false;
- oncallCancelBtn.disabled = false;
- }
- }
- });
-
- oncallCancelBtn.addEventListener("click", () => {
- oncallEditing = false;
- oncallEditBtn.textContent = "✏️ Edit";
- oncallCancelBtn.hidden = true;
- const { timeZone, schedule } = getLocalOnCallSchedule();
- renderOnCallTimezone(timeZone, false);
- renderOnCallSchedule(schedule, false);
- });
- </script>
-</html>
diff --git a/174bg/manager/public/theme.css b/174bg/manager/public/theme.css
deleted file mode 100644
index 907a77e..0000000
--- a/174bg/manager/public/theme.css
+++ /dev/null
@@ -1,423 +0,0 @@
-:root {
- --bg: #070b14;
- --bg-grid: rgba(56, 189, 248, 0.06);
- --panel: rgba(15, 23, 42, 0.72);
- --panel-border: rgba(56, 189, 248, 0.22);
- --panel-border-strong: rgba(56, 189, 248, 0.5);
- --text: #dbe7f3;
- --text-dim: #7e93ab;
- --accent: #38bdf8;
- --accent-2: #22d3ee;
- --accent-glow: rgba(56, 189, 248, 0.45);
- --danger: #f87171;
- --danger-bg: rgba(248, 113, 113, 0.12);
- --success: #34d399;
- --row-alt: rgba(56, 189, 248, 0.04);
-}
-
-* {
- box-sizing: border-box;
-}
-
-html {
- font-family: "Rajdhani", system-ui, sans-serif;
- font-size: 16px;
- color-scheme: dark;
-}
-
-body {
- margin: 0;
- min-height: 100vh;
- padding: 2.5rem 1.25rem 4rem;
- color: var(--text);
- background-color: var(--bg);
- background-image:
- radial-gradient(
- ellipse 80% 60% at 50% -10%,
- rgba(56, 189, 248, 0.18),
- transparent 60%
- ),
- linear-gradient(var(--bg-grid) 1px, transparent 1px),
- linear-gradient(90deg, var(--bg-grid) 1px, transparent 1px);
- background-size:
- 100% 100%,
- 44px 44px,
- 44px 44px;
- background-attachment: fixed;
- display: flex;
- flex-direction: column;
- align-items: center;
-}
-
-h1 {
- font-family: "Orbitron", sans-serif;
- font-weight: 900;
- font-size: clamp(1.5rem, 4vw, 2.4rem);
- letter-spacing: 0.04em;
- text-align: center;
- margin: 0 0 1.75rem;
- background: linear-gradient(120deg, var(--accent), var(--accent-2));
- -webkit-background-clip: text;
- background-clip: text;
- color: transparent;
- text-shadow: 0 0 28px var(--accent-glow);
-}
-
-h2 {
- font-family: "Orbitron", sans-serif;
- font-size: 1.05rem;
- letter-spacing: 0.06em;
- text-transform: uppercase;
- color: var(--accent);
- margin: 0 0 0.75rem;
-}
-
-a {
- color: var(--accent-2);
- text-decoration: none;
- border-bottom: 1px solid transparent;
- transition: border-color 0.15s ease;
-}
-
-a:hover {
- border-bottom-color: var(--accent-2);
-}
-
-body > h1,
-#anonymous,
-#oauth-status,
-#authed {
- width: 100%;
- max-width: 760px;
-}
-
-/* Panels ----------------------------------------------------------- */
-#anonymous,
-#required-info,
-#preferences,
-#oncall,
-#ledger,
-#welcome {
- background: var(--panel);
- border: 1px solid var(--panel-border);
- border-radius: 14px;
- padding: 1.25rem 1.4rem;
- backdrop-filter: blur(10px);
- box-shadow:
- 0 0 0 1px rgba(0, 0, 0, 0.3),
- 0 18px 48px rgba(0, 0, 0, 0.45);
-}
-
-#anonymous p {
- margin-top: 0;
-}
-
-/* Buttons ---------------------------------------------------------- */
-button {
- font-family: "Rajdhani", sans-serif;
- font-weight: 600;
- font-size: 0.95rem;
- letter-spacing: 0.03em;
- color: #04121f;
- background: linear-gradient(120deg, var(--accent), var(--accent-2));
- border: none;
- border-radius: 8px;
- padding: 0.55rem 1.1rem;
- cursor: pointer;
- transition:
- transform 0.12s ease,
- box-shadow 0.2s ease,
- filter 0.2s ease;
- box-shadow: 0 6px 18px var(--accent-glow);
-}
-
-button:hover:not(:disabled) {
- transform: translateY(-1px);
- box-shadow: 0 8px 26px var(--accent-glow);
- filter: brightness(1.08);
-}
-
-button:active:not(:disabled) {
- transform: translateY(0);
-}
-
-button:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- box-shadow: none;
-}
-
-/* Required info ---------------------------------------------------- */
-#required-info > div:not(:first-child):not(#profile-warning) {
- display: flex;
- justify-content: space-between;
- gap: 1rem;
- padding: 0.4rem 0;
- border-bottom: 1px solid rgba(56, 189, 248, 0.08);
-}
-
-#required-info > div b {
- color: var(--text-dim);
- font-weight: 600;
-}
-
-/* Tables ----------------------------------------------------------- */
-#preferences table,
-#oncall table,
-#ledger table {
- border-collapse: collapse;
- width: 100%;
- min-width: 16rem;
-}
-
-#preferences thead th,
-#oncall thead th,
-#ledger thead th {
- text-align: left;
- padding: 0.5rem 0.75rem;
- border-bottom: 2px solid var(--panel-border-strong);
- font-family: "Orbitron", sans-serif;
- font-size: 0.68rem;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--accent);
-}
-
-#preferences tbody tr:nth-child(even),
-#oncall tbody tr:nth-child(even),
-#ledger tbody tr:nth-child(even) {
- background: var(--row-alt);
-}
-
-#preferences tbody td,
-#oncall tbody td,
-#ledger tbody td {
- padding: 0.45rem 0.75rem;
- border-bottom: 1px solid rgba(56, 189, 248, 0.1);
-}
-
-#preferences tbody tr:hover:not([data-separator]),
-#oncall tbody tr:hover,
-#ledger tbody tr:hover {
- background: rgba(56, 189, 248, 0.1);
-}
-
-#preferences thead th:not(:first-child) {
- text-align: center;
- width: 2.5rem;
-}
-
-#preferences tbody td:not(:first-child) {
- text-align: center;
- width: 2.5rem;
-}
-
-#ledger td.uec {
- text-align: right;
- font-variant-numeric: tabular-nums;
- color: var(--accent-2);
- font-weight: 600;
-}
-
-#ledger .empty {
- margin: 0.25rem 0 0;
- font-size: 0.9rem;
- color: var(--text-dim);
-}
-
-input[type="checkbox"] {
- width: 1.05rem;
- height: 1.05rem;
- accent-color: var(--accent);
- cursor: pointer;
-}
-
-#oncall input[type="time"] {
- font-family: "Rajdhani", sans-serif;
- font-size: 0.9rem;
- color: var(--text);
- background: rgba(15, 23, 42, 0.6);
- border: 1px solid var(--panel-border);
- border-radius: 6px;
- padding: 0.3rem 0.5rem;
- color-scheme: dark;
-}
-
-#oncall input[type="time"]:disabled {
- opacity: 0.35;
-}
-
-#oncall select {
- font-family: "Rajdhani", sans-serif;
- font-size: 0.9rem;
- color: var(--text);
- background: rgba(15, 23, 42, 0.6);
- border: 1px solid var(--panel-border);
- border-radius: 6px;
- padding: 0.3rem 0.5rem;
- color-scheme: dark;
- max-width: 16rem;
-}
-
-/* Status & warnings ------------------------------------------------ */
-#oauth-status {
- font-size: 0.9rem;
-}
-
-#profile-warning {
- margin-top: 0.75rem;
- padding: 0.7rem 1rem;
- background: var(--danger-bg);
- border: 1px solid var(--danger);
- border-radius: 8px;
- color: var(--danger);
- font-weight: 700;
-}
-
-/* Welcome ---------------------------------------------------------- */
-#welcome {
- display: flex;
- align-items: center;
- gap: 1rem;
-}
-
-#welcome-avatar {
- width: 3.25rem;
- height: 3.25rem;
- border-radius: 50%;
- object-fit: cover;
- border: 2px solid var(--accent);
- box-shadow: 0 0 18px var(--accent-glow);
-}
-
-#welcome-text {
- font-family: "Orbitron", sans-serif;
- font-size: 1.2rem;
- font-weight: 700;
- letter-spacing: 0.02em;
-}
-
-/* Tables can scroll horizontally if they ever overflow ------------- */
-#preferences,
-#oncall,
-#ledger {
- overflow-x: auto;
-}
-
-/* ================================================================= */
-/* Responsive — tablet */
-/* ================================================================= */
-@media (max-width: 640px) {
- body {
- padding: 1.5rem 0.85rem 3rem;
- }
-
- #anonymous,
- #required-info,
- #preferences,
- #oncall,
- #ledger,
- #welcome {
- padding: 1rem 1.1rem;
- border-radius: 12px;
- }
-
- #welcome {
- gap: 0.75rem;
- }
-
- #welcome-avatar {
- width: 2.75rem;
- height: 2.75rem;
- }
-
- #welcome-text {
- font-size: 1.05rem;
- }
-
- /* Stack required-info rows so long values don't get squeezed */
- #required-info > div:not(:first-child):not(#profile-warning) {
- flex-direction: column;
- gap: 0.1rem;
- word-break: break-word;
- }
-
- button {
- width: 100%;
- }
-
- #preferences thead th:first-child,
- #preferences tbody td:first-child {
- white-space: normal;
- }
-}
-
-/* ================================================================= */
-/* Responsive — phone: turn the ledger into stacked cards */
-/* ================================================================= */
-@media (max-width: 520px) {
- #ledger {
- overflow-x: visible;
- }
-
- #ledger table,
- #ledger thead,
- #ledger tbody,
- #ledger tr,
- #ledger td {
- display: block;
- width: 100%;
- }
-
- /* Hide the table header row; labels come from data-label instead */
- #ledger thead {
- position: absolute;
- width: 1px;
- height: 1px;
- overflow: hidden;
- clip: rect(0 0 0 0);
- white-space: nowrap;
- }
-
- #ledger tbody tr {
- margin-bottom: 0.85rem;
- border: 1px solid var(--panel-border);
- border-radius: 10px;
- padding: 0.35rem 0.6rem;
- background: var(--row-alt);
- }
-
- #ledger tbody tr:nth-child(even) {
- background: rgba(56, 189, 248, 0.08);
- }
-
- #ledger tbody td {
- display: flex;
- justify-content: space-between;
- align-items: baseline;
- gap: 1rem;
- padding: 0.4rem 0.25rem;
- border-bottom: 1px solid rgba(56, 189, 248, 0.1);
- text-align: right;
- word-break: break-word;
- }
-
- #ledger tbody tr td:last-child {
- border-bottom: none;
- }
-
- #ledger tbody td::before {
- content: attr(data-label);
- font-family: "Orbitron", sans-serif;
- font-size: 0.62rem;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- color: var(--accent);
- text-align: left;
- flex-shrink: 0;
- }
-
- #ledger td.uec {
- text-align: right;
- }
-}