Initial commit - v1
This commit is contained in:
918
index.html
Normal file
918
index.html
Normal 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 <en-US>...</en-US></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 > TitleData > Primary Title Data > <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, it’s 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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 doesn’t 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>
|
||||||
Reference in New Issue
Block a user