Compare commits
4 Commits
9ade0820d6
...
41bbb7a44b
| Author | SHA1 | Date | |
|---|---|---|---|
| 41bbb7a44b | |||
| a9dc508940 | |||
| 32991843d8 | |||
| 52ee6ea26b |
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids/
|
||||
*.pid
|
||||
*.seed
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Cache
|
||||
.npm
|
||||
.eslintcache
|
||||
.parcel-cache
|
||||
.cache/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
.vscode/settings.json
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
@@ -14,9 +14,9 @@
|
||||
--text-dim: #7a8a82;
|
||||
--text-bright: #ede8dc;
|
||||
--crisis: #e74c3c;
|
||||
--font-display: 'Cinzel', serif;
|
||||
--font-body: 'Crimson Text', serif;
|
||||
--font-mono: 'Inconsolata', monospace;
|
||||
--font-display: "Cinzel", serif;
|
||||
--font-body: "Crimson Text", serif;
|
||||
--font-mono: "Inconsolata", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -32,8 +32,16 @@ body {
|
||||
font-size: 16px;
|
||||
min-height: 100vh;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 10%, rgba(78, 205, 196, 0.04) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 90%, rgba(201, 168, 76, 0.04) 0%, transparent 50%);
|
||||
radial-gradient(
|
||||
ellipse at 20% 10%,
|
||||
rgba(78, 205, 196, 0.04) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse at 80% 90%,
|
||||
rgba(201, 168, 76, 0.04) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
}
|
||||
|
||||
/* ── HEADER ─────────────────────────────────────── */
|
||||
@@ -62,7 +70,7 @@ header {
|
||||
}
|
||||
|
||||
.logo::before {
|
||||
content: '✦';
|
||||
content: "✦";
|
||||
color: var(--gold);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
@@ -146,7 +154,7 @@ header {
|
||||
}
|
||||
|
||||
.section::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -204,7 +212,9 @@ select {
|
||||
font-size: 1rem;
|
||||
padding: 8px 11px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
@@ -502,7 +512,7 @@ input[type="number"] {
|
||||
}
|
||||
|
||||
.fp-rule::before {
|
||||
content: '✦';
|
||||
content: "✦";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
font-size: 0.5rem;
|
||||
@@ -964,6 +974,55 @@ input[type="number"] {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── URL BANNER ──────────────────────────────────── */
|
||||
.url-banner {
|
||||
background: var(--surface2);
|
||||
border-bottom: 1px solid var(--teal-dim);
|
||||
padding: 10px 32px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.url-banner strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.url-banner-icon {
|
||||
color: var(--teal);
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── MANAGE PAGE ─────────────────────────────────── */
|
||||
.manage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.manage-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.manage-btn-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
font-size: 0.6rem;
|
||||
padding: 11px 22px;
|
||||
}
|
||||
|
||||
/* ── LIGHT THEME ─────────────────────────────────── */
|
||||
[data-theme="light"] {
|
||||
--bg: #f4efe4;
|
||||
@@ -985,8 +1044,16 @@ input[type="number"] {
|
||||
|
||||
[data-theme="light"] body {
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 10%, rgba(26, 122, 118, 0.07) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 90%, rgba(122, 90, 24, 0.07) 0%, transparent 50%);
|
||||
radial-gradient(
|
||||
ellipse at 20% 10%,
|
||||
rgba(26, 122, 118, 0.07) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse at 80% 90%,
|
||||
rgba(122, 90, 24, 0.07) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
}
|
||||
|
||||
[data-theme="light"] header {
|
||||
@@ -1026,7 +1093,6 @@ input[type="number"] {
|
||||
|
||||
/* ── RESPONSIVE ──────────────────────────────────── */
|
||||
@media (max-width: 900px) {
|
||||
|
||||
.grid-2,
|
||||
.grid-3 {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,50 @@
|
||||
// ── STATE ──────────────────────────────────────────
|
||||
let urlMode = false;
|
||||
let level = 1;
|
||||
let fpCount = 0;
|
||||
let primaryClasses = [];
|
||||
let otherClasses = [];
|
||||
let spells = [];
|
||||
let bonds = [
|
||||
{ name: '', feelings: [] },
|
||||
{ name: '', feelings: [] },
|
||||
{ name: '', feelings: [] },
|
||||
{ name: '', feelings: [] },
|
||||
{ name: '', feelings: [] },
|
||||
{ name: '', feelings: [] },
|
||||
{ name: "", feelings: [] },
|
||||
{ name: "", feelings: [] },
|
||||
{ name: "", feelings: [] },
|
||||
{ name: "", feelings: [] },
|
||||
{ name: "", feelings: [] },
|
||||
{ name: "", feelings: [] },
|
||||
];
|
||||
|
||||
const STATUSES = ['Slow', 'Enraged', 'Dazed', 'Weak', 'Poisoned', 'Shaken'];
|
||||
const FEELINGS = ['Admiration', 'Inferiority', 'Loyalty', 'Mistrust', 'Affection', 'Hatred'];
|
||||
const STATUSES = ["Slow", "Enraged", "Dazed", "Weak", "Poisoned", "Shaken"];
|
||||
const FEELINGS = [
|
||||
"Admiration",
|
||||
"Inferiority",
|
||||
"Loyalty",
|
||||
"Mistrust",
|
||||
"Affection",
|
||||
"Hatred",
|
||||
];
|
||||
|
||||
// ── TABS ───────────────────────────────────────────
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||
event.currentTarget.classList.add('active');
|
||||
document.getElementById('page-' + tab).classList.add('active');
|
||||
document
|
||||
.querySelectorAll(".tab")
|
||||
.forEach((t) => t.classList.remove("active"));
|
||||
document
|
||||
.querySelectorAll(".page")
|
||||
.forEach((p) => p.classList.remove("active"));
|
||||
event.currentTarget.classList.add("active");
|
||||
document.getElementById("page-" + tab).classList.add("active");
|
||||
}
|
||||
|
||||
// ── INIT ───────────────────────────────────────────
|
||||
function init() {
|
||||
const savedTheme = localStorage.getItem('fabulaUltimaTheme')
|
||||
|| (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
||||
document.getElementById('themeToggle').textContent = savedTheme === 'light' ? '☾ Dark' : '☀ Light';
|
||||
async function init() {
|
||||
const savedTheme =
|
||||
localStorage.getItem("fabulaUltimaTheme") ||
|
||||
(window.matchMedia("(prefers-color-scheme: light)").matches
|
||||
? "light"
|
||||
: "dark");
|
||||
document.getElementById("themeToggle").textContent =
|
||||
savedTheme === "light" ? "☾ Dark" : "☀ Light";
|
||||
renderStatuses();
|
||||
renderFP();
|
||||
renderBonds();
|
||||
@@ -36,18 +52,69 @@ function init() {
|
||||
renderOtherClasses();
|
||||
renderSpells();
|
||||
updateXPBar();
|
||||
tryAutoLoad();
|
||||
if (!(await tryLoadFromURL())) tryAutoLoad();
|
||||
}
|
||||
|
||||
// ── URL STATE ──────────────────────────────────────
|
||||
async function compressToBase64(str) {
|
||||
const stream = new CompressionStream("deflate-raw");
|
||||
const writer = stream.writable.getWriter();
|
||||
writer.write(new TextEncoder().encode(str));
|
||||
writer.close();
|
||||
const buf = await new Response(stream.readable).arrayBuffer();
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function decompressFromBase64(b64) {
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
const stream = new DecompressionStream("deflate-raw");
|
||||
const writer = stream.writable.getWriter();
|
||||
writer.write(bytes);
|
||||
writer.close();
|
||||
const buf = await new Response(stream.readable).arrayBuffer();
|
||||
return new TextDecoder().decode(buf);
|
||||
} catch {
|
||||
// Fall back to legacy uncompressed URLs
|
||||
return decodeURIComponent(escape(atob(b64)));
|
||||
}
|
||||
}
|
||||
|
||||
async function tryLoadFromURL() {
|
||||
const encoded = new URLSearchParams(window.location.search).get("c");
|
||||
if (!encoded) return false;
|
||||
try {
|
||||
const json = await decompressFromBase64(encoded);
|
||||
applyData(JSON.parse(json));
|
||||
urlMode = true;
|
||||
document.getElementById("urlBanner").style.display = "block";
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn("Could not load state from URL:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── STATUS EFFECTS ─────────────────────────────────
|
||||
function renderStatuses() {
|
||||
const g = document.getElementById('statusGrid');
|
||||
g.innerHTML = '';
|
||||
STATUSES.forEach(s => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'status-item';
|
||||
const g = document.getElementById("statusGrid");
|
||||
g.innerHTML = "";
|
||||
STATUSES.forEach((s) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "status-item";
|
||||
el.dataset.status = s;
|
||||
el.onclick = () => { el.classList.toggle('active-status'); el.querySelector('.status-check').textContent = el.classList.contains('active-status') ? '✗' : ''; };
|
||||
el.onclick = () => {
|
||||
el.classList.toggle("active-status");
|
||||
el.querySelector(".status-check").textContent = el.classList.contains(
|
||||
"active-status",
|
||||
)
|
||||
? "✗"
|
||||
: "";
|
||||
};
|
||||
el.innerHTML = `<div class="status-check"></div><div class="status-label">${s}</div>`;
|
||||
g.appendChild(el);
|
||||
});
|
||||
@@ -55,16 +122,16 @@ function renderStatuses() {
|
||||
|
||||
// ── FABULA POINTS ──────────────────────────────────
|
||||
function renderFP() {
|
||||
fpCount = parseInt(document.getElementById('fpCount').value) || 0;
|
||||
const pips = document.getElementById('fpPips');
|
||||
fpCount = parseInt(document.getElementById("fpCount").value) || 0;
|
||||
const pips = document.getElementById("fpPips");
|
||||
const total = Math.max(10, fpCount);
|
||||
pips.innerHTML = '';
|
||||
pips.innerHTML = "";
|
||||
for (let i = 0; i < total; i++) {
|
||||
const pip = document.createElement('div');
|
||||
pip.className = 'fp-pip' + (i < fpCount ? ' filled' : '');
|
||||
const pip = document.createElement("div");
|
||||
pip.className = "fp-pip" + (i < fpCount ? " filled" : "");
|
||||
pip.onclick = () => {
|
||||
const newVal = i < fpCount ? i : i + 1;
|
||||
document.getElementById('fpCount').value = newVal;
|
||||
document.getElementById("fpCount").value = newVal;
|
||||
renderFP();
|
||||
};
|
||||
pips.appendChild(pip);
|
||||
@@ -73,22 +140,24 @@ function renderFP() {
|
||||
|
||||
// ── BONDS ──────────────────────────────────────────
|
||||
function renderBonds() {
|
||||
const container = document.getElementById('bondsContainer');
|
||||
container.innerHTML = '';
|
||||
const container = document.getElementById("bondsContainer");
|
||||
container.innerHTML = "";
|
||||
bonds.forEach((bond, idx) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'bond-block';
|
||||
const el = document.createElement("div");
|
||||
el.className = "bond-block";
|
||||
el.innerHTML = `
|
||||
<div class="bond-header">
|
||||
<div class="bond-num">${idx + 1}</div>
|
||||
<input type="text" placeholder="Bond target name…" value="${bond.name}" oninput="bonds[${idx}].name=this.value">
|
||||
</div>
|
||||
<div class="bond-feelings">
|
||||
${FEELINGS.map(f => `
|
||||
<div class="bond-feeling${bond.feelings.includes(f) ? ' active' : ''}" onclick="toggleFeeling(${idx},'${f}',this)">
|
||||
<div class="bond-feeling-box">${bond.feelings.includes(f) ? '✓' : ''}</div>
|
||||
${FEELINGS.map(
|
||||
(f) => `
|
||||
<div class="bond-feeling${bond.feelings.includes(f) ? " active" : ""}" onclick="toggleFeeling(${idx},'${f}',this)">
|
||||
<div class="bond-feeling-box">${bond.feelings.includes(f) ? "✓" : ""}</div>
|
||||
<span>${f}</span>
|
||||
</div>`).join('')}
|
||||
</div>`,
|
||||
).join("")}
|
||||
</div>`;
|
||||
container.appendChild(el);
|
||||
});
|
||||
@@ -97,71 +166,88 @@ function renderBonds() {
|
||||
function toggleFeeling(bondIdx, feeling, el) {
|
||||
const bond = bonds[bondIdx];
|
||||
if (bond.feelings.includes(feeling)) {
|
||||
bond.feelings = bond.feelings.filter(f => f !== feeling);
|
||||
el.classList.remove('active');
|
||||
el.querySelector('.bond-feeling-box').textContent = '';
|
||||
bond.feelings = bond.feelings.filter((f) => f !== feeling);
|
||||
el.classList.remove("active");
|
||||
el.querySelector(".bond-feeling-box").textContent = "";
|
||||
} else {
|
||||
bond.feelings.push(feeling);
|
||||
el.classList.add('active');
|
||||
el.querySelector('.bond-feeling-box').textContent = '✓';
|
||||
el.classList.add("active");
|
||||
el.querySelector(".bond-feeling-box").textContent = "✓";
|
||||
}
|
||||
}
|
||||
|
||||
// ── LEVEL ──────────────────────────────────────────
|
||||
function adjustLevel(delta) {
|
||||
level = Math.max(1, Math.min(50, level + delta));
|
||||
document.getElementById('levelDisplay').textContent = level;
|
||||
document.getElementById("levelDisplay").textContent = level;
|
||||
}
|
||||
|
||||
// ── XP BAR ─────────────────────────────────────────
|
||||
function updateXPBar() {
|
||||
const xp = parseInt(document.getElementById('xpCurrent').value) || 0;
|
||||
const xp = parseInt(document.getElementById("xpCurrent").value) || 0;
|
||||
const pct = Math.min((xp % 10) * 10, 100);
|
||||
document.getElementById('xpBar').style.width = pct + '%';
|
||||
document.getElementById('xpVal').textContent = xp + ' XP';
|
||||
document.getElementById("xpBar").style.width = pct + "%";
|
||||
document.getElementById("xpVal").textContent = xp + " XP";
|
||||
}
|
||||
|
||||
// ── STAT CALC ──────────────────────────────────────
|
||||
function recalcStats() { /* auto-calc on keyup */ }
|
||||
function recalcStats() {
|
||||
/* auto-calc on keyup */
|
||||
}
|
||||
|
||||
function calcHP() {
|
||||
const mig = parseInt(document.getElementById('mig-base').value) || 6;
|
||||
const mig = parseInt(document.getElementById("mig-base").value) || 6;
|
||||
const max = mig * 5 + level;
|
||||
document.getElementById('hpMax').value = max;
|
||||
if (!document.getElementById('hpCur').value) document.getElementById('hpCur').value = max;
|
||||
document.getElementById("hpMax").value = max;
|
||||
if (!document.getElementById("hpCur").value)
|
||||
document.getElementById("hpCur").value = max;
|
||||
updateCrisis();
|
||||
}
|
||||
|
||||
function calcMP() {
|
||||
const wlp = parseInt(document.getElementById('wlp-base').value) || 6;
|
||||
const wlp = parseInt(document.getElementById("wlp-base").value) || 6;
|
||||
const max = wlp * 5 + level;
|
||||
document.getElementById('mpMax').value = max;
|
||||
if (!document.getElementById('mpCur').value) document.getElementById('mpCur').value = max;
|
||||
document.getElementById("mpMax").value = max;
|
||||
if (!document.getElementById("mpCur").value)
|
||||
document.getElementById("mpCur").value = max;
|
||||
}
|
||||
|
||||
function updateCrisis() {
|
||||
const max = parseInt(document.getElementById('hpMax').value) || 0;
|
||||
const cur = parseInt(document.getElementById('hpCur').value) || 0;
|
||||
const badge = document.getElementById('crisisBadge');
|
||||
badge.style.display = (max > 0 && cur <= Math.floor(max / 2)) ? 'block' : 'none';
|
||||
const max = parseInt(document.getElementById("hpMax").value) || 0;
|
||||
const cur = parseInt(document.getElementById("hpCur").value) || 0;
|
||||
const badge = document.getElementById("crisisBadge");
|
||||
badge.style.display =
|
||||
max > 0 && cur <= Math.floor(max / 2) ? "block" : "none";
|
||||
}
|
||||
|
||||
// ── MARTIAL ────────────────────────────────────────
|
||||
function toggleMartial(el) { el.classList.toggle('checked'); el.querySelector('.martial-box').textContent = el.classList.contains('checked') ? '✓' : ''; }
|
||||
function toggleMartial(el) {
|
||||
el.classList.toggle("checked");
|
||||
el.querySelector(".martial-box").textContent = el.classList.contains(
|
||||
"checked",
|
||||
)
|
||||
? "✓"
|
||||
: "";
|
||||
}
|
||||
|
||||
// ── DISCIPLINES ────────────────────────────────────
|
||||
function toggleDisc(el) { el.classList.toggle('checked'); el.querySelector('.disc-box').textContent = el.classList.contains('checked') ? '✓' : ''; }
|
||||
function toggleDisc(el) {
|
||||
el.classList.toggle("checked");
|
||||
el.querySelector(".disc-box").textContent = el.classList.contains("checked")
|
||||
? "✓"
|
||||
: "";
|
||||
}
|
||||
|
||||
// ── CLASSES ────────────────────────────────────────
|
||||
function classBlockHTML(cls, idx, type) {
|
||||
return `
|
||||
<div class="class-block" id="${type}-class-${idx}">
|
||||
<div class="class-header">
|
||||
<input type="text" placeholder="Class name…" value="${cls.name || ''}" oninput="${type}Classes[${idx}].name=this.value">
|
||||
<input type="text" placeholder="Free benefits…" value="${cls.benefits || ''}" oninput="${type}Classes[${idx}].benefits=this.value">
|
||||
<input type="text" placeholder="Class name…" value="${cls.name || ""}" oninput="${type}Classes[${idx}].name=this.value">
|
||||
<input type="text" placeholder="Free benefits…" value="${cls.benefits || ""}" oninput="${type}Classes[${idx}].benefits=this.value">
|
||||
</div>
|
||||
<div class="class-skills">
|
||||
<textarea placeholder="Skill information…" oninput="${type}Classes[${idx}].skills=this.value">${cls.skills || ''}</textarea>
|
||||
<textarea placeholder="Skill information…" oninput="${type}Classes[${idx}].skills=this.value">${cls.skills || ""}</textarea>
|
||||
</div>
|
||||
<div style="padding:6px 10px; border-top:1px solid var(--border); display:flex; justify-content:flex-end;">
|
||||
<button class="spell-del-btn" onclick="removeClass('${type}',${idx})">✕ Remove</button>
|
||||
@@ -170,141 +256,284 @@ function classBlockHTML(cls, idx, type) {
|
||||
}
|
||||
|
||||
function renderPrimaryClasses() {
|
||||
document.getElementById('primaryClassContainer').innerHTML = primaryClasses.map((c, i) => classBlockHTML(c, i, 'primary')).join('');
|
||||
document.getElementById("primaryClassContainer").innerHTML = primaryClasses
|
||||
.map((c, i) => classBlockHTML(c, i, "primary"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderOtherClasses() {
|
||||
document.getElementById('otherClassContainer').innerHTML = otherClasses.map((c, i) => classBlockHTML(c, i, 'other')).join('');
|
||||
document.getElementById("otherClassContainer").innerHTML = otherClasses
|
||||
.map((c, i) => classBlockHTML(c, i, "other"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function addPrimaryClass() { primaryClasses.push({}); renderPrimaryClasses(); }
|
||||
function addOtherClass() { otherClasses.push({}); renderOtherClasses(); }
|
||||
function addPrimaryClass() {
|
||||
primaryClasses.push({});
|
||||
renderPrimaryClasses();
|
||||
}
|
||||
function addOtherClass() {
|
||||
otherClasses.push({});
|
||||
renderOtherClasses();
|
||||
}
|
||||
function removeClass(type, idx) {
|
||||
if (type === 'primary') { primaryClasses.splice(idx, 1); renderPrimaryClasses(); }
|
||||
else { otherClasses.splice(idx, 1); renderOtherClasses(); }
|
||||
if (type === "primary") {
|
||||
primaryClasses.splice(idx, 1);
|
||||
renderPrimaryClasses();
|
||||
} else {
|
||||
otherClasses.splice(idx, 1);
|
||||
renderOtherClasses();
|
||||
}
|
||||
}
|
||||
|
||||
// ── SPELLS ─────────────────────────────────────────
|
||||
function renderSpells() {
|
||||
const tbody = document.getElementById('spellsBody');
|
||||
tbody.innerHTML = spells.map((s, i) => `
|
||||
const tbody = document.getElementById("spellsBody");
|
||||
tbody.innerHTML = spells
|
||||
.map(
|
||||
(s, i) => `
|
||||
<tr>
|
||||
<td class="spell-name-col">
|
||||
<input type="text" placeholder="Spell / Arcana name…" value="${s.name || ''}" oninput="spells[${i}].name=this.value">
|
||||
<textarea placeholder="Notes / effect description…" oninput="spells[${i}].notes=this.value">${s.notes || ''}</textarea>
|
||||
<input type="text" placeholder="Spell / Arcana name…" value="${s.name || ""}" oninput="spells[${i}].name=this.value">
|
||||
<textarea placeholder="Notes / effect description…" oninput="spells[${i}].notes=this.value">${s.notes || ""}</textarea>
|
||||
</td>
|
||||
<td class="spell-mp-col"><input type="number" placeholder="0" value="${s.mp || ''}" oninput="spells[${i}].mp=this.value" style="min-height:32px;"></td>
|
||||
<td class="spell-targets-col"><input type="text" placeholder="Target(s)…" value="${s.targets || ''}" oninput="spells[${i}].targets=this.value"></td>
|
||||
<td class="spell-dur-col"><input type="text" placeholder="Duration…" value="${s.duration || ''}" oninput="spells[${i}].duration=this.value"></td>
|
||||
<td class="spell-mp-col"><input type="number" placeholder="0" value="${s.mp || ""}" oninput="spells[${i}].mp=this.value" style="min-height:32px;"></td>
|
||||
<td class="spell-targets-col"><input type="text" placeholder="Target(s)…" value="${s.targets || ""}" oninput="spells[${i}].targets=this.value"></td>
|
||||
<td class="spell-dur-col"><input type="text" placeholder="Duration…" value="${s.duration || ""}" oninput="spells[${i}].duration=this.value"></td>
|
||||
<td class="spell-del-col"><button class="spell-del-btn" onclick="removeSpell(${i})">✕</button></td>
|
||||
</tr>`).join('');
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function addSpell() { spells.push({}); renderSpells(); }
|
||||
function removeSpell(i) { spells.splice(i, 1); renderSpells(); }
|
||||
function addSpell() {
|
||||
spells.push({});
|
||||
renderSpells();
|
||||
}
|
||||
function removeSpell(i) {
|
||||
spells.splice(i, 1);
|
||||
renderSpells();
|
||||
}
|
||||
|
||||
// ── SAVE / LOAD ────────────────────────────────────
|
||||
// Short key legend (serialized form only; in-memory objects use full names):
|
||||
// n=name, pn=pronouns, id=identity, th=theme, og=origin, tr=traits
|
||||
// lv=level, xp=xp, z=zenit
|
||||
// im=initMod, df=defense, md=magDef
|
||||
// dxb=dexBase, dxc=dexCur, inb=insBase, inc=insCur
|
||||
// mgb=migBase, mgc=migCur, wpb=wlpBase, wpc=wlpCur
|
||||
// hx=hpMax, hc=hpCur, mx=mpMax, mc=mpCur, ix=ipMax, ic=ipCur
|
||||
// fp=fp, bp=backpack
|
||||
// acn=accName, acd=accDesc, amn=armName, amd=armDesc
|
||||
// mhn=mhName, mhd=mhDesc, ohn=ohName, ohd=ohDesc
|
||||
// hs=heroicSkills, rn=ritualsNotes
|
||||
// sa=statusesActive, ma=martialChecked, da=disciplinesChecked
|
||||
// bo=bonds, pc=primaryClasses, oc=otherClasses, sp=spells
|
||||
// Nested bonds: n=name, f=feelings
|
||||
// Nested classes: n=name, b=benefits, s=skills
|
||||
// Nested spells: n=name, nt=notes, mp=mp, tg=targets, dr=duration
|
||||
function collectData() {
|
||||
const get = id => { const el = document.getElementById(id); return el ? el.value : ''; };
|
||||
const statusesActive = [...document.querySelectorAll('.status-item.active-status')].map(el => el.dataset.status);
|
||||
const martialChecked = [...document.querySelectorAll('.martial-item.checked')].map(el => el.querySelector('span').textContent);
|
||||
const disciplinesChecked = [...document.querySelectorAll('.disc-item.checked')].map(el => el.querySelector('span').textContent);
|
||||
const get = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
return el ? el.value : "";
|
||||
};
|
||||
const sa = [...document.querySelectorAll(".status-item.active-status")].map(
|
||||
(el) => el.dataset.status,
|
||||
);
|
||||
const ma = [...document.querySelectorAll(".martial-item.checked")].map(
|
||||
(el) => el.querySelector("span").textContent,
|
||||
);
|
||||
const da = [...document.querySelectorAll(".disc-item.checked")].map(
|
||||
(el) => el.querySelector("span").textContent,
|
||||
);
|
||||
|
||||
return {
|
||||
name: get('charName'), pronouns: get('charPronouns'),
|
||||
identity: get('charIdentity'), theme: get('charTheme'), origin: get('charOrigin'),
|
||||
traits: get('charTraits'),
|
||||
level, xp: get('xpCurrent'), zenit: get('zenit'),
|
||||
initMod: get('initMod'), defense: get('defense'), magDef: get('magDef'),
|
||||
dexBase: get('dex-base'), dexCur: get('dex-cur'),
|
||||
insBase: get('ins-base'), insCur: get('ins-cur'),
|
||||
migBase: get('mig-base'), migCur: get('mig-cur'),
|
||||
wlpBase: get('wlp-base'), wlpCur: get('wlp-cur'),
|
||||
hpMax: get('hpMax'), hpCur: get('hpCur'),
|
||||
mpMax: get('mpMax'), mpCur: get('mpCur'),
|
||||
ipMax: get('ipMax'), ipCur: get('ipCur'),
|
||||
fp: get('fpCount'),
|
||||
backpack: get('backpack'),
|
||||
accName: get('acc-name'), accDesc: get('acc-desc'),
|
||||
armName: get('arm-name'), armDesc: get('arm-desc'),
|
||||
mhName: get('mh-name'), mhDesc: get('mh-desc'),
|
||||
ohName: get('oh-name'), ohDesc: get('oh-desc'),
|
||||
heroicSkills: get('heroicSkills'),
|
||||
ritualsNotes: get('ritualsNotes'),
|
||||
statusesActive, martialChecked, disciplinesChecked,
|
||||
bonds, primaryClasses, otherClasses, spells
|
||||
n: get("charName"),
|
||||
pn: get("charPronouns"),
|
||||
id: get("charIdentity"),
|
||||
th: get("charTheme"),
|
||||
og: get("charOrigin"),
|
||||
tr: get("charTraits"),
|
||||
lv: level,
|
||||
xp: get("xpCurrent"),
|
||||
z: get("zenit"),
|
||||
im: get("initMod"),
|
||||
df: get("defense"),
|
||||
md: get("magDef"),
|
||||
dxb: get("dex-base"),
|
||||
dxc: get("dex-cur"),
|
||||
inb: get("ins-base"),
|
||||
inc: get("ins-cur"),
|
||||
mgb: get("mig-base"),
|
||||
mgc: get("mig-cur"),
|
||||
wpb: get("wlp-base"),
|
||||
wpc: get("wlp-cur"),
|
||||
hx: get("hpMax"),
|
||||
hc: get("hpCur"),
|
||||
mx: get("mpMax"),
|
||||
mc: get("mpCur"),
|
||||
ix: get("ipMax"),
|
||||
ic: get("ipCur"),
|
||||
fp: get("fpCount"),
|
||||
bp: get("backpack"),
|
||||
acn: get("acc-name"),
|
||||
acd: get("acc-desc"),
|
||||
amn: get("arm-name"),
|
||||
amd: get("arm-desc"),
|
||||
mhn: get("mh-name"),
|
||||
mhd: get("mh-desc"),
|
||||
ohn: get("oh-name"),
|
||||
ohd: get("oh-desc"),
|
||||
hs: get("heroicSkills"),
|
||||
rn: get("ritualsNotes"),
|
||||
sa,
|
||||
ma,
|
||||
da,
|
||||
bo: bonds.map((b) => ({ n: b.name, f: b.feelings })),
|
||||
pc: primaryClasses.map((c) => ({ n: c.name, b: c.benefits, s: c.skills })),
|
||||
oc: otherClasses.map((c) => ({ n: c.name, b: c.benefits, s: c.skills })),
|
||||
sp: spells.map((s) => ({
|
||||
n: s.name,
|
||||
nt: s.notes,
|
||||
mp: s.mp,
|
||||
tg: s.targets,
|
||||
dr: s.duration,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function saveSheet() {
|
||||
function saveSheet(isAuto = false) {
|
||||
if (isAuto && urlMode) return;
|
||||
const data = collectData();
|
||||
localStorage.setItem('fabulaUltimaSheet', JSON.stringify(data));
|
||||
const st = document.getElementById('saveStatus');
|
||||
st.classList.add('show');
|
||||
setTimeout(() => st.classList.remove('show'), 2000);
|
||||
localStorage.setItem("fabulaUltimaSheet", JSON.stringify(data));
|
||||
const st = document.getElementById("saveStatus");
|
||||
st.classList.add("show");
|
||||
setTimeout(() => st.classList.remove("show"), 2000);
|
||||
}
|
||||
|
||||
function tryAutoLoad() {
|
||||
const raw = localStorage.getItem('fabulaUltimaSheet');
|
||||
if (raw) try { applyData(JSON.parse(raw)); } catch (e) { }
|
||||
const raw = localStorage.getItem("fabulaUltimaSheet");
|
||||
if (raw)
|
||||
try {
|
||||
applyData(JSON.parse(raw));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function loadSheet() {
|
||||
const raw = localStorage.getItem('fabulaUltimaSheet');
|
||||
if (!raw) return alert('No saved sheet found.');
|
||||
try { applyData(JSON.parse(raw)); } catch (e) { alert('Could not load sheet.'); }
|
||||
const raw = localStorage.getItem("fabulaUltimaSheet");
|
||||
if (!raw) return alert("No saved sheet found.");
|
||||
try {
|
||||
applyData(JSON.parse(raw));
|
||||
} catch (e) {
|
||||
alert("Could not load sheet.");
|
||||
}
|
||||
}
|
||||
|
||||
function applyData(d) {
|
||||
const set = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined) el.value = val; };
|
||||
set('charName', d.name); set('charPronouns', d.pronouns);
|
||||
set('charIdentity', d.identity); set('charTheme', d.theme); set('charOrigin', d.origin);
|
||||
set('charTraits', d.traits);
|
||||
level = d.level || 1;
|
||||
document.getElementById('levelDisplay').textContent = level;
|
||||
set('xpCurrent', d.xp); set('zenit', d.zenit);
|
||||
set('initMod', d.initMod); set('defense', d.defense); set('magDef', d.magDef);
|
||||
set('dex-base', d.dexBase); set('dex-cur', d.dexCur);
|
||||
set('ins-base', d.insBase); set('ins-cur', d.insCur);
|
||||
set('mig-base', d.migBase); set('mig-cur', d.migCur);
|
||||
set('wlp-base', d.wlpBase); set('wlp-cur', d.wlpCur);
|
||||
set('hpMax', d.hpMax); set('hpCur', d.hpCur);
|
||||
set('mpMax', d.mpMax); set('mpCur', d.mpCur);
|
||||
set('ipMax', d.ipMax); set('ipCur', d.ipCur);
|
||||
set('fpCount', d.fp);
|
||||
set('backpack', d.backpack);
|
||||
set('acc-name', d.accName); set('acc-desc', d.accDesc);
|
||||
set('arm-name', d.armName); set('arm-desc', d.armDesc);
|
||||
set('mh-name', d.mhName); set('mh-desc', d.mhDesc);
|
||||
set('oh-name', d.ohName); set('oh-desc', d.ohDesc);
|
||||
set('heroicSkills', d.heroicSkills);
|
||||
set('ritualsNotes', d.ritualsNotes);
|
||||
const set = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el && val !== undefined) el.value = val;
|
||||
};
|
||||
set("charName", d.n ?? d.name);
|
||||
set("charPronouns", d.pn ?? d.pronouns);
|
||||
set("charIdentity", d.id ?? d.identity);
|
||||
set("charTheme", d.th ?? d.theme);
|
||||
set("charOrigin", d.og ?? d.origin);
|
||||
set("charTraits", d.tr ?? d.traits);
|
||||
level = d.lv ?? d.level ?? 1;
|
||||
document.getElementById("levelDisplay").textContent = level;
|
||||
set("xpCurrent", d.xp);
|
||||
set("zenit", d.z ?? d.zenit);
|
||||
set("initMod", d.im ?? d.initMod);
|
||||
set("defense", d.df ?? d.defense);
|
||||
set("magDef", d.md ?? d.magDef);
|
||||
set("dex-base", d.dxb ?? d.dexBase);
|
||||
set("dex-cur", d.dxc ?? d.dexCur);
|
||||
set("ins-base", d.inb ?? d.insBase);
|
||||
set("ins-cur", d.inc ?? d.insCur);
|
||||
set("mig-base", d.mgb ?? d.migBase);
|
||||
set("mig-cur", d.mgc ?? d.migCur);
|
||||
set("wlp-base", d.wpb ?? d.wlpBase);
|
||||
set("wlp-cur", d.wpc ?? d.wlpCur);
|
||||
set("hpMax", d.hx ?? d.hpMax);
|
||||
set("hpCur", d.hc ?? d.hpCur);
|
||||
set("mpMax", d.mx ?? d.mpMax);
|
||||
set("mpCur", d.mc ?? d.mpCur);
|
||||
set("ipMax", d.ix ?? d.ipMax);
|
||||
set("ipCur", d.ic ?? d.ipCur);
|
||||
set("fpCount", d.fp);
|
||||
set("backpack", d.bp ?? d.backpack);
|
||||
set("acc-name", d.acn ?? d.accName);
|
||||
set("acc-desc", d.acd ?? d.accDesc);
|
||||
set("arm-name", d.amn ?? d.armName);
|
||||
set("arm-desc", d.amd ?? d.armDesc);
|
||||
set("mh-name", d.mhn ?? d.mhName);
|
||||
set("mh-desc", d.mhd ?? d.mhDesc);
|
||||
set("oh-name", d.ohn ?? d.ohName);
|
||||
set("oh-desc", d.ohd ?? d.ohDesc);
|
||||
set("heroicSkills", d.hs ?? d.heroicSkills);
|
||||
set("ritualsNotes", d.rn ?? d.ritualsNotes);
|
||||
|
||||
// Statuses
|
||||
document.querySelectorAll('.status-item').forEach(el => {
|
||||
const active = (d.statusesActive || []).includes(el.dataset.status);
|
||||
el.classList.toggle('active-status', active);
|
||||
el.querySelector('.status-check').textContent = active ? '✗' : '';
|
||||
const sa = d.sa ?? d.statusesActive ?? [];
|
||||
document.querySelectorAll(".status-item").forEach((el) => {
|
||||
const active = sa.includes(el.dataset.status);
|
||||
el.classList.toggle("active-status", active);
|
||||
el.querySelector(".status-check").textContent = active ? "✗" : "";
|
||||
});
|
||||
|
||||
// Martial
|
||||
document.querySelectorAll('.martial-item').forEach(el => {
|
||||
const checked = (d.martialChecked || []).includes(el.querySelector('span').textContent);
|
||||
el.classList.toggle('checked', checked);
|
||||
el.querySelector('.martial-box').textContent = checked ? '✓' : '';
|
||||
const ma = d.ma ?? d.martialChecked ?? [];
|
||||
document.querySelectorAll(".martial-item").forEach((el) => {
|
||||
const checked = ma.includes(el.querySelector("span").textContent);
|
||||
el.classList.toggle("checked", checked);
|
||||
el.querySelector(".martial-box").textContent = checked ? "✓" : "";
|
||||
});
|
||||
|
||||
// Disciplines
|
||||
document.querySelectorAll('.disc-item').forEach(el => {
|
||||
const checked = (d.disciplinesChecked || []).includes(el.querySelector('span').textContent);
|
||||
el.classList.toggle('checked', checked);
|
||||
el.querySelector('.disc-box').textContent = checked ? '✓' : '';
|
||||
const da = d.da ?? d.disciplinesChecked ?? [];
|
||||
document.querySelectorAll(".disc-item").forEach((el) => {
|
||||
const checked = da.includes(el.querySelector("span").textContent);
|
||||
el.classList.toggle("checked", checked);
|
||||
el.querySelector(".disc-box").textContent = checked ? "✓" : "";
|
||||
});
|
||||
|
||||
if (d.bonds) { bonds = d.bonds; renderBonds(); }
|
||||
if (d.primaryClasses) { primaryClasses = d.primaryClasses; renderPrimaryClasses(); }
|
||||
if (d.otherClasses) { otherClasses = d.otherClasses; renderOtherClasses(); }
|
||||
if (d.spells) { spells = d.spells; renderSpells(); }
|
||||
const rawBonds = d.bo ?? d.bonds;
|
||||
if (rawBonds) {
|
||||
bonds = rawBonds.map((b) => ({
|
||||
name: b.n ?? b.name,
|
||||
feelings: b.f ?? b.feelings ?? [],
|
||||
}));
|
||||
renderBonds();
|
||||
}
|
||||
const rawPrimary = d.pc ?? d.primaryClasses;
|
||||
if (rawPrimary) {
|
||||
primaryClasses = rawPrimary.map((c) => ({
|
||||
name: c.n ?? c.name,
|
||||
benefits: c.b ?? c.benefits,
|
||||
skills: c.s ?? c.skills,
|
||||
}));
|
||||
renderPrimaryClasses();
|
||||
}
|
||||
const rawOther = d.oc ?? d.otherClasses;
|
||||
if (rawOther) {
|
||||
otherClasses = rawOther.map((c) => ({
|
||||
name: c.n ?? c.name,
|
||||
benefits: c.b ?? c.benefits,
|
||||
skills: c.s ?? c.skills,
|
||||
}));
|
||||
renderOtherClasses();
|
||||
}
|
||||
const rawSpells = d.sp ?? d.spells;
|
||||
if (rawSpells) {
|
||||
spells = rawSpells.map((s) => ({
|
||||
name: s.n ?? s.name,
|
||||
notes: s.nt ?? s.notes,
|
||||
mp: s.mp,
|
||||
targets: s.tg ?? s.targets,
|
||||
duration: s.dr ?? s.duration,
|
||||
}));
|
||||
renderSpells();
|
||||
}
|
||||
|
||||
renderFP();
|
||||
updateXPBar();
|
||||
@@ -315,51 +544,72 @@ function applyData(d) {
|
||||
function exportSheet() {
|
||||
const data = collectData();
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const charName = (data.name || 'character').replace(/[^a-z0-9_\- ]/gi, '').trim() || 'character';
|
||||
const a = document.createElement("a");
|
||||
const charName =
|
||||
(data.n || "character").replace(/[^a-z0-9_\- ]/gi, "").trim() ||
|
||||
"character";
|
||||
a.href = url;
|
||||
a.download = charName + '-fabula-ultima.json';
|
||||
a.download = charName + "-fabula-ultima.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function importSheet() {
|
||||
document.getElementById('importFileInput').value = '';
|
||||
document.getElementById('importFileInput').click();
|
||||
document.getElementById("importFileInput").value = "";
|
||||
document.getElementById("importFileInput").click();
|
||||
}
|
||||
|
||||
function handleImportFile(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.target.result);
|
||||
applyData(data);
|
||||
saveSheet();
|
||||
const st = document.getElementById('saveStatus');
|
||||
st.textContent = 'Imported!';
|
||||
st.classList.add('show');
|
||||
setTimeout(() => { st.classList.remove('show'); st.textContent = 'Saved!'; }, 2500);
|
||||
const st = document.getElementById("saveStatus");
|
||||
st.textContent = "Imported!";
|
||||
st.classList.add("show");
|
||||
setTimeout(() => {
|
||||
st.classList.remove("show");
|
||||
st.textContent = "Saved!";
|
||||
}, 2500);
|
||||
} catch (err) {
|
||||
alert('Could not import: invalid JSON file.');
|
||||
alert("Could not import: invalid JSON file.");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
async function copyShareURL() {
|
||||
const json = JSON.stringify(collectData());
|
||||
const encoded = await compressToBase64(json);
|
||||
const url =
|
||||
window.location.origin +
|
||||
window.location.pathname +
|
||||
"?c=" +
|
||||
encodeURIComponent(encoded);
|
||||
await navigator.clipboard.writeText(url);
|
||||
const st = document.getElementById("copyStatus");
|
||||
st.classList.add("show");
|
||||
setTimeout(() => st.classList.remove("show"), 2000);
|
||||
}
|
||||
|
||||
// ── THEME ──────────────────────────────────────────
|
||||
function toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
const goLight = html.dataset.theme !== 'light';
|
||||
html.dataset.theme = goLight ? 'light' : 'dark';
|
||||
document.getElementById('themeToggle').textContent = goLight ? '☾ Dark' : '☀ Light';
|
||||
localStorage.setItem('fabulaUltimaTheme', html.dataset.theme);
|
||||
const goLight = html.dataset.theme !== "light";
|
||||
html.dataset.theme = goLight ? "light" : "dark";
|
||||
document.getElementById("themeToggle").textContent = goLight
|
||||
? "☾ Dark"
|
||||
: "☀ Light";
|
||||
localStorage.setItem("fabulaUltimaTheme", html.dataset.theme);
|
||||
}
|
||||
|
||||
// Auto-save every 30s
|
||||
setInterval(saveSheet, 30000);
|
||||
setInterval(() => saveSheet(true), 30000);
|
||||
|
||||
init();
|
||||
|
||||
27
package-lock.json
generated
Normal file
27
package-lock.json
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "fabula-ultima-html",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"prettier": "^3.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"prettier": "^3.8.3"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user