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
|
# 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