Initial commit - v1

This commit is contained in:
Gurpreet Singh
2026-01-16 17:24:59 -05:00
commit 5cc4a3f917

918
index.html Normal file
View File

@@ -0,0 +1,918 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Release Notes Formatter</title>
<style>
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
margin: 0;
line-height: 1.35;
background: #f6f7f9;
color: #111;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 28px 28px 48px;
}
.row { display: grid; grid-template-columns: 1fr; gap: 14px; }
.card {
border: 1px solid rgba(0,0,0,0.08);
border-radius: 12px;
padding: 14px;
background: #fff;
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
}
.muted { color: #666; font-size: 14px; }
.btn {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.15);
background: #fff;
cursor: pointer;
}
.btn:hover { background: #f2f4f7; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn:active { transform: translateY(1px); }
textarea {
width: 100%;
min-height: 260px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
padding: 10px;
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.15);
background: #fbfcfe;
}
.grid2 { display: grid; grid-template-columns: 1fr; gap: 12px; }
@media (min-width: 980px) { .grid2 { grid-template-columns: 1fr 1fr; } }
.topbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.navRow { display: flex; flex-wrap: wrap; gap: 10px; }
.navBtn {
padding: 8px 10px;
border-radius: 999px;
border: 1px solid rgba(0,0,0,0.12);
background: #fff;
cursor: pointer;
font-size: 13px;
}
.navBtn:hover { background: #f2f4f7; }
.ok { color: #0a7; }
.err { color: #b00; white-space: pre-wrap; }
.small { font-size: 12px; color: #666; }
label { font-weight: 600; }
input[type="file"] { padding: 6px; }
.ascGrid { display: grid; grid-template-columns: 1fr; gap: 12px; margin-top: 10px; }
@media (min-width: 980px) { .ascGrid { grid-template-columns: 1fr 1fr; } }
.singleCol { grid-template-columns: 1fr !important; }
.ascBoxHeader { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom: 8px; }
.ascLocale { font-weight: 700; }
.twoCol { display: grid; grid-template-columns: 1fr; gap: 10px; }
@media (min-width: 980px) { .twoCol { grid-template-columns: 1fr 1fr; } }
.fieldLabel { font-size: 12px; color: #444; font-weight: 700; margin: 0 0 6px 0; }
.fieldHeader { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
.fieldBox textarea { min-height: 140px; }
@keyframes uploadFlash {
0% { background-color: #eafff6; }
100% { background-color: #fff; }
}
#uploadCard.flash {
animation: uploadFlash 5s ease;
}
/* Smooth copy feedback */
.btnLabel {
display: inline-block;
transition: opacity 180ms ease, transform 180ms ease;
will-change: opacity, transform;
}
.btn.copied {
border-color: rgba(0,170,119,0.6);
background: #eafff6;
}
/* Section highlight pulse animation */
@keyframes sectionPulse {
0% { box-shadow: 0 0 0 0 rgba(0,170,119,0.0); border-color: rgba(0,0,0,0.08); }
20% { box-shadow: 0 0 0 6px rgba(0,170,119,0.22); border-color: rgba(0,170,119,0.55); }
60% { box-shadow: 0 0 0 10px rgba(0,170,119,0.10); border-color: rgba(0,170,119,0.35); }
100% { box-shadow: 0 0 0 0 rgba(0,170,119,0.0); border-color: rgba(0,0,0,0.08); }
}
.sectionHighlight {
animation: sectionPulse 1.4s ease;
}
</style>
</head>
<body>
<div class="container">
<h2 style="margin: 0 0 6px 0;">Release Notes Formatter</h2>
<p class="muted" style="margin: 0 0 14px 0;">Upload a Gridly CSV export.</p>
<div class="row">
<div class="card" id="uploadCard">
<div class="topbar">
<div>
<label for="file">CSV file</label><br/>
<input id="file" type="file" accept=".csv,text/csv" />
<div id="fileInfo" class="small"></div>
</div>
<div>
<label for="gameVersion">Game version</label><br/>
<input id="gameVersion" type="text" inputmode="decimal" placeholder="e.g. 6.51.0" style="padding: 8px 10px; border-radius: 10px; border: 1px solid rgba(0,0,0,0.15); background:#fff; min-width: 160px;" />
<div id="versionHelp" class="small muted">Used for PlayFab News titles only. Format: x.xx.x</div>
</div>
<div style="flex:1"></div>
<button class="btn" id="btnRefresh" disabled>Refresh</button>
</div>
</div>
<div class="card" style="padding: 12px;">
<div class="navRow">
<button class="navBtn" type="button" data-scroll="googleSection">Google</button>
<button class="navBtn" type="button" data-scroll="appleSection">Apple</button>
<button class="navBtn" type="button" data-scroll="playfabAdcapSection">Playfab (AdCap)</button>
<button class="navBtn" type="button" data-scroll="playfabAdcomSection">Playfab (AdCom/AdAges)</button>
</div>
</div>
<div class="grid2">
<div class="card" id="googleSection">
<div class="topbar" style="justify-content: space-between;">
<div>
<h3 style="margin:0 0 6px 0;">Google Play output</h3>
<div class="muted">Tagged format like &lt;en-US&gt;...&lt;/en-US&gt;</div>
</div>
<button class="btn" id="btnCopyGoogle" disabled><span class="btnLabel">Copy</span></button>
</div>
<textarea id="outGoogle" readonly></textarea>
</div>
<div class="card" id="appleSection">
<h3 style="margin:0 0 6px 0;">App Store Connect output</h3>
<div id="ascBoxes" class="ascGrid"></div>
</div>
<div class="card" id="playfabAdcapSection">
<h3 style="margin:0 0 6px 0;">PlayFab News output (AdCap only)</h3>
<div class="muted" style="margin-bottom: 10px;">Add this JSON to: AdCap &gt; TitleData &gt; Primary Title Data &gt; <code>newsfeed_mobile</code> (PlayFab).</div>
<div id="adcapHelp" class="small muted" style="margin-bottom: 10px;">Requires a valid game version above. Uses English text only.</div>
<div class="topbar" style="justify-content: space-between;">
<div class="small muted">Output JSON</div>
<button class="btn" id="btnCopyAdcap" disabled><span class="btnLabel">Copy</span></button>
</div>
<textarea id="outAdcap" readonly></textarea>
</div>
<div class="card" id="playfabAdcomSection">
<h3 style="margin:0 0 6px 0;">PlayFab News output (AdCom/AdAges)</h3>
<div class="muted">Per-language Title + Body. Requires a valid game version above.</div>
<div id="playfabBoxes" class="ascGrid singleCol"></div>
</div>
</div>
<div class="card">
<div class="muted">
Notes:
<ul>
<li>If a locale column is missing or empty, its skipped.</li>
</ul>
</div>
</div>
<div class="card">
<h3 style="margin:0 0 6px 0;">Skipped locales</h3>
<div class="muted" style="margin-bottom: 10px;">Shows any expected locales that were missing or empty in the uploaded CSV.</div>
<div id="skippedLocales" class="small">Upload a CSV to see results.</div>
</div>
</div>
</div>
<script>
// --- Config: map Gridly column headers -> output tags (Google).
// Use array-of-pairs so the same Gridly column can be emitted multiple times.
const GOOGLE_TAGS = [
["English", "en-US"],
["Arabic", "ar"],
["MSA", "ar"],
["German", "de-DE"],
["English", "en-GB"],
["Spanish (LatAm)", "es-419"],
["Spanish (Spain)", "es-ES"],
["Spanish (LatAm)", "es-US"],
["French (France)", "fr-FR"],
["Italian", "it-IT"],
["Indonesian", "id"],
["Japanese", "ja-JP"],
["Korean", "ko-KR"],
["Polish", "pl-PL"],
["Portuguese (Brazil)", "pt-BR"],
["Russian", "ru-RU"],
["Turkish", "tr-TR"],
["Chinese (Traditional)", "zh-TW"],
["Chinese (Simplified)", "zh-CN"],
["Chinese (Simplified)", "zh-HK"],
["Tr Chinese, Taiwan", "zh-TW"],
];
// App Store Connect order: (display label not used), [possible Gridly columns], output tag
const ASC_ORDER = [
["English (US)", ["English"], "English (US)"],
["Arabic", ["Arabic", "MSA"], "Arabic"],
["Chinese Traditional", ["Tr Chinese, Taiwan", "Chinese Traditional", "Chinese (Traditional)", "Traditional Chinese"], "Chinese Traditional"],
["Chinese Simplified", ["Chinese Simplified", "Chinese (Simplified)", "Simplified Chinese"], "Chinese Simplified"],
["English (Canada)", ["English", "English (Canada)", "English CA"], "English (Canada)"],
["French", ["French (France)", "French"], "French"],
["German", ["German"], "German"],
["Indonesian", ["Indonesian"], "Indonesian"],
["Italian", ["Italian"], "Italian"],
["Japanese", ["Japanese"], "Japanese"],
["Korean", ["Korean"], "Korean"],
["Mexican Spanish", ["Spanish (LatAm)", "Spanish (Mexico)", "Mexican Spanish", "Spanish (Spain)"], "Spanish (Mexico)"],
["Polish", ["Polish"], "Polish"],
["Portuguese", ["Portuguese (Brazil)", "Portuguese"], "Portuguese (Brazil)"],
["Russian", ["Russian"], "Russian"],
["Spanish Spain", ["Spanish (Spain)"], "Spanish (Spain)"],
["Turkish", ["Turkish"], "Turkish"],
["English UK", ["English", "English (UK)", "English UK"], "English (UK)"],
];
// PlayFab News language order + templates
const PLAYFAB_ORDER = [
{ label: "English", columns: ["English"], titleTemplate: "{v} Update Live!" },
{ label: "Spanish", columns: ["Spanish (Spain)", "Spanish (LatAm)", "Spanish"], titleTemplate: "¡Actualización {v} disponible!" },
{ label: "Portuguese", columns: ["Portuguese (Brazil)", "Portuguese"], titleTemplate: "Atualização {v} disponível!" },
{ label: "French", columns: ["French (France)", "French"], titleTemplate: "Mise à jour {v} disponible !" },
{ label: "German", columns: ["German"], titleTemplate: "{v} Update Live!" },
{ label: "Italian", columns: ["Italian"], titleTemplate: "L'aggiornamento {v} è qui!" },
{ label: "Russian", columns: ["Russian"], titleTemplate: "Вышло обновление {v}!" },
{ label: "Japanese", columns: ["Japanese"], titleTemplate: "アップデート {v} 配信中!" },
{ label: "Korean", columns: ["Korean"], titleTemplate: "업데이트 {v} 라이브!" },
{ label: "Turkish", columns: ["Turkish"], titleTemplate: "{v} Güncellemesi Geldi!" },
{ label: "Polish", columns: ["Polish"], titleTemplate: "{v} Aktualizacja na żywo!" },
{ label: "Latin", columns: ["Spanish (LatAm)", "Spanish (Mexico)", "Mexican Spanish"], titleTemplate: "¡Actualización {v} disponible!" },
];
const fileInput = document.getElementById("file");
const fileInfo = document.getElementById("fileInfo");
const uploadCard = document.getElementById("uploadCard");
const outGoogle = document.getElementById("outGoogle");
const ascBoxes = document.getElementById("ascBoxes");
const btnCopyGoogle = document.getElementById("btnCopyGoogle");
const btnRefresh = document.getElementById("btnRefresh");
const skippedLocalesEl = document.getElementById("skippedLocales");
const gameVersionInput = document.getElementById("gameVersion");
const versionHelpEl = document.getElementById("versionHelp");
const playfabBoxes = document.getElementById("playfabBoxes");
const outAdcap = document.getElementById("outAdcap");
const btnCopyAdcap = document.getElementById("btnCopyAdcap");
const adcapHelpEl = document.getElementById("adcapHelp");
clearAdcap();
function clearAdcap() {
outAdcap.value = "";
btnCopyAdcap.disabled = true;
adcapHelpEl.classList.remove("err");
adcapHelpEl.classList.add("muted");
adcapHelpEl.textContent = "Requires a valid game version above. Uses English text only.";
}
function buildAdcapJson(rowObj, headers, version) {
if (!version || !isValidVersion(version)) return "";
const normalizedColMap = {};
for (const h of headers) normalizedColMap[h.trim()] = h;
const englishCol = normalizedColMap["English"];
const body = englishCol ? normalizeText(rowObj[englishCol]) : "";
if (!body) return "";
const payload = {
newsArticleTitle: `${version.trim()} Update Live!`,
newsArticleBody: body,
};
return JSON.stringify(payload, null, 2);
}
function renderAdcap(rowObj, headers, version) {
const json = buildAdcapJson(rowObj, headers, version);
if (!json) {
clearAdcap();
if ((version || "").trim() && !isValidVersion(version)) {
adcapHelpEl.textContent = "Invalid version format. Use x.xx.x (e.g. 6.51.0).";
adcapHelpEl.classList.remove("muted");
adcapHelpEl.classList.add("err");
}
return;
}
outAdcap.value = json;
btnCopyAdcap.disabled = false;
}
function isValidVersion(v) {
// x.xx.x where the middle is exactly 2 digits (per requirement)
return /^\d+\.\d{2}\.\d+$/.test(v.trim());
}
function getPlayfabTitle(template, version) {
return template.replaceAll("{v}", version);
}
function pickFirstNonEmpty(rowObj, normalizedColMap, possibleCols) {
for (const c of possibleCols) {
const actualCol = normalizedColMap[c.trim()];
if (!actualCol) continue;
const t = normalizeText(rowObj[actualCol]);
if (t) return t;
}
return "";
}
function clearPlayfab() {
playfabBoxes.innerHTML = "";
}
function renderPlayfabBoxes(rowObj, headers, version) {
clearPlayfab();
if (!version || !isValidVersion(version)) {
// show nothing if version missing/invalid
return;
}
// map trimmed header -> actual header
const normalizedColMap = {};
for (const h of headers) normalizedColMap[h.trim()] = h;
let any = false;
for (const item of PLAYFAB_ORDER) {
const body = pickFirstNonEmpty(rowObj, normalizedColMap, item.columns);
if (!body) continue; // skip if body missing
any = true;
const title = getPlayfabTitle(item.titleTemplate, version.trim());
const card = document.createElement("div");
card.className = "card";
const header = document.createElement("div");
header.className = "ascBoxHeader";
const locale = document.createElement("div");
locale.className = "ascLocale";
locale.textContent = item.label;
header.appendChild(locale);
card.appendChild(header);
const grid = document.createElement("div");
grid.style.display = "grid";
grid.style.gridTemplateColumns = "1fr";
grid.style.gap = "12px";
// Title box
{
const wrap = document.createElement("div");
const headerRow = document.createElement("div");
headerRow.className = "fieldHeader";
const lbl = document.createElement("div");
lbl.className = "fieldLabel";
lbl.textContent = "Title";
const btn = document.createElement("button");
btn.className = "btn";
btn.innerHTML = '<span class="btnLabel">Copy</span>';
btn.addEventListener("click", async () => {
try {
await copyToClipboard(title);
pulseCopied(btn);
setStatus(`✅ Copied PlayFab ${item.label} title`, "ok");
} catch {
setStatus("❌ Could not copy. Select and copy manually.", "err");
}
});
headerRow.appendChild(lbl);
headerRow.appendChild(btn);
const ta = document.createElement("textarea");
ta.readOnly = true;
ta.value = title;
ta.rows = 1;
ta.style.minHeight = "40px";
wrap.className = "fieldBox";
wrap.appendChild(headerRow);
wrap.appendChild(ta);
grid.appendChild(wrap);
}
// Body box
{
const wrap = document.createElement("div");
const headerRow = document.createElement("div");
headerRow.className = "fieldHeader";
const lbl = document.createElement("div");
lbl.className = "fieldLabel";
lbl.textContent = "Body";
const btn = document.createElement("button");
btn.className = "btn";
btn.innerHTML = '<span class="btnLabel">Copy</span>';
btn.addEventListener("click", async () => {
try {
await copyToClipboard(body);
pulseCopied(btn);
setStatus(`✅ Copied PlayFab ${item.label} body`, "ok");
} catch {
setStatus("❌ Could not copy. Select and copy manually.", "err");
}
});
headerRow.appendChild(lbl);
headerRow.appendChild(btn);
const ta = document.createElement("textarea");
ta.readOnly = true;
ta.value = body;
ta.style.minHeight = "120px";
wrap.className = "fieldBox";
wrap.appendChild(headerRow);
wrap.appendChild(ta);
grid.appendChild(wrap);
}
card.appendChild(grid);
playfabBoxes.appendChild(card);
}
// If nothing rendered, keep empty (no message), per requirement.
if (!any) {
clearPlayfab();
}
}
function setStatus(_msg, _type="") {
// Status UI removed; keep function so existing calls don't error.
}
function isCharCountCol(name) {
const c = (name || "").trim().toLowerCase();
return c.endsWith(" chars") || c.endsWith(" chrs");
}
function normalizeText(s) {
if (s == null) return "";
return String(s).replaceAll("\\n", "\n").replaceAll("\r\n", "\n").trim();
}
// Simple CSV parser that handles quotes + commas + newlines.
// (Good enough for typical Gridly exports; if you run into edge cases,
// you can swap this with PapaParse later.)
function parseCSV(text) {
const rows = [];
let row = [];
let cell = "";
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const next = text[i + 1];
if (inQuotes) {
if (ch === '"' && next === '"') { // escaped quote
cell += '"';
i++;
} else if (ch === '"') {
inQuotes = false;
} else {
cell += ch;
}
} else {
if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
row.push(cell);
cell = "";
} else if (ch === '\n') {
row.push(cell);
rows.push(row);
row = [];
cell = "";
} else if (ch === '\r') {
// ignore
} else {
cell += ch;
}
}
}
// last cell
row.push(cell);
rows.push(row);
// remove trailing empty rows
while (rows.length && rows[rows.length - 1].every(v => String(v || "").trim() === "")) rows.pop();
return rows;
}
function escapeHtml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function renderSkippedLocales(missingCols, emptyCols) {
if ((!missingCols || missingCols.length === 0) && (!emptyCols || emptyCols.length === 0)) {
skippedLocalesEl.innerHTML = '<span class="ok">✅ No skipped locales.</span>';
return;
}
const parts = [];
if (missingCols && missingCols.length) {
parts.push(
'<div style="margin-bottom:8px;"><b>Missing columns</b><div class="small muted">Not present in the CSV headers.</div></div>' +
'<div style="margin-bottom:12px;">' +
missingCols.map(c => `<code>${escapeHtml(c)}</code>`).join(' ') +
'</div>'
);
}
if (emptyCols && emptyCols.length) {
parts.push(
'<div style="margin-bottom:8px;"><b>Empty values</b><div class="small muted">Column exists but the selected row is blank.</div></div>' +
'<div>' +
emptyCols.map(c => `<code>${escapeHtml(c)}</code>`).join(' ') +
'</div>'
);
}
skippedLocalesEl.innerHTML = parts.join('');
}
function findFirstMeaningfulRow(objects, headers) {
for (const obj of objects) {
let hasText = false;
for (const h of headers) {
if (h === "Record ID" || isCharCountCol(h)) continue;
if (normalizeText(obj[h])) { hasText = true; break; }
}
if (hasText) return obj;
}
return objects[0] || null;
}
function buildOutputs(rowObj, headers) {
// map trimmed header -> actual header (tolerate whitespace)
const normalizedColMap = {};
for (const h of headers) normalizedColMap[h.trim()] = h;
const missingCols = [];
const emptyCols = [];
// GOOGLE
const googleBlocks = [];
for (const [gridlyCol, tag] of GOOGLE_TAGS) {
const actualCol = normalizedColMap[gridlyCol.trim()];
if (!actualCol) {
missingCols.push(gridlyCol);
continue;
}
const text = normalizeText(rowObj[actualCol]);
if (!text) {
emptyCols.push(gridlyCol);
continue;
}
googleBlocks.push(`<${tag}>\n${text}\n</${tag}>`);
}
// APP STORE CONNECT (structured + ordered)
const ascItems = [];
for (const [_label, possibleCols, tag] of ASC_ORDER) {
let found = "";
let sawAnyColumn = false;
let sawAnyNonEmpty = false;
for (const c of possibleCols) {
const actualCol = normalizedColMap[c.trim()];
if (!actualCol) continue;
sawAnyColumn = true;
const t = normalizeText(rowObj[actualCol]);
if (t) {
sawAnyNonEmpty = true;
found = t;
break;
}
}
if (!found) {
// Pick the first name as the representative for reporting
const representative = possibleCols[0];
if (!sawAnyColumn) missingCols.push(representative);
else if (!sawAnyNonEmpty) emptyCols.push(representative);
continue;
}
ascItems.push({ tag, text: found });
}
const uniq = (arr) => Array.from(new Set(arr));
return {
google: googleBlocks.join("\n\n"),
asc: ascItems,
skipped: {
missing: uniq(missingCols),
empty: uniq(emptyCols),
}
};
}
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text);
}
function flashUploadCard() {
if (!uploadCard) return;
uploadCard.classList.remove("flash");
void uploadCard.offsetHeight; // restart animation
uploadCard.classList.add("flash");
}
function pulseCopied(btn, doneText = "✓") {
if (!btn) return;
const labelEl = btn.querySelector(".btnLabel");
const hasLabel = !!labelEl;
const prevText = hasLabel ? labelEl.textContent : btn.textContent;
btn.classList.add("copied");
const fadeOut = () => {
if (!hasLabel) return;
labelEl.style.opacity = "0";
labelEl.style.transform = "scale(0.98)";
};
const fadeIn = (scale = "1.08") => {
if (!hasLabel) return;
labelEl.style.opacity = "1";
labelEl.style.transform = `scale(${scale})`;
};
// Swap to check
fadeOut();
window.setTimeout(() => {
if (hasLabel) labelEl.textContent = doneText;
else btn.textContent = doneText;
fadeIn("1.08");
}, 140);
// Revert after 2.5s
window.setTimeout(() => {
fadeOut();
window.setTimeout(() => {
if (hasLabel) labelEl.textContent = prevText;
else btn.textContent = prevText;
fadeIn("1.0");
btn.classList.remove("copied");
}, 140);
}, 2500);
}
function renderAscBoxes(items) {
// items: [{ tag: "en-US", text: "...." }, ...]
ascBoxes.innerHTML = "";
if (!items.length) {
ascBoxes.innerHTML = `<div class="muted">No App Store locales found in the CSV.</div>`;
return;
}
for (const { tag, text } of items) {
const block = text;
const card = document.createElement("div");
card.className = "card";
const header = document.createElement("div");
header.className = "ascBoxHeader";
const locale = document.createElement("div");
locale.className = "ascLocale";
locale.textContent = tag;
const btn = document.createElement("button");
btn.className = "btn";
btn.innerHTML = '<span class="btnLabel">Copy</span>';
btn.addEventListener("click", async () => {
try {
await copyToClipboard(block);
pulseCopied(btn);
setStatus(`✅ Copied ${tag} App Store block`, "ok");
} catch {
setStatus("❌ Could not copy (browser blocked clipboard). Select text and copy manually.", "err");
}
});
header.appendChild(locale);
header.appendChild(btn);
const ta = document.createElement("textarea");
ta.readOnly = true;
ta.value = block;
card.appendChild(header);
card.appendChild(ta);
ascBoxes.appendChild(card);
}
}
function getAllAscCombinedText() {
const blocks = Array.from(ascBoxes.querySelectorAll("textarea")).map(t => t.value);
return blocks.join("\n\n");
}
// Helper to regenerate all outputs from a stored payload
function regenerateFromPayload(payload) {
if (!payload) return;
const { rowObj, headers, recordId } = payload;
const { google, asc, skipped } = buildOutputs(rowObj, headers);
outGoogle.value = google;
renderAscBoxes(asc);
renderSkippedLocales(skipped.missing, skipped.empty);
btnCopyGoogle.disabled = !google;
const version = (gameVersionInput.value || "").trim();
renderPlayfabBoxes(rowObj, headers, version);
renderAdcap(rowObj, headers, version);
// Version help text (non-blocking)
if (version && !isValidVersion(version)) {
versionHelpEl.textContent = "Invalid version format. Use x.xx.x (e.g. 6.51.0). PlayFab output hidden.";
versionHelpEl.classList.remove("muted");
versionHelpEl.classList.add("err");
} else {
versionHelpEl.textContent = "Used for PlayFab News titles only. Format: x.xx.x";
versionHelpEl.classList.remove("err");
versionHelpEl.classList.add("muted");
}
flashUploadCard();
setStatus(`✅ Generated output${recordId ? ` (Record ID: ${recordId})` : ""}`, "ok");
}
fileInput.addEventListener("change", async () => {
const file = fileInput.files?.[0];
outGoogle.value = "";
ascBoxes.innerHTML = "";
btnCopyGoogle.disabled = true;
btnRefresh.disabled = true;
skippedLocalesEl.textContent = "Upload a CSV to see results.";
clearPlayfab();
clearAdcap();
if (!file) return;
fileInfo.textContent = `${file.name} (${Math.round(file.size/1024)} KB)`;
setStatus("Reading CSV...", "small");
try {
const text = await file.text();
const grid = parseCSV(text);
if (grid.length < 2) {
throw new Error("CSV doesnt look like it has a header row + data row.");
}
const headers = grid[0].map(h => String(h ?? "").trim());
const dataRows = grid.slice(1);
// Convert to objects
const objects = dataRows
.filter(r => r.some(v => String(v ?? "").trim() !== ""))
.map(r => {
const obj = {};
for (let i = 0; i < headers.length; i++) obj[headers[i]] = r[i] ?? "";
return obj;
});
const rowObj = findFirstMeaningfulRow(objects, headers);
if (!rowObj) throw new Error("No usable data row found in CSV.");
const recordId = normalizeText(rowObj["Record ID"] || "");
const { google, asc, skipped } = buildOutputs(rowObj, headers);
outGoogle.value = google;
renderAscBoxes(asc);
renderSkippedLocales(skipped.missing, skipped.empty);
btnCopyGoogle.disabled = !google;
// ---- PlayFab output ----
const version = (gameVersionInput.value || "").trim();
renderPlayfabBoxes(rowObj, headers, version);
renderAdcap(rowObj, headers, version);
// Update help text styling (non-blocking)
if (version && !isValidVersion(version)) {
versionHelpEl.textContent = "Invalid version format. Use x.xx.x (e.g. 6.51.0). PlayFab output hidden.";
versionHelpEl.classList.remove("muted");
versionHelpEl.classList.add("err");
} else {
versionHelpEl.textContent = "Used for PlayFab News titles only. Format: x.xx.x";
versionHelpEl.classList.remove("err");
versionHelpEl.classList.add("muted");
}
window.__lastParsedPayload = { rowObj, headers, recordId };
btnRefresh.disabled = false;
flashUploadCard();
flashUploadCard();
setStatus(`✅ Generated output${recordId ? ` (Record ID: ${recordId})` : ""}`, "ok");
} catch (e) {
setStatus(`${e.message || e}`, "err");
}
});
btnCopyGoogle.addEventListener("click", async () => {
try {
await copyToClipboard(outGoogle.value);
pulseCopied(btnCopyGoogle);
setStatus("✅ Copied Google output to clipboard", "ok");
} catch {
setStatus("❌ Could not copy (browser blocked clipboard). Select text and copy manually.", "err");
}
});
btnCopyAdcap.addEventListener("click", async () => {
try {
await copyToClipboard(outAdcap.value);
pulseCopied(btnCopyAdcap);
setStatus("✅ Copied AdCap JSON to clipboard", "ok");
} catch {
setStatus("❌ Could not copy (browser blocked clipboard). Select text and copy manually.", "err");
}
});
btnRefresh.addEventListener("click", () => {
const payload = window.__lastParsedPayload;
if (!payload) return;
regenerateFromPayload(payload);
});
gameVersionInput.addEventListener("input", () => {
const payload = window.__lastParsedPayload;
if (!payload) {
clearPlayfab();
clearAdcap();
return;
}
regenerateFromPayload(payload);
});
</script>
<script>
// Section navigation buttons scroll + highlight
document.querySelectorAll('[data-scroll]').forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-scroll');
const el = document.getElementById(id);
if (!el) return;
// Remove highlight from any previously highlighted section
document.querySelectorAll('.sectionHighlight').forEach((x) => x.classList.remove('sectionHighlight'));
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Wait briefly for the scroll to settle, then pulse-highlight the card
window.setTimeout(() => {
el.classList.remove('sectionHighlight');
void el.offsetHeight; // restart animation
el.classList.add('sectionHighlight');
// Clean up the class after the animation finishes
window.setTimeout(() => {
el.classList.remove('sectionHighlight');
}, 1500);
}, 250);
});
});
</script>
</body>
</html>