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))
|
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 ────────────────────────────────────────────────────────────────────
|
# ── Wizard ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route("/api/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 json
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_DIR = Path(__file__).parent.parent / "translations"
|
_DIR = Path(__file__).parent.parent / "translations"
|
||||||
_cache: dict[str, dict] = {}
|
_cache: dict[str, dict] = {}
|
||||||
_langs: 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():
|
def load():
|
||||||
_cache.clear()
|
_cache.clear()
|
||||||
|
|
@ -26,14 +31,82 @@ def load():
|
||||||
def available() -> list[dict]:
|
def available() -> list[dict]:
|
||||||
if not _langs:
|
if not _langs:
|
||||||
load()
|
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()]
|
for k, v in _langs.items()]
|
||||||
|
|
||||||
|
|
||||||
def strings(lang: str) -> dict:
|
def strings(lang: str) -> dict:
|
||||||
|
"""Samengevoegde strings: Engelse basis + taal-overrides (voor weergave)."""
|
||||||
if not _cache:
|
if not _cache:
|
||||||
load()
|
load()
|
||||||
base = dict(_cache.get("en", {}))
|
base = dict(_cache.get("en", {}))
|
||||||
if lang != "en" and lang in _cache:
|
if lang != "en" and lang in _cache:
|
||||||
base.update(_cache[lang])
|
base.update(_cache[lang])
|
||||||
return base
|
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 }
|
.log-section { color:#22d3ee; font-weight:600 }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</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 -->
|
<!-- 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">
|
<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="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>
|
<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-wh dark:text-wh-dark">Server<span class="text-ac dark:text-ac-dark">Up</span></span>
|
<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="ml-2 text-[10px] font-mono text-t2 dark:text-t2-dark" x-text="'v'+version"></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="flex-1"></div>
|
||||||
<div class="hidden sm:flex items-center gap-3 text-[11px] font-mono text-t2 dark:text-t2-dark">
|
<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">
|
<span class="flex items-center gap-1.5">
|
||||||
<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="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"></span><span x-text="docker.running ?? '–'"></span>
|
<span class="mdi mdi-play-circle-outline text-base"></span><span x-text="docker.running ?? '–'"></span>
|
||||||
</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>
|
</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="termOpen = !termOpen" :title="t('log')" class="btn-icon"><span class="mdi mdi-console text-xl"></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="toggleTheme()" :title="t('theme')" class="btn-icon"><span class="mdi text-xl" :class="theme==='dark' ? 'mdi-weather-sunny' : 'mdi-weather-night'"></span></button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- 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"
|
<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 md:translate-x-0'">
|
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
|
||||||
<nav class="p-2 space-y-0.5">
|
<nav class="p-3 space-y-1.5">
|
||||||
<template x-for="item in navItems" :key="item.id">
|
<template x-for="item in navItems" :key="item.id">
|
||||||
<a href="#" @click.prevent="goto(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="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/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'">
|
: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-base w-5 text-center" :class="item.icon"></span>
|
<span class="mdi text-xl w-6 text-center" :class="item.icon"></span>
|
||||||
<span x-text="t(item.label)"></span>
|
<span x-text="t(item.label)"></span>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="modules.length > 0">
|
<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>
|
||||||
<template x-for="m in modules" :key="m.id">
|
<template x-for="m in modules" :key="m.id">
|
||||||
<a href="#" @click.prevent="goto('mod:'+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">
|
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-base w-5 text-center" x-text="m.icon || '🧩'"></span>
|
<span class="text-xl w-6 text-center" x-text="m.icon || '🧩'"></span>
|
||||||
<span x-text="m.name"></span>
|
<span x-text="m.name"></span>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</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 -->
|
||||||
<main class="md:ml-56 pt-12 h-screen overflow-y-auto">
|
<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-5 pb-48">
|
<div class="p-4 sm:p-6 max-w-6xl mx-auto pb-48">
|
||||||
|
|
||||||
<!-- Dashboard -->
|
<!-- Dashboard -->
|
||||||
<section x-show="page === 'dashboard'" x-cloak>
|
<section x-show="page === 'dashboard'" x-cloak>
|
||||||
|
|
@ -325,13 +325,48 @@ tailwind.config = {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
<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="bg-s2 dark:bg-s2-dark border border-bd dark:border-bd-dark rounded-md px-2 py-1 text-xs">
|
<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>
|
<template x-for="l in languages" :key="l.code"><option :value="l.code" x-text="(l.flag||'')+' '+l.name"></option></template>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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>
|
<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">
|
<div class="px-4 pb-4 grid sm:grid-cols-3 gap-3">
|
||||||
|
|
@ -370,8 +405,8 @@ tailwind.config = {
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Terminal -->
|
<!-- 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"
|
<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="termOpen ? 'translate-y-0' : 'translate-y-[180px]'">
|
: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 @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>
|
<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>
|
<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>
|
<button class="btn" @click="modal=null" x-text="t('close')">Sluiten</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- First-run wizard -->
|
<!-- First-run wizard -->
|
||||||
|
|
@ -667,19 +735,19 @@ tailwind.config = {
|
||||||
<!-- Tailwind component classes -->
|
<!-- Tailwind component classes -->
|
||||||
<style type="text/tailwindcss">
|
<style type="text/tailwindcss">
|
||||||
@layer components {
|
@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 { @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 font-semibold; }
|
.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-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-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 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; }
|
.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-xl overflow-hidden; }
|
.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-3 flex items-center gap-3; }
|
.stat-card { @apply card p-4 flex items-center gap-4; }
|
||||||
.stat-icon { @apply w-9 h-9 rounded-lg grid place-items-center text-base; }
|
.stat-icon { @apply w-12 h-12 rounded-xl grid place-items-center text-xl; }
|
||||||
.stat-value { @apply text-xl font-extrabold text-wh dark:text-wh-dark leading-none; }
|
.stat-value { @apply text-2xl 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; }
|
.stat-label { @apply text-[10px] text-t2 dark:text-t2-dark mt-1 uppercase tracking-wider font-semibold; }
|
||||||
.form-label { @apply block text-[9px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1; }
|
.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-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; }
|
.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-[8px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded-full; }
|
.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-ok { @apply bg-ok/10 text-ok; }
|
||||||
.badge-warn { @apply bg-wr/10 text-wr; }
|
.badge-warn { @apply bg-wr/10 text-wr; }
|
||||||
.badge-info { @apply bg-bl/10 text-bl; }
|
.badge-info { @apply bg-bl/10 text-bl; }
|
||||||
|
|
@ -693,7 +761,8 @@ function app() {
|
||||||
return {
|
return {
|
||||||
version: '0.3.0',
|
version: '0.3.0',
|
||||||
page: location.hash.slice(1) || 'dashboard',
|
page: location.hash.slice(1) || 'dashboard',
|
||||||
sidebarOpen: window.innerWidth >= 768,
|
isMobile: window.innerWidth < 768,
|
||||||
|
sidebarOpen: window.innerWidth >= 768 && localStorage.getItem('sidebar') !== 'closed',
|
||||||
termOpen: false,
|
termOpen: false,
|
||||||
theme: localStorage.getItem('theme') || 'dark',
|
theme: localStorage.getItem('theme') || 'dark',
|
||||||
saved: false,
|
saved: false,
|
||||||
|
|
@ -724,6 +793,7 @@ function app() {
|
||||||
},
|
},
|
||||||
editor: { stack:'', type:'env', title:'', content:'' },
|
editor: { stack:'', type:'env', title:'', content:'' },
|
||||||
logs: { 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:'',
|
wizard: { open:false, step:0, steps: [], LIBRARY_DIR:'', DATA_DIR:'', BACKUP_DIR:'',
|
||||||
git: { choice:'skip', url:'', token:'', subdir:'', port_web:3000, port_ssh:2222 } },
|
git: { choice:'skip', url:'', token:'', subdir:'', port_web:3000, port_ssh:2222 } },
|
||||||
|
|
||||||
|
|
@ -738,6 +808,10 @@ function app() {
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
window.addEventListener('hashchange', () => this.page = location.hash.slice(1) || 'dashboard');
|
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.loadLangs();
|
||||||
await this.loadLang(localStorage.getItem('lang') || 'nl');
|
await this.loadLang(localStorage.getItem('lang') || 'nl');
|
||||||
await this.checkWizard();
|
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) {} },
|
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); },
|
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(); },
|
toggleTheme() { this.theme = this.theme === 'dark' ? 'light' : 'dark'; localStorage.setItem('theme', this.theme); this.autoSave(); },
|
||||||
|
|
||||||
toast(msg, kind='in') {
|
toast(msg, kind='in') {
|
||||||
|
|
@ -776,7 +901,12 @@ function app() {
|
||||||
goto(id) {
|
goto(id) {
|
||||||
this.page = id;
|
this.page = id;
|
||||||
location.hash = '#' + 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) {
|
pollJob(jid, onDone) {
|
||||||
|
|
|
||||||
|
|
@ -88,5 +88,21 @@
|
||||||
"available": "Available",
|
"available": "Available",
|
||||||
"synced": "Synced",
|
"synced": "Synced",
|
||||||
"not_cloned": "Not cloned",
|
"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",
|
"available": "Beschikbaar",
|
||||||
"synced": "Gesynchroniseerd",
|
"synced": "Gesynchroniseerd",
|
||||||
"not_cloned": "Niet gecloned",
|
"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