Code générique de la page d'accueil personnalisée YunoHost (Makefile, scripts, éditeur visuel)
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
output/
|
||||
__pycache__/
|
||||
src/config.json
|
||||
@@ -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
|
||||
@@ -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/<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"
|
||||
```
|
||||
|
||||
Executable
+53
@@ -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}"
|
||||
Executable
+16
@@ -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"
|
||||
Executable
+146
@@ -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
|
||||
Executable
+34
@@ -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"
|
||||
Executable
+162
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user