Initial psopeeps site import
This commit is contained in:
@@ -0,0 +1,555 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const API = {
|
||||
list: "/api/keys",
|
||||
register: "/api/keys/register",
|
||||
delete: (id) => `/api/keys/${encodeURIComponent(id)}`,
|
||||
reveal: (id) => `/api/keys/${encodeURIComponent(id)}/access-key`,
|
||||
};
|
||||
|
||||
let bound = false;
|
||||
|
||||
function qs(sel, root = document) {
|
||||
return root.querySelector(sel);
|
||||
}
|
||||
|
||||
function esc(v) {
|
||||
return String(v ?? "").replace(/[&<>"']/g, ch => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"\"": """,
|
||||
"'": "'",
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
async function readJson(res) {
|
||||
const text = await res.text();
|
||||
if (!text) return {};
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { error: text };
|
||||
}
|
||||
}
|
||||
|
||||
function formEl() {
|
||||
const version = qs("#key-version");
|
||||
return version ? version.closest("form") : null;
|
||||
}
|
||||
|
||||
function listEl() {
|
||||
return qs(".key-list");
|
||||
}
|
||||
|
||||
function setMessage(text, kind = "") {
|
||||
const form = formEl();
|
||||
if (!form) return;
|
||||
|
||||
let box = qs("#key-profile-status");
|
||||
if (!box) {
|
||||
box = document.createElement("p");
|
||||
box.id = "key-profile-status";
|
||||
box.className = "key-status-message";
|
||||
form.insertAdjacentElement("afterend", box);
|
||||
}
|
||||
|
||||
box.textContent = text || "";
|
||||
box.dataset.kind = kind;
|
||||
}
|
||||
|
||||
function normalizeSerial(raw, gameVersion) {
|
||||
const value = String(raw || "").trim();
|
||||
|
||||
if (gameVersion === "dc_v2") {
|
||||
let v = value.toUpperCase();
|
||||
if (v.startsWith("0X")) v = v.slice(2);
|
||||
if (/^[0-9A-F]{1,8}$/.test(v)) {
|
||||
return v.padStart(8, "0");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
if (gameVersion === "pc_v2") {
|
||||
if (/^[0-9]{1,10}$/.test(value)) {
|
||||
const n = Number(value);
|
||||
if (Number.isSafeInteger(n) && n >= 0 && n <= 0xFFFFFFFF) {
|
||||
return n.toString(16).toUpperCase().padStart(8, "0");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
if (gameVersion === "gc_v3") {
|
||||
if (/^[0-9]{2}-[0-9]{4}-[0-9]{4}$/.test(value)) {
|
||||
const n = Number(value.replaceAll("-", ""));
|
||||
if (Number.isSafeInteger(n) && n >= 0 && n <= 0xFFFFFFFF) {
|
||||
return n.toString(16).toUpperCase().padStart(8, "0");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function setupForm(form) {
|
||||
const version = qs("#key-version", form);
|
||||
if (version) {
|
||||
version.innerHTML = [
|
||||
'<option value="dc_v2">DC V2</option>',
|
||||
'<option value="pc_v2">PC V2</option>',
|
||||
'<option value="gc_v3">GC V3</option>',
|
||||
].join("");
|
||||
}
|
||||
|
||||
updateSerialHint(form);
|
||||
|
||||
const versionSelect = qs("#key-version", form);
|
||||
if (versionSelect && !versionSelect.dataset.hintBound) {
|
||||
versionSelect.dataset.hintBound = "1";
|
||||
versionSelect.addEventListener("change", () => updateSerialHint(form));
|
||||
}
|
||||
|
||||
const keyPassword = qs("#key-password", form);
|
||||
if (keyPassword) {
|
||||
const label = qs('label[for="key-password"]', form);
|
||||
if (label) label.remove();
|
||||
keyPassword.remove();
|
||||
}
|
||||
|
||||
const serial = qs("#key-serial", form);
|
||||
if (serial && !qs("#key-display-serial", form)) {
|
||||
const displayLabel = document.createElement("label");
|
||||
displayLabel.id = "key-display-serial-label";
|
||||
displayLabel.setAttribute("for", "key-display-serial");
|
||||
displayLabel.textContent = "Confirm Serial Number";
|
||||
|
||||
const displayInput = document.createElement("input");
|
||||
displayInput.id = "key-display-serial";
|
||||
displayInput.name = "display_serial";
|
||||
displayInput.type = "text";
|
||||
displayInput.autocomplete = "off";
|
||||
displayInput.placeholder = "confirm serial number";
|
||||
|
||||
serial.insertAdjacentElement("afterend", displayInput);
|
||||
serial.insertAdjacentElement("afterend", displayLabel);
|
||||
}
|
||||
|
||||
const access = qs("#key-access", form);
|
||||
if (access && !qs("#key-password", form)) {
|
||||
const passwordLabel = document.createElement("label");
|
||||
passwordLabel.id = "key-password-label";
|
||||
passwordLabel.setAttribute("for", "key-password");
|
||||
passwordLabel.textContent = "GC Password";
|
||||
passwordLabel.style.display = "none";
|
||||
|
||||
const passwordInput = document.createElement("input");
|
||||
passwordInput.id = "key-password";
|
||||
passwordInput.name = "password";
|
||||
passwordInput.type = "text";
|
||||
passwordInput.autocomplete = "off";
|
||||
passwordInput.placeholder = "GC password";
|
||||
passwordInput.style.display = "none";
|
||||
|
||||
access.insertAdjacentElement("afterend", passwordInput);
|
||||
access.insertAdjacentElement("afterend", passwordLabel);
|
||||
}
|
||||
|
||||
const button = qs('button[type="submit"]', form) || qs("button", form);
|
||||
if (button) {
|
||||
button.type = "button";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function syncDotClass(status) {
|
||||
const value = String(status || "").toLowerCase();
|
||||
return (value === "synced" || value === "current") ? "is-synced" : "is-syncing";
|
||||
}
|
||||
|
||||
function renderSyncStatus(label, status) {
|
||||
const safeStatus = status || "unknown";
|
||||
return `
|
||||
<span class="key-sync-status">
|
||||
<span class="key-sync-dot ${syncDotClass(safeStatus)}" style="color: ${syncDotClass(safeStatus) === 'is-synced' ? '#38d66b' : '#e05252'};" aria-hidden="true">●</span>
|
||||
<span>${esc(label)}: <strong>${esc(safeStatus)}</strong></span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderKeySyncSummary(data) {
|
||||
const box = qs("#key-sync-summary");
|
||||
if (!box) return;
|
||||
|
||||
const sync = data.sync_status || "unknown";
|
||||
const us = data.regions?.us?.status || "unknown";
|
||||
const eu = data.regions?.eu?.status || "unknown";
|
||||
|
||||
box.innerHTML = `
|
||||
${renderSyncStatus("Key sync", sync)}
|
||||
${renderSyncStatus("US", us)}
|
||||
${renderSyncStatus("EU", eu)}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
async function loadKeys() {
|
||||
const res = await fetch("/api/keys", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
const data = await readJson(res);
|
||||
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(data.error || data.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
renderKeys(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
function renderKeys(data) {
|
||||
const list = listEl();
|
||||
if (!list) return;
|
||||
|
||||
const keys = Array.isArray(data.keys) ? data.keys : [];
|
||||
const sync = data.sync_status || "unknown";
|
||||
const us = data.regions?.us?.status || "unknown";
|
||||
const eu = data.regions?.eu?.status || "unknown";
|
||||
|
||||
renderKeySyncSummary(data);
|
||||
const summary = "";
|
||||
|
||||
if (!keys.length) {
|
||||
list.innerHTML = summary + `
|
||||
<article class="key-row key-row--empty" role="listitem">
|
||||
<div>
|
||||
<h3>No V2 / V3 keys registered</h3>
|
||||
<p>Add a DC V2, PC V2, or GC V3 key profile above.</p>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = summary + keys.map(key => {
|
||||
const version = key.game_version_label || key.game_version || "Key";
|
||||
const label = key.label || "";
|
||||
const serial = key.serial_number_hex || "";
|
||||
let displaySerial = key.display_serial || serial;
|
||||
if (!key.display_serial && serial) {
|
||||
const n = Number.parseInt(serial, 16);
|
||||
if (Number.isFinite(n)) {
|
||||
if (key.game_version === "pc_v2") {
|
||||
displaySerial = String(n);
|
||||
} else if (key.game_version === "gc_v3") {
|
||||
const dec = String(n).padStart(10, "0");
|
||||
displaySerial = `${dec.slice(0, 2)}-${dec.slice(2, 6)}-${dec.slice(6, 10)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
const isGC = key.game_version === "gc_v3";
|
||||
|
||||
return `
|
||||
<article class="key-row" role="listitem" data-key-id="${esc(key.id)}" data-key-version="${esc(key.game_version || "")}">
|
||||
<div>
|
||||
<h3>${esc(version)}</h3>
|
||||
<p>Registered profile <strong>${esc(serial)}</strong></p>
|
||||
<p class="key-meta">Serial: ${esc(displaySerial)}</p>
|
||||
${label ? `<p class="key-meta">Label: ${esc(label)}</p>` : ""}
|
||||
<p class="key-secret-line">
|
||||
<span>Access key:</span>
|
||||
<code class="key-secret-value" data-key-secret="${esc(key.id)}" data-key-secret-kind="access_key">••••••••••••</code>
|
||||
<button class="inline-link key-reveal-button" type="button" data-key-id="${esc(key.id)}">
|
||||
show
|
||||
</button>
|
||||
</p>
|
||||
${isGC ? `<p class="key-secret-line">
|
||||
<span>Password:</span>
|
||||
<code class="key-secret-value" data-key-secret="${esc(key.id)}" data-key-secret-kind="password">••••••••••••</code>
|
||||
</p>` : ""}
|
||||
</div>
|
||||
<button class="button-danger key-delete-button" type="button" data-key-id="${esc(key.id)}">
|
||||
Delete Key
|
||||
</button>
|
||||
</article>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
|
||||
function updateSerialHint(form) {
|
||||
const gameVersion = qs("#key-version", form)?.value || "";
|
||||
const serial = qs("#key-serial", form);
|
||||
const displaySerial = qs("#key-display-serial", form);
|
||||
const access = qs("#key-access", form);
|
||||
const password = qs("#key-password", form);
|
||||
const passwordLabel = qs("#key-password-label", form);
|
||||
|
||||
if (!serial) return;
|
||||
|
||||
if (gameVersion === "dc_v2") {
|
||||
serial.placeholder = "DC V2 serial number";
|
||||
if (displaySerial) displaySerial.placeholder = "confirm DC V2 serial number";
|
||||
if (access) access.placeholder = "access key";
|
||||
} else if (gameVersion === "pc_v2") {
|
||||
serial.placeholder = "PC V2 decimal serial number";
|
||||
if (displaySerial) displaySerial.placeholder = "confirm PC V2 decimal serial number";
|
||||
if (access) access.placeholder = "access key";
|
||||
} else if (gameVersion === "gc_v3") {
|
||||
serial.placeholder = "GC V3 serial number";
|
||||
if (displaySerial) displaySerial.placeholder = "confirm GC V3 serial number";
|
||||
if (access) access.placeholder = "access key";
|
||||
}
|
||||
|
||||
if (passwordLabel) {
|
||||
passwordLabel.style.display = gameVersion === "gc_v3" ? "" : "none";
|
||||
}
|
||||
if (password) {
|
||||
password.style.display = gameVersion === "gc_v3" ? "" : "none";
|
||||
if (gameVersion !== "gc_v3") {
|
||||
password.value = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function registerKey() {
|
||||
const form = formEl();
|
||||
if (!form) return;
|
||||
|
||||
const button = qs("button", form);
|
||||
const gameVersion = qs("#key-version", form)?.value || "";
|
||||
const label = qs("#key-label", form)?.value.trim() || "";
|
||||
const rawSerial = qs("#key-serial", form)?.value.trim() || "";
|
||||
const displaySerial = qs("#key-display-serial", form)?.value.trim() || "";
|
||||
const accessKey = qs("#key-access", form)?.value.trim() || "";
|
||||
const password = qs("#key-password", form)?.value.trim() || "";
|
||||
|
||||
if (!["dc_v2", "pc_v2", "gc_v3"].includes(gameVersion)) {
|
||||
setMessage("Choose DC V2, PC V2, or GC V3.", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
const serialChecks = {
|
||||
dc_v2: /^(?:0x)?[0-9A-Fa-f]{1,8}$/,
|
||||
pc_v2: /^[0-9]{1,10}$/,
|
||||
gc_v3: /^[0-9]{2}-[0-9]{4}-[0-9]{4}$/,
|
||||
};
|
||||
|
||||
if (!serialChecks[gameVersion]?.test(rawSerial)) {
|
||||
const examples = {
|
||||
dc_v2: "DC V2 serial must be hex, like 4E62F237.",
|
||||
pc_v2: "PC V2 serial must be digits only.",
|
||||
gc_v3: "GC V3 serial must use the dashed format: NN-NNNN-NNNN.",
|
||||
};
|
||||
setMessage(examples[gameVersion] || "Enter a valid serial number.", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
const serial = rawSerial;
|
||||
|
||||
if (!serial) {
|
||||
setMessage("Enter a valid serial number.", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!displaySerial) {
|
||||
setMessage("Confirm serial number is required.", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
if (displaySerial !== rawSerial) {
|
||||
setMessage("Serial number confirmation does not match.", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessKey) {
|
||||
setMessage("Enter the key.", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameVersion === "gc_v3" && !password) {
|
||||
setMessage("Enter the GC password.", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
if (button) button.disabled = true;
|
||||
setMessage("Registering key profile...", "pending");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/keys/register", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
game_version: gameVersion,
|
||||
label,
|
||||
serial_number: serial,
|
||||
display_serial: displaySerial,
|
||||
access_key: accessKey,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await readJson(res);
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `Register failed: ${res.status}`);
|
||||
}
|
||||
|
||||
form.reset();
|
||||
setupForm(form);
|
||||
setMessage(`Saved. Sync status: ${data.sync_status}. Refresh status in a few seconds.`, "ok");
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setMessage(err.message || String(err), "warn");
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function revealAccessKey(id, button) {
|
||||
if (!id || !button) return;
|
||||
|
||||
const card = button.closest("article, .key-card, .key-row, .registered-key");
|
||||
const values = Array.from(card?.querySelectorAll(`[data-key-secret="${String(id).replace(/"/g, "\\\"")}"]`) || []);
|
||||
if (!values.length) {
|
||||
setMessage("Could not find this key on the page. Refresh and try again.", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.dataset.visible === "1") {
|
||||
values.forEach(value => value.textContent = "••••••••••••");
|
||||
button.textContent = "show";
|
||||
button.dataset.visible = "0";
|
||||
return;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(API.reveal(id), {
|
||||
credentials: "same-origin",
|
||||
headers: { "Accept": "application/json" },
|
||||
});
|
||||
|
||||
const data = await readJson(res);
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `Reveal failed: ${res.status}`);
|
||||
}
|
||||
|
||||
values.forEach(value => {
|
||||
const kind = value.dataset.keySecretKind || "access_key";
|
||||
value.textContent = data.key?.[kind] || "";
|
||||
});
|
||||
|
||||
button.textContent = "hide";
|
||||
button.dataset.visible = "1";
|
||||
} catch (err) {
|
||||
setMessage(err.message || String(err), "warn");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteKey(id, button) {
|
||||
if (!id) return;
|
||||
|
||||
const card = button?.closest("article, .key-card, .key-row, .registered-key");
|
||||
const gameVersion = card?.dataset?.keyVersion || "";
|
||||
|
||||
let confirmMessage = "Delete this V2 key profile from this website account?";
|
||||
if (gameVersion === "pc_v2") {
|
||||
confirmMessage += "\n\nPC V2 serial/key is forever tied to your local character saves. Please be sure you have your keys backed up before removing this key from your profile.";
|
||||
}
|
||||
|
||||
if (!window.confirm(confirmMessage)) return;
|
||||
|
||||
button.disabled = true;
|
||||
setMessage("Deleting key profile...", "pending");
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/keys/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
headers: { "Accept": "application/json" },
|
||||
});
|
||||
|
||||
const data = await readJson(res);
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `Delete failed: ${res.status}`);
|
||||
}
|
||||
|
||||
setMessage(`Deleted. Sync status: ${data.sync_status}.`, "ok");
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setMessage(err.message || String(err), "warn");
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bind() {
|
||||
if (bound) return true;
|
||||
|
||||
const form = formEl();
|
||||
const list = listEl();
|
||||
if (!form || !list) return false;
|
||||
|
||||
setupForm(form);
|
||||
|
||||
const button = qs("button", form);
|
||||
if (button) {
|
||||
button.addEventListener("click", registerKey);
|
||||
}
|
||||
|
||||
form.addEventListener("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
registerKey();
|
||||
});
|
||||
|
||||
list.addEventListener("click", (ev) => {
|
||||
const revealButton = ev.target.closest(".key-reveal-button");
|
||||
if (revealButton) {
|
||||
revealAccessKey(revealButton.dataset.keyId, revealButton);
|
||||
return;
|
||||
}
|
||||
|
||||
const button = ev.target.closest(".key-delete-button");
|
||||
if (!button) return;
|
||||
deleteKey(button.dataset.keyId, button);
|
||||
});
|
||||
|
||||
bound = true;
|
||||
|
||||
loadKeys().catch(err => {
|
||||
list.innerHTML = `
|
||||
<article class="key-row key-row--empty" role="listitem">
|
||||
<div>
|
||||
<h3>Could not load keys</h3>
|
||||
<p>${esc(err.message || err)}</p>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function start() {
|
||||
[0, 500, 1500, 3000].forEach(ms => {
|
||||
window.setTimeout(bind, ms);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user