diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..045fd85 --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# Copie ce fichier en .env (jamais commité) et remplis les valeurs pour TON +# instance YunoHost. Rien de ce qui suit n'a de valeur par défaut dans les +# scripts : sans .env correctement rempli, deploy/sync s'arrêtent avec un +# message expliquant quoi renseigner. + +# --- Déploiement (make export / make deploy) --- +# Domaine de ton instance YunoHost. +REMOTE_HOST=mondomaine.exemple + +# Nom de l'app "Custom Webapp" (my_webapp) installée pour servir cette page +# (visible dans YunoHost admin > Applications, ou `yunohost app list`). +REMOTE_USER=my_webapp__x + +# Port SSH du serveur (22 sauf si tu l'as changé). +REMOTE_PORT=22 + +# Chemin distant relatif à la racine du chroot SFTP de cette app (ne pas +# mettre le chemin absolu /var/www/... — voir le README). +REMOTE_PATH=/www/ + +# Mot de passe du compte REMOTE_USER (celui défini à l'installation de +# l'app). Laisse vide pour que ssh/scp le demande à chaque fois. +REMOTE_PASSWORD= + +# --- Synchronisation des apps (make sync) --- +# Utilisateur et hôte SSH ayant accès en lecture à la configuration YunoHost +# (root, ou un utilisateur avec sudo -- voir le README pour les limites). +SYNC_SSH_USER=root +SYNC_SSH_HOST=mondomaine.exemple + +# Domaine YunoHost dont on lit le portail (souvent le même que REMOTE_HOST). +PORTAL_DOMAIN=mondomaine.exemple diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9191f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +output/ +__pycache__/ +src/config.json diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9290fc2 --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +.DEFAULT_GOAL := help +PORT ?= 8000 + +.PHONY: help install config serve sync export deploy + +help: + @echo "Cibles disponibles :" + @echo " make install - installe les dependances necessaires aux scripts (sshpass)" + @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 export - copie src/ vers output/ (equivalent au bouton Exporter)" + @echo " make deploy - envoie output/ sur le serveur YunoHost" + +install: + sudo apt-get update + sudo apt-get install -y sshpass + +config: + @if [ -f .env ]; then \ + echo ".env existe deja."; \ + else \ + cp .env.example .env; \ + echo ".env cree depuis .env.example : edite-le pour renseigner le mot de passe."; \ + fi + @if [ -f src/config.json ]; then \ + echo "src/config.json existe deja."; \ + else \ + cp src/config.example.json src/config.json; \ + echo "src/config.json cree depuis src/config.example.json (donnees d'exemple)."; \ + echo "Remplace-le par tes propres apps, ou copie ton config.json personnel ici."; \ + fi + +serve: + scripts/start.sh $(PORT) + +sync: + python3 scripts/sync-apps.py + +export: + scripts/export.sh + +deploy: + scripts/deploy.sh diff --git a/README.md b/README.md index da43644..cf5780c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,204 @@ # Yunohost_homePage +Page d'accueil YunoHost personnalisée : applications épinglées en haut de +page, groupes repliables/réordonnables, applications sans groupe, bouton +Administration à côté de Déconnexion, et détection automatique du logo de +chaque application (favicon ou logo officiel YunoHost). + +Ce dépôt contient le **code générique**, réutilisable par n'importe qui a +une instance YunoHost — toutes les informations spécifiques à un serveur +(domaine, identifiants SSH, mot de passe) vivent uniquement dans un fichier +`.env` local, jamais commité. `src/config.json` (tes apps/groupes) est lui +aussi local et ignoré par git — voir plus bas comment le renseigner. + +## Structure du projet + +``` +src/ sources de la page (index.html, style.css, app.js) + et de l'éditeur visuel (editor.html/css/js) + + config.json (tes données, ignoré par git — voir ci-dessous) + + config.example.json (exemple versionné, point de départ) +scripts/ scripts d'outillage (preview, export, sync, déploiement) +output/ généré par "make export" — c'est ce dossier qui est envoyé + sur le serveur YunoHost (ignoré par git) +Makefile regroupe toutes les commandes ci-dessous +.env tes identifiants/domaine (créé par "make config", jamais commité) +``` + +## Démarrage rapide + +```bash +make help # liste toutes les commandes +make config # crée .env et src/config.json (depuis les .example) si absents +``` + +Édite ensuite : +- `.env` avec les infos de **ton** instance YunoHost (voir les commentaires + dans `.env.example`), +- `src/config.json` avec tes propres applications et groupes — soit à la + main (voir la section dédiée plus bas), soit via l'éditeur visuel une + fois le serveur lancé, soit en copiant un fichier existant si tu en as + déjà un ailleurs (ex. un dépôt séparé où tu gardes tes configs + personnelles). + +Puis : + +```bash +make serve # lance l'apercu + l'editeur en local +``` + +## Aperçu et édition en local + +```bash +make serve +``` + +(Ctrl+C arrête le serveur.) Un port différent : `make serve PORT=8080`. + +Ça ouvre deux onglets : +- **`http://localhost:8000/editor.html`** — éditeur : glisser-déposer les + applications entre groupes, vers/depuis "Sans groupe" ou vers/depuis + "Épinglées" (tout en haut, en dehors de tout groupe), réordonner les + groupes (poignée `⠿`), les renommer (clic sur le nom), cocher "replié par + défaut", ajouter (`+`) ou supprimer (`✕`) une application ou un groupe. + - Le bouton **Enregistrer** écrit dans `src/config.json`. + - Le bouton **Exporter** enregistre puis copie les fichiers déployables + (`index.html`, `style.css`, `app.js`, `config.json`) dans `output/` — + c'est ce dossier, pas `src/`, qui est envoyé sur le serveur. +- **`http://localhost:8000/`** — aperçu de la page finale (celle de + `src/`), avec rechargement automatique dès qu'un fichier change. + +Cette partie ne nécessite aucune information sur ton serveur : tout est +local. + +## Récupérer automatiquement les apps installées sur YunoHost + +```bash +make sync +``` + +Se connecte en SSH (`SYNC_SSH_USER@SYNC_SSH_HOST` défini dans `.env`), +récupère la vraie liste des apps installées sur `PORTAL_DOMAIN` avec leur +nom/URL/logo officiels, et ajoute dans `ungrouped` uniquement celles qui +manquent encore (comparaison par URL). N'écrase jamais une app déjà classée +dans un groupe ou épinglée. + +⚠️ Nécessite un accès **root** (ou un compte avec sudo, mot de passe requis +à chaque appel) sur le serveur : le fichier lu +(`/etc/yunohost/portal/.json`) n'est lisible par aucun compte moins +privilégié, y compris le compte applicatif utilisé pour le déploiement. + +## Gérer les applications à la main (optionnel) + +Tout se configure aussi directement dans `src/config.json` (voir +`src/config.example.json` pour un point de départ), si tu préfères éditer +le fichier toi-même plutôt que d'utiliser l'éditeur visuel. + +### Ajouter une application + +```json +{ "id": "monapp", "name": "Mon App", "url": "/monapp/", "color": "#2196f3", "initials": "MA" } +``` + +- **`id`** : identifiant unique, sans espace. +- **`name`** : nom affiché sous la tuile. +- **`url`** : chemin réel de l'appli sur le domaine (ex. `/navidrome/`). C'est + à partir de cette URL que la page tente de charger `favicon.ico` + automatiquement. +- **`color`** / **`initials`** : filet de sécurité si aucun favicon ne charge + (pastille colorée avec initiales). +- **`icon`** *(optionnel)* : force une image précise plutôt que le favicon + auto-détecté, ex. `"icon": "https://..."`. + +Ajoute l'objet dans le tableau `apps` d'un groupe existant, dans le tableau +racine `pinned` (épinglées en haut de page) ou dans `ungrouped` si elle ne +doit appartenir à aucun groupe. + +### Supprimer une application + +Supprime son objet `{ ... }` dans le tableau où elle se trouve. Attention aux +virgules JSON (pas de virgule surnuméraire). + +### Gérer les groupes + +- **Créer** : ajoute un objet dans le tableau racine `groups` : + ```json + { "id": "domotique", "name": "Domotique", "collapsed": false, "apps": [] } + ``` +- **Réordonner** : l'ordre des objets dans `groups` = l'ordre vertical affiché. +- **Renommer** : change le champ `name`. +- **Replié par défaut** : `"collapsed": true` (le choix de chaque visiteur + est ensuite mémorisé dans son navigateur et prend le dessus). +- **Supprimer** : retire l'objet de `groups` — déplace d'abord ses apps + ailleurs, sinon elles disparaissent avec lui. + +### Vérifier que le JSON reste valide + +```bash +python3 -m json.tool src/config.json > /dev/null && echo "JSON valide" +``` + +## Déploiement sur YunoHost + +Cette page est conçue pour être servie par une app "Custom Webapp" +(`my_webapp`) YunoHost, définie comme application par défaut du domaine +pour être visible à la racine (`https://tondomaine/`) — voir la doc +YunoHost pour installer ce type d'app si ce n'est pas déjà fait. + +Le déploiement se fait toujours à partir de `output/`, jamais de `src/` +directement : + +```bash +make export # (ou le bouton Exporter de l'éditeur) : src/ -> output/ +make deploy # envoie output/ sur le serveur +``` + +### Configurer `.env` + +```bash +make config +``` + +crée `.env` à partir de `.env.example` (déjà ignoré par git, ne sera jamais +commité). **Toutes** les infos de connexion viennent de ce fichier — sans +lui, `make deploy` et `make sync` s'arrêtent avec un message expliquant quoi +renseigner : + +```dotenv +# Déploiement +REMOTE_HOST=tondomaine.exemple +REMOTE_USER=my_webapp__x +REMOTE_PORT=22 +REMOTE_PATH=/www/ +REMOTE_PASSWORD=le-mot-de-passe-ici + +# Synchronisation des apps (make sync) +SYNC_SSH_USER=root +SYNC_SSH_HOST=tondomaine.exemple +PORTAL_DOMAIN=tondomaine.exemple +``` + +`scripts/deploy.sh` charge ce fichier. Si `REMOTE_PASSWORD` est renseigné, +il utilise `sshpass` (installé via `make install`) pour se connecter sans +invite interactive. Sans mot de passe dans `.env`, `ssh` demandera la +saisie manuelle — utile en terminal interactif, mais ne fonctionne pas dans +un contexte sans terminal (script automatisé), où `ssh` échoue avec une +erreur `ssh_askpass`. + +⚠️ Le compte applicatif (`REMOTE_USER`) d'une "Custom Webapp" YunoHost est +en général un compte **SFTP uniquement**, chrooté dans son propre dossier +(`ForceCommand internal-sftp` côté serveur) : +- **`rsync` ne fonctionne pas** (il a besoin d'exécuter un shell distant) — + `scripts/deploy.sh` utilise `scp`. +- `REMOTE_PATH` est **relatif à la racine du chroot** de cette app + (`/var/www/`), donc `/www/` et pas + `/var/www//www/`. + +### Authentification par clé SSH (alternative) + +Pour ne plus dépendre du mot de passe pour le déploiement : + +```bash +ssh-keygen -t ed25519 -C "deploy-yunohost-pageaccueil" +ssh-copy-id -p "$REMOTE_PORT" "$REMOTE_USER@$REMOTE_HOST" +``` diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..4398d65 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# Deploie le contenu de output/ (voir "make export") sur l'app YunoHost. +# Voir README.md pour le detail des parametres et l'authentification par cle SSH. +# +# Le compte SSH d'une app "Custom Webapp" YunoHost est en general limite a +# SFTP (ForceCommand internal-sftp) et chroote dans son propre dossier home +# (/var/www/) : rsync ne fonctionne donc pas (il a besoin +# d'executer un shell distant), et le chemin distant doit etre relatif a la +# racine du chroot, pas un chemin absolu du systeme de fichiers du serveur. + +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +PROJECT_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)" +OUTPUT_DIR="$PROJECT_ROOT/output" + +if [ ! -f "${PROJECT_ROOT}/.env" ]; then + echo "Aucun .env trouve : lance 'make config' puis renseigne-le (voir .env.example)." >&2 + exit 1 +fi +# shellcheck disable=SC1090,SC1091 +. "${PROJECT_ROOT}/.env" + +if [ ! -f "${OUTPUT_DIR}/index.html" ]; then + echo "output/ est vide ou incomplet : lance d'abord 'make export' (ou le bouton Export de l'editeur)." >&2 + exit 1 +fi + +if [ -z "${REMOTE_HOST:-}" ] || [ -z "${REMOTE_USER:-}" ]; then + echo "REMOTE_HOST et REMOTE_USER doivent etre renseignes dans .env (voir .env.example)." >&2 + exit 1 +fi + +REMOTE_PORT="${REMOTE_PORT:-22}" +# Chemin relatif a la racine du chroot SFTP (pas le chemin absolu serveur). +REMOTE_PATH="${REMOTE_PATH:-/www/}" + +SCP="scp" + +if [ -n "${REMOTE_PASSWORD:-}" ]; then + if ! command -v sshpass > /dev/null 2>&1; then + echo "REMOTE_PASSWORD est defini dans .env mais sshpass n'est pas installe (apt install sshpass)." >&2 + exit 1 + fi + SCP="sshpass -p ${REMOTE_PASSWORD} scp" +fi + +${SCP} -P "${REMOTE_PORT}" \ + "${OUTPUT_DIR}/index.html" \ + "${OUTPUT_DIR}/style.css" \ + "${OUTPUT_DIR}/app.js" \ + "${OUTPUT_DIR}/config.json" \ + "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}" diff --git a/scripts/export.sh b/scripts/export.sh new file mode 100755 index 0000000..9871541 --- /dev/null +++ b/scripts/export.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Copie les fichiers deployables de src/ vers output/ (equivalent en ligne +# de commande du bouton "Exporter" de l'editeur). + +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +PROJECT_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)" +SRC_DIR="$PROJECT_ROOT/src" +OUTPUT_DIR="$PROJECT_ROOT/output" + +mkdir -p "$OUTPUT_DIR" +cp "$SRC_DIR/index.html" "$SRC_DIR/style.css" "$SRC_DIR/app.js" "$SRC_DIR/config.json" "$OUTPUT_DIR/" + +echo "Exporte vers $OUTPUT_DIR :" +ls "$OUTPUT_DIR" diff --git a/scripts/preview.py b/scripts/preview.py new file mode 100755 index 0000000..b3279a5 --- /dev/null +++ b/scripts/preview.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Serveur de previsualisation locale (sert src/), avec rechargement +automatique, sauvegarde et export depuis l'editeur visuel (editor.html). + +Usage: python3 scripts/preview.py [port] +""" +import http.server +import json +import shutil +import sys +from pathlib import Path + +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8000 + +SCRIPT_DIR = Path(__file__).resolve().parent +PROJECT_ROOT = SCRIPT_DIR.parent +SRC_DIR = PROJECT_ROOT / "src" +OUTPUT_DIR = PROJECT_ROOT / "output" + +WATCHED_FILES = ["index.html", "style.css", "app.js", "config.json"] +EXPORTED_FILES = ["index.html", "style.css", "app.js", "config.json"] + +RELOAD_SNIPPET = b""" + + +""" + + +def watched_mtime(): + mtimes = [] + for name in WATCHED_FILES: + try: + mtimes.append((SRC_DIR / name).stat().st_mtime) + except OSError: + pass + return str(max(mtimes)) if mtimes else "0" + + +class ReloadingHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(SRC_DIR), **kwargs) + + def do_GET(self): + if self.path == "/__mtime": + body = watched_mtime().encode() + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + + if self.path in ("/", "/index.html"): + try: + html = (SRC_DIR / "index.html").read_bytes() + except OSError: + return super().do_GET() + html = html.replace(b"", RELOAD_SNIPPET) + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(html))) + self.end_headers() + self.wfile.write(html) + return + + super().do_GET() + + def do_POST(self): + if self.path == "/__save": + length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(length) + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + self.send_error(400, f"JSON invalide: {exc}") + return + with open(SRC_DIR / "config.json", "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + f.write("\n") + self._send_json({"ok": True}) + return + + if self.path == "/__export": + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + copied = [] + for name in EXPORTED_FILES: + src = SRC_DIR / name + if src.exists(): + shutil.copy(src, OUTPUT_DIR / name) + copied.append(name) + self._send_json({"ok": True, "files": copied, "dir": str(OUTPUT_DIR)}) + return + + self.send_error(404) + + def _send_json(self, payload): + body = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def end_headers(self): + # Empeche le navigateur de mettre en cache src/*, sinon un simple + # rechargement (ou le reload automatique) peut afficher une version + # perimee et forcer a faire Ctrl+F5. + self.send_header("Cache-Control", "no-store, must-revalidate") + super().end_headers() + + def log_message(self, format, *args): + if args and "__mtime" in str(args[0]): + return + super().log_message(format, *args) + + +if __name__ == "__main__": + with http.server.ThreadingHTTPServer(("127.0.0.1", PORT), ReloadingHandler) as httpd: + print(f"Apercu : http://localhost:{PORT}/") + print(f"Editeur : http://localhost:{PORT}/editor.html") + print("Ctrl+C pour arreter.") + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..19cb760 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Lance le serveur de previsualisation locale et ouvre l'editeur + l'apercu +# dans le navigateur. Ctrl+C arrete tout. + +set -eu + +PORT="${1:-8000}" +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +cd "$SCRIPT_DIR" + +python3 preview.py "$PORT" & +SERVER_PID=$! +trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT INT TERM + +# Laisse le temps au serveur de demarrer avant d'ouvrir le navigateur. +sleep 1 + +EDITOR_URL="http://localhost:${PORT}/editor.html" +PREVIEW_URL="http://localhost:${PORT}/" + +open_url() { + if command -v xdg-open > /dev/null 2>&1; then + xdg-open "$1" > /dev/null 2>&1 & + elif command -v open > /dev/null 2>&1; then + open "$1" > /dev/null 2>&1 & + else + echo "Ouvre manuellement : $1" + fi +} + +open_url "$EDITOR_URL" +open_url "$PREVIEW_URL" + +wait "$SERVER_PID" diff --git a/scripts/sync-apps.py b/scripts/sync-apps.py new file mode 100755 index 0000000..1652c4c --- /dev/null +++ b/scripts/sync-apps.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Recupere la liste des apps installees sur le serveur YunoHost et ajoute +celles qui manquent dans src/config.json (dans "ungrouped"), sans toucher a +celles deja placees dans un groupe, dans "pinned" ou deja presentes. + +Usage: python3 scripts/sync-apps.py [utilisateur@hote] [domaine] + +Toute la configuration vient de .env (SYNC_SSH_USER, SYNC_SSH_HOST, +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 + + +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 + +if not SSH_TARGET or not DOMAIN: + print( + "Configuration manquante : renseigne SYNC_SSH_HOST (ou REMOTE_HOST) " + "et PORTAL_DOMAIN dans .env (lance 'make config' si .env n'existe pas " + "encore), ou passe-les en argument :\n" + " python3 scripts/sync-apps.py utilisateur@hote domaine", + file=sys.stderr, + ) + 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", {}) + existing_urls = known_urls(config) + + 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: + print("Rien a ajouter, config.json est deja a jour.") + return + + 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}") + + +if __name__ == "__main__": + main() diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..fced8a9 --- /dev/null +++ b/src/app.js @@ -0,0 +1,160 @@ +const COLLAPSE_STORAGE_KEY = "yunohost-portal-collapsed-groups"; + +function loadCollapsedState() { + try { + return JSON.parse(localStorage.getItem(COLLAPSE_STORAGE_KEY)) || {}; + } catch (e) { + return {}; + } +} + +function saveCollapsedState(state) { + try { + localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(state)); + } catch (e) { + /* ignore */ + } +} + +function guessFaviconUrl(appUrl) { + try { + return new URL("favicon.ico", new URL(appUrl, window.location.href)).href; + } catch (e) { + return null; + } +} + +function showInitialsFallback(icon, app) { + icon.innerHTML = ""; + icon.style.background = app.color || "#8e8e8e"; + icon.textContent = app.initials || app.name.slice(0, 2).toUpperCase(); +} + +function tileNode(app) { + const a = document.createElement("a"); + a.className = "tile"; + a.href = app.url; + + const icon = document.createElement("div"); + icon.className = "tile-icon"; + + const iconSrc = app.icon || guessFaviconUrl(app.url); + if (iconSrc) { + const img = document.createElement("img"); + img.src = iconSrc; + img.alt = ""; + img.addEventListener("error", () => showInitialsFallback(icon, app), { once: true }); + icon.appendChild(img); + } else { + showInitialsFallback(icon, app); + } + + const name = document.createElement("div"); + name.className = "tile-name"; + name.textContent = app.name; + + a.appendChild(icon); + a.appendChild(name); + return a; +} + +function groupNode(group, collapsedState) { + const wrapper = document.createElement("section"); + wrapper.className = "group"; + const isCollapsed = collapsedState[group.id] ?? group.collapsed ?? false; + if (isCollapsed) wrapper.classList.add("collapsed"); + + const header = document.createElement("div"); + header.className = "group-header"; + + const toggle = document.createElement("span"); + toggle.className = "group-toggle"; + toggle.textContent = "▾"; + + const title = document.createElement("span"); + title.className = "group-title"; + title.textContent = group.name; + + const count = document.createElement("span"); + count.className = "group-count"; + count.textContent = `(${group.apps.length})`; + + header.appendChild(toggle); + header.appendChild(title); + header.appendChild(count); + + header.addEventListener("click", () => { + const nowCollapsed = wrapper.classList.toggle("collapsed"); + collapsedState[group.id] = nowCollapsed; + saveCollapsedState(collapsedState); + }); + + const grid = document.createElement("div"); + grid.className = "tile-grid"; + group.apps.forEach((app) => grid.appendChild(tileNode(app))); + + wrapper.appendChild(header); + wrapper.appendChild(grid); + return wrapper; +} + +function render(config) { + document.getElementById("user-fullname").textContent = config.user.username; + document.getElementById("user-sub").textContent = config.user.fullname; + document.getElementById("user-domain").textContent = `${config.user.username}@${config.user.domain}`; + + const adminUrl = config.portal.adminUrl || "/yunohost/admin/"; + document.getElementById("admin-btn").href = adminUrl; + document.getElementById("footer-admin").href = adminUrl; + document.getElementById("footer-edit-profile").href = config.portal.editProfileUrl || "#"; + document.getElementById("edit-profile").href = config.portal.editProfileUrl || "#"; + document.getElementById("footer-doc").href = config.portal.documentationUrl || "#"; + document.getElementById("footer-support").href = config.portal.supportUrl || "#"; + + const logoutUrl = config.portal.logoutUrl || "/yunohost/portalapi/logout"; + document.getElementById("logout-btn").addEventListener("click", (e) => { + e.preventDefault(); + fetch(logoutUrl, { credentials: "same-origin" }).finally(() => { + window.location.href = "/yunohost/sso/"; + }); + }); + + const app = document.getElementById("app"); + app.innerHTML = ""; + + const collapsedState = loadCollapsedState(); + + if (config.pinned && config.pinned.length) { + const section = document.createElement("section"); + section.className = "pinned-section"; + const grid = document.createElement("div"); + grid.className = "tile-grid"; + config.pinned.forEach((appItem) => grid.appendChild(tileNode(appItem))); + section.appendChild(grid); + app.appendChild(section); + } + + const groups = [...(config.groups || [])]; + groups.forEach((group) => { + app.appendChild(groupNode(group, collapsedState)); + }); + + if (config.ungrouped && config.ungrouped.length) { + const section = document.createElement("section"); + section.className = "ungrouped-section"; + const grid = document.createElement("div"); + grid.className = "tile-grid"; + config.ungrouped.forEach((appItem) => grid.appendChild(tileNode(appItem))); + section.appendChild(grid); + app.appendChild(section); + } +} + +fetch("config.json") + .then((r) => r.json()) + .then(render) + .catch((err) => { + document.getElementById("app").textContent = + "Erreur de chargement de la configuration (config.json)."; + console.error(err); + }); diff --git a/src/config.example.json b/src/config.example.json new file mode 100644 index 0000000..3ca487c --- /dev/null +++ b/src/config.example.json @@ -0,0 +1,33 @@ +{ + "portal": { + "logoText": "YUNO\nHOST", + "adminUrl": "/yunohost/admin/", + "editProfileUrl": "/yunohost/sso/edit", + "logoutUrl": "/yunohost/portalapi/logout", + "documentationUrl": "https://yunohost.org/docs", + "supportUrl": "https://yunohost.org/help" + }, + "user": { + "fullname": "Nom complet", + "username": "utilisateur", + "domain": "domaine.exemple" + }, + "pinned": [], + "groups": [ + { + "id": "exemple", + "name": "Exemple de groupe", + "collapsed": false, + "apps": [ + { + "id": "monapp", + "name": "Mon App", + "url": "https://monapp.domaine.exemple/", + "color": "#2196f3", + "initials": "MA" + } + ] + } + ], + "ungrouped": [] +} diff --git a/src/editor.css b/src/editor.css new file mode 100644 index 0000000..2319417 --- /dev/null +++ b/src/editor.css @@ -0,0 +1,339 @@ +:root { + --purple: #4c2b8f; + --purple-dark: #3a2070; + --bg: #ffffff; + --panel-bg: #f6f6f6; + --tile-bg: #efefef; + --tile-hover: #e2e2e2; + --text: #1a1a1a; + --muted: #6b6b6b; + --border: #dcdcdc; + --danger: #c0392b; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: var(--text); + background: var(--bg); +} + +.editor-topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + background: var(--bg); + z-index: 10; +} + +.editor-topbar h1 { + font-size: 18px; + margin: 0; +} + +.editor-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.status { + font-size: 13px; + color: var(--muted); + min-width: 90px; + text-align: right; +} + +.status.saved { color: #2f855a; } +.status.error { color: var(--danger); } + +.btn { + display: inline-block; + padding: 8px 16px; + border-radius: 8px; + font-weight: 600; + font-size: 14px; + text-decoration: none; + cursor: pointer; + border: 2px solid transparent; + background: none; + font-family: inherit; +} + +.btn-primary { + background: var(--purple); + color: #fff; +} +.btn-primary:hover { background: var(--purple-dark); } + +.btn-outline { + background: transparent; + color: var(--purple); + border-color: var(--purple); +} + +#editor-root { + max-width: 1100px; + margin: 0 auto; + padding: 24px; +} + +.group-card { + background: var(--panel-bg); + border: 1px solid var(--border); + border-radius: 14px; + margin-bottom: 20px; + padding: 14px 16px 16px; +} + +.group-card.drag-over { + outline: 2px dashed var(--purple); + outline-offset: 4px; +} + +.group-card.dragging { + opacity: 0.4; +} + +.group-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.drag-handle { + cursor: grab; + color: var(--muted); + font-size: 18px; + user-select: none; + padding: 2px 4px; +} + +.group-name-input { + font-size: 16px; + font-weight: 700; + border: 1px solid transparent; + background: transparent; + padding: 4px 8px; + border-radius: 6px; + flex: 1; + min-width: 0; +} + +.group-name-input:hover, +.group-name-input:focus { + border-color: var(--border); + background: #fff; + outline: none; +} + +.group-collapsed-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--muted); + white-space: nowrap; +} + +.icon-btn { + border: none; + background: transparent; + cursor: pointer; + color: var(--muted); + font-size: 16px; + padding: 4px 6px; + border-radius: 6px; + line-height: 1; +} +.icon-btn:hover { + background: var(--tile-hover); + color: var(--danger); +} + +.tile-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 14px; + min-height: 96px; +} + +.tile-grid.drag-over { + background: rgba(76, 43, 143, 0.06); + border-radius: 10px; +} + +.app-tile { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 14px 8px 10px; + border-radius: 12px; + background: var(--tile-bg); + cursor: grab; + text-align: center; +} + +.app-tile.dragging { + opacity: 0.35; +} + +.app-tile .remove-btn { + position: absolute; + top: 2px; + right: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.55); + color: #fff; + font-size: 12px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.app-tile .remove-btn:hover { background: var(--danger); } + +.tile-icon { + width: 56px; + height: 56px; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 18px; + color: #fff; + overflow: hidden; +} + +.tile-icon img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.tile-name { + font-size: 12px; + line-height: 1.25; + word-break: break-word; +} + +.add-tile { + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + border: 2px dashed var(--border); + min-height: 96px; + cursor: pointer; + color: var(--muted); + font-size: 28px; + background: transparent; +} +.add-tile:hover { + border-color: var(--purple); + color: var(--purple); +} + +.ungrouped-section { + margin-top: 32px; + padding-top: 16px; + border-top: 2px solid var(--border); +} + +.ungrouped-section h2 { + font-size: 15px; + color: var(--muted); + margin: 0 0 12px; +} + +.pinned-section { + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 2px solid var(--border); +} + +.pinned-section h2 { + font-size: 15px; + color: var(--muted); + margin: 0 0 12px; +} + +.add-group-btn { + display: block; + width: 100%; + padding: 14px; + border-radius: 12px; + border: 2px dashed var(--border); + background: transparent; + color: var(--muted); + font-size: 14px; + font-weight: 600; + cursor: pointer; +} +.add-group-btn:hover { + border-color: var(--purple); + color: var(--purple); +} + +.app-form-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.app-form { + background: #fff; + border-radius: 14px; + padding: 24px; + width: 100%; + max-width: 380px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.app-form h3 { + margin: 0 0 4px; + font-size: 16px; +} + +.app-form label { + font-size: 13px; + color: var(--muted); + display: flex; + flex-direction: column; + gap: 4px; +} + +.app-form input { + font-family: inherit; + font-size: 14px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border); +} + +.app-form-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} diff --git a/src/editor.html b/src/editor.html new file mode 100644 index 0000000..f35eec9 --- /dev/null +++ b/src/editor.html @@ -0,0 +1,24 @@ + + + + + + Éditeur du portail + + + +
+

