@cond/i18n-editor
Version:
A web-based i18n translation editor to manage locale files easily.
208 lines (185 loc) • 8.6 kB
HTML
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>i18n Editor</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<script src="https://unpkg.com/mithril/mithril.js"></script>
<style>
/* Sticky Table Header */
.table-container {
max-height: calc(100vh - 180px);
overflow-y: auto;
background: #f8f9fa;
border-radius: 5px;
padding: 15px;
box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.1);
position: relative;
}
.table thead {
position: sticky;
top: 0;
z-index: 10;
background: white;
box-shadow: 0px 2px 5px rgba(220, 219, 219, 0.1);
}
.table th,
.table td {
vertical-align: middle;
text-align: center;
}
</style>
</head>
<body>
<div class="header text-center py-3 bg-primary text-white">
🌍 i18n Editor - Manage Your Translations
</div>
<div id="app" class="container mt-4"></div>
<!-- Toast Notification -->
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 5">
<div id="toastContainer" class="toast align-items-center text-bg-success border-0" role="alert"
aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"
aria-label="Close"></button>
</div>
</div>
</div>
<script>
const App = {
translations: {},
languages: [],
newKey: "",
newValue: "",
loading: false,
loadTranslations: async function () {
this.loading = true;
const res = await fetch("/translations");
const data = await res.json();
this.translations = data.translations;
this.languages = data.languages;
this.loading = false;
m.redraw();
// Restore scroll position safely
requestAnimationFrame(() => {
const tableContainer = document.querySelector(".table-container");
if (tableContainer) {
const scrollPosition = localStorage.getItem("tableScrollPosition");
if (scrollPosition) {
tableContainer.scrollTop = parseInt(scrollPosition, 10);
}
}
});
},
addNewKey: async function () {
if (!App.newKey.trim()) return;
await fetch(`/translations/${App.newKey}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ defaultValue: App.newValue || "" })
});
App.showToast(`Added new key: ${App.newKey}`);
App.newKey = "";
App.newValue = "";
App.loadTranslations();
},
updateTranslation: async function (lang, file, key, value) {
this.translations[key][lang][file] = value;
await fetch(`/translations/${lang}/${file || "global"}/${key}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value })
});
this.showToast(`Updated ${lang}/${file || "global"}`);
},
showToast: function (message) {
const toastElement = document.getElementById("toastContainer");
if (toastElement) {
toastElement.querySelector(".toast-body").textContent = message;
const toast = new bootstrap.Toast(toastElement);
toast.show();
}
},
view: function () {
return m(".container", [
m("h2", "Translations"),
// New Key Input
m(".row.mb-3", [
m(".col-md-4", [
m("input.form-control", {
placeholder: "New key name...",
value: App.newKey,
oninput: (e) => App.newKey = e.target.value
})
]),
m(".col-md-4", [
m("input.form-control", {
placeholder: "Default value...",
value: App.newValue,
oninput: (e) => App.newValue = e.target.value
})
]),
m(".col-md-4", [
m("button.btn.btn-success.w-100", { onclick: this.addNewKey }, "➕ Add Key")
])
]),
this.loading ? m("p", "Loading...") :
m(".table-container", {
onscroll: (e) => {
localStorage.setItem("tableScrollPosition", e.target.scrollTop);
}
}, [
m("table.table.table-striped.table-bordered", [
// Table Header
m("thead", [
m("tr", [
m("th", "Key"),
...this.languages.flatMap(lang =>
Object.keys(this.translations[Object.keys(this.translations)[0]][lang] || {}).map(file =>
m("th", file ? `${lang}/${file}` : lang)
)
)
])
]),
// Table Body
m("tbody",
Object.keys(this.translations).map(key =>
m("tr", [
m("td", key),
...this.languages.flatMap(lang =>
Object.keys(this.translations[key][lang] || {}).map(file =>
m("td", [
m("input", {
class: "form-control",
value: this.translations[key][lang][file] || "",
oninput: (e) => this.updateTranslation(lang, file, key, e.target.value)
})
])
)
),
m("td", [
m("button.btn.btn-danger.btn-sm", {
onclick: async () => {
await fetch(`/translations/${key}`, { method: "DELETE" });
App.showToast(`Deleted key: ${key}`);
App.loadTranslations();
}
}, "x")
])
])
)
)
])
])
]);
}
};
App.loadTranslations();
m.mount(document.getElementById("app"), App);
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>