diff options
| author | Alex Pooley (@zuedev) <zuedev@gmail.com> | 2026-07-25 17:27:38 +0100 |
|---|---|---|
| committer | Alex Pooley (@zuedev) <zuedev@gmail.com> | 2026-07-25 17:27:38 +0100 |
| commit | a85a54651070765d6254287369835d747be1c276 (patch) | |
| tree | 6598d2e36bf5a3770c9450e1acceefcfcb092842 /174bg/manager/public | |
| parent | 0d001f0094f2784033be52fa2a28102c271ffb1e (diff) | |
| download | unnamed-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.html | 1163 | ||||
| -rw-r--r-- | 174bg/manager/public/theme.css | 423 |
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; - } -} |
