1155 lines
39 KiB
HTML
1155 lines
39 KiB
HTML
<!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 <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 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, 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>
|
||
|
||
<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("&", "&")
|
||
.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;
|
||
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 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);
|
||
|
||
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> |