Files
ReleaseNotesFormatter/index.html
Gurpreet Singh 7126fd7497 More Ui updates
2026-01-16 17:51:18 -05:00

1155 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@600;700&display=swap" rel="stylesheet">
<title>Release Notes Formatter</title>
<style>
:root {
--bg: #f5f7fa;
--bg2: #eef2f7;
--card: #ffffff;
--text: #0f172a;
--muted: #64748b;
--border: rgba(2,6,23,0.10);
--shadow: 0 10px 26px rgba(2,6,23,0.07);
/* Palette */
--primary: #0D9488; /* teal */
--primary2: #146C94; /* deep blue */
--accent: #FF6B6B; /* coral */
--successBg: #eafff6;
}
[data-theme="dark"] {
--bg: #0b1220;
--bg2: #0f172a;
--card: #0f172a;
--text: #e5e7eb;
--muted: #94a3b8;
--border: rgba(226,232,240,0.14);
--shadow: 0 12px 34px rgba(0,0,0,0.40);
--successBg: rgba(13,148,136,0.16);
}
body {
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
margin: 0;
line-height: 1.45;
background: radial-gradient(1200px 600px at 10% 0%, var(--bg2), var(--bg));
color: var(--text);
}
.container {
width: min(92vw, 1120px);
max-width: 1120px;
margin: 0 auto;
padding: 26px 18px 56px;
}
.row { display: grid; grid-template-columns: 1fr; gap: 16px; }
.card {
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
background: var(--card);
box-shadow: var(--shadow);
}
.muted { color: var(--muted); font-size: 14px; }
.btn {
padding: 9px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--card) 92%, var(--primary) 8%);
color: var(--text);
cursor: pointer;
transition: transform 140ms ease, background 140ms ease, border-color 140ms ease;
}
.btn:hover {
background: color-mix(in srgb, var(--card) 86%, var(--primary) 14%);
border-color: color-mix(in srgb, var(--border) 60%, var(--primary) 40%);
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn:active { transform: scale(0.98); }
textarea {
width: 100%;
min-height: 260px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
padding: 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--card) 92%, var(--bg) 8%);
color: var(--text);
}
.grid2 { display: grid; grid-template-columns: 1fr; gap: 14px; }
.topbar { display: flex; gap: 14px; align-items: stretch; flex-wrap: wrap; }
.uploadRow {
display: flex;
gap: 14px;
width: 100%;
align-items: stretch;
flex-wrap: wrap;
}
.uploadRow > .dropZone {
flex: 1 1 420px;
min-width: 320px;
}
.uploadRow > .versionBox {
flex: 1 1 420px;
min-width: 260px;
display: flex;
flex-direction: column;
justify-content: center;
}
.versionBox input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--card) 92%, var(--bg) 8%);
color: var(--text);
}
.navRow { display: flex; flex-wrap: wrap; gap: 10px; }
.navBtn {
padding: 8px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--card) 92%, var(--primary2) 8%);
color: var(--text);
cursor: pointer;
font-size: 13px;
transition: transform 140ms ease, background 140ms ease, border-color 140ms ease;
}
.navBtn:hover {
background: color-mix(in srgb, var(--card) 86%, var(--primary2) 14%);
border-color: color-mix(in srgb, var(--border) 60%, var(--primary2) 40%);
}
.ok { color: var(--primary); }
.err { color: #ef4444; white-space: pre-wrap; }
.small { font-size: 12px; color: var(--muted); }
label { font-weight: 600; }
.dropZone {
border: 1.5px dashed color-mix(in srgb, var(--border) 55%, var(--primary2) 45%);
border-radius: 14px;
padding: 18px;
background: color-mix(in srgb, var(--card) 88%, var(--primary2) 12%);
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
cursor: pointer;
user-select: none;
transition: box-shadow 180ms ease, border-color 180ms ease, transform 180ms ease, background 180ms ease;
}
.dropZone:hover {
background: color-mix(in srgb, var(--card) 84%, var(--primary2) 16%);
border-color: color-mix(in srgb, var(--border) 40%, var(--primary2) 60%);
}
.dropZone.dragOver {
border-color: var(--primary2);
box-shadow: 0 0 0 8px color-mix(in srgb, var(--primary2) 18%, transparent 82%);
transform: translateY(-1px);
}
.dropZone input[type="file"] {
display: none;
}
.dropZoneContent {
display: grid;
gap: 6px;
place-items: center;
}
.dropZoneIcon {
font-size: 22px;
line-height: 1;
}
.dropZoneTitle {
font-weight: 700;
letter-spacing: -0.01em;
}
.ascGrid { display: grid; grid-template-columns: 1fr; gap: 14px; margin-top: 10px; }
.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: var(--card); }
}
#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: var(--successBg);
}
/* 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;
}
h2 { font-family: Poppins, Inter, system-ui, sans-serif; letter-spacing: -0.02em; }
h3 { letter-spacing: -0.01em; }
.dropZone {
border: 1.5px dashed color-mix(in srgb, var(--border) 60%, var(--primary) 40%);
border-radius: 14px;
padding: 12px;
background: color-mix(in srgb, var(--card) 92%, var(--bg) 8%);
transition: box-shadow 160ms ease, border-color 160ms ease, transform 160ms ease;
}
.dropZone.dragOver {
border-color: var(--primary);
box-shadow: 0 0 0 6px color-mix(in srgb, var(--primary) 20%, transparent 80%);
transform: translateY(-1px);
}
.hintPill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--card) 92%, var(--primary2) 8%);
font-size: 12px;
color: var(--muted);
}
.toastHost {
position: fixed;
top: 16px;
right: 16px;
display: grid;
gap: 10px;
z-index: 9999;
pointer-events: none;
}
.toast {
pointer-events: none;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--card) 88%, var(--bg) 12%);
color: var(--text);
box-shadow: var(--shadow);
opacity: 0;
transform: translateY(-6px);
animation: toastIn 180ms ease forwards;
max-width: min(380px, 88vw);
font-size: 13px;
}
.toast.ok { border-color: color-mix(in srgb, var(--border) 40%, var(--primary) 60%); }
.toast.err { border-color: color-mix(in srgb, var(--border) 40%, #ef4444 60%); }
@keyframes toastIn { to { opacity: 1; transform: translateY(0); } }
@keyframes toastOut { to { opacity: 0; transform: translateY(-6px); } }
</style>
</head>
<body>
<div class="container">
<button class="btn" id="btnTheme" type="button" title="Toggle dark mode" style="position: fixed; top: 18px; right: 18px; z-index: 10000; border-radius: 999px; padding: 10px 12px;">
🌙
</button>
<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 class="uploadRow">
<div class="dropZone" id="dropZone" title="Drag & drop a CSV here or click to browse">
<input id="file" type="file" accept=".csv,text/csv" />
<div class="dropZoneContent">
<div class="dropZoneIcon">⬇️</div>
<div class="dropZoneTitle">Drag & drop your Gridly CSV here</div>
<div class="small muted">or click anywhere in this box to browse</div>
<div id="fileInfo" class="small" style="margin-top:6px;"></div>
</div>
</div>
<div class="versionBox">
<label for="gameVersion">Game version</label>
<input id="gameVersion" type="text" inputmode="decimal" placeholder="e.g. 6.51.0" />
<div id="versionHelp" class="small muted" style="margin-top:6px;">Used for PlayFab News titles only. Format: x.xx.x</div>
</div>
</div>
</div>
</div>
<div class="card" style="padding: 12px;">
<div class="small muted" style="margin-bottom: 6px; font-weight: 600;">Jump to</div>
<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 rows="3" style="min-height: 92px;"></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>
<div id="toastHost" class="toastHost"></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 dropZone = document.getElementById("dropZone");
const btnTheme = document.getElementById("btnTheme");
const toastHost = document.getElementById("toastHost");
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();
// Restore saved settings
(function initPrefs() {
try {
const savedVersion = localStorage.getItem("rn_gameVersion");
if (savedVersion && !gameVersionInput.value) gameVersionInput.value = savedVersion;
const theme = localStorage.getItem("rn_theme") || "light";
document.documentElement.setAttribute("data-theme", theme);
if (btnTheme) btnTheme.textContent = theme === "dark" ? "☀️" : "🌙";
} catch {}
})();
gameVersionInput.addEventListener("input", () => {
try { localStorage.setItem("rn_gameVersion", gameVersionInput.value || ""); } catch {}
});
if (btnTheme) {
btnTheme.addEventListener("click", () => {
const cur = document.documentElement.getAttribute("data-theme") || "light";
const next = cur === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
btnTheme.textContent = next === "dark" ? "☀️" : "🌙";
try { localStorage.setItem("rn_theme", next); } catch {}
});
}
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 = "44px";
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 showToast(msg, type = "ok") {
if (!toastHost) return;
const t = document.createElement("div");
t.className = `toast ${type === "err" ? "err" : "ok"}`;
t.textContent = msg;
toastHost.appendChild(t);
window.setTimeout(() => {
t.style.animation = "toastOut 220ms ease forwards";
window.setTimeout(() => t.remove(), 240);
}, 2200);
}
function setStatus(msg, type = "ok") {
// Back-compat: map to toast
showToast(String(msg || ""), type || "ok");
}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;
ta.rows = 5;
ta.style.minHeight = "120px";
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");
}
let __uploadCount = 0;
async function processFile(file) {
outGoogle.value = "";
ascBoxes.innerHTML = "";
btnCopyGoogle.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("Parsing CSV…", "ok");
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);
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;
const version = (gameVersionInput.value || "").trim();
renderPlayfabBoxes(rowObj, headers, version);
renderAdcap(rowObj, headers, version);
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 };
flashUploadCard();
__uploadCount += 1;
if (__uploadCount === 6) showToast("You rock 🤘", "ok");
setStatus("✅ Outputs generated", "ok");
} catch (e) {
setStatus(`${e.message || e}`, "err");
}
}
fileInput.addEventListener("change", async () => {
const file = fileInput.files?.[0];
await processFile(file);
});
// Drag & drop CSV
if (dropZone) {
const openPicker = () => fileInput && fileInput.click();
dropZone.addEventListener("click", openPicker);
dropZone.addEventListener("dragenter", (e) => {
e.preventDefault();
dropZone.classList.add("dragOver");
});
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
dropZone.classList.add("dragOver");
});
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("dragOver"));
dropZone.addEventListener("drop", async (e) => {
e.preventDefault();
dropZone.classList.remove("dragOver");
const f = e.dataTransfer?.files?.[0];
if (!f) return;
await processFile(f);
});
}
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");
}
});
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>