diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..045fd85
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,32 @@
+# Copie ce fichier en .env (jamais commité) et remplis les valeurs pour TON
+# instance YunoHost. Rien de ce qui suit n'a de valeur par défaut dans les
+# scripts : sans .env correctement rempli, deploy/sync s'arrêtent avec un
+# message expliquant quoi renseigner.
+
+# --- Déploiement (make export / make deploy) ---
+# Domaine de ton instance YunoHost.
+REMOTE_HOST=mondomaine.exemple
+
+# Nom de l'app "Custom Webapp" (my_webapp) installée pour servir cette page
+# (visible dans YunoHost admin > Applications, ou `yunohost app list`).
+REMOTE_USER=my_webapp__x
+
+# Port SSH du serveur (22 sauf si tu l'as changé).
+REMOTE_PORT=22
+
+# Chemin distant relatif à la racine du chroot SFTP de cette app (ne pas
+# mettre le chemin absolu /var/www/... — voir le README).
+REMOTE_PATH=/www/
+
+# Mot de passe du compte REMOTE_USER (celui défini à l'installation de
+# l'app). Laisse vide pour que ssh/scp le demande à chaque fois.
+REMOTE_PASSWORD=
+
+# --- Synchronisation des apps (make sync) ---
+# Utilisateur et hôte SSH ayant accès en lecture à la configuration YunoHost
+# (root, ou un utilisateur avec sudo -- voir le README pour les limites).
+SYNC_SSH_USER=root
+SYNC_SSH_HOST=mondomaine.exemple
+
+# Domaine YunoHost dont on lit le portail (souvent le même que REMOTE_HOST).
+PORTAL_DOMAIN=mondomaine.exemple
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b9191f2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+.env
+output/
+__pycache__/
+src/config.json
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..9290fc2
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,44 @@
+.DEFAULT_GOAL := help
+PORT ?= 8000
+
+.PHONY: help install config serve sync export deploy
+
+help:
+ @echo "Cibles disponibles :"
+ @echo " make install - installe les dependances necessaires aux scripts (sshpass)"
+ @echo " make config - cree .env et src/config.json si absents"
+ @echo " make serve - lance l'apercu + l'editeur en local (PORT=$(PORT))"
+ @echo " make sync - recupere dans src/config.json les apps YunoHost manquantes"
+ @echo " make export - copie src/ vers output/ (equivalent au bouton Exporter)"
+ @echo " make deploy - envoie output/ sur le serveur YunoHost"
+
+install:
+ sudo apt-get update
+ sudo apt-get install -y sshpass
+
+config:
+ @if [ -f .env ]; then \
+ echo ".env existe deja."; \
+ else \
+ cp .env.example .env; \
+ echo ".env cree depuis .env.example : edite-le pour renseigner le mot de passe."; \
+ fi
+ @if [ -f src/config.json ]; then \
+ echo "src/config.json existe deja."; \
+ else \
+ cp src/config.example.json src/config.json; \
+ echo "src/config.json cree depuis src/config.example.json (donnees d'exemple)."; \
+ echo "Remplace-le par tes propres apps, ou copie ton config.json personnel ici."; \
+ fi
+
+serve:
+ scripts/start.sh $(PORT)
+
+sync:
+ python3 scripts/sync-apps.py
+
+export:
+ scripts/export.sh
+
+deploy:
+ scripts/deploy.sh
diff --git a/README.md b/README.md
index da43644..cf5780c 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,204 @@
# Yunohost_homePage
+Page d'accueil YunoHost personnalisée : applications épinglées en haut de
+page, groupes repliables/réordonnables, applications sans groupe, bouton
+Administration à côté de Déconnexion, et détection automatique du logo de
+chaque application (favicon ou logo officiel YunoHost).
+
+Ce dépôt contient le **code générique**, réutilisable par n'importe qui a
+une instance YunoHost — toutes les informations spécifiques à un serveur
+(domaine, identifiants SSH, mot de passe) vivent uniquement dans un fichier
+`.env` local, jamais commité. `src/config.json` (tes apps/groupes) est lui
+aussi local et ignoré par git — voir plus bas comment le renseigner.
+
+## Structure du projet
+
+```
+src/ sources de la page (index.html, style.css, app.js)
+ et de l'éditeur visuel (editor.html/css/js)
+ + config.json (tes données, ignoré par git — voir ci-dessous)
+ + config.example.json (exemple versionné, point de départ)
+scripts/ scripts d'outillage (preview, export, sync, déploiement)
+output/ généré par "make export" — c'est ce dossier qui est envoyé
+ sur le serveur YunoHost (ignoré par git)
+Makefile regroupe toutes les commandes ci-dessous
+.env tes identifiants/domaine (créé par "make config", jamais commité)
+```
+
+## Démarrage rapide
+
+```bash
+make help # liste toutes les commandes
+make config # crée .env et src/config.json (depuis les .example) si absents
+```
+
+Édite ensuite :
+- `.env` avec les infos de **ton** instance YunoHost (voir les commentaires
+ dans `.env.example`),
+- `src/config.json` avec tes propres applications et groupes — soit à la
+ main (voir la section dédiée plus bas), soit via l'éditeur visuel une
+ fois le serveur lancé, soit en copiant un fichier existant si tu en as
+ déjà un ailleurs (ex. un dépôt séparé où tu gardes tes configs
+ personnelles).
+
+Puis :
+
+```bash
+make serve # lance l'apercu + l'editeur en local
+```
+
+## Aperçu et édition en local
+
+```bash
+make serve
+```
+
+(Ctrl+C arrête le serveur.) Un port différent : `make serve PORT=8080`.
+
+Ça ouvre deux onglets :
+- **`http://localhost:8000/editor.html`** — éditeur : glisser-déposer les
+ applications entre groupes, vers/depuis "Sans groupe" ou vers/depuis
+ "Épinglées" (tout en haut, en dehors de tout groupe), réordonner les
+ groupes (poignée `⠿`), les renommer (clic sur le nom), cocher "replié par
+ défaut", ajouter (`+`) ou supprimer (`✕`) une application ou un groupe.
+ - Le bouton **Enregistrer** écrit dans `src/config.json`.
+ - Le bouton **Exporter** enregistre puis copie les fichiers déployables
+ (`index.html`, `style.css`, `app.js`, `config.json`) dans `output/` —
+ c'est ce dossier, pas `src/`, qui est envoyé sur le serveur.
+- **`http://localhost:8000/`** — aperçu de la page finale (celle de
+ `src/`), avec rechargement automatique dès qu'un fichier change.
+
+Cette partie ne nécessite aucune information sur ton serveur : tout est
+local.
+
+## Récupérer automatiquement les apps installées sur YunoHost
+
+```bash
+make sync
+```
+
+Se connecte en SSH (`SYNC_SSH_USER@SYNC_SSH_HOST` défini dans `.env`),
+récupère la vraie liste des apps installées sur `PORTAL_DOMAIN` avec leur
+nom/URL/logo officiels, et ajoute dans `ungrouped` uniquement celles qui
+manquent encore (comparaison par URL). N'écrase jamais une app déjà classée
+dans un groupe ou épinglée.
+
+⚠️ Nécessite un accès **root** (ou un compte avec sudo, mot de passe requis
+à chaque appel) sur le serveur : le fichier lu
+(`/etc/yunohost/portal/
+""" + + +def watched_mtime(): + mtimes = [] + for name in WATCHED_FILES: + try: + mtimes.append((SRC_DIR / name).stat().st_mtime) + except OSError: + pass + return str(max(mtimes)) if mtimes else "0" + + +class ReloadingHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(SRC_DIR), **kwargs) + + def do_GET(self): + if self.path == "/__mtime": + body = watched_mtime().encode() + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + + if self.path in ("/", "/index.html"): + try: + html = (SRC_DIR / "index.html").read_bytes() + except OSError: + return super().do_GET() + html = html.replace(b"", RELOAD_SNIPPET) + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(html))) + self.end_headers() + self.wfile.write(html) + return + + super().do_GET() + + def do_POST(self): + if self.path == "/__save": + length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(length) + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + self.send_error(400, f"JSON invalide: {exc}") + return + with open(SRC_DIR / "config.json", "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + f.write("\n") + self._send_json({"ok": True}) + return + + if self.path == "/__export": + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + copied = [] + for name in EXPORTED_FILES: + src = SRC_DIR / name + if src.exists(): + shutil.copy(src, OUTPUT_DIR / name) + copied.append(name) + self._send_json({"ok": True, "files": copied, "dir": str(OUTPUT_DIR)}) + return + + self.send_error(404) + + def _send_json(self, payload): + body = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def end_headers(self): + # Empeche le navigateur de mettre en cache src/*, sinon un simple + # rechargement (ou le reload automatique) peut afficher une version + # perimee et forcer a faire Ctrl+F5. + self.send_header("Cache-Control", "no-store, must-revalidate") + super().end_headers() + + def log_message(self, format, *args): + if args and "__mtime" in str(args[0]): + return + super().log_message(format, *args) + + +if __name__ == "__main__": + with http.server.ThreadingHTTPServer(("127.0.0.1", PORT), ReloadingHandler) as httpd: + print(f"Apercu : http://localhost:{PORT}/") + print(f"Editeur : http://localhost:{PORT}/editor.html") + print("Ctrl+C pour arreter.") + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..19cb760 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Lance le serveur de previsualisation locale et ouvre l'editeur + l'apercu +# dans le navigateur. Ctrl+C arrete tout. + +set -eu + +PORT="${1:-8000}" +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +cd "$SCRIPT_DIR" + +python3 preview.py "$PORT" & +SERVER_PID=$! +trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT INT TERM + +# Laisse le temps au serveur de demarrer avant d'ouvrir le navigateur. +sleep 1 + +EDITOR_URL="http://localhost:${PORT}/editor.html" +PREVIEW_URL="http://localhost:${PORT}/" + +open_url() { + if command -v xdg-open > /dev/null 2>&1; then + xdg-open "$1" > /dev/null 2>&1 & + elif command -v open > /dev/null 2>&1; then + open "$1" > /dev/null 2>&1 & + else + echo "Ouvre manuellement : $1" + fi +} + +open_url "$EDITOR_URL" +open_url "$PREVIEW_URL" + +wait "$SERVER_PID" diff --git a/scripts/sync-apps.py b/scripts/sync-apps.py new file mode 100755 index 0000000..1652c4c --- /dev/null +++ b/scripts/sync-apps.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Recupere la liste des apps installees sur le serveur YunoHost et ajoute +celles qui manquent dans src/config.json (dans "ungrouped"), sans toucher a +celles deja placees dans un groupe, dans "pinned" ou deja presentes. + +Usage: python3 scripts/sync-apps.py [utilisateur@hote] [domaine] + +Toute la configuration vient de .env (SYNC_SSH_USER, SYNC_SSH_HOST, +PORTAL_DOMAIN, REMOTE_USER) ; les arguments en ligne de commande, s'ils sont +fournis, ont priorite sur .env. +""" +import json +import subprocess +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +PROJECT_ROOT = SCRIPT_DIR.parent +CONFIG_PATH = PROJECT_ROOT / "src" / "config.json" +ENV_PATH = PROJECT_ROOT / ".env" + + +def load_env(): + env = {} + if not ENV_PATH.exists(): + return env + for line in ENV_PATH.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + env[key.strip()] = value.strip() + return env + + +ENV = load_env() + +SYNC_SSH_USER = ENV.get("SYNC_SSH_USER", "root") +SYNC_SSH_HOST = ENV.get("SYNC_SSH_HOST") or ENV.get("REMOTE_HOST") +PORTAL_DOMAIN = ENV.get("PORTAL_DOMAIN") or ENV.get("REMOTE_HOST") +SELF_APP_ID = ENV.get("REMOTE_USER", "") # cette page elle-meme, jamais ajoutee + +if len(sys.argv) > 1: + SSH_TARGET = sys.argv[1] +elif SYNC_SSH_HOST: + SSH_TARGET = f"{SYNC_SSH_USER}@{SYNC_SSH_HOST}" +else: + SSH_TARGET = None + +DOMAIN = sys.argv[2] if len(sys.argv) > 2 else PORTAL_DOMAIN + +if not SSH_TARGET or not DOMAIN: + print( + "Configuration manquante : renseigne SYNC_SSH_HOST (ou REMOTE_HOST) " + "et PORTAL_DOMAIN dans .env (lance 'make config' si .env n'existe pas " + "encore), ou passe-les en argument :\n" + " python3 scripts/sync-apps.py utilisateur@hote domaine", + file=sys.stderr, + ) + sys.exit(1) + +PALETTE = [ + "#1db9c3", "#ff5a1f", "#2094f3", "#ffc230", "#00a651", "#e6721a", + "#b71c1c", "#f39c12", "#0d3b66", "#3b8a8c", "#568d34", "#609926", + "#d24939", "#0066cc", "#8e44ad", "#16a085", "#c0392b", "#2c3e50", +] + + +def color_of(name): + h = 0 + for ch in name: + h = (h * 31 + ord(ch)) & 0xFFFFFFFF + return PALETTE[h % len(PALETTE)] + + +def initials_of(name): + words = name.strip().split() + if len(words) == 1: + return words[0][:2].upper() + return (words[0][0] + words[1][0]).upper() + + +def fetch_remote_apps(): + cmd = ["ssh", "-o", "BatchMode=yes", SSH_TARGET, f"cat /etc/yunohost/portal/{DOMAIN}.json"] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"Erreur SSH: {result.stderr}", file=sys.stderr) + sys.exit(1) + return json.loads(result.stdout) + + +def normalize_url(url): + return url.replace("https://", "").replace("http://", "").rstrip("/").lower() + + +def known_urls(config): + urls = set() + for app in config.get("pinned", []): + urls.add(normalize_url(app["url"])) + for group in config.get("groups", []): + for app in group["apps"]: + urls.add(normalize_url(app["url"])) + for app in config.get("ungrouped", []): + urls.add(normalize_url(app["url"])) + return urls + + +def main(): + with open(CONFIG_PATH, encoding="utf-8") as f: + config = json.load(f) + + remote = fetch_remote_apps() + remote_apps = remote.get("apps", {}) + existing_urls = known_urls(config) + + added = [] + for perm_key, info in sorted(remote_apps.items()): + if not perm_key.endswith(".main"): + continue + app_id = perm_key[: -len(".main")] + if app_id == SELF_APP_ID: + continue + + url = info["url"] + if not url.startswith("http"): + url = "https://" + url + if not url.endswith("/"): + url += "/" + + if normalize_url(url) in existing_urls: + continue + + name = info.get("label", app_id) + app = { + "id": app_id, + "name": name, + "url": url, + "color": color_of(name), + "initials": initials_of(name), + } + logo = info.get("logo") + if logo: + app["icon"] = f"https://{DOMAIN}{logo}" + + config.setdefault("ungrouped", []).append(app) + added.append(name) + + if not added: + print("Rien a ajouter, config.json est deja a jour.") + return + + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=2) + f.write("\n") + + print(f"{len(added)} application(s) ajoutee(s) dans \"ungrouped\" :") + for name in added: + print(f" - {name}") + + +if __name__ == "__main__": + main() diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..fced8a9 --- /dev/null +++ b/src/app.js @@ -0,0 +1,160 @@ +const COLLAPSE_STORAGE_KEY = "yunohost-portal-collapsed-groups"; + +function loadCollapsedState() { + try { + return JSON.parse(localStorage.getItem(COLLAPSE_STORAGE_KEY)) || {}; + } catch (e) { + return {}; + } +} + +function saveCollapsedState(state) { + try { + localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(state)); + } catch (e) { + /* ignore */ + } +} + +function guessFaviconUrl(appUrl) { + try { + return new URL("favicon.ico", new URL(appUrl, window.location.href)).href; + } catch (e) { + return null; + } +} + +function showInitialsFallback(icon, app) { + icon.innerHTML = ""; + icon.style.background = app.color || "#8e8e8e"; + icon.textContent = app.initials || app.name.slice(0, 2).toUpperCase(); +} + +function tileNode(app) { + const a = document.createElement("a"); + a.className = "tile"; + a.href = app.url; + + const icon = document.createElement("div"); + icon.className = "tile-icon"; + + const iconSrc = app.icon || guessFaviconUrl(app.url); + if (iconSrc) { + const img = document.createElement("img"); + img.src = iconSrc; + img.alt = ""; + img.addEventListener("error", () => showInitialsFallback(icon, app), { once: true }); + icon.appendChild(img); + } else { + showInitialsFallback(icon, app); + } + + const name = document.createElement("div"); + name.className = "tile-name"; + name.textContent = app.name; + + a.appendChild(icon); + a.appendChild(name); + return a; +} + +function groupNode(group, collapsedState) { + const wrapper = document.createElement("section"); + wrapper.className = "group"; + const isCollapsed = collapsedState[group.id] ?? group.collapsed ?? false; + if (isCollapsed) wrapper.classList.add("collapsed"); + + const header = document.createElement("div"); + header.className = "group-header"; + + const toggle = document.createElement("span"); + toggle.className = "group-toggle"; + toggle.textContent = "▾"; + + const title = document.createElement("span"); + title.className = "group-title"; + title.textContent = group.name; + + const count = document.createElement("span"); + count.className = "group-count"; + count.textContent = `(${group.apps.length})`; + + header.appendChild(toggle); + header.appendChild(title); + header.appendChild(count); + + header.addEventListener("click", () => { + const nowCollapsed = wrapper.classList.toggle("collapsed"); + collapsedState[group.id] = nowCollapsed; + saveCollapsedState(collapsedState); + }); + + const grid = document.createElement("div"); + grid.className = "tile-grid"; + group.apps.forEach((app) => grid.appendChild(tileNode(app))); + + wrapper.appendChild(header); + wrapper.appendChild(grid); + return wrapper; +} + +function render(config) { + document.getElementById("user-fullname").textContent = config.user.username; + document.getElementById("user-sub").textContent = config.user.fullname; + document.getElementById("user-domain").textContent = `${config.user.username}@${config.user.domain}`; + + const adminUrl = config.portal.adminUrl || "/yunohost/admin/"; + document.getElementById("admin-btn").href = adminUrl; + document.getElementById("footer-admin").href = adminUrl; + document.getElementById("footer-edit-profile").href = config.portal.editProfileUrl || "#"; + document.getElementById("edit-profile").href = config.portal.editProfileUrl || "#"; + document.getElementById("footer-doc").href = config.portal.documentationUrl || "#"; + document.getElementById("footer-support").href = config.portal.supportUrl || "#"; + + const logoutUrl = config.portal.logoutUrl || "/yunohost/portalapi/logout"; + document.getElementById("logout-btn").addEventListener("click", (e) => { + e.preventDefault(); + fetch(logoutUrl, { credentials: "same-origin" }).finally(() => { + window.location.href = "/yunohost/sso/"; + }); + }); + + const app = document.getElementById("app"); + app.innerHTML = ""; + + const collapsedState = loadCollapsedState(); + + if (config.pinned && config.pinned.length) { + const section = document.createElement("section"); + section.className = "pinned-section"; + const grid = document.createElement("div"); + grid.className = "tile-grid"; + config.pinned.forEach((appItem) => grid.appendChild(tileNode(appItem))); + section.appendChild(grid); + app.appendChild(section); + } + + const groups = [...(config.groups || [])]; + groups.forEach((group) => { + app.appendChild(groupNode(group, collapsedState)); + }); + + if (config.ungrouped && config.ungrouped.length) { + const section = document.createElement("section"); + section.className = "ungrouped-section"; + const grid = document.createElement("div"); + grid.className = "tile-grid"; + config.ungrouped.forEach((appItem) => grid.appendChild(tileNode(appItem))); + section.appendChild(grid); + app.appendChild(section); + } +} + +fetch("config.json") + .then((r) => r.json()) + .then(render) + .catch((err) => { + document.getElementById("app").textContent = + "Erreur de chargement de la configuration (config.json)."; + console.error(err); + }); diff --git a/src/config.example.json b/src/config.example.json new file mode 100644 index 0000000..3ca487c --- /dev/null +++ b/src/config.example.json @@ -0,0 +1,33 @@ +{ + "portal": { + "logoText": "YUNO\nHOST", + "adminUrl": "/yunohost/admin/", + "editProfileUrl": "/yunohost/sso/edit", + "logoutUrl": "/yunohost/portalapi/logout", + "documentationUrl": "https://yunohost.org/docs", + "supportUrl": "https://yunohost.org/help" + }, + "user": { + "fullname": "Nom complet", + "username": "utilisateur", + "domain": "domaine.exemple" + }, + "pinned": [], + "groups": [ + { + "id": "exemple", + "name": "Exemple de groupe", + "collapsed": false, + "apps": [ + { + "id": "monapp", + "name": "Mon App", + "url": "https://monapp.domaine.exemple/", + "color": "#2196f3", + "initials": "MA" + } + ] + } + ], + "ungrouped": [] +} diff --git a/src/editor.css b/src/editor.css new file mode 100644 index 0000000..2319417 --- /dev/null +++ b/src/editor.css @@ -0,0 +1,339 @@ +:root { + --purple: #4c2b8f; + --purple-dark: #3a2070; + --bg: #ffffff; + --panel-bg: #f6f6f6; + --tile-bg: #efefef; + --tile-hover: #e2e2e2; + --text: #1a1a1a; + --muted: #6b6b6b; + --border: #dcdcdc; + --danger: #c0392b; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: var(--text); + background: var(--bg); +} + +.editor-topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + background: var(--bg); + z-index: 10; +} + +.editor-topbar h1 { + font-size: 18px; + margin: 0; +} + +.editor-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.status { + font-size: 13px; + color: var(--muted); + min-width: 90px; + text-align: right; +} + +.status.saved { color: #2f855a; } +.status.error { color: var(--danger); } + +.btn { + display: inline-block; + padding: 8px 16px; + border-radius: 8px; + font-weight: 600; + font-size: 14px; + text-decoration: none; + cursor: pointer; + border: 2px solid transparent; + background: none; + font-family: inherit; +} + +.btn-primary { + background: var(--purple); + color: #fff; +} +.btn-primary:hover { background: var(--purple-dark); } + +.btn-outline { + background: transparent; + color: var(--purple); + border-color: var(--purple); +} + +#editor-root { + max-width: 1100px; + margin: 0 auto; + padding: 24px; +} + +.group-card { + background: var(--panel-bg); + border: 1px solid var(--border); + border-radius: 14px; + margin-bottom: 20px; + padding: 14px 16px 16px; +} + +.group-card.drag-over { + outline: 2px dashed var(--purple); + outline-offset: 4px; +} + +.group-card.dragging { + opacity: 0.4; +} + +.group-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.drag-handle { + cursor: grab; + color: var(--muted); + font-size: 18px; + user-select: none; + padding: 2px 4px; +} + +.group-name-input { + font-size: 16px; + font-weight: 700; + border: 1px solid transparent; + background: transparent; + padding: 4px 8px; + border-radius: 6px; + flex: 1; + min-width: 0; +} + +.group-name-input:hover, +.group-name-input:focus { + border-color: var(--border); + background: #fff; + outline: none; +} + +.group-collapsed-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--muted); + white-space: nowrap; +} + +.icon-btn { + border: none; + background: transparent; + cursor: pointer; + color: var(--muted); + font-size: 16px; + padding: 4px 6px; + border-radius: 6px; + line-height: 1; +} +.icon-btn:hover { + background: var(--tile-hover); + color: var(--danger); +} + +.tile-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 14px; + min-height: 96px; +} + +.tile-grid.drag-over { + background: rgba(76, 43, 143, 0.06); + border-radius: 10px; +} + +.app-tile { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 14px 8px 10px; + border-radius: 12px; + background: var(--tile-bg); + cursor: grab; + text-align: center; +} + +.app-tile.dragging { + opacity: 0.35; +} + +.app-tile .remove-btn { + position: absolute; + top: 2px; + right: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.55); + color: #fff; + font-size: 12px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.app-tile .remove-btn:hover { background: var(--danger); } + +.tile-icon { + width: 56px; + height: 56px; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 18px; + color: #fff; + overflow: hidden; +} + +.tile-icon img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.tile-name { + font-size: 12px; + line-height: 1.25; + word-break: break-word; +} + +.add-tile { + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + border: 2px dashed var(--border); + min-height: 96px; + cursor: pointer; + color: var(--muted); + font-size: 28px; + background: transparent; +} +.add-tile:hover { + border-color: var(--purple); + color: var(--purple); +} + +.ungrouped-section { + margin-top: 32px; + padding-top: 16px; + border-top: 2px solid var(--border); +} + +.ungrouped-section h2 { + font-size: 15px; + color: var(--muted); + margin: 0 0 12px; +} + +.pinned-section { + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 2px solid var(--border); +} + +.pinned-section h2 { + font-size: 15px; + color: var(--muted); + margin: 0 0 12px; +} + +.add-group-btn { + display: block; + width: 100%; + padding: 14px; + border-radius: 12px; + border: 2px dashed var(--border); + background: transparent; + color: var(--muted); + font-size: 14px; + font-weight: 600; + cursor: pointer; +} +.add-group-btn:hover { + border-color: var(--purple); + color: var(--purple); +} + +.app-form-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.app-form { + background: #fff; + border-radius: 14px; + padding: 24px; + width: 100%; + max-width: 380px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.app-form h3 { + margin: 0 0 4px; + font-size: 16px; +} + +.app-form label { + font-size: 13px; + color: var(--muted); + display: flex; + flex-direction: column; + gap: 4px; +} + +.app-form input { + font-family: inherit; + font-size: 14px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border); +} + +.app-form-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} diff --git a/src/editor.html b/src/editor.html new file mode 100644 index 0000000..f35eec9 --- /dev/null +++ b/src/editor.html @@ -0,0 +1,24 @@ + + +
+ + +
+ + +
+