Compare commits

...

2 commits

Author SHA1 Message Date
bes-r
d866a13c20 UI: grotere/mobielvriendelijke restyle, inklapbaar menu, taalmodule add-on
Some checks failed
Deploy server-up (prod) / deploy (push) Failing after 5s
2026-06-02 23:07:27 +02:00
bes-r
f96a9407d5 fix: voorkom duplicate repo-IDs bij toevoegen via UI 2026-05-30 00:55:16 +02:00
5 changed files with 331 additions and 49 deletions

View file

@ -316,7 +316,13 @@ def api_repos_add():
if not url: if not url:
return jsonify(ok=False, msg="URL vereist") return jsonify(ok=False, msg="URL vereist")
repos = list(cfg.load().get("APP_REPOS", [])) repos = list(cfg.load().get("APP_REPOS", []))
rid = d.get("id", url.split("/")[-1].replace(".git", "")).strip()[:30] or str(int(time.time())) existing_ids = {r["id"] for r in repos}
base_rid = d.get("id", url.split("/")[-1].replace(".git", "")).strip()[:30] or str(int(time.time()))
rid = base_rid
counter = 1
while rid in existing_ids:
rid = f"{base_rid}-{counter}"
counter += 1
repos.append({"id": rid, "name": d.get("name", "").strip() or url.split("/")[-1], repos.append({"id": rid, "name": d.get("name", "").strip() or url.split("/")[-1],
"url": url, "branch": d.get("branch", "main"), "url": url, "branch": d.get("branch", "main"),
"token": d.get("token", ""), "subdir": d.get("subdir", "")}) "token": d.get("token", ""), "subdir": d.get("subdir", "")})
@ -877,8 +883,14 @@ def api_modrepos_add():
if not url: if not url:
return jsonify(ok=False, msg="URL vereist") return jsonify(ok=False, msg="URL vereist")
repos = list(cfg.load().get("MODULE_REPOS", [])) repos = list(cfg.load().get("MODULE_REPOS", []))
repos.append({"id": d.get("id", url.split("/")[-1].replace(".git", ""))[:30], existing_ids = {r["id"] for r in repos}
"name": d.get("name", "") or url.split("/")[-1], base_rid = d.get("id", url.split("/")[-1].replace(".git", ""))[:30]
rid = base_rid
counter = 1
while rid in existing_ids:
rid = f"{base_rid}-{counter}"
counter += 1
repos.append({"id": rid, "name": d.get("name", "") or url.split("/")[-1],
"url": url, "branch": d.get("branch", "main"), "url": url, "branch": d.get("branch", "main"),
"subdir": d.get("subdir", "")}) "subdir": d.get("subdir", "")})
cfg.patch({"MODULE_REPOS": repos}) cfg.patch({"MODULE_REPOS": repos})
@ -1000,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")

View file

@ -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."}

View file

@ -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) {

View file

@ -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?"
} }

View file

@ -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?"
} }