UI: grotere/mobielvriendelijke restyle, inklapbaar menu, taalmodule add-on
Some checks failed
Deploy server-up (prod) / deploy (push) Failing after 5s
Some checks failed
Deploy server-up (prod) / deploy (push) Failing after 5s
This commit is contained in:
parent
f96a9407d5
commit
d866a13c20
5 changed files with 316 additions and 46 deletions
|
|
@ -1012,6 +1012,41 @@ def api_i18n_lang(lang):
|
|||
return jsonify(strings=i18n.strings(lang))
|
||||
|
||||
|
||||
@app.route("/api/i18n/keys")
|
||||
def api_i18n_keys():
|
||||
"""Referentie: alle vertaalsleutels + Engelse waardes (voor de editor)."""
|
||||
return jsonify(keys=i18n.reference_keys(), en=i18n.strings("en"))
|
||||
|
||||
|
||||
@app.route("/api/i18n/<lang>/raw")
|
||||
def api_i18n_lang_raw(lang):
|
||||
"""Alleen de eigen strings van deze taal (voor bewerken)."""
|
||||
return jsonify(strings=i18n.raw(lang), builtin=i18n.is_builtin(lang))
|
||||
|
||||
|
||||
@app.route("/api/i18n", methods=["POST"])
|
||||
def api_i18n_save():
|
||||
"""Voeg een taal toe of werk er een bij (add-on)."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
res = i18n.save(
|
||||
code=body.get("code", ""),
|
||||
name=body.get("name", ""),
|
||||
flag=body.get("flag", ""),
|
||||
entries=body.get("strings", {}),
|
||||
)
|
||||
if res.get("ok"):
|
||||
audit.log("i18n", "save", "ok", ref=res.get("code"))
|
||||
return jsonify(res), (200 if res.get("ok") else 400)
|
||||
|
||||
|
||||
@app.route("/api/i18n/<lang>", methods=["DELETE"])
|
||||
def api_i18n_delete(lang):
|
||||
res = i18n.delete(lang)
|
||||
if res.get("ok"):
|
||||
audit.log("i18n", "delete", "ok", ref=lang)
|
||||
return jsonify(res), (200 if res.get("ok") else 400)
|
||||
|
||||
|
||||
# ── Wizard ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/api/wizard")
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
"""Internationalisation — built-in NL + EN."""
|
||||
"""Internationalisation — built-in NL + EN, plus add-on talen via UI."""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_DIR = Path(__file__).parent.parent / "translations"
|
||||
_cache: dict[str, dict] = {}
|
||||
_langs: dict[str, dict] = {}
|
||||
|
||||
# Ingebouwde talen worden meegeleverd en kunnen niet verwijderd worden.
|
||||
BUILTIN = frozenset({"nl", "en"})
|
||||
_CODE_RE = re.compile(r"^[a-z]{2,3}(-[a-z0-9]{2,8})?$", re.IGNORECASE)
|
||||
|
||||
|
||||
def load():
|
||||
_cache.clear()
|
||||
|
|
@ -26,14 +31,82 @@ def load():
|
|||
def available() -> list[dict]:
|
||||
if not _langs:
|
||||
load()
|
||||
return [{"code": k, "name": v.get("name", k), "flag": v.get("flag", "")}
|
||||
return [{"code": k,
|
||||
"name": v.get("name", k),
|
||||
"flag": v.get("flag", ""),
|
||||
"builtin": k in BUILTIN,
|
||||
"count": len(_cache.get(k, {}))}
|
||||
for k, v in _langs.items()]
|
||||
|
||||
|
||||
def strings(lang: str) -> dict:
|
||||
"""Samengevoegde strings: Engelse basis + taal-overrides (voor weergave)."""
|
||||
if not _cache:
|
||||
load()
|
||||
base = dict(_cache.get("en", {}))
|
||||
if lang != "en" and lang in _cache:
|
||||
base.update(_cache[lang])
|
||||
return base
|
||||
|
||||
|
||||
def raw(lang: str) -> dict:
|
||||
"""Alleen de eigen strings van deze taal (voor bewerken)."""
|
||||
if not _cache:
|
||||
load()
|
||||
return dict(_cache.get(lang, {}))
|
||||
|
||||
|
||||
def reference_keys() -> list[str]:
|
||||
"""Alle vertaalsleutels (op basis van de Engelse bron) als referentie."""
|
||||
if not _cache:
|
||||
load()
|
||||
return sorted(_cache.get("en", {}).keys())
|
||||
|
||||
|
||||
def is_builtin(code: str) -> bool:
|
||||
return code in BUILTIN
|
||||
|
||||
|
||||
def _path_for(code: str) -> Path:
|
||||
return _DIR / f"{code}.json"
|
||||
|
||||
|
||||
def save(code: str, name: str, flag: str, entries: dict) -> dict:
|
||||
"""Sla een (nieuwe of bestaande) taal op als add-on. Retourneert {ok, msg}."""
|
||||
code = (code or "").strip().lower()
|
||||
if not _CODE_RE.match(code):
|
||||
return {"ok": False, "msg": "Ongeldige taalcode (bv. 'de', 'fr', 'pt-br')."}
|
||||
if code in BUILTIN:
|
||||
return {"ok": False, "msg": f"'{code}' is een ingebouwde taal en kan niet overschreven worden."}
|
||||
if not isinstance(entries, dict):
|
||||
return {"ok": False, "msg": "Strings moeten een object zijn."}
|
||||
|
||||
_DIR.mkdir(parents=True, exist_ok=True)
|
||||
clean = {k: str(v) for k, v in entries.items()
|
||||
if not str(k).startswith("_") and v not in (None, "")}
|
||||
doc = {"_meta": {"code": code,
|
||||
"name": (name or code).strip(),
|
||||
"flag": (flag or "").strip()}}
|
||||
doc.update(clean)
|
||||
try:
|
||||
_path_for(code).write_text(
|
||||
json.dumps(doc, ensure_ascii=False, indent=2), "utf-8")
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": f"Schrijven mislukt: {e}"}
|
||||
load()
|
||||
return {"ok": True, "msg": f"Taal '{name or code}' opgeslagen.", "code": code}
|
||||
|
||||
|
||||
def delete(code: str) -> dict:
|
||||
code = (code or "").strip().lower()
|
||||
if code in BUILTIN:
|
||||
return {"ok": False, "msg": "Ingebouwde talen kunnen niet verwijderd worden."}
|
||||
p = _path_for(code)
|
||||
if not p.exists():
|
||||
return {"ok": False, "msg": "Taal niet gevonden."}
|
||||
try:
|
||||
p.unlink()
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": f"Verwijderen mislukt: {e}"}
|
||||
load()
|
||||
return {"ok": True, "msg": "Taal verwijderd."}
|
||||
|
|
|
|||
|
|
@ -56,54 +56,54 @@ tailwind.config = {
|
|||
.log-section { color:#22d3ee; font-weight:600 }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-bg dark:bg-bg-dark text-tx dark:text-tx-dark text-sm h-screen overflow-hidden">
|
||||
<body class="bg-bg dark:bg-bg-dark text-tx dark:text-tx-dark text-[15px] h-screen overflow-hidden">
|
||||
|
||||
<!-- Topbar -->
|
||||
<header class="h-12 px-4 flex items-center gap-2 bg-s1 dark:bg-s1-dark border-b border-bd dark:border-bd-dark fixed inset-x-0 top-0 z-40">
|
||||
<button @click="sidebarOpen = !sidebarOpen" class="w-8 h-8 grid place-items-center rounded-lg hover:bg-s2 dark:hover:bg-s2-dark"><span class="mdi mdi-menu text-lg"></span></button>
|
||||
<span class="font-extrabold text-wh dark:text-wh-dark">Server<span class="text-ac dark:text-ac-dark">Up</span></span>
|
||||
<span class="ml-2 text-[10px] font-mono text-t2 dark:text-t2-dark" x-text="'v'+version"></span>
|
||||
<header class="h-16 px-3 sm:px-5 flex items-center gap-2 sm:gap-3 bg-s1 dark:bg-s1-dark border-b border-bd dark:border-bd-dark fixed inset-x-0 top-0 z-40">
|
||||
<button @click="toggleSidebar()" :title="sidebarOpen ? t('hide_menu') : t('show_menu')" class="w-11 h-11 grid place-items-center rounded-xl hover:bg-s2 dark:hover:bg-s2-dark active:scale-95 transition"><span class="mdi text-2xl" :class="sidebarOpen ? 'mdi-backburger' : 'mdi-menu'"></span></button>
|
||||
<span class="font-extrabold text-lg text-wh dark:text-wh-dark">Server<span class="text-ac dark:text-ac-dark">Up</span></span>
|
||||
<span class="text-[11px] font-mono text-t2 dark:text-t2-dark" x-text="'v'+version"></span>
|
||||
<div class="flex-1"></div>
|
||||
<div class="hidden sm:flex items-center gap-3 text-[11px] font-mono text-t2 dark:text-t2-dark">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="docker.running>0 ? 'bg-ok shadow-[0_0_5px_#4ade80]' : 'bg-dm dark:bg-dm-dark'"></span>
|
||||
<span class="mdi mdi-play-circle-outline"></span><span x-text="docker.running ?? '–'"></span>
|
||||
<div class="hidden sm:flex items-center gap-4 text-xs font-mono text-t2 dark:text-t2-dark">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full" :class="docker.running>0 ? 'bg-ok shadow-[0_0_6px_#4ade80]' : 'bg-dm dark:bg-dm-dark'"></span>
|
||||
<span class="mdi mdi-play-circle-outline text-base"></span><span x-text="docker.running ?? '–'"></span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1"><span class="mdi mdi-layers-outline"></span><span x-text="docker.images ?? '–'"></span></span>
|
||||
<span class="flex items-center gap-1.5"><span class="mdi mdi-layers-outline text-base"></span><span x-text="docker.images ?? '–'"></span></span>
|
||||
</div>
|
||||
<button @click="termOpen = !termOpen" class="px-2 py-1 rounded-md border border-bd dark:border-bd-dark text-t2 dark:text-t2-dark hover:text-ac hover:border-ac flex items-center gap-1 text-xs"><span class="mdi mdi-console"></span></button>
|
||||
<button @click="toggleTheme()" class="px-2 py-1 rounded-md hover:bg-s2 dark:hover:bg-s2-dark"><span class="mdi" :class="theme==='dark' ? 'mdi-weather-sunny' : 'mdi-weather-night'"></span></button>
|
||||
<button @click="termOpen = !termOpen" :title="t('log')" class="btn-icon"><span class="mdi mdi-console text-xl"></span></button>
|
||||
<button @click="toggleTheme()" :title="t('theme')" class="btn-icon"><span class="mdi text-xl" :class="theme==='dark' ? 'mdi-weather-sunny' : 'mdi-weather-night'"></span></button>
|
||||
</header>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="fixed top-12 bottom-0 left-0 w-56 bg-s1 dark:bg-s1-dark border-r border-bd dark:border-bd-dark transition-transform z-30 overflow-y-auto"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'">
|
||||
<nav class="p-2 space-y-0.5">
|
||||
<aside class="fixed top-16 bottom-0 left-0 w-72 max-w-[85vw] bg-s1 dark:bg-s1-dark border-r border-bd dark:border-bd-dark transition-transform duration-200 ease-out z-30 overflow-y-auto"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
|
||||
<nav class="p-3 space-y-1.5">
|
||||
<template x-for="item in navItems" :key="item.id">
|
||||
<a href="#" @click.prevent="goto(item.id)"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer text-xs font-medium transition"
|
||||
:class="page === item.id ? 'bg-ac/10 text-ac dark:text-ac-dark border border-ac/20' : 'text-t2 dark:text-t2-dark hover:bg-s2 dark:hover:bg-s2-dark hover:text-tx dark:hover:text-tx-dark border border-transparent'">
|
||||
<span class="mdi text-base w-5 text-center" :class="item.icon"></span>
|
||||
class="flex items-center gap-3 px-4 py-3.5 rounded-xl cursor-pointer text-sm font-semibold transition"
|
||||
:class="page === item.id ? 'bg-ac/10 text-ac dark:text-ac-dark border border-ac/30 shadow-sm' : 'text-t2 dark:text-t2-dark hover:bg-s2 dark:hover:bg-s2-dark hover:text-tx dark:hover:text-tx-dark border border-transparent'">
|
||||
<span class="mdi text-xl w-6 text-center" :class="item.icon"></span>
|
||||
<span x-text="t(item.label)"></span>
|
||||
</a>
|
||||
</template>
|
||||
<template x-if="modules.length > 0">
|
||||
<div class="pt-3 pb-1 px-3 text-[8px] font-bold tracking-widest uppercase text-dm dark:text-dm-dark" x-text="t('modules')">Modules</div>
|
||||
<div class="pt-4 pb-1 px-4 text-[10px] font-bold tracking-widest uppercase text-dm dark:text-dm-dark" x-text="t('modules')">Modules</div>
|
||||
</template>
|
||||
<template x-for="m in modules" :key="m.id">
|
||||
<a href="#" @click.prevent="goto('mod:'+m.id)"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer text-xs font-medium transition text-t2 dark:text-t2-dark hover:bg-s2 dark:hover:bg-s2-dark">
|
||||
<span class="text-base w-5 text-center" x-text="m.icon || '🧩'"></span>
|
||||
class="flex items-center gap-3 px-4 py-3.5 rounded-xl cursor-pointer text-sm font-semibold transition text-t2 dark:text-t2-dark hover:bg-s2 dark:hover:bg-s2-dark">
|
||||
<span class="text-xl w-6 text-center" x-text="m.icon || '🧩'"></span>
|
||||
<span x-text="m.name"></span>
|
||||
</a>
|
||||
</template>
|
||||
</nav>
|
||||
</aside>
|
||||
<div x-show="sidebarOpen" @click="sidebarOpen=false" class="md:hidden fixed inset-0 bg-black/40 z-20" x-cloak></div>
|
||||
<div x-show="sidebarOpen && isMobile" @click="sidebarOpen=false" class="fixed inset-0 top-16 bg-black/50 backdrop-blur-sm z-20" x-cloak></div>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="md:ml-56 pt-12 h-screen overflow-y-auto">
|
||||
<div class="p-5 pb-48">
|
||||
<main class="pt-16 h-screen overflow-y-auto transition-[margin] duration-200 ease-out" :class="(sidebarOpen && !isMobile) ? 'md:ml-72' : 'ml-0'">
|
||||
<div class="p-4 sm:p-6 max-w-6xl mx-auto pb-48">
|
||||
|
||||
<!-- Dashboard -->
|
||||
<section x-show="page === 'dashboard'" x-cloak>
|
||||
|
|
@ -325,13 +325,48 @@ tailwind.config = {
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[9px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1" x-text="t('language')">Taal</div>
|
||||
<select x-model="settings.LANGUAGE" @change="setLang(settings.LANGUAGE)" class="bg-s2 dark:bg-s2-dark border border-bd dark:border-bd-dark rounded-md px-2 py-1 text-xs">
|
||||
<div class="text-[10px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1.5" x-text="t('language')">Taal</div>
|
||||
<select x-model="settings.LANGUAGE" @change="setLang(settings.LANGUAGE)" class="form-input !w-auto min-w-[160px]">
|
||||
<template x-for="l in languages" :key="l.code"><option :value="l.code" x-text="(l.flag||'')+' '+l.name"></option></template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Taalmodule (add-on) -->
|
||||
<details class="card mb-3" id="lang-addon">
|
||||
<summary class="px-4 py-3.5 cursor-pointer font-bold text-wh dark:text-wh-dark text-sm flex items-center gap-2">
|
||||
<span class="mdi mdi-translate text-ac dark:text-ac-dark text-lg"></span>
|
||||
<span x-text="t('language_addon')">Taalmodule</span>
|
||||
<span class="badge badge-info ml-1" x-text="languages.length"></span>
|
||||
<span class="ml-auto flex gap-2" @click.stop>
|
||||
<button class="btn-sm" @click="openLangEditor(null)"><span class="mdi mdi-plus"></span> <span x-text="t('add_language')">Taal toevoegen</span></button>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="px-4 pb-4">
|
||||
<p class="text-xs text-t2 dark:text-t2-dark mb-3" x-text="t('language_addon_hint')">Voeg extra talen toe als add-on. Ingebouwde talen kunnen niet bewerkt of verwijderd worden.</p>
|
||||
<div class="space-y-2">
|
||||
<template x-for="l in languages" :key="l.code">
|
||||
<div class="flex items-center gap-3 p-3 rounded-xl bg-s2 dark:bg-s2-dark">
|
||||
<span class="text-2xl" x-text="l.flag || '🌐'"></span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-wh dark:text-wh-dark text-sm truncate"><span x-text="l.name"></span> <span class="font-mono text-[11px] text-t2 dark:text-t2-dark" x-text="'('+l.code+')'"></span></div>
|
||||
<div class="text-[11px] text-dm dark:text-dm-dark"><span x-text="l.count"></span> <span x-text="t('strings_count')">strings</span><template x-if="l.builtin"><span class="ml-2 badge badge-dim" x-text="t('builtin')">ingebouwd</span></template></div>
|
||||
</div>
|
||||
<template x-if="!l.builtin">
|
||||
<button class="btn-sm" @click="openLangEditor(l.code)"><span class="mdi mdi-pencil-outline"></span> <span x-text="t('edit')">Bewerken</span></button>
|
||||
</template>
|
||||
<template x-if="!l.builtin">
|
||||
<button class="btn-sm btn-danger" @click="deleteLang(l.code)"><span class="mdi mdi-delete-outline"></span></button>
|
||||
</template>
|
||||
<template x-if="l.builtin">
|
||||
<button class="btn-sm" @click="openLangEditor(l.code)"><span class="mdi mdi-content-copy"></span> <span x-text="t('duplicate')">Dupliceren</span></button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="card mb-3" open>
|
||||
<summary class="px-4 py-3 cursor-pointer font-semibold text-wh dark:text-wh-dark text-xs flex items-center gap-2"><span class="mdi mdi-folder-cog-outline text-ac dark:text-ac-dark"></span> <span x-text="t('paths')">Paden</span></summary>
|
||||
<div class="px-4 pb-4 grid sm:grid-cols-3 gap-3">
|
||||
|
|
@ -370,8 +405,8 @@ tailwind.config = {
|
|||
</main>
|
||||
|
||||
<!-- Terminal -->
|
||||
<div class="fixed bottom-0 right-0 left-0 md:left-56 bg-bg-dark/95 border-t border-bd dark:border-bd-dark transition-transform z-30"
|
||||
:class="termOpen ? 'translate-y-0' : 'translate-y-[180px]'">
|
||||
<div class="fixed bottom-0 right-0 left-0 bg-bg-dark/95 border-t border-bd dark:border-bd-dark transition-all duration-200 z-30"
|
||||
:class="[(sidebarOpen && !isMobile) ? 'md:left-72' : 'left-0', termOpen ? 'translate-y-0' : 'translate-y-[180px]']">
|
||||
<div @click="termOpen = !termOpen" class="h-9 px-3 flex items-center gap-2 bg-s1 dark:bg-s1-dark border-b border-bd dark:border-bd-dark cursor-pointer">
|
||||
<div class="flex gap-1.5"><span class="w-2 h-2 rounded-full bg-[#ff5f57]"></span><span class="w-2 h-2 rounded-full bg-[#febc2e]"></span><span class="w-2 h-2 rounded-full bg-[#28c840]"></span></div>
|
||||
<span class="text-[11px] font-mono text-t2 dark:text-t2-dark flex-1"><span class="mdi mdi-console"></span> Log</span>
|
||||
|
|
@ -565,6 +600,39 @@ tailwind.config = {
|
|||
<button class="btn" @click="modal=null" x-text="t('close')">Sluiten</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language editor -->
|
||||
<div x-show="modal==='lang'" class="bg-s1 dark:bg-s1-dark border border-bd2 dark:border-bd2-dark rounded-2xl p-5 w-full max-w-2xl max-h-[88vh] flex flex-col">
|
||||
<h3 class="text-lg font-extrabold text-wh dark:text-wh-dark mb-4 flex items-center gap-2">
|
||||
<span class="mdi mdi-translate text-ac dark:text-ac-dark"></span>
|
||||
<span x-text="langEdit.isNew ? t('add_language') : t('edit_language')">Taal</span>
|
||||
</h3>
|
||||
<div class="grid sm:grid-cols-3 gap-3 mb-3">
|
||||
<div><label class="form-label" x-text="t('lang_code')">Code</label><input class="form-input" x-model="langEdit.code" :readonly="!langEdit.isNew" placeholder="de, fr, pt-br"></div>
|
||||
<div><label class="form-label" x-text="t('lang_name')">Naam</label><input class="form-input" x-model="langEdit.name" placeholder="Deutsch"></div>
|
||||
<div><label class="form-label" x-text="t('lang_flag')">Vlag</label><input class="form-input" x-model="langEdit.flag" placeholder="🇩🇪" maxlength="8"></div>
|
||||
</div>
|
||||
<div class="relative mb-3">
|
||||
<span class="mdi mdi-magnify absolute left-3 top-1/2 -translate-y-1/2 text-dm dark:text-dm-dark"></span>
|
||||
<input x-model="langEdit.query" :placeholder="t('search_keys')" class="form-input pl-9">
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto -mx-1 px-1 space-y-2" style="max-height:48vh">
|
||||
<template x-for="k in langKeysFiltered" :key="k">
|
||||
<div class="grid sm:grid-cols-2 gap-2 items-center">
|
||||
<div class="text-xs">
|
||||
<div class="font-mono text-[11px] text-ac dark:text-ac-dark" x-text="k"></div>
|
||||
<div class="text-t2 dark:text-t2-dark text-[11px] truncate" :title="langEdit.ref[k]" x-text="langEdit.ref[k]"></div>
|
||||
</div>
|
||||
<input class="form-input" x-model="langEdit.values[k]" :placeholder="langEdit.ref[k]">
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="langKeysFiltered.length===0" class="text-center text-dm dark:text-dm-dark text-xs py-6" x-text="t('no_results')">Geen resultaten</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-bd dark:border-bd-dark">
|
||||
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
|
||||
<button class="btn btn-primary" @click="saveLang()"><span class="mdi mdi-content-save"></span> <span x-text="t('save')">Opslaan</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- First-run wizard -->
|
||||
|
|
@ -667,19 +735,19 @@ tailwind.config = {
|
|||
<!-- Tailwind component classes -->
|
||||
<style type="text/tailwindcss">
|
||||
@layer components {
|
||||
.btn { @apply inline-flex items-center gap-1 px-3 py-1.5 rounded-md border border-bd dark:border-bd-dark bg-s1 dark:bg-s1-dark text-tx dark:text-tx-dark text-xs font-medium hover:border-ac hover:text-ac dark:hover:text-ac-dark transition cursor-pointer; }
|
||||
.btn-primary { @apply !bg-ac !text-black !border-ac hover:!brightness-110 font-semibold; }
|
||||
.btn { @apply inline-flex items-center justify-center gap-1.5 px-4 py-2.5 min-h-[44px] rounded-xl border border-bd dark:border-bd-dark bg-s1 dark:bg-s1-dark text-tx dark:text-tx-dark text-sm font-semibold hover:border-ac hover:text-ac dark:hover:text-ac-dark active:scale-[.97] transition cursor-pointer; }
|
||||
.btn-primary { @apply !bg-ac !text-black !border-ac hover:!brightness-110 shadow-sm shadow-ac/20 font-bold; }
|
||||
.btn-danger { @apply !text-er !border-er/30 hover:!bg-er/10; }
|
||||
.btn-icon { @apply w-8 h-8 grid place-items-center rounded-md border border-bd dark:border-bd-dark text-t2 dark:text-t2-dark hover:text-ac hover:border-ac transition cursor-pointer; }
|
||||
.btn-sm { @apply inline-flex items-center gap-1 px-2 py-1 rounded border border-bd dark:border-bd-dark text-tx dark:text-tx-dark text-[10px] hover:border-ac hover:text-ac dark:hover:text-ac-dark cursor-pointer transition; }
|
||||
.card { @apply bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-xl overflow-hidden; }
|
||||
.stat-card { @apply card p-3 flex items-center gap-3; }
|
||||
.stat-icon { @apply w-9 h-9 rounded-lg grid place-items-center text-base; }
|
||||
.stat-value { @apply text-xl font-extrabold text-wh dark:text-wh-dark leading-none; }
|
||||
.stat-label { @apply text-[9px] text-t2 dark:text-t2-dark mt-0.5 uppercase tracking-wider; }
|
||||
.form-label { @apply block text-[9px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1; }
|
||||
.form-input { @apply w-full bg-s2 dark:bg-s2-dark border border-bd dark:border-bd-dark rounded-md px-3 py-1.5 text-xs font-mono text-tx dark:text-tx-dark outline-none focus:border-ac focus:bg-s1 dark:focus:bg-s1-dark transition; }
|
||||
.badge { @apply text-[8px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded-full; }
|
||||
.btn-icon { @apply w-11 h-11 grid place-items-center rounded-xl border border-bd dark:border-bd-dark text-t2 dark:text-t2-dark hover:text-ac hover:border-ac active:scale-95 transition cursor-pointer; }
|
||||
.btn-sm { @apply inline-flex items-center justify-center gap-1.5 px-3 py-2 min-h-[38px] rounded-lg border border-bd dark:border-bd-dark text-tx dark:text-tx-dark text-xs font-semibold hover:border-ac hover:text-ac dark:hover:text-ac-dark active:scale-95 cursor-pointer transition; }
|
||||
.card { @apply bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl overflow-hidden shadow-sm shadow-black/[0.03] dark:shadow-black/20; }
|
||||
.stat-card { @apply card p-4 flex items-center gap-4; }
|
||||
.stat-icon { @apply w-12 h-12 rounded-xl grid place-items-center text-xl; }
|
||||
.stat-value { @apply text-2xl font-extrabold text-wh dark:text-wh-dark leading-none; }
|
||||
.stat-label { @apply text-[10px] text-t2 dark:text-t2-dark mt-1 uppercase tracking-wider font-semibold; }
|
||||
.form-label { @apply block text-[10px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1.5; }
|
||||
.form-input { @apply w-full bg-s2 dark:bg-s2-dark border border-bd dark:border-bd-dark rounded-lg px-3 py-2.5 min-h-[44px] text-sm font-mono text-tx dark:text-tx-dark outline-none focus:border-ac focus:ring-2 focus:ring-ac/20 focus:bg-s1 dark:focus:bg-s1-dark transition; }
|
||||
.badge { @apply text-[9px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full; }
|
||||
.badge-ok { @apply bg-ok/10 text-ok; }
|
||||
.badge-warn { @apply bg-wr/10 text-wr; }
|
||||
.badge-info { @apply bg-bl/10 text-bl; }
|
||||
|
|
@ -693,7 +761,8 @@ function app() {
|
|||
return {
|
||||
version: '0.3.0',
|
||||
page: location.hash.slice(1) || 'dashboard',
|
||||
sidebarOpen: window.innerWidth >= 768,
|
||||
isMobile: window.innerWidth < 768,
|
||||
sidebarOpen: window.innerWidth >= 768 && localStorage.getItem('sidebar') !== 'closed',
|
||||
termOpen: false,
|
||||
theme: localStorage.getItem('theme') || 'dark',
|
||||
saved: false,
|
||||
|
|
@ -724,6 +793,7 @@ function app() {
|
|||
},
|
||||
editor: { stack:'', type:'env', title:'', content:'' },
|
||||
logs: { title:'', content:'' },
|
||||
langEdit: { isNew:true, code:'', name:'', flag:'', keys:[], ref:{}, values:{}, query:'' },
|
||||
wizard: { open:false, step:0, steps: [], LIBRARY_DIR:'', DATA_DIR:'', BACKUP_DIR:'',
|
||||
git: { choice:'skip', url:'', token:'', subdir:'', port_web:3000, port_ssh:2222 } },
|
||||
|
||||
|
|
@ -738,6 +808,10 @@ function app() {
|
|||
|
||||
async init() {
|
||||
window.addEventListener('hashchange', () => this.page = location.hash.slice(1) || 'dashboard');
|
||||
window.addEventListener('resize', () => {
|
||||
const m = window.innerWidth < 768;
|
||||
if (m !== this.isMobile) { this.isMobile = m; if (m) this.sidebarOpen = false; else this.sidebarOpen = localStorage.getItem('sidebar') !== 'closed'; }
|
||||
});
|
||||
await this.loadLangs();
|
||||
await this.loadLang(localStorage.getItem('lang') || 'nl');
|
||||
await this.checkWizard();
|
||||
|
|
@ -759,6 +833,57 @@ function app() {
|
|||
async loadLang(code) { try { const d = await fetch('/api/i18n/'+code).then(r=>r.json()); this.T = d.strings || {}; } catch(e) {} },
|
||||
setLang(c) { localStorage.setItem('lang', c); this.loadLang(c); },
|
||||
|
||||
async openLangEditor(code) {
|
||||
const ref = await this.rpc('/api/i18n/keys');
|
||||
const keys = ref.keys || [];
|
||||
const refMap = ref.en || {};
|
||||
let values = {}, meta = { code:'', name:'', flag:'' }, isNew = true;
|
||||
if (code) {
|
||||
const raw = await this.rpc('/api/i18n/'+code+'/raw');
|
||||
const existing = this.languages.find(l => l.code === code) || {};
|
||||
if (existing.builtin) {
|
||||
// Dupliceren: leeg code, gebruik bestaande strings als startpunt
|
||||
values = { ...(raw.strings || {}) };
|
||||
meta = { code:'', name: (existing.name||'')+' (kopie)', flag: existing.flag||'' };
|
||||
isNew = true;
|
||||
} else {
|
||||
values = { ...(raw.strings || {}) };
|
||||
meta = { code, name: existing.name||'', flag: existing.flag||'' };
|
||||
isNew = false;
|
||||
}
|
||||
}
|
||||
this.langEdit = { isNew, ...meta, keys, ref: refMap, values, query:'' };
|
||||
this.modal = 'lang';
|
||||
},
|
||||
get langKeysFiltered() {
|
||||
const q = (this.langEdit.query||'').toLowerCase();
|
||||
if (!q) return this.langEdit.keys;
|
||||
return this.langEdit.keys.filter(k =>
|
||||
k.toLowerCase().includes(q) || String(this.langEdit.ref[k]||'').toLowerCase().includes(q));
|
||||
},
|
||||
async saveLang() {
|
||||
const e = this.langEdit;
|
||||
if (!e.code.trim()) { this.toast(this.t('lang_code')+' ?', 'wr'); return; }
|
||||
const strings = {};
|
||||
for (const [k,v] of Object.entries(e.values)) { if (v !== undefined && v !== null && String(v).trim() !== '') strings[k] = v; }
|
||||
const d = await this.rpc('/api/i18n', { code: e.code.trim().toLowerCase(), name: e.name.trim(), flag: e.flag.trim(), strings });
|
||||
if (d.ok) {
|
||||
this.toast(d.msg || this.t('saved'), 'ok');
|
||||
this.modal = null;
|
||||
await this.loadLangs();
|
||||
if (d.code && d.code === (localStorage.getItem('lang') || 'nl')) await this.loadLang(d.code);
|
||||
} else { this.toast(d.msg || 'Fout', 'er'); }
|
||||
},
|
||||
async deleteLang(code) {
|
||||
if (!confirm(this.t('delete_language_confirm') || ('Taal '+code+' verwijderen?'))) return;
|
||||
const d = await fetch('/api/i18n/'+code, { method:'DELETE' }).then(r=>r.json()).catch(()=>({}));
|
||||
if (d.ok) {
|
||||
this.toast(d.msg || this.t('deleted'), 'ok');
|
||||
if (localStorage.getItem('lang') === code) this.setLang('nl');
|
||||
await this.loadLangs();
|
||||
} else { this.toast(d.msg || 'Fout', 'er'); }
|
||||
},
|
||||
|
||||
toggleTheme() { this.theme = this.theme === 'dark' ? 'light' : 'dark'; localStorage.setItem('theme', this.theme); this.autoSave(); },
|
||||
|
||||
toast(msg, kind='in') {
|
||||
|
|
@ -776,7 +901,12 @@ function app() {
|
|||
goto(id) {
|
||||
this.page = id;
|
||||
location.hash = '#' + id;
|
||||
if (window.innerWidth < 768) this.sidebarOpen = false;
|
||||
if (this.isMobile) this.sidebarOpen = false;
|
||||
},
|
||||
|
||||
toggleSidebar() {
|
||||
this.sidebarOpen = !this.sidebarOpen;
|
||||
if (!this.isMobile) localStorage.setItem('sidebar', this.sidebarOpen ? 'open' : 'closed');
|
||||
},
|
||||
|
||||
pollJob(jid, onDone) {
|
||||
|
|
|
|||
|
|
@ -88,5 +88,21 @@
|
|||
"available": "Available",
|
||||
"synced": "Synced",
|
||||
"not_cloned": "Not cloned",
|
||||
"auto_saved": "Auto-saved"
|
||||
"auto_saved": "Auto-saved",
|
||||
"hide_menu": "Hide menu",
|
||||
"show_menu": "Show menu",
|
||||
"log": "Log",
|
||||
"deleted": "Deleted",
|
||||
"duplicate": "Duplicate",
|
||||
"builtin": "built-in",
|
||||
"strings_count": "translations",
|
||||
"language_addon": "Language module",
|
||||
"language_addon_hint": "Add extra languages as an add-on. Built-in languages can be duplicated as a starting point, but not edited or removed.",
|
||||
"add_language": "Add language",
|
||||
"edit_language": "Edit language",
|
||||
"lang_code": "Language code",
|
||||
"lang_name": "Name",
|
||||
"lang_flag": "Flag",
|
||||
"search_keys": "Search key or text…",
|
||||
"delete_language_confirm": "Are you sure you want to delete this language?"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,5 +88,21 @@
|
|||
"available": "Beschikbaar",
|
||||
"synced": "Gesynchroniseerd",
|
||||
"not_cloned": "Niet gecloned",
|
||||
"auto_saved": "Automatisch opgeslagen"
|
||||
"auto_saved": "Automatisch opgeslagen",
|
||||
"hide_menu": "Menu verbergen",
|
||||
"show_menu": "Menu tonen",
|
||||
"log": "Log",
|
||||
"deleted": "Verwijderd",
|
||||
"duplicate": "Dupliceren",
|
||||
"builtin": "ingebouwd",
|
||||
"strings_count": "vertalingen",
|
||||
"language_addon": "Taalmodule",
|
||||
"language_addon_hint": "Voeg extra talen toe als add-on. Ingebouwde talen kun je dupliceren als startpunt, maar niet bewerken of verwijderen.",
|
||||
"add_language": "Taal toevoegen",
|
||||
"edit_language": "Taal bewerken",
|
||||
"lang_code": "Taalcode",
|
||||
"lang_name": "Naam",
|
||||
"lang_flag": "Vlag",
|
||||
"search_keys": "Zoek vertaalsleutel of tekst…",
|
||||
"delete_language_confirm": "Weet je zeker dat je deze taal wilt verwijderen?"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue