Toutes les app sur les cotés

This commit is contained in:
para
2026-09-25 00:02:53 +02:00
parent 455988e49c
commit 93dcf514d2
5 changed files with 262 additions and 132 deletions
+7 -1
View File
@@ -1,7 +1,8 @@
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
include .env
PORT ?= 8000 PORT ?= 8000
.PHONY: help install config serve sync export deploy .PHONY: help install config serve sync fetch export deploy
help: help:
@echo "Cibles disponibles :" @echo "Cibles disponibles :"
@@ -9,6 +10,7 @@ help:
@echo " make config - cree .env et src/config.json si absents" @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 serve - lance l'apercu + l'editeur en local (PORT=$(PORT))"
@echo " make sync - recupere dans src/config.json les apps YunoHost manquantes" @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 export - copie src/ vers output/ (equivalent au bouton Exporter)"
@echo " make deploy - envoie output/ sur le serveur YunoHost" @echo " make deploy - envoie output/ sur le serveur YunoHost"
@@ -42,3 +44,7 @@ export:
deploy: deploy:
scripts/deploy.sh 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"
+23
View File
@@ -10,6 +10,9 @@ import shutil
import sys import sys
from pathlib import Path 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 PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8000
SCRIPT_DIR = Path(__file__).resolve().parent SCRIPT_DIR = Path(__file__).resolve().parent
@@ -71,6 +74,10 @@ class ReloadingHandler(http.server.SimpleHTTPRequestHandler):
self.wfile.write(body) self.wfile.write(body)
return return
if self.path == "/__catalog":
self._send_catalog()
return
if self.path in ("/", "/index.html"): if self.path in ("/", "/index.html"):
try: try:
html = (SRC_DIR / "index.html").read_bytes() html = (SRC_DIR / "index.html").read_bytes()
@@ -114,6 +121,22 @@ class ReloadingHandler(http.server.SimpleHTTPRequestHandler):
self.send_error(404) 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): def _send_json(self, payload):
body = json.dumps(payload).encode() body = json.dumps(payload).encode()
self.send_response(200) self.send_response(200)
+18 -116
View File
@@ -10,44 +10,16 @@ PORTAL_DOMAIN, REMOTE_USER) ; les arguments en ligne de commande, s'ils sont
fournis, ont priorite sur .env. fournis, ont priorite sur .env.
""" """
import json import json
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(Path(__file__).resolve().parent))
PROJECT_ROOT = SCRIPT_DIR.parent from yunohost_sync import CONFIG_PATH, build_app_list, fetch_remote_apps, known_urls, load_env, sync_target
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
ENV = load_env() ENV = load_env()
arg_target = sys.argv[1] if len(sys.argv) > 1 else None
SYNC_SSH_USER = ENV.get("SYNC_SSH_USER", "root") arg_domain = sys.argv[2] if len(sys.argv) > 2 else None
SYNC_SSH_HOST = ENV.get("SYNC_SSH_HOST") or ENV.get("REMOTE_HOST") SSH_TARGET, DOMAIN, SELF_APP_ID = sync_target(ENV, arg_target, arg_domain)
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
if not SSH_TARGET or not DOMAIN: if not SSH_TARGET or not DOMAIN:
print( print(
@@ -59,103 +31,33 @@ if not SSH_TARGET or not DOMAIN:
) )
sys.exit(1) 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(): def main():
with open(CONFIG_PATH, encoding="utf-8") as f: with open(CONFIG_PATH, encoding="utf-8") as f:
config = json.load(f) config = json.load(f)
remote = fetch_remote_apps() try:
remote_apps = remote.get("apps", {}) 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) existing_urls = known_urls(config)
to_add = build_app_list(remote, DOMAIN, SELF_APP_ID, exclude_urls=existing_urls)
added = [] if not to_add:
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:
print("Rien a ajouter, config.json est deja a jour.") print("Rien a ajouter, config.json est deja a jour.")
return return
config.setdefault("ungrouped", []).extend(to_add)
with open(CONFIG_PATH, "w", encoding="utf-8") as f: with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2) json.dump(config, f, ensure_ascii=False, indent=2)
f.write("\n") f.write("\n")
print(f"{len(added)} application(s) ajoutee(s) dans \"ungrouped\" :") print(f"{len(to_add)} application(s) ajoutee(s) dans \"ungrouped\" :")
for name in added: for app in to_add:
print(f" - {name}") print(f" - {app['name']}")
if __name__ == "__main__": if __name__ == "__main__":
+71 -1
View File
@@ -79,11 +79,81 @@ body {
} }
#editor-root { #editor-root {
max-width: 1100px; max-width: 1500px;
margin: 0 auto; margin: 0 auto;
padding: 24px; 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 { .group-card {
background: var(--panel-bg); background: var(--panel-bg);
border: 1px solid var(--border); border: 1px solid var(--border);
+140 -11
View File
@@ -1,6 +1,8 @@
let state = null; let state = null;
let dirty = false; 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 = [ const PALETTE = [
"#1db9c3", "#ff5a1f", "#2094f3", "#ffc230", "#00a651", "#e6721a", "#1db9c3", "#ff5a1f", "#2094f3", "#ffc230", "#00a651", "#e6721a",
@@ -147,7 +149,7 @@ function renderAddTile(onAdd) {
function attachGridDropzone(gridEl, targetListGetter) { function attachGridDropzone(gridEl, targetListGetter) {
gridEl.addEventListener("dragover", (e) => { gridEl.addEventListener("dragover", (e) => {
if (!dragSource || !dragSource.appId) return; if (!dragSource || (!dragSource.appId && !dragSource.catalogApp)) return;
e.preventDefault(); e.preventDefault();
gridEl.classList.add("drag-over"); gridEl.classList.add("drag-over");
}); });
@@ -157,19 +159,112 @@ function attachGridDropzone(gridEl, targetListGetter) {
gridEl.addEventListener("drop", (e) => { gridEl.addEventListener("drop", (e) => {
e.preventDefault(); e.preventDefault();
gridEl.classList.remove("drag-over"); gridEl.classList.remove("drag-over");
if (!dragSource || !dragSource.appId) return; if (!dragSource) return;
const appId = dragSource.appId;
const loc = findAppLocation(appId);
if (!loc) return;
const [app] = loc.list.splice(loc.index, 1);
const targetList = targetListGetter(); const targetList = targetListGetter();
const dropIndex = computeDropIndexGrid(gridEl, e.clientX, e.clientY); const dropIndex = computeDropIndexGrid(gridEl, e.clientX, e.clientY);
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); targetList.splice(Math.min(dropIndex, targetList.length), 0, app);
markDirty(); markDirty();
render(); 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) { function openAppForm(onSubmit) {
const backdrop = document.createElement("div"); const backdrop = document.createElement("div");
backdrop.className = "app-form-backdrop"; backdrop.className = "app-form-backdrop";
@@ -326,6 +421,21 @@ function render() {
const root = document.getElementById("editor-root"); const root = document.getElementById("editor-root");
root.innerHTML = ""; 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"); const pinnedSection = document.createElement("section");
pinnedSection.className = "pinned-section"; pinnedSection.className = "pinned-section";
const pinnedHeading = document.createElement("h2"); const pinnedHeading = document.createElement("h2");
@@ -345,13 +455,13 @@ function render() {
})); }));
attachGridDropzone(pinnedGrid, () => (state.pinned = state.pinned || [])); attachGridDropzone(pinnedGrid, () => (state.pinned = state.pinned || []));
pinnedSection.appendChild(pinnedGrid); pinnedSection.appendChild(pinnedGrid);
root.appendChild(pinnedSection); center.appendChild(pinnedSection);
const groupsList = document.createElement("div"); const groupsList = document.createElement("div");
groupsList.id = "groups-list"; groupsList.id = "groups-list";
state.groups.forEach((group, i) => groupsList.appendChild(renderGroup(group, i))); state.groups.forEach((group, i) => groupsList.appendChild(renderGroup(group, i)));
attachGroupsDropzone(groupsList); attachGroupsDropzone(groupsList);
root.appendChild(groupsList); center.appendChild(groupsList);
const addGroupBtn = document.createElement("button"); const addGroupBtn = document.createElement("button");
addGroupBtn.className = "add-group-btn"; addGroupBtn.className = "add-group-btn";
@@ -362,7 +472,7 @@ function render() {
markDirty(); markDirty();
render(); render();
}); });
root.appendChild(addGroupBtn); center.appendChild(addGroupBtn);
const ungroupedSection = document.createElement("section"); const ungroupedSection = document.createElement("section");
ungroupedSection.className = "ungrouped-section"; ungroupedSection.className = "ungrouped-section";
@@ -383,12 +493,31 @@ function render() {
attachGridDropzone(ungroupedGrid, () => state.ungrouped); attachGridDropzone(ungroupedGrid, () => state.ungrouped);
ungroupedSection.appendChild(ungroupedGrid); 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() { async function load() {
const res = await fetch("config.json", { cache: "no-store" }); const res = await fetch("config.json", { cache: "no-store" });
state = await res.json(); state = await res.json();
await loadCatalog();
render(); render();
} }