Éditeur du portail

+
+ + Aperçu ↗ + + +
+
+ +
+ + + + diff --git a/src/editor.js b/src/editor.js new file mode 100644 index 0000000..9d02421 --- /dev/null +++ b/src/editor.js @@ -0,0 +1,435 @@ +let state = null; +let dirty = false; +let dragSource = null; // { appId } or { groupId } currently being dragged + +const PALETTE = [ + "#1db9c3", "#ff5a1f", "#2094f3", "#ffc230", "#00a651", "#e6721a", + "#b71c1c", "#f39c12", "#0d3b66", "#3b8a8c", "#568d34", "#609926", + "#d24939", "#0066cc", "#8e44ad", "#16a085", "#c0392b", "#2c3e50", +]; + +function uid(prefix) { + return `${prefix}-${Math.random().toString(36).slice(2, 8)}`; +} + +function initialsOf(name) { + const words = name.trim().split(/\s+/); + if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); + return (words[0][0] + words[1][0]).toUpperCase(); +} + +function colorOf(name) { + let hash = 0; + for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0; + return PALETTE[hash % PALETTE.length]; +} + +function setStatus(text, kind) { + const el = document.getElementById("status"); + el.textContent = text; + el.className = "status" + (kind ? " " + kind : ""); +} + +function markDirty() { + dirty = true; + setStatus("Modifications non enregistrées"); +} + +function findAppLocation(appId) { + const pinnedIdx = (state.pinned || []).findIndex((a) => a.id === appId); + if (pinnedIdx !== -1) return { list: state.pinned, index: pinnedIdx }; + for (const group of state.groups) { + const idx = group.apps.findIndex((a) => a.id === appId); + if (idx !== -1) return { list: group.apps, index: idx }; + } + const idx = state.ungrouped.findIndex((a) => a.id === appId); + if (idx !== -1) return { list: state.ungrouped, index: idx }; + return null; +} + +function computeDropIndexGrid(containerEl, clientX, clientY) { + const items = Array.from(containerEl.querySelectorAll(":scope > .app-tile")); + if (items.length === 0) return 0; + let closestIndex = items.length; + let closestDist = Infinity; + items.forEach((item, i) => { + const rect = item.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const dist = Math.hypot(clientX - cx, clientY - cy); + if (dist < closestDist) { + closestDist = dist; + closestIndex = clientX < cx ? i : i + 1; + } + }); + return closestIndex; +} + +function computeDropIndexList(containerEl, selector, clientY) { + const items = Array.from(containerEl.querySelectorAll(selector)); + if (items.length === 0) return 0; + let closestIndex = items.length; + let closestDist = Infinity; + items.forEach((item, i) => { + const rect = item.getBoundingClientRect(); + const cy = rect.top + rect.height / 2; + const dist = Math.abs(clientY - cy); + if (dist < closestDist) { + closestDist = dist; + closestIndex = clientY < cy ? i : i + 1; + } + }); + return closestIndex; +} + +function renderTile(app) { + const tile = document.createElement("div"); + tile.className = "app-tile"; + tile.draggable = true; + tile.dataset.appId = app.id; + + tile.addEventListener("dragstart", (e) => { + e.stopPropagation(); + dragSource = { appId: app.id }; + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", app.id); + tile.classList.add("dragging"); + }); + tile.addEventListener("dragend", (e) => { + e.stopPropagation(); + tile.classList.remove("dragging"); + dragSource = null; + }); + + const removeBtn = document.createElement("button"); + removeBtn.className = "remove-btn"; + removeBtn.textContent = "✕"; + removeBtn.title = "Supprimer cette application"; + removeBtn.addEventListener("click", () => { + if (!confirm(`Supprimer "${app.name}" définitivement ?`)) return; + const loc = findAppLocation(app.id); + if (loc) loc.list.splice(loc.index, 1); + markDirty(); + render(); + }); + + 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(removeBtn); + tile.appendChild(icon); + tile.appendChild(name); + return tile; +} + +function renderAddTile(onAdd) { + const add = document.createElement("button"); + add.className = "add-tile"; + add.type = "button"; + add.textContent = "+"; + add.title = "Ajouter une application"; + add.addEventListener("click", onAdd); + return add; +} + +function attachGridDropzone(gridEl, targetListGetter) { + gridEl.addEventListener("dragover", (e) => { + if (!dragSource || !dragSource.appId) return; + e.preventDefault(); + gridEl.classList.add("drag-over"); + }); + gridEl.addEventListener("dragleave", () => { + gridEl.classList.remove("drag-over"); + }); + 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); + const targetList = targetListGetter(); + const dropIndex = computeDropIndexGrid(gridEl, e.clientX, e.clientY); + targetList.splice(Math.min(dropIndex, targetList.length), 0, app); + markDirty(); + render(); + }); +} + +function openAppForm(onSubmit) { + const backdrop = document.createElement("div"); + backdrop.className = "app-form-backdrop"; + + const form = document.createElement("form"); + form.className = "app-form"; + form.innerHTML = ` +

Nouvelle application

+ + + +
+ + +
+ `; + + backdrop.appendChild(form); + document.body.appendChild(backdrop); + form.querySelector('input[name="name"]').focus(); + + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) backdrop.remove(); + }); + form.querySelector("[data-cancel]").addEventListener("click", () => backdrop.remove()); + + form.addEventListener("submit", (e) => { + e.preventDefault(); + const data = new FormData(form); + const name = data.get("name").trim(); + const url = data.get("url").trim(); + const icon = data.get("icon").trim(); + if (!name || !url) return; + const app = { + id: uid("app"), + name, + url, + color: colorOf(name), + initials: initialsOf(name), + }; + if (icon) app.icon = icon; + onSubmit(app); + backdrop.remove(); + }); +} + +function renderGroup(group, index) { + const card = document.createElement("section"); + card.className = "group-card"; + card.draggable = true; + card.dataset.groupId = group.id; + + card.addEventListener("dragstart", (e) => { + dragSource = { groupId: group.id }; + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", group.id); + card.classList.add("dragging"); + }); + card.addEventListener("dragend", () => { + card.classList.remove("dragging"); + dragSource = null; + }); + + const header = document.createElement("div"); + header.className = "group-header"; + + const handle = document.createElement("span"); + handle.className = "drag-handle"; + handle.textContent = "⠿"; + handle.title = "Glisser pour réordonner les groupes"; + + const nameInput = document.createElement("input"); + nameInput.className = "group-name-input"; + nameInput.value = group.name; + nameInput.draggable = false; + nameInput.addEventListener("input", () => { + group.name = nameInput.value; + markDirty(); + }); + nameInput.addEventListener("mousedown", (e) => e.stopPropagation()); + + const collapsedLabel = document.createElement("label"); + collapsedLabel.className = "group-collapsed-label"; + const collapsedCheckbox = document.createElement("input"); + collapsedCheckbox.type = "checkbox"; + collapsedCheckbox.checked = !!group.collapsed; + collapsedCheckbox.addEventListener("change", () => { + group.collapsed = collapsedCheckbox.checked; + markDirty(); + }); + collapsedLabel.appendChild(collapsedCheckbox); + collapsedLabel.appendChild(document.createTextNode("replié par défaut")); + + const deleteBtn = document.createElement("button"); + deleteBtn.className = "icon-btn"; + deleteBtn.textContent = "✕"; + deleteBtn.title = "Supprimer le groupe (les apps repassent en \"Sans groupe\")"; + deleteBtn.addEventListener("click", () => { + if (!confirm(`Supprimer le groupe "${group.name}" ? Les apps repasseront en "Sans groupe".`)) return; + state.ungrouped.push(...group.apps); + state.groups.splice(index, 1); + markDirty(); + render(); + }); + + header.appendChild(handle); + header.appendChild(nameInput); + header.appendChild(collapsedLabel); + header.appendChild(deleteBtn); + + const grid = document.createElement("div"); + grid.className = "tile-grid"; + group.apps.forEach((app) => grid.appendChild(renderTile(app))); + grid.appendChild(renderAddTile(() => { + openAppForm((app) => { + group.apps.push(app); + markDirty(); + render(); + }); + })); + attachGridDropzone(grid, () => group.apps); + + card.appendChild(header); + card.appendChild(grid); + return card; +} + +function attachGroupsDropzone(containerEl) { + containerEl.addEventListener("dragover", (e) => { + if (!dragSource || !dragSource.groupId) return; + e.preventDefault(); + }); + containerEl.addEventListener("drop", (e) => { + if (!dragSource || !dragSource.groupId) return; + e.preventDefault(); + const groupId = dragSource.groupId; + const fromIndex = state.groups.findIndex((g) => g.id === groupId); + if (fromIndex === -1) return; + const [group] = state.groups.splice(fromIndex, 1); + const dropIndex = computeDropIndexList(containerEl, ".group-card", e.clientY); + state.groups.splice(Math.min(dropIndex, state.groups.length), 0, group); + markDirty(); + render(); + }); +} + +function render() { + const root = document.getElementById("editor-root"); + root.innerHTML = ""; + + const pinnedSection = document.createElement("section"); + pinnedSection.className = "pinned-section"; + const pinnedHeading = document.createElement("h2"); + pinnedHeading.textContent = "Épinglées (en haut de la page)"; + pinnedSection.appendChild(pinnedHeading); + + const pinnedGrid = document.createElement("div"); + pinnedGrid.className = "tile-grid"; + (state.pinned || []).forEach((app) => pinnedGrid.appendChild(renderTile(app))); + pinnedGrid.appendChild(renderAddTile(() => { + openAppForm((app) => { + state.pinned = state.pinned || []; + state.pinned.push(app); + markDirty(); + render(); + }); + })); + attachGridDropzone(pinnedGrid, () => (state.pinned = state.pinned || [])); + pinnedSection.appendChild(pinnedGrid); + root.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); + + const addGroupBtn = document.createElement("button"); + addGroupBtn.className = "add-group-btn"; + addGroupBtn.type = "button"; + addGroupBtn.textContent = "+ Nouveau groupe"; + addGroupBtn.addEventListener("click", () => { + state.groups.push({ id: uid("group"), name: "Nouveau groupe", collapsed: false, apps: [] }); + markDirty(); + render(); + }); + root.appendChild(addGroupBtn); + + const ungroupedSection = document.createElement("section"); + ungroupedSection.className = "ungrouped-section"; + const heading = document.createElement("h2"); + heading.textContent = "Sans groupe"; + ungroupedSection.appendChild(heading); + + const ungroupedGrid = document.createElement("div"); + ungroupedGrid.className = "tile-grid"; + state.ungrouped.forEach((app) => ungroupedGrid.appendChild(renderTile(app))); + ungroupedGrid.appendChild(renderAddTile(() => { + openAppForm((app) => { + state.ungrouped.push(app); + markDirty(); + render(); + }); + })); + attachGridDropzone(ungroupedGrid, () => state.ungrouped); + ungroupedSection.appendChild(ungroupedGrid); + + root.appendChild(ungroupedSection); +} + +async function load() { + const res = await fetch("config.json", { cache: "no-store" }); + state = await res.json(); + render(); +} + +async function save() { + try { + const res = await fetch("/__save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(state, null, 2), + }); + if (!res.ok) throw new Error(await res.text()); + dirty = false; + setStatus("Enregistré ✓", "saved"); + setTimeout(() => setStatus(""), 2000); + } catch (err) { + setStatus("Erreur d'enregistrement", "error"); + console.error(err); + } +} + +async function exportToOutput() { + await save(); + try { + const res = await fetch("/__export", { method: "POST" }); + if (!res.ok) throw new Error(await res.text()); + setStatus("Exporté vers output/ ✓", "saved"); + setTimeout(() => setStatus(""), 2500); + } catch (err) { + setStatus("Erreur d'export", "error"); + console.error(err); + } +} + +document.getElementById("save-btn").addEventListener("click", save); +document.getElementById("export-btn").addEventListener("click", exportToOutput); + +window.addEventListener("beforeunload", (e) => { + if (dirty) { + e.preventDefault(); + e.returnValue = ""; + } +}); + +load(); diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..2b37279 --- /dev/null +++ b/src/index.html @@ -0,0 +1,68 @@ + + + + + + Portail + + + +
+
+ + +
+ +
+ +
+ + + + + + diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..2240fb3 --- /dev/null +++ b/src/style.css @@ -0,0 +1,273 @@ +:root { + --purple: #4c2b8f; + --purple-dark: #3a2070; + --bg: #ffffff; + --tile-bg: #efefef; + --tile-hover: #e2e2e2; + --text: #1a1a1a; + --muted: #6b6b6b; + --border: #e2e2e2; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: var(--text); + background: var(--bg); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 32px; +} + +.brand { + display: flex; + align-items: center; + gap: 16px; +} + +.logo { + height: 54px; + width: auto; + flex-shrink: 0; +} + +.logo path { + fill: #111111; +} + +.user-name { + font-weight: 700; + display: flex; + align-items: center; + gap: 6px; +} + +.edit-link { + color: var(--muted); + text-decoration: none; + font-size: 13px; +} + +.user-sub { + font-size: 13px; + color: var(--muted); +} + +.actions { + display: flex; + align-items: center; + gap: 12px; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border-radius: 8px; + font-family: inherit; + font-weight: 600; + font-size: 14px; + text-decoration: none; + cursor: pointer; + border: 2px solid transparent; +} + +.btn-icon { + width: 18px; + height: 18px; + fill: currentColor; + flex-shrink: 0; +} + +.btn-primary { + background: var(--purple); + color: #fff; +} +.btn-primary:hover { background: var(--purple-dark); } + +.btn-outline { + background: transparent; + color: var(--purple); + border-color: var(--purple); +} +.btn-outline:hover { background: rgba(76, 43, 143, 0.08); } + +main#app { + padding: 8px 32px 32px; + max-width: 1200px; + margin: 0 auto; +} + +.group { + margin-bottom: 20px; +} + +.group-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 4px; + cursor: pointer; + user-select: none; +} + +.group-title { + font-size: 16px; + font-weight: 700; + color: var(--text); +} + +.group-toggle { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + transition: transform 0.15s ease; + font-size: 12px; +} + +.group.collapsed .group-toggle { + transform: rotate(-90deg); +} + +.group-count { + font-size: 12px; + color: var(--muted); + font-weight: 400; +} + +.tile-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 20px; + overflow: hidden; + transition: grid-template-rows 0.2s ease; +} + +.group.collapsed .tile-grid { + display: none; +} + +.tile { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + text-decoration: none; + color: var(--text); + padding: 20px 12px 16px; + border-radius: 16px; + background: var(--tile-bg); + transition: background 0.15s ease; + text-align: center; +} + +.tile:hover { + background: var(--tile-hover); +} + +.tile-icon { + width: 72px; + height: 72px; + border-radius: 16px; + background: transparent; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 22px; + color: #fff; + overflow: hidden; +} + +.tile-icon img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 16px; +} + +.tile-name { + font-size: 14px; + font-weight: 600; + line-height: 1.25; + word-break: break-word; +} + +.ungrouped-section { + margin-top: 8px; +} + +.pinned-section { + margin-bottom: 24px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border); +} + +.footer { + display: flex; + gap: 24px; + justify-content: center; + padding: 24px; + border-top: 1px solid var(--border); + margin-top: 32px; +} + +.footer a { + color: var(--muted); + text-decoration: none; + font-size: 13px; +} +.footer a:hover { text-decoration: underline; } + +@media (max-width: 600px) { + .topbar { + flex-direction: column; + align-items: flex-start; + gap: 16px; + padding: 20px; + } + + .actions { + width: 100%; + flex-direction: column; + } + + .btn { + width: 100%; + justify-content: center; + } + + main#app { + padding: 0 20px 24px; + } + + .group-title { + font-size: 17px; + } + + .tile-grid { + grid-template-columns: repeat(2, 1fr); + gap: 16px; + } + + .tile-icon { + width: 84px; + height: 84px; + font-size: 26px; + border-radius: 20px; + } + + .tile-icon img { + border-radius: 20px; + } +}