Code générique de la page d'accueil personnalisée YunoHost (Makefile, scripts, éditeur visuel)

This commit is contained in:
mathieu
2026-09-19 17:47:32 +02:00
parent 44d01ea7cd
commit 455988e49c
16 changed files with 2025 additions and 0 deletions
+32
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
.env
output/
__pycache__/
src/config.json
+44
View File
@@ -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
+202
View File
@@ -1,2 +1,204 @@
# Yunohost_homePage # 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/<domaine>.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/<REMOTE_USER>`), donc `/www/` et pas
`/var/www/<REMOTE_USER>/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"
```
+53
View File
@@ -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/<REMOTE_USER>) : 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}"
+16
View File
@@ -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"
+146
View File
@@ -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"""
<script>
(function () {
var lastSeen = null;
var polling = false;
function poll() {
if (polling) { return; }
polling = true;
fetch("/__mtime", { cache: "no-store" })
.then(function (r) { return r.text(); })
.then(function (text) {
if (lastSeen === null) {
lastSeen = text;
} else if (text !== lastSeen) {
location.reload();
}
})
.catch(function (err) { console.error("live-reload:", err); })
.finally(function () { polling = false; });
}
setInterval(poll, 1000);
})();
</script>
</body>
"""
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"</body>", 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
+34
View File
@@ -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"
+162
View File
@@ -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()
+160
View File
@@ -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);
});
+33
View File
@@ -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": []
}
+339
View File
@@ -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;
}
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Éditeur du portail</title>
<link rel="stylesheet" href="editor.css" />
</head>
<body>
<header class="editor-topbar">
<h1>Éditeur du portail</h1>
<div class="editor-actions">
<span id="status" class="status"></span>
<a href="/" target="_blank" class="btn btn-outline">Aperçu ↗</a>
<button id="save-btn" class="btn btn-outline">Enregistrer</button>
<button id="export-btn" class="btn btn-primary">Exporter</button>
</div>
</header>
<main id="editor-root"></main>
<script src="editor.js"></script>
</body>
</html>
+435
View File
@@ -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 = `
<h3>Nouvelle application</h3>
<label>Nom
<input type="text" name="name" required placeholder="Ex: Navidrome" />
</label>
<label>URL
<input type="url" name="url" required placeholder="https://sous-domaine.tondomaine.exemple/" />
</label>
<label>Icône (optionnel, sinon détection auto)
<input type="url" name="icon" placeholder="https://..." />
</label>
<div class="app-form-actions">
<button type="button" class="btn btn-outline" data-cancel>Annuler</button>
<button type="submit" class="btn btn-primary">Ajouter</button>
</div>
`;
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();
+68
View File
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Portail</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="topbar">
<div class="brand">
<svg class="logo" version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 98 85" aria-hidden="true">
<path d="M97,51c-2.02,4.98-8.33,5.67-14,7c-0.609,6.29,3.05,10.95-1,16c-6.41-0.26-7.471-5.859-7-13c-1,0-2,0-3,0
c-2.09,2.77,0.9,4.52,0,8c-1.12,4.34-7.88,7.91-11,7c-2.18-0.641-5.96-6.63-5-12c2.82-2.71,2.76,3.12,6,3c5.05-7.84-9.63-8.55-8-17
c1.24-6.42,11.66-9.66,15-1c1.54,4.21-5.17,0.16-5,3c-0.279,1.62,0.95,1.72,1,3c2.52,0.77,1.68-2.16,3-3c1.859-1.17,3.09-0.75,6-1
c2.45-2.55,1.08-8.92,4-11c3.87,0.46,6.08,2.59,6,7C91.01,46.109,94.3,46.05,97,51z" />
<path d="M87,13c0.609,3.21,2.32,4.98,2,8c-0.34,3.21-2.9,8.83-4,9c-1.17,0.18-1.34,1.78-2,2
c-4.66,1.57-12.391-1.48-14-7c-1.16-3.97,1.9-13.37,4-17c1.3-2.25,1.221-2.99,5-4c2.41-0.65,3.65-2.25,6,0
c0.471,0.45,1.3,0.49,1.85,0.89c-0.199,0,2,3.14,2.15,4.11C88.32,11.07,86.77,11.78,87,13z M79,22c1.779-1.89,3.29-4.04,3-8
C77.49,12.33,74.67,21.3,79,22z" />
<path d="M67,21c-0.07,5.81,2.48,10.7,0,15c-6.73,1.06-7.24-4.1-11-6c-1.939,1.39-1.49,5.18-3,7
c-3.78,0.44-4.69-1.97-7-3c2.47-7.81,1.26-18.98,2-26c8.58-0.58,7.68,8.32,12,12c0.52-4.34-0.359-15.52,3-20
C70.33,3.29,67.09,12.99,67,21z" />
<path d="M52,55c1.93,8.41,0.12,22.689-12,20c-1.59-0.35-8.42-5.22-9-7c-1.62-5,0.34-13.34,3-16
C39.03,46.97,45.48,50.359,52,55z M39,66c4.55,0.96,6.3-4.2,4-7C39.37,59.03,38.61,61.939,39,66z" />
<path d="M39,8c5.58,0.9,6.4,6.81,5,15c-1.43,8.38-3.02,14.59-9,15c-9.57,0.65-12.25-16.69-9-29
c8.32,1.27,6.59,10.36,6,17c2.71,0.83,2.2-0.85,3-2C37.05,21.04,37.82,13.61,39,8z" />
<path d="M28,62c0.1,5.67,4.4,11.33,2,17c-4.32-1.01-6.57-4.09-9-7c-3.15-0.48-2.26,3.07-6,2
c-0.67,5.061,2.29,7.57-1,10c-4.7-0.63-6.66-4-8-8c-2.61-1.38-5.48-2.52-6-6c0.14-3.53,4.48-2.85,7-4c0.47-5.53-1.41-13.41,2-16
c8.31,0.49,8.21,7.13,7,15c4.36,0.29,4.94-4.35,5-7c0.06-2.43-1.82-8.26,2-11c3.06-0.73,2.94,1.73,6,1
C32.35,52.7,27.92,57.439,28,62z" />
<path d="M24,12c1.07,7.07-3.86,8.14-6,12c0.21,6.88-0.47,12.86-2,18c-5.86-1.32-8.7-10.38-6-17
c-0.33-3.52-5.26-4.22-7-8c-0.3-0.66-0.47-4.43-1-7C1.09,5.63,0.55,4.31,3,1c8.16-0.49,7.21,8.13,9,14c5.05,0.39,3.91-5.42,8-6
C20.98,10.35,22.67,11,24,12z" />
</svg>
<div class="user-info">
<div class="user-name">
<span id="user-fullname">utilisateur</span>
<a href="#" id="edit-profile" class="edit-link" title="Éditer mon profil">✎</a>
</div>
<div class="user-sub" id="user-sub">Nom complet</div>
<div class="user-sub" id="user-domain">utilisateur@domaine.exemple</div>
</div>
</div>
<div class="actions">
<a href="#" id="admin-btn" class="btn btn-outline">Administration</a>
<a href="#" id="logout-btn" class="btn btn-primary">
<svg class="btn-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M10 17v-2H4V9h6V7l5 5-5 5z" />
<path d="M12 3h7a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-7v-2h7V5h-7V3z" />
</svg>
Déconnexion
</a>
</div>
</header>
<main id="app"></main>
<footer class="footer">
<a href="#" id="footer-edit-profile">Éditer mon profil</a>
<a href="#" id="footer-doc">Documentation</a>
<a href="#" id="footer-support">Support</a>
<a href="#" id="footer-admin">Administration</a>
</footer>
<script src="app.js"></script>
</body>
</html>
+273
View File
@@ -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;
}
}