diff options
Diffstat (limited to '174bg/manager/src')
| -rw-r--r-- | 174bg/manager/src/App.jsx | 182 | ||||
| -rw-r--r-- | 174bg/manager/src/components/Ledger.jsx | 73 | ||||
| -rw-r--r-- | 174bg/manager/src/components/OnCallSchedule.jsx | 186 | ||||
| -rw-r--r-- | 174bg/manager/src/components/RequiredInfo.jsx | 66 | ||||
| -rw-r--r-- | 174bg/manager/src/components/RolePreferences.jsx | 171 | ||||
| -rw-r--r-- | 174bg/manager/src/components/Welcome.jsx | 14 | ||||
| -rw-r--r-- | 174bg/manager/src/lib/oncallTime.js | 242 | ||||
| -rw-r--r-- | 174bg/manager/src/lib/otherJsonData.js | 13 | ||||
| -rw-r--r-- | 174bg/manager/src/lib/pocketbase.js | 3 | ||||
| -rw-r--r-- | 174bg/manager/src/lib/roles.js | 142 | ||||
| -rw-r--r-- | 174bg/manager/src/main.jsx | 10 | ||||
| -rw-r--r-- | 174bg/manager/src/theme.css | 423 |
12 files changed, 1525 insertions, 0 deletions
diff --git a/174bg/manager/src/App.jsx b/174bg/manager/src/App.jsx new file mode 100644 index 0000000..5f455bb --- /dev/null +++ b/174bg/manager/src/App.jsx @@ -0,0 +1,182 @@ +import { useCallback, useEffect, useState } from "react"; +import { pb } from "./lib/pocketbase"; +import Welcome from "./components/Welcome.jsx"; +import RequiredInfo from "./components/RequiredInfo.jsx"; +import RolePreferences from "./components/RolePreferences.jsx"; +import OnCallSchedule from "./components/OnCallSchedule.jsx"; +import Ledger from "./components/Ledger.jsx"; + +export default function App() { + const [record, setRecord] = useState(pb.authStore.record); + const [loggedIn, setLoggedIn] = useState(pb.authStore.isValid); + const [loginDisabled, setLoginDisabled] = useState(false); + const [oauthStatus, setOauthStatus] = useState({ text: "" }); + + const refreshFromStore = useCallback(() => { + setRecord(pb.authStore.record); + setLoggedIn(pb.authStore.isValid); + }, []); + + useEffect(() => pb.authStore.onChange(refreshFromStore), [refreshFromStore]); + + useEffect(() => { + let cancelled = false; + + async function init() { + if (pb.authStore.isValid) { + try { + await pb.collection("members").authRefresh(); + } catch { + pb.authStore.clear(); + } + } + + const oauthParams = new URLSearchParams(window.location.search); + const storedProvider = localStorage.getItem("pb_oauth_provider"); + + if (oauthParams.has("code") && oauthParams.has("state")) { + if (!storedProvider) { + setOauthStatus({ + text: "Login error: OAuth state lost (localStorage empty). Please try again.", + variant: "error-plain", + }); + window.history.replaceState({}, "", window.location.pathname); + } else { + setOauthStatus({ text: "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, + ); + setOauthStatus({ text: "✅ Login successful!", variant: "success" }); + window.history.replaceState({}, "", window.location.pathname); + if (!cancelled) refreshFromStore(); + } 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(" | "); + setOauthStatus({ + text: `Login error — send this to your admin:\n${detail}`, + variant: "error", + }); + window.history.replaceState({}, "", window.location.pathname); + } + } + } else if (!cancelled) { + refreshFromStore(); + } + } + + init(); + return () => { + cancelled = true; + }; + }, [refreshFromStore]); + + const handleLogin = useCallback(async () => { + setLoginDisabled(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); + setLoginDisabled(false); + } + }, []); + + const handleLogout = useCallback(() => { + pb.authStore.clear(); + refreshFromStore(); + }, [refreshFromStore]); + + const handleRecordUpdate = useCallback( + (updated) => { + pb.authStore.save(pb.authStore.token, updated); + refreshFromStore(); + }, + [refreshFromStore], + ); + + let oauthStatusStyle = { fontSize: "0.9rem" }; + if (oauthStatus.variant === "success") { + oauthStatusStyle = { + color: "green", + background: "#dfd", + border: "1px solid #080", + borderRadius: "4px", + padding: "0.5rem", + }; + } else if (oauthStatus.variant === "error") { + oauthStatusStyle = { + color: "red", + background: "#fdd", + border: "1px solid #c00", + borderRadius: "4px", + padding: "0.5rem", + fontFamily: "monospace", + fontSize: "0.8rem", + whiteSpace: "pre-wrap", + wordBreak: "break-all", + }; + } else if (oauthStatus.variant === "error-plain") { + oauthStatusStyle = { fontSize: "0.9rem", color: "red" }; + } + + return ( + <> + <h1>174th Battle Group: Manager</h1> + + {!loggedIn && ( + <section id="anonymous"> + <p>You are not logged in. Please log in to access this site:</p> + <button id="login" onClick={handleLogin} disabled={loginDisabled}> + Login with Discord + </button> + </section> + )} + + {oauthStatus.text && ( + <p id="oauth-status" style={oauthStatusStyle}> + {oauthStatus.text} + </p> + )} + + {loggedIn && ( + <section + id="authed" + style={{ display: "flex", flexDirection: "column", gap: "1em" }} + > + <Welcome record={record} /> + <RequiredInfo record={record} /> + <RolePreferences record={record} onUpdate={handleRecordUpdate} /> + <OnCallSchedule record={record} onUpdate={handleRecordUpdate} /> + <Ledger /> + <div> + <button id="logout" onClick={handleLogout}> + Logout + </button> + </div> + </section> + )} + </> + ); +} diff --git a/174bg/manager/src/components/Ledger.jsx b/174bg/manager/src/components/Ledger.jsx new file mode 100644 index 0000000..247b343 --- /dev/null +++ b/174bg/manager/src/components/Ledger.jsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from "react"; +import { pb } from "../lib/pocketbase"; + +function memberLabel(record, id) { + const m = record?.expand?.[id]; + return m ? m.RSI_Handle || m.name || m.id : (record?.[id] ?? "—"); +} + +export default function Ledger() { + const [records, setRecords] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + pb + .collection("ledger") + .getFullList({ expand: "sender,recipient", sort: "-created", requestKey: null }) + .then((recs) => { + if (!cancelled) setRecords(recs); + }) + .catch((err) => { + console.error("Failed to load ledger:", err); + if (!cancelled) setError(err); + }); + return () => { + cancelled = true; + }; + }, []); + + return ( + <div id="ledger"> + <div> + <b>Ledger</b> + </div> + {error && ( + <p className="empty" style={{ color: "red" }}> + Failed to load ledger: {error?.message ?? String(error)} + </p> + )} + {!error && records && records.length === 0 && ( + <p className="empty">No ledger entries.</p> + )} + {!error && records && records.length > 0 && ( + <table> + <thead> + <tr> + <th>Date</th> + <th>Sender</th> + <th>Recipient</th> + <th>UEC</th> + <th>Note</th> + </tr> + </thead> + <tbody> + {records.map((rec) => ( + <tr key={rec.id}> + <td data-label="Date"> + {rec.created ? new Date(rec.created).toLocaleString() : "—"} + </td> + <td data-label="Sender">{memberLabel(rec, "sender")}</td> + <td data-label="Recipient">{memberLabel(rec, "recipient")}</td> + <td className="uec" data-label="UEC"> + {Number(rec.uec ?? 0).toLocaleString()} + </td> + <td data-label="Note">{rec.note ?? ""}</td> + </tr> + ))} + </tbody> + </table> + )} + </div> + ); +} diff --git a/174bg/manager/src/components/OnCallSchedule.jsx b/174bg/manager/src/components/OnCallSchedule.jsx new file mode 100644 index 0000000..725e5c8 --- /dev/null +++ b/174bg/manager/src/components/OnCallSchedule.jsx @@ -0,0 +1,186 @@ +import { useMemo, useState } from "react"; +import { pb } from "../lib/pocketbase"; +import { + DAYS_OF_WEEK, + formatUtcOffset, + getLocalOnCallSchedule, + listTimeZones, + localScheduleToUtc, +} from "../lib/oncallTime"; + +export default function OnCallSchedule({ record, onUpdate }) { + const [editing, setEditing] = useState(false); + const [timeZone, setTimeZone] = useState(null); + const [schedule, setSchedule] = useState({}); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState({ text: "", error: false }); + + const display = useMemo(() => getLocalOnCallSchedule(record), [record]); + const zones = useMemo(() => listTimeZones(), []); + + function startEdit() { + const { timeZone: tz, schedule: sched } = getLocalOnCallSchedule(record); + setTimeZone(tz); + setSchedule(sched); + setEditing(true); + setStatus({ text: "", error: false }); + } + + function cancelEdit() { + setEditing(false); + } + + function toggleDay(day, available) { + setSchedule((prev) => { + const next = { ...prev }; + if (available) { + next[day] = { + available: true, + start: prev[day]?.start ?? "", + end: prev[day]?.end ?? "", + }; + } else { + delete next[day]; + } + return next; + }); + } + + function setDayTime(day, key, value) { + setSchedule((prev) => ({ + ...prev, + [day]: { ...prev[day], [key]: value }, + })); + } + + async function save() { + setSaving(true); + try { + const utcSchedule = localScheduleToUtc(schedule, timeZone); + const payload = { timezone: timeZone, ...utcSchedule }; + const updated = await pb.collection("members").update(record.id, { + onCallSchedule: payload, + }); + onUpdate(updated); + setEditing(false); + setStatus({ text: "", error: false }); + } catch (err) { + const msg = err?.response + ? JSON.stringify(err.response) + : (err?.message ?? String(err)); + setStatus({ text: `Save failed: ${msg}`, error: true }); + } finally { + setSaving(false); + } + } + + const shownTz = editing ? timeZone : display.timeZone; + const shownSchedule = editing ? schedule : display.schedule; + const tzOptions = zones.includes(shownTz) ? zones : [shownTz, ...zones]; + + return ( + <div id="oncall"> + <div> + <b>On-Call Schedule</b>{" "} + {!editing && ( + <button type="button" onClick={startEdit}> + ✏️ Edit + </button> + )} + {editing && ( + <> + <button type="button" disabled={saving} onClick={save}> + 💾 Save + </button>{" "} + <button type="button" disabled={saving} onClick={cancelEdit}> + ❌ Cancel + </button> + </> + )} + </div> + <p style={{ margin: "0.25rem 0 0.75rem", fontSize: "0.85rem", color: "var(--text-dim)" }}> + Let the group know which days and hours you're usually free to join operations. + </p> + <div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem" }}> + <b>Timezone:</b> + {editing ? ( + <select value={timeZone} onChange={(e) => setTimeZone(e.target.value)}> + {tzOptions.map((tz) => ( + <option key={tz} value={tz}> + {tz} ({formatUtcOffset(tz)}) + </option> + ))} + </select> + ) : ( + <span> + {shownTz} ({formatUtcOffset(shownTz)}) + </span> + )} + </div> + <table> + <thead> + <tr> + <th>Day</th> + <th>Free</th> + <th>From</th> + <th>To</th> + </tr> + </thead> + <tbody> + {DAYS_OF_WEEK.map((day) => { + const entry = shownSchedule[day] ?? {}; + return ( + <tr key={day}> + <td>{day}</td> + <td> + {editing ? ( + <input + type="checkbox" + checked={!!entry.available} + onChange={(e) => toggleDay(day, e.target.checked)} + /> + ) : entry.available ? ( + "✅" + ) : ( + "❌" + )} + </td> + <td> + {editing ? ( + <input + type="time" + value={entry.start ?? ""} + disabled={!entry.available} + onChange={(e) => setDayTime(day, "start", e.target.value)} + /> + ) : entry.available && entry.start ? ( + entry.start + ) : ( + "—" + )} + </td> + <td> + {editing ? ( + <input + type="time" + value={entry.end ?? ""} + disabled={!entry.available} + onChange={(e) => setDayTime(day, "end", e.target.value)} + /> + ) : entry.available && entry.end ? ( + entry.end + ) : ( + "—" + )} + </td> + </tr> + ); + })} + </tbody> + </table> + <p style={{ margin: "0.5rem 0 0", fontSize: "0.85rem", color: status.error ? "red" : "" }}> + {status.text} + </p> + </div> + ); +} diff --git a/174bg/manager/src/components/RequiredInfo.jsx b/174bg/manager/src/components/RequiredInfo.jsx new file mode 100644 index 0000000..67c6799 --- /dev/null +++ b/174bg/manager/src/components/RequiredInfo.jsx @@ -0,0 +1,66 @@ +import { RANK_NAMES } from "../lib/roles"; + +const REQUIRED_INFO_FIELDS = [ + { field: "id", label: "174BG ID" }, + { field: "UEE_Citizen_Record_ID", label: "UEE Citizen Record ID" }, + { field: "RSI_Display_Name", label: "RSI Display Name" }, + { field: "RSI_Handle", label: "RSI Handle" }, + { field: "joined_rsi_org", label: "Joined RSI Organisation" }, + { field: "email", label: "Email" }, + { field: "Branch", label: "Branch" }, + { field: "Rank_Number", label: "Rank Number" }, +]; + +export default function RequiredInfo({ record }) { + let anyMissing = false; + + const rows = REQUIRED_INFO_FIELDS.map(({ field, label }) => { + 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. + const missing = value == null || value === ""; + if (missing) anyMissing = true; + return { field, label, value, missing }; + }); + + const branch = record?.Branch; + const rankNumber = record?.Rank_Number; + const rankName = RANK_NAMES[branch]?.[rankNumber]; + if (!rankName) anyMissing = true; + + const staffRolesRaw = record?.StaffRoles; + const staffList = Array.isArray(staffRolesRaw) + ? staffRolesRaw + : staffRolesRaw + ? [staffRolesRaw] + : []; + + return ( + <div id="required-info"> + <h2>Required Information</h2> + {rows.map(({ field, label, value, missing }) => ( + <div key={field}> + <b>{label}:</b>{" "} + <span style={{ color: missing ? "red" : "" }}> + {missing ? "MISSING" : value} + </span> + </div> + ))} + <div> + <b>Rank Name:</b>{" "} + <span style={{ color: rankName ? "" : "red" }}> + {rankName ?? "MISSING"} + </span> + </div> + <div> + <b>Staff Roles:</b>{" "} + <span>{staffList.length > 0 ? staffList.join(", ") : "none"}</span> + </div> + {anyMissing && ( + <div id="profile-warning"> + ⚠️ Your profile is incomplete. Message an officer ASAP! + </div> + )} + </div> + ); +} diff --git a/174bg/manager/src/components/RolePreferences.jsx b/174bg/manager/src/components/RolePreferences.jsx new file mode 100644 index 0000000..ab3f282 --- /dev/null +++ b/174bg/manager/src/components/RolePreferences.jsx @@ -0,0 +1,171 @@ +import { Fragment, useState } from "react"; +import { pb } from "../lib/pocketbase"; +import { ROLES } from "../lib/roles"; +import { getOtherJsonData } from "../lib/otherJsonData"; + +export default function RolePreferences({ record, onUpdate }) { + const [editing, setEditing] = useState(false); + const [selected, setSelected] = useState([]); + const [favourite, setFavourite] = useState(null); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState({ text: "", error: false }); + + const savedPrefs = record?.RolePreferencesSelect ?? []; + const savedFavourite = getOtherJsonData(record).FavouriteRole ?? null; + + function startEdit() { + setSelected(savedPrefs); + setFavourite(savedFavourite); + setEditing(true); + setStatus({ text: "", error: false }); + } + + function cancelEdit() { + setEditing(false); + } + + function toggleRole(value) { + setSelected((prev) => + prev.includes(value) + ? prev.filter((v) => v !== value) + : [...prev, value], + ); + } + + function toggleFavourite(value) { + setFavourite((prev) => (prev === value ? null : value)); + } + + async function save() { + setSaving(true); + try { + const fav = favourite && selected.includes(favourite) ? favourite : null; + const other = getOtherJsonData(record); + if (fav) other.FavouriteRole = fav; + else delete other.FavouriteRole; + + const updated = await pb.collection("members").update(record.id, { + RolePreferencesSelect: selected, + OtherJsonData: other, + }); + onUpdate(updated); + setEditing(false); + setStatus({ text: "", error: false }); + } catch (err) { + const msg = err?.response + ? JSON.stringify(err.response) + : (err?.message ?? String(err)); + setStatus({ text: `Save failed: ${msg}`, error: true }); + } finally { + setSaving(false); + } + } + + const displayPrefs = editing ? selected : savedPrefs; + const displayFavourite = editing ? favourite : savedFavourite; + + let lastBranch = null; + + return ( + <div id="preferences"> + <div> + <b>Role Preferences</b>{" "} + {!editing && ( + <button type="button" onClick={startEdit}> + ✏️ Edit + </button> + )} + {editing && ( + <> + <button type="button" disabled={saving} onClick={save}> + 💾 Save + </button>{" "} + <button type="button" disabled={saving} onClick={cancelEdit}> + ❌ Cancel + </button> + </> + )} + </div> + <table> + <thead> + <tr> + <th>Role</th> + <th>✅</th> + <th>⭐</th> + </tr> + </thead> + <tbody> + {ROLES.map((role) => { + const branchHeader = role.branch !== lastBranch; + lastBranch = role.branch; + return ( + <Fragment key={role.value}> + {branchHeader && ( + <tr data-separator="true"> + <td + colSpan={3} + style={{ + fontWeight: 600, + paddingTop: "0.75rem", + paddingBottom: "0.25rem", + borderBottom: "none", + opacity: 0.6, + fontSize: "0.8rem", + textTransform: "uppercase", + letterSpacing: "0.05em", + }} + > + {role.branch ?? ""} + </td> + </tr> + )} + <tr> + <td> + {role.link ? ( + <a href={role.link} target="_blank" rel="noopener noreferrer"> + {role.text} + </a> + ) : ( + role.text + )} + </td> + <td> + {editing ? ( + <input + type="checkbox" + checked={selected.includes(role.value)} + onChange={() => toggleRole(role.value)} + /> + ) : displayPrefs.includes(role.value) ? ( + "✅" + ) : ( + "❌" + )} + </td> + <td> + {editing ? ( + <input + type="radio" + name="favourite-role" + checked={favourite === role.value} + onChange={() => {}} + onClick={() => toggleFavourite(role.value)} + /> + ) : displayFavourite === role.value ? ( + "⭐" + ) : ( + "" + )} + </td> + </tr> + </Fragment> + ); + })} + </tbody> + </table> + <p style={{ margin: 0, fontSize: "0.85rem", color: status.error ? "red" : "" }}> + {status.text} + </p> + </div> + ); +} diff --git a/174bg/manager/src/components/Welcome.jsx b/174bg/manager/src/components/Welcome.jsx new file mode 100644 index 0000000..da771cf --- /dev/null +++ b/174bg/manager/src/components/Welcome.jsx @@ -0,0 +1,14 @@ +export default function Welcome({ record }) { + const name = record?.RSI_Handle || record?.name || "Pilot"; + const avatarFile = record?.avatar; + const avatarUrl = avatarFile + ? `https://db.174bg.net/api/files/${record.collectionId}/${record.id}/${avatarFile}` + : null; + + return ( + <div id="welcome"> + {avatarUrl && <img id="welcome-avatar" src={avatarUrl} alt={name} />} + <span id="welcome-text">Welcome back, {name}!</span> + </div> + ); +} diff --git a/174bg/manager/src/lib/oncallTime.js b/174bg/manager/src/lib/oncallTime.js new file mode 100644 index 0000000..e8afaf3 --- /dev/null +++ b/174bg/manager/src/lib/oncallTime.js @@ -0,0 +1,242 @@ +export const DAYS_OF_WEEK = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", +]; + +// Sunday-first, matching Date#getDay()/getUTCDay() and Intl's "weekday" +export const WEEKDAY_NAMES = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; +export const WEEKDAY_INDEX = Object.fromEntries( + WEEKDAY_NAMES.map((d, i) => [d, i]), +); + +export function pad2(n) { + return String(n).padStart(2, "0"); +} + +export function getBrowserTimeZone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } +} + +export 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. +export 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". +export 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). +export 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`. +export 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}`, + }; +} + +export function getEffectiveTimezone(raw) { + return raw.timezone || getBrowserTimeZone(); +} + +// Converts the stored UTC schedule to local wall-clock times, keyed by +// local weekday, for display/editing. +export 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. +export 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; +} + +// onCallSchedule is stored as JSON, UTC-normalized: +// { timezone: "IANA/Zone", [utcDay]: { available, start, end } } +export function getOnCallRaw(record) { + const raw = record?.onCallSchedule; + if (!raw) return {}; + if (typeof raw === "string") { + try { + return JSON.parse(raw) || {}; + } catch { + return {}; + } + } + return { ...raw }; +} + +// Legacy records saved before timezone support has no `timezone` key - those +// values are already local wall-clock times, so skip conversion. +export function getLocalOnCallSchedule(record) { + const raw = getOnCallRaw(record); + const timeZone = getEffectiveTimezone(raw); + const schedule = raw.timezone ? utcScheduleToLocal(raw, timeZone) : raw; + return { timeZone, schedule }; +} diff --git a/174bg/manager/src/lib/otherJsonData.js b/174bg/manager/src/lib/otherJsonData.js new file mode 100644 index 0000000..866e6fc --- /dev/null +++ b/174bg/manager/src/lib/otherJsonData.js @@ -0,0 +1,13 @@ +// OtherJsonData may be stored as an object or a JSON string +export function getOtherJsonData(record) { + const raw = record?.OtherJsonData; + if (!raw) return {}; + if (typeof raw === "string") { + try { + return JSON.parse(raw) || {}; + } catch { + return {}; + } + } + return { ...raw }; +} diff --git a/174bg/manager/src/lib/pocketbase.js b/174bg/manager/src/lib/pocketbase.js new file mode 100644 index 0000000..ed0b2eb --- /dev/null +++ b/174bg/manager/src/lib/pocketbase.js @@ -0,0 +1,3 @@ +import PocketBase from "pocketbase"; + +export const pb = new PocketBase("https://db.174bg.net"); diff --git a/174bg/manager/src/lib/roles.js b/174bg/manager/src/lib/roles.js new file mode 100644 index 0000000..1d02e37 --- /dev/null +++ b/174bg/manager/src/lib/roles.js @@ -0,0 +1,142 @@ +export const 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", + }, +]; + +export const RANK_NAMES = { + Naval: ["Cadet", "Ensign", "Lieutenant", "Captain", "Commodore", "Admiral"], + Marine: [ + "Private", + "Corporal", + "Sergeant", + "Major", + "Commander", + "General", + ], + Auxiliary: [ + "Trainee", + "Technician", + "Specialist", + "Supervisor", + "Chief", + "Marshal", + ], +}; diff --git a/174bg/manager/src/main.jsx b/174bg/manager/src/main.jsx new file mode 100644 index 0000000..ed1507f --- /dev/null +++ b/174bg/manager/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.jsx"; +import "./theme.css"; + +createRoot(document.getElementById("root")).render( + <StrictMode> + <App /> + </StrictMode>, +); diff --git a/174bg/manager/src/theme.css b/174bg/manager/src/theme.css new file mode 100644 index 0000000..907a77e --- /dev/null +++ b/174bg/manager/src/theme.css @@ -0,0 +1,423 @@ +: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; + } +} |
