From 93dcf514d22c73cb6c75785a4e5bd28c3498e2d8 Mon Sep 17 00:00:00 2001 From: para <157126561+mdegallaix@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:02:53 +0200 Subject: [PATCH] =?UTF-8?q?Toutes=20les=20app=20sur=20les=20cot=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 8 ++- scripts/preview.py | 23 +++++++ scripts/sync-apps.py | 134 +++++------------------------------- src/editor.css | 72 +++++++++++++++++++- src/editor.js | 157 +++++++++++++++++++++++++++++++++++++++---- 5 files changed, 262 insertions(+), 132 deletions(-) diff --git a/Makefile b/Makefile index 9290fc2..371def6 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ .DEFAULT_GOAL := help +include .env PORT ?= 8000 -.PHONY: help install config serve sync export deploy +.PHONY: help install config serve sync fetch export deploy help: @echo "Cibles disponibles :" @@ -9,6 +10,7 @@ help: @echo " make config - cree .env et src/config.json si absents" @echo " make serve - lance l'apercu + l'editeur en local (PORT=$(PORT))" @echo " make sync - recupere dans src/config.json les apps YunoHost manquantes" + @echo " make fetch - recupere src/config.json depuis le serveur (config actuellement deployee)" @echo " make export - copie src/ vers output/ (equivalent au bouton Exporter)" @echo " make deploy - envoie output/ sur le serveur YunoHost" @@ -42,3 +44,7 @@ export: deploy: scripts/deploy.sh + +fetch: + scp akbar:/var/www/$(REMOTE_USER)/www/config.json src/config.json + @echo "Config de la HomePage téléchargée depuis le serveur" diff --git a/scripts/preview.py b/scripts/preview.py index b3279a5..ec9aa14 100755 --- a/scripts/preview.py +++ b/scripts/preview.py @@ -10,6 +10,9 @@ import shutil import sys from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from yunohost_sync import build_app_list, fetch_remote_apps, known_urls, load_env, sync_target + PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8000 SCRIPT_DIR = Path(__file__).resolve().parent @@ -71,6 +74,10 @@ class ReloadingHandler(http.server.SimpleHTTPRequestHandler): self.wfile.write(body) return + if self.path == "/__catalog": + self._send_catalog() + return + if self.path in ("/", "/index.html"): try: html = (SRC_DIR / "index.html").read_bytes() @@ -114,6 +121,22 @@ class ReloadingHandler(http.server.SimpleHTTPRequestHandler): self.send_error(404) + def _send_catalog(self): + env = load_env() + ssh_target, domain, self_app_id = sync_target(env) + if not ssh_target or not domain: + self._send_json({"error": "SYNC_SSH_HOST (ou REMOTE_HOST) et PORTAL_DOMAIN manquants dans .env"}) + return + try: + with open(SRC_DIR / "config.json", encoding="utf-8") as f: + config = json.load(f) + remote = fetch_remote_apps(ssh_target, domain) + apps = build_app_list(remote, domain, self_app_id, exclude_urls=known_urls(config)) + except Exception as exc: # SSH/JSON/reseau : on remonte un message lisible + self._send_json({"error": str(exc)}) + return + self._send_json({"apps": apps}) + def _send_json(self, payload): body = json.dumps(payload).encode() self.send_response(200) diff --git a/scripts/sync-apps.py b/scripts/sync-apps.py index 1652c4c..393d384 100755 --- a/scripts/sync-apps.py +++ b/scripts/sync-apps.py @@ -10,44 +10,16 @@ PORTAL_DOMAIN, REMOTE_USER) ; les arguments en ligne de commande, s'ils sont fournis, ont priorite sur .env. """ import json -import subprocess import sys from pathlib import Path -SCRIPT_DIR = Path(__file__).resolve().parent -PROJECT_ROOT = SCRIPT_DIR.parent -CONFIG_PATH = PROJECT_ROOT / "src" / "config.json" -ENV_PATH = PROJECT_ROOT / ".env" - - -def load_env(): - env = {} - if not ENV_PATH.exists(): - return env - for line in ENV_PATH.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - env[key.strip()] = value.strip() - return env - +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from yunohost_sync import CONFIG_PATH, build_app_list, fetch_remote_apps, known_urls, load_env, sync_target ENV = load_env() - -SYNC_SSH_USER = ENV.get("SYNC_SSH_USER", "root") -SYNC_SSH_HOST = ENV.get("SYNC_SSH_HOST") or ENV.get("REMOTE_HOST") -PORTAL_DOMAIN = ENV.get("PORTAL_DOMAIN") or ENV.get("REMOTE_HOST") -SELF_APP_ID = ENV.get("REMOTE_USER", "") # cette page elle-meme, jamais ajoutee - -if len(sys.argv) > 1: - SSH_TARGET = sys.argv[1] -elif SYNC_SSH_HOST: - SSH_TARGET = f"{SYNC_SSH_USER}@{SYNC_SSH_HOST}" -else: - SSH_TARGET = None - -DOMAIN = sys.argv[2] if len(sys.argv) > 2 else PORTAL_DOMAIN +arg_target = sys.argv[1] if len(sys.argv) > 1 else None +arg_domain = sys.argv[2] if len(sys.argv) > 2 else None +SSH_TARGET, DOMAIN, SELF_APP_ID = sync_target(ENV, arg_target, arg_domain) if not SSH_TARGET or not DOMAIN: print( @@ -59,103 +31,33 @@ if not SSH_TARGET or not DOMAIN: ) sys.exit(1) -PALETTE = [ - "#1db9c3", "#ff5a1f", "#2094f3", "#ffc230", "#00a651", "#e6721a", - "#b71c1c", "#f39c12", "#0d3b66", "#3b8a8c", "#568d34", "#609926", - "#d24939", "#0066cc", "#8e44ad", "#16a085", "#c0392b", "#2c3e50", -] - - -def color_of(name): - h = 0 - for ch in name: - h = (h * 31 + ord(ch)) & 0xFFFFFFFF - return PALETTE[h % len(PALETTE)] - - -def initials_of(name): - words = name.strip().split() - if len(words) == 1: - return words[0][:2].upper() - return (words[0][0] + words[1][0]).upper() - - -def fetch_remote_apps(): - cmd = ["ssh", "-o", "BatchMode=yes", SSH_TARGET, f"cat /etc/yunohost/portal/{DOMAIN}.json"] - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: - print(f"Erreur SSH: {result.stderr}", file=sys.stderr) - sys.exit(1) - return json.loads(result.stdout) - - -def normalize_url(url): - return url.replace("https://", "").replace("http://", "").rstrip("/").lower() - - -def known_urls(config): - urls = set() - for app in config.get("pinned", []): - urls.add(normalize_url(app["url"])) - for group in config.get("groups", []): - for app in group["apps"]: - urls.add(normalize_url(app["url"])) - for app in config.get("ungrouped", []): - urls.add(normalize_url(app["url"])) - return urls - def main(): with open(CONFIG_PATH, encoding="utf-8") as f: config = json.load(f) - remote = fetch_remote_apps() - remote_apps = remote.get("apps", {}) + try: + remote = fetch_remote_apps(SSH_TARGET, DOMAIN) + except (RuntimeError, json.JSONDecodeError) as exc: + print(f"Erreur SSH: {exc}", file=sys.stderr) + sys.exit(1) + existing_urls = known_urls(config) + to_add = build_app_list(remote, DOMAIN, SELF_APP_ID, exclude_urls=existing_urls) - added = [] - for perm_key, info in sorted(remote_apps.items()): - if not perm_key.endswith(".main"): - continue - app_id = perm_key[: -len(".main")] - if app_id == SELF_APP_ID: - continue - - url = info["url"] - if not url.startswith("http"): - url = "https://" + url - if not url.endswith("/"): - url += "/" - - if normalize_url(url) in existing_urls: - continue - - name = info.get("label", app_id) - app = { - "id": app_id, - "name": name, - "url": url, - "color": color_of(name), - "initials": initials_of(name), - } - logo = info.get("logo") - if logo: - app["icon"] = f"https://{DOMAIN}{logo}" - - config.setdefault("ungrouped", []).append(app) - added.append(name) - - if not added: + if not to_add: print("Rien a ajouter, config.json est deja a jour.") return + config.setdefault("ungrouped", []).extend(to_add) + with open(CONFIG_PATH, "w", encoding="utf-8") as f: json.dump(config, f, ensure_ascii=False, indent=2) f.write("\n") - print(f"{len(added)} application(s) ajoutee(s) dans \"ungrouped\" :") - for name in added: - print(f" - {name}") + print(f"{len(to_add)} application(s) ajoutee(s) dans \"ungrouped\" :") + for app in to_add: + print(f" - {app['name']}") if __name__ == "__main__": diff --git a/src/editor.css b/src/editor.css index 2319417..b08069d 100644 --- a/src/editor.css +++ b/src/editor.css @@ -79,11 +79,81 @@ body { } #editor-root { - max-width: 1100px; + max-width: 1500px; margin: 0 auto; padding: 24px; } +.editor-layout { + display: flex; + align-items: flex-start; + gap: 24px; +} + +.editor-center { + flex: 1; + min-width: 0; +} + +.catalog-column { + width: 200px; + flex-shrink: 0; + position: sticky; + top: 88px; + max-height: calc(100vh - 112px); + overflow-y: auto; + padding-right: 4px; +} + +.catalog-column h2 { + font-size: 13px; + color: var(--muted); + margin: 0 0 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.catalog-message { + font-size: 13px; + color: var(--muted); +} + +.catalog-error { + color: var(--danger); +} + +.catalog-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); + gap: 10px; +} + +.catalog-tile { + cursor: grab; + background: var(--panel-bg); + border: 1px dashed var(--border); +} + +.catalog-tile:hover { + border-color: var(--purple); +} + +.catalog-tile .tile-icon { + width: 44px; + height: 44px; + font-size: 14px; +} + +.catalog-tile .tile-name { + font-size: 11px; +} + +@media (max-width: 1100px) { + .catalog-column { + display: none; + } +} + .group-card { background: var(--panel-bg); border: 1px solid var(--border); diff --git a/src/editor.js b/src/editor.js index 9d02421..f24acf2 100644 --- a/src/editor.js +++ b/src/editor.js @@ -1,6 +1,8 @@ let state = null; let dirty = false; -let dragSource = null; // { appId } or { groupId } currently being dragged +let dragSource = null; // { appId }, { groupId } or { catalogApp } currently being dragged +let catalog = []; // apps YunoHost pas encore dans le portail (voir /__catalog) +let catalogError = null; const PALETTE = [ "#1db9c3", "#ff5a1f", "#2094f3", "#ffc230", "#00a651", "#e6721a", @@ -147,7 +149,7 @@ function renderAddTile(onAdd) { function attachGridDropzone(gridEl, targetListGetter) { gridEl.addEventListener("dragover", (e) => { - if (!dragSource || !dragSource.appId) return; + if (!dragSource || (!dragSource.appId && !dragSource.catalogApp)) return; e.preventDefault(); gridEl.classList.add("drag-over"); }); @@ -157,19 +159,112 @@ function attachGridDropzone(gridEl, targetListGetter) { gridEl.addEventListener("drop", (e) => { e.preventDefault(); gridEl.classList.remove("drag-over"); - if (!dragSource || !dragSource.appId) return; - const appId = dragSource.appId; - const loc = findAppLocation(appId); - if (!loc) return; - const [app] = loc.list.splice(loc.index, 1); + if (!dragSource) return; const targetList = targetListGetter(); const dropIndex = computeDropIndexGrid(gridEl, e.clientX, e.clientY); - targetList.splice(Math.min(dropIndex, targetList.length), 0, app); - markDirty(); - render(); + + if (dragSource.catalogApp) { + const app = { ...dragSource.catalogApp }; + targetList.splice(Math.min(dropIndex, targetList.length), 0, app); + catalog = catalog.filter((a) => a.id !== app.id); + markDirty(); + render(); + return; + } + + if (dragSource.appId) { + const loc = findAppLocation(dragSource.appId); + if (!loc) return; + const [app] = loc.list.splice(loc.index, 1); + targetList.splice(Math.min(dropIndex, targetList.length), 0, app); + markDirty(); + render(); + } }); } +function addCatalogAppTo(app, targetList) { + targetList.push({ ...app }); + catalog = catalog.filter((a) => a.id !== app.id); + markDirty(); + render(); +} + +function renderCatalogTile(app) { + const tile = document.createElement("div"); + tile.className = "app-tile catalog-tile"; + tile.draggable = true; + tile.title = "Glisser dans le portail, ou cliquer pour ajouter dans \"Sans groupe\""; + + tile.addEventListener("dragstart", (e) => { + e.stopPropagation(); + dragSource = { catalogApp: app }; + e.dataTransfer.effectAllowed = "copy"; + e.dataTransfer.setData("text/plain", app.id); + tile.classList.add("dragging"); + }); + tile.addEventListener("dragend", (e) => { + e.stopPropagation(); + tile.classList.remove("dragging"); + dragSource = null; + }); + tile.addEventListener("click", () => { + state.ungrouped = state.ungrouped || []; + addCatalogAppTo(app, state.ungrouped); + }); + + const icon = document.createElement("div"); + icon.className = "tile-icon"; + if (app.icon) { + const img = document.createElement("img"); + img.src = app.icon; + img.alt = ""; + icon.appendChild(img); + } else { + icon.style.background = app.color || "#8e8e8e"; + icon.textContent = app.initials || initialsOf(app.name); + } + + const name = document.createElement("div"); + name.className = "tile-name"; + name.textContent = app.name; + + tile.appendChild(icon); + tile.appendChild(name); + return tile; +} + +function renderCatalogColumn(title, apps) { + const column = document.createElement("aside"); + column.className = "catalog-column"; + + const heading = document.createElement("h2"); + heading.textContent = title; + column.appendChild(heading); + + if (catalogError) { + const msg = document.createElement("p"); + msg.className = "catalog-message catalog-error"; + msg.textContent = catalogError; + column.appendChild(msg); + return column; + } + + if (apps.length === 0) { + const msg = document.createElement("p"); + msg.className = "catalog-message"; + msg.textContent = "Aucune app à ajouter."; + column.appendChild(msg); + return column; + } + + const list = document.createElement("div"); + list.className = "catalog-list"; + apps.forEach((app) => list.appendChild(renderCatalogTile(app))); + column.appendChild(list); + return column; +} + function openAppForm(onSubmit) { const backdrop = document.createElement("div"); backdrop.className = "app-form-backdrop"; @@ -326,6 +421,21 @@ function render() { const root = document.getElementById("editor-root"); root.innerHTML = ""; + const layout = document.createElement("div"); + layout.className = "editor-layout"; + + const half = Math.ceil(catalog.length / 2); + const leftCatalog = renderCatalogColumn("Catalogue YunoHost", catalog.slice(0, half)); + const rightCatalog = renderCatalogColumn("Catalogue YunoHost", catalog.slice(half)); + + const center = document.createElement("div"); + center.className = "editor-center"; + + layout.appendChild(leftCatalog); + layout.appendChild(center); + layout.appendChild(rightCatalog); + root.appendChild(layout); + const pinnedSection = document.createElement("section"); pinnedSection.className = "pinned-section"; const pinnedHeading = document.createElement("h2"); @@ -345,13 +455,13 @@ function render() { })); attachGridDropzone(pinnedGrid, () => (state.pinned = state.pinned || [])); pinnedSection.appendChild(pinnedGrid); - root.appendChild(pinnedSection); + center.appendChild(pinnedSection); const groupsList = document.createElement("div"); groupsList.id = "groups-list"; state.groups.forEach((group, i) => groupsList.appendChild(renderGroup(group, i))); attachGroupsDropzone(groupsList); - root.appendChild(groupsList); + center.appendChild(groupsList); const addGroupBtn = document.createElement("button"); addGroupBtn.className = "add-group-btn"; @@ -362,7 +472,7 @@ function render() { markDirty(); render(); }); - root.appendChild(addGroupBtn); + center.appendChild(addGroupBtn); const ungroupedSection = document.createElement("section"); ungroupedSection.className = "ungrouped-section"; @@ -383,12 +493,31 @@ function render() { attachGridDropzone(ungroupedGrid, () => state.ungrouped); ungroupedSection.appendChild(ungroupedGrid); - root.appendChild(ungroupedSection); + center.appendChild(ungroupedSection); +} + +async function loadCatalog() { + try { + const res = await fetch("/__catalog", { cache: "no-store" }); + const data = await res.json(); + if (data.error) { + catalog = []; + catalogError = data.error; + } else { + catalog = data.apps || []; + catalogError = null; + } + } catch (err) { + catalog = []; + catalogError = "Catalogue indisponible (aperçu local requis)."; + console.error(err); + } } async function load() { const res = await fetch("config.json", { cache: "no-store" }); state = await res.json(); + await loadCatalog(); render(); }