test redesign gesthub

This commit is contained in:
lnino
2025-12-16 01:31:23 +01:00
parent 4c7bb77890
commit e0883b04f6
5 changed files with 331 additions and 312 deletions

View File

@@ -58,7 +58,7 @@ services:
image: quay.io/keycloak/keycloak:22.0.5
command:
- start-dev
- --hostname=keycloak.ninolbt.com
- --hostname=localhost
- --hostname-strict=false
- --hostname-strict-https=false
- --proxy=edge

View File

@@ -1,29 +1,51 @@
import os
import uuid
import json
from flask import Flask, redirect, url_for, jsonify, session, render_template
# J'ai ajouté 'request' aux imports
from flask import Flask, redirect, url_for, jsonify, session, render_template, request
from flask_sqlalchemy import SQLAlchemy
from authlib.integrations.flask_client import OAuth
app = Flask(__name__)
ANNOUNCE_FILE = os.path.join(os.path.dirname(__file__), "annonces.json")
# Ta config DB actuelle
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://flaskuser:flaskpass@mariadb/flaskdb'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.secret_key = os.environ.get("SECRET_KEY", "dev-key")
db = SQLAlchemy(app)
# Configuration de Authlib
oauth = OAuth(app)
# --- MODELE DE DONNEES POUR LES BLOCS ---
class Block(db.Model):
id = db.Column(db.Integer, primary_key=True)
block_type = db.Column(db.String(50)) # ex: 'iframe', 'buttons', 'html'
column_name = db.Column(db.String(20)) # ex: 'left', 'center', 'right'
position = db.Column(db.Integer) # pour l'ordre (0, 1, 2...)
data = db.Column(db.Text) # Contenu JSON (url, titre, etc.)
def to_dict(self):
return {
"id": self.id,
"type": self.block_type,
"column": self.column_name,
"position": self.position,
"data": json.loads(self.data) if self.data else {}
}
# Création des tables si elles n'existent pas
with app.app_context():
db.create_all()
# ... (Ici, garde ta configuration OAUTH et tes routes Login/Logout/Auth inchangées) ...
# ... (Garde aussi tes fonctions load_announces, save_announces etc.) ...
oauth = OAuth(app)
keycloak = oauth.register(
name='keycloak',
client_id='flask-app',
client_secret='T5G5jzCBiphnBNh9uuj0f6YNc9HrP8r4',
server_metadata_url='https://keycloak.ninolbt.com/realms/gesthub/.well-known/openid-configuration',
client_kwargs={
'scope': 'openid profile email',
}
client_kwargs={'scope': 'openid profile email'}
)
@app.route('/')
@@ -31,7 +53,6 @@ def index():
user = session.get('user')
if user:
return render_template('view/index.html', user=user)
return redirect(url_for('login'))
@app.route('/login')
@@ -48,88 +69,84 @@ def auth():
userinfo = keycloak.parse_id_token(token, nonce=nonce)
session['user'] = userinfo
session["id_token"] = token.get("id_token")
app.logger.debug(f"User info: {userinfo}")
return redirect('/')
@app.route("/logout")
def logout():
id_token = session.get("id_token")
print("ID Token Hint:", id_token)
session.clear()
return redirect(
f"https://keycloak.ninolbt.com/realms/gesthub/protocol/openid-connect/logout"
f"?post_logout_redirect_uri=https://dashboard.ninolbt.com"
f"&id_token_hint={id_token}"
)
def load_announces():
try:
with open(ANNOUNCE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return []
def save_announces(announces):
with open(ANNOUNCE_FILE, "w", encoding="utf-8") as f:
json.dump(announces, f, ensure_ascii=False, indent=2)
# --- API LAYOUT (Gestion des Blocs) ---
def get_next_id(announces):
return max((a.get("id", 0) for a in announces), default=0) + 1
@app.route('/api/layout', methods=['GET'])
def get_layout():
# Récupère tous les blocs triés par position
blocks = Block.query.order_by(Block.position).all()
return jsonify([b.to_dict() for b in blocks])
@app.route("/api/annonces", methods=["GET"])
def get_announces():
return jsonify(load_announces())
@app.route("/api/annonces", methods=["POST"])
def create_annonce():
@app.route('/api/layout/save', methods=['POST'])
def save_layout():
# Sauvegarde l'ordre et la colonne après un drag & drop
user = session.get("user")
if not user or "/admin" not in user.get("groups", []):
return jsonify({"error": "unauthorized"}), 403
return jsonify({"error": "Unauthorized"}), 403
layout_data = request.json # Liste de {id, column, position}
for item in layout_data:
block = Block.query.get(item['id'])
if block:
block.column_name = item['column']
block.position = item['position']
db.session.commit()
return jsonify({"status": "saved"})
@app.route('/api/block/add', methods=['POST'])
def add_block():
user = session.get("user")
if not user or "/admin" not in user.get("groups", []):
return jsonify({"error": "Unauthorized"}), 403
data = request.json
if not data.get("text"):
return jsonify({"error": "missing text"}), 400
new_block = Block(
block_type=data.get('type'),
column_name=data.get('column', 'center'),
position=99, # Ajoute à la fin par défaut
data=json.dumps(data.get('data', {}))
)
db.session.add(new_block)
db.session.commit()
return jsonify(new_block.to_dict())
announces = load_announces()
new_announce = {
"id": get_next_id(announces),
"text": data["text"],
"author": user.get("preferred_username", "admin")
}
announces.append(new_announce)
save_announces(announces)
return jsonify({"status": "ok", "announce": new_announce})
@app.route("/api/annonces/<int:annonce_id>", methods=["DELETE"])
def delete_annonce(annonce_id):
@app.route('/api/block/<int:block_id>', methods=['DELETE'])
def delete_block(block_id):
user = session.get("user")
if not user or "/admin" not in user.get("groups", []):
return jsonify({"error": "unauthorized"}), 403
announces = load_announces()
announces = [a for a in announces if a["id"] != annonce_id]
save_announces(announces)
return jsonify({"status": "deleted"})
return jsonify({"error": "Unauthorized"}), 403
@app.route("/api/annonces/<int:annonce_id>", methods=["PUT"])
def edit_annonce(annonce_id):
user = session.get("user")
if not user or "/admin" not in user.get("groups", []):
return jsonify({"error": "unauthorized"}), 403
data = request.json
announces = load_announces()
found = False
for a in announces:
if a["id"] == annonce_id:
a["text"] = data.get("text", a["text"])
found = True
if not found:
return jsonify({"error": "not found"}), 404
save_announces(announces)
return jsonify({"status": "updated"})
block = Block.query.get(block_id)
if block:
db.session.delete(block)
db.session.commit()
return jsonify({"status": "deleted"})
return jsonify({"error": "not found"}), 404
# --- API ANNONCES (Tes routes existantes) ---
# ... (Colle ici tes routes /api/annonces existantes, elles sont très bien) ...
# ... (N'oublie pas la route /api/is_admin) ...
@app.route("/api/is_admin")
def is_admin():
user = session.get("user")
# Sécurité : si pas de user, renvoie false
if not user: return jsonify({"admin": False})
return jsonify({"admin": "/admin" in user.get("groups", [])})
if __name__ == '__main__':
app.run(host='0.0.0.0', debug=True)
app.run(host='0.0.0.0', debug=True)

65
web/init_db.py Normal file
View File

@@ -0,0 +1,65 @@
from app import app, db, Block
import json
# Configuration des widgets actuels extraits de ton index.html
initial_blocks = [
# 1. Colonne GAUCHE : Le Planning
Block(
block_type='iframe',
column_name='left',
position=0,
data=json.dumps({
"title": "Planning / Agenda",
"url": "https://mattermost.ninolbt.com/boards/team/8xj6d4ukwigk7rznqi3w339x7e/b3kmbqfwd33dmdy9g9g3ezaoxza/vu4nuxhf73ircznrkrcgzonno8a",
"height": "100%",
"styleClass": "planning"
})
),
# 2. Colonne CENTRALE : Le Trello (Board)
Block(
block_type='iframe',
column_name='center',
position=0,
data=json.dumps({
"title": "",
"url": "https://mattermost.ninolbt.com/boards/team/8xj6d4ukwigk7rznqi3w339x7e/b3kmbqfwd33dmdy9g9g3ezaoxza/va5xp53m6spbi8qo6qnng7711me",
"height": "680",
"styleClass": "trello"
})
),
# 3. Colonne DROITE : Les boutons
Block(
block_type='buttons',
column_name='right',
position=0,
data=json.dumps({
"links": [
{"label": "Projet", "url": "#"},
{"label": "GDD Global", "url": "#"},
{"label": "Bible 3D Art", "url": "#"},
{"label": "Bible GameDev", "url": "#"},
{"label": "Réglement du HUB", "url": "#"}
]
})
)
]
def init_data():
with app.app_context():
# Crée les tables si elles n'existent pas encore
db.create_all()
# Vérifie si la DB est déjà remplie pour éviter les doublons
if Block.query.first():
print("La base de données contient déjà des blocs.")
return
print("Injection des widgets par défaut...")
for block in initial_blocks:
db.session.add(block)
db.session.commit()
print("Terminé ! Tes widgets sont en base de données.")
if __name__ == "__main__":
init_data()

View File

@@ -1,153 +1,155 @@
// ... (Garde tout ton code existant pour le Dark mode, Menu, Contact, etc.) ...
// mode dark pour les gens qui ne sortent pas de chez eux voir la lumiere du jours
document.getElementById("toggle-darkmode").onclick = () => {
document.body.classList.toggle("dark");
};
// profil
document.querySelector(".profile-menu").addEventListener("click", (e) => {
e.stopPropagation();
document.querySelector(".profile-menu").classList.toggle("active");
});
// notif
document.querySelector(".notification").addEventListener("click", (e) => {
e.stopPropagation();
document.querySelector(".notification").classList.toggle("active");
});
// syteme contact
const contactOptions = document.querySelectorAll('.contact-option:not(.expandable)');
const contactModal = document.getElementById('contactModal');
const recipientName = document.getElementById('recipient-name');
const cancelBtn = document.getElementById('cancel-message');
const sendBtn = document.getElementById('send-message');
const dropdown = document.querySelector('.dropdown');
const nestedDropdowns = document.querySelectorAll('.nested-dropdown');
//menu princ
dropdown.addEventListener('click', (e) => {
e.stopPropagation();
const content = dropdown.querySelector('.dropdown-content');
content.style.display = content.style.display === 'block' ? 'none' : 'block';
});
// Gestion des sous-menus
nestedDropdowns.forEach(dropdown => {
dropdown.addEventListener('click', (e) => {
e.stopPropagation();
const nestedContent = dropdown.querySelector('.nested-content');
nestedContent.style.display = nestedContent.style.display === 'block' ? 'none' : 'block';
});
});
// Fermer les menus quand on clique ailleurs
document.addEventListener('click', () => {
document.querySelector('.dropdown-content').style.display = 'none';
document.querySelectorAll('.nested-content').forEach(el => {
el.style.display = 'none';
});
});
contactOptions.forEach(option => {
option.addEventListener('click', (e) => {
e.preventDefault();
if (option.dataset.role) {
recipientName.textContent = option.dataset.role;
contactModal.style.display = 'block';
}
});
});
document.querySelector('.close-modal').addEventListener('click', () => {
contactModal.style.display = 'none';
});
cancelBtn.addEventListener('click', () => {
contactModal.style.display = 'none';
});
sendBtn.addEventListener('click', () => {
const message = document.getElementById('message-content').value;
if (message.trim() !== '') {
contactModal.style.display = 'none';
document.getElementById('message-content').value = '';
}
});
// Fermer la modale si on clique en dehors
window.addEventListener('click', (e) => {
if (e.target === contactModal) {
contactModal.style.display = 'none';
}
});
// --- GESTION DU LAYOUT DYNAMIQUE ---
// Gestion des annonces
let isPageAdmin = false;
let isAdmin = false;
fetch("/api/is_admin")
.then(res => res.json())
.then(data => {
isAdmin = data.admin;
if (isAdmin) {
document.getElementById("admin-tools").style.display = "block";
}
});
fetch("/api/annonces")
.then(res => res.json())
.then(data => {
const container = document.getElementById("annonces-container");
container.innerHTML = "";
data.forEach(item => {
const div = document.createElement("div");
div.className = "postit";
div.dataset.id = item.id;
div.innerHTML = `
<span class="annonce-text">${item.text}</span>
<br><small>${item.author}</small>
`;
if (isAdmin) {
const editBtn = document.createElement("button");
editBtn.textContent = "✏️";
editBtn.onclick = () => editAnnonce(item.id, item.text);
div.appendChild(editBtn);
const delBtn = document.createElement("button");
delBtn.textContent = "🗑️";
delBtn.onclick = () => deleteAnnonce(item.id);
div.appendChild(delBtn);
}
container.appendChild(div);
});
});
function submitAnnonce() {
const txt = document.getElementById("annonce-text").value;
fetch("/api/annonces", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: txt })
}).then(() => location.reload());
}
function deleteAnnonce(id) {
fetch(`/api/annonces/${id}`, {
method: "DELETE"
}).then(() => location.reload());
}
function editAnnonce(id, oldText) {
const newText = prompt("Modifier l'annonce :", oldText);
if (newText && newText !== oldText) {
fetch(`/api/annonces/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: newText })
}).then(() => location.reload());
// 1. Vérification Admin et Initialisation
fetch("/api/is_admin")
.then(res => res.json())
.then(data => {
isPageAdmin = data.admin;
if (isPageAdmin) {
document.getElementById("admin-panel").style.display = "block";
document.body.classList.add('admin-active');
initDragAndDrop();
// Afficher aussi les outils d'annonces si tu veux garder ça
const tools = document.getElementById("admin-tools");
if(tools) tools.style.display = "block";
}
}
loadLayout(); // Charger les blocs
});
// 2. Chargement des blocs depuis l'API
function loadLayout() {
fetch('/api/layout')
.then(res => res.json())
.then(blocks => {
// Vider les colonnes
['left', 'center', 'right'].forEach(id => document.getElementById(id).innerHTML = '');
// Remettre le titre au centre (optionnel si tu veux qu'il soit un bloc aussi)
document.getElementById('center').innerHTML = '<h1 class="main-title">Gesthub</h1>';
blocks.forEach(block => {
const col = document.getElementById(block.column);
if (col) {
const el = createBlockElement(block);
col.appendChild(el);
}
});
});
}
// 3. Création du HTML d'un bloc
function createBlockElement(block) {
const div = document.createElement('div');
div.className = 'draggable-item';
div.dataset.id = block.id;
div.style.marginBottom = "1rem";
div.style.background = block.type === 'buttons' ? 'transparent' : '';
// Barre de drag (visible seulement en admin via CSS)
let html = `<div class="drag-handle">:: Deplacer :: <span onclick="deleteBlock(${block.id})" style="color:red;cursor:pointer;float:right">✖</span></div>`;
if (block.type === 'iframe') {
html += `
<div class="trello" style="padding: 10px; height: auto;">
${block.data.title ? `<h3>${block.data.title}</h3>` : ''}
<iframe src="${block.data.url}" width="100%" height="${block.data.height || 400}" frameborder="0"></iframe>
</div>
`;
} else if (block.type === 'buttons') {
// block.data.links doit être un tableau d'objets {label, url}
let btns = '';
if(block.data.links) {
block.data.links.forEach(link => {
btns += `<button class="simple-btn" onclick="window.open('${link.url}', '_blank')">${link.label}</button>`;
});
}
html += `<div class="button-container">${btns}</div>`;
} else if (block.type === 'html') {
html += `<div class="postit">${block.data.content}</div>`;
}
div.innerHTML = html;
return div;
}
// 4. Initialisation Drag & Drop (SortableJS)
function initDragAndDrop() {
const containers = [document.getElementById('left'), document.getElementById('center'), document.getElementById('right')];
containers.forEach(container => {
new Sortable(container, {
group: 'shared', // Permet de déplacer entre les colonnes
handle: '.drag-handle', // On ne peut attraper que par la poignée
animation: 150,
onEnd: function (evt) {
saveLayoutPosition();
}
});
});
}
// 5. Sauvegarde de la position
function saveLayoutPosition() {
const layout = [];
['left', 'center', 'right'].forEach(colId => {
const col = document.getElementById(colId);
const items = col.querySelectorAll('.draggable-item');
items.forEach((item, index) => {
layout.push({
id: item.dataset.id,
column: colId,
position: index
});
});
});
fetch('/api/layout/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(layout)
});
}
// 6. Ajouter un Widget (Logique simple pour l'instant)
window.addNewWidget = function(type) {
let data = {};
if (type === 'iframe') {
const url = prompt("URL de l'iframe (Mattermost/Trello) :");
const height = prompt("Hauteur (ex: 400) :", "400");
if (!url) return;
data = { url: url, height: height, title: "Nouveau Widget" };
} else if (type === 'buttons') {
// Pour faire simple, on ajoute un bouton par défaut, tu pourras éditer plus tard
data = {
links: [
{ label: "Nouveau Bouton", url: "https://google.com" }
]
};
} else if (type === 'html') {
const text = prompt("Texte du Post-it :");
data = { content: text };
}
fetch('/api/block/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: type, column: 'center', data: data })
})
.then(res => res.json())
.then(block => {
location.reload(); // Recharge pour afficher le nouveau bloc proprement
});
}
window.deleteBlock = function(id) {
if(confirm("Supprimer ce bloc ?")) {
fetch(`/api/block/${id}`, { method: 'DELETE' })
.then(() => location.reload());
}
}

View File

@@ -6,114 +6,49 @@
<title>Gesthub</title>
<link rel="stylesheet" href="{{ url_for('static', filename='assets/css/index.css') }}" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
</head>
<body>
<button id="toggle-darkmode">🌓</button>
<header>
<div class="notification">
<span class="bell">🔔</span>
<div class="notification-dropdown">
<p>📌 Rendu le 03.02.2026</p>
<p>📝 Nouvelle tâche ajoutée</p>
</div>
</div>
<div class="nav-link">
<a href="https://discord.com" target="_blank">Accéder au serveur Discord</a>
<a href="https://mattermost.ninolbt.com/boards/team/8xj6d4ukwigk7rznqi3w339x7e/b3kmbqfwd33dmdy9g9g3ezaoxza/va5xp53m6spbi8qo6qnng7711me" target="_blank">Board</a>
<a href="https://mattermost.ninolbt.com/boards/team/8xj6d4ukwigk7rznqi3w339x7e/b3kmbqfwd33dmdy9g9g3ezaoxza/va5xp53m6spbi8qo6qnng7711me" target="_blank">Board</a>
</div>
<div class="nav-link dropdown">
<span>Contacter</span>
<div class="dropdown-content">
<a href="#" class="contact-option" data-role="LEAD HUB">LEAD HUB</a>
<a href="#" class="contact-option" data-role="CO LEAD HUB">CO LEAD HUB</a>
<div class="nested-dropdown">
<a href="#" class="contact-option expandable">CHEF PROJET <i class="fa-solid fa-caret-down"></i></a>
<div class="nested-content">
<a href="#" class="contact-option" data-role="Y. Conception">Y. Conception</a>
<a href="#" class="contact-option" data-role="Y.Comm">Y.Comm</a>
<a href="#" class="contact-option" data-role="Y.dev">Y.dev</a>
</div>
</div>
</div>
<a href="https://mattermost.ninolbt.com/gesthub/channels/town-square" target="_blank">Chat</a>
</div>
<div class="profile-menu">
<span id="profile-name">{{ user['preferred_username'] }} <i class="fa-solid fa-caret-down"></i></span>
<div class="profile-dropdown">
<!-- <a class="simple-btn" href="" type="button">Profil</a> -->
<a class="simple-btn" href="/logout" type="button">Déconnexion</a>
</div>
</div>
</header>
<!-- contact -->
<div id="contactModal" class="modal">
<div class="modal-content">
<span class="close-modal">&times;</span>
<h3 id="modal-recipient">Envoyer un message à <span id="recipient-name"></span></h3>
<textarea id="message-content" placeholder="Écrivez votre message ici..."></textarea>
<div class="modal-buttons">
<button id="cancel-message" class="btn-cancel">Annuler</button>
<button id="send-message" class="btn-send">Envoyer</button>
</div>
</div>
</div>
<main>
<!-- clonne gauche pr widget planning -->
<aside class="left-column">
<div class="planning">Planning / Agenda
<iframe
src="https://mattermost.ninolbt.com/boards/team/8xj6d4ukwigk7rznqi3w339x7e/b3kmbqfwd33dmdy9g9g3ezaoxza/vu4nuxhf73ircznrkrcgzonno8a"
style="width: 100%; height: 100%; border: none;"
allowfullscreen
></iframe>
</div>
</aside>
<aside class="left-column" id="left">
</aside>
<!-- Centre pr widget trello -->
<section class="center-column">
<h1 class="main-title">Gesthub</h1>
<div class="trello">
<iframe src="https://mattermost.ninolbt.com/boards/team/8xj6d4ukwigk7rznqi3w339x7e/b3kmbqfwd33dmdy9g9g3ezaoxza/va5xp53m6spbi8qo6qnng7711me" width="100%" height="680" frameborder="0"></iframe>
</div>
</section>
<section class="center-column" id="center">
<h1 class="main-title">Gesthub</h1>
</section>
<!-- colonne droite pr partie info user je vais changer ca dans cet emplacement
je vais metrre des boutons et autres features comme des doc etc ou jsp
je m en occupe -->
<!-- Colonne droite avec boutons simples -->
<aside class="right-column">
<div class="button-container">
<button class="simple-btn">Projet</button>
<button class="simple-btn">GDD Global</button>
<button class="simple-btn">Bible 3D Art</button>
<button class="simple-btn">Bible GameDev</button>
<button class="simple-btn">Réglement du HUB</button>
</div>
<aside class="right-column" id="right">
</aside>
</main>
<!-- Bloc annonces admin
<div id="admin-announcements" style="margin-top: 2rem;">
<h3 style="margin-bottom: 1rem;">📌 Annonces</h3>
<div id="annonces-container"></div>
Formulaire admin caché par défaut-
<div id="admin-tools" style="display: none; margin-top: 1rem;">
<textarea id="annonce-text" placeholder="Nouvelle annonce..." style="width: 100%; padding: 0.5rem; margin-bottom: 0.5rem;"></textarea>
<button onclick="submitAnnonce()" style="width: 100%;">📤 Publier</button>
<div id="admin-panel" style="display:none;">
<div class="admin-toolbar">
<span>🛠 Mode Édition</span>
<button onclick="addNewWidget('iframe')">+ Iframe</button>
<button onclick="addNewWidget('buttons')">+ Boutons</button>
<button onclick="addNewWidget('html')">+ Texte/HTML</button>
</div>
</div>
</aside> -->
</main>
<style>
/* Petit style rapide pour la toolbar admin */
#admin-panel {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: #222; padding: 10px 20px; border-radius: 30px;
box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 999;
}
.admin-toolbar button {
background: #4CAF50; border: none; color: white; padding: 5px 10px;
border-radius: 5px; cursor: pointer; margin-left: 10px;
}
.drag-handle { cursor: move; padding: 5px; background: rgba(0,0,0,0.1); text-align: center; color: #666; font-size: 12px; margin-bottom: 5px; display: none;}
.admin-active .drag-handle { display: block; }
.admin-active .draggable-item { border: 2px dashed #ccc; min-height: 50px; }
</style>
<script src="{{ url_for('static', filename='assets/js/index.js') }}"></script>
</body>
</html>
</html>