J’ai fourni à Gemini les attributs principaux d’une catégorie (nom, couleur, balises, etc.) et j’ai demandé : « Peux-tu générer un outil en js/html5 pour créer et organiser des catégories avec ces attributs ? Nous devrions également pouvoir les modifier et les réorganiser rapidement ».
L’outil généré était déjà excellent, mais je l’ai affiné en demandant de supprimer/ajouter des attributs et des fonctionnalités, ainsi qu’en ajustant le design.
Probablement. Tu peux envoyer le code à Gemini (en utilisant le mode Canvas) et lui demander de l’ajuster comme tu le souhaites :
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Planificateur de Catégories Discourse</title>
<style>
/* Style de base */
body { font-family: 'Inter', sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; background-color: #f8f9fa; }
h1 { border-bottom: 2px solid #e9ecef; padding-bottom: 10px; margin-bottom: 20px; color: #343a40; }
/* Mise en page */
.main-container { display: flex; gap: 30px; }
@media (max-width: 900px) {
.main-container { flex-direction: column; }
}
.form-section { flex: 1.2; padding: 15px; border-radius: 8px; background: #fff; box-shadow: 0 4px 6px rgba(0,0,0,0.05); }
.list-section { flex: 1.8; padding: 15px; border-radius: 8px; background: #fff; box-shadow: 0 4px 6px rgba(0,0,0,0.05); }
/* Contrôles de formulaire */
.form-row { display: flex; gap: 15px; margin-bottom: 10px; }
.form-group { flex: 1; display: flex; flex-direction: column; }
label { font-weight: 600; margin-bottom: 3px; font-size: 0.9em; color: #495057; }
input[type="text"], select, textarea { padding: 8px; border: 1px solid #ced4da; border-radius: 4px; }
/* Boutons */
.action-button { padding: 10px 15px; border: none; border-radius: 44px; cursor: pointer; background: #007bff; color: white; margin-top: 10px; transition: background 0.2s; }
.action-button:hover { background: #0056b3; }
.utility-buttons { display: flex; gap: 10px; margin-top: 20px; }
.utility-buttons .action-button { background: #6c757d; }
.utility-buttons .action-button:hover { background: #5a6268; }
/* --- Style de l'entrée de couleur --- */
input[type="color"] {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: 1px solid #ced4da;
padding: 0;
height: 38px;
cursor: pointer;
border-radius: 4px;
overflow: hidden;
}
input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 4px;
height: 100%;
display: block;
}
/* --- Fin du style de l'entrée de couleur --- */
/* Styles de la liste de gestion (déplaçable) */
#category-list { list-style: none; padding: 0; margin-top: 15px; }
.category-item {
display: flex;
align-items: center;
padding: 12px;
border: 1px solid #dee2e6;
border-radius: 6px;
margin-bottom: 8px;
cursor: move;
background: #ffffff;
transition: box-shadow 0.2s;
}
.category-item:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.category-item.dragging { opacity: 0.6; border: 2px dashed #007bff; background: #eaf3ff; }
/* Indentation des sous-catégories dans la liste de gestion (colonne du milieu) */
.category-item.is-sub {
margin-left: 30px;
border-left: 3px solid #ced4da;
background-color: #f7f7f7;
}
/* --- STYLES DES INDICES DE GLISSEMENT --- */
.category-item.drag-over { border-bottom: 2px solid #007bff; }
.category-item.indent-hint {
border-bottom: 2px solid #28a745;
box-shadow: 5px 0 0 0 #28a745 inset;
margin-left: 30px !important;
background-color: #eaf3ff;
}
.category-item.outdent-hint {
border-bottom: 2px solid #dc3545;
box-shadow: -5px 0 0 0 #dc3545 inset;
margin-left: 0 !important;
background-color: #ffeaea;
}
/* --- FIN DES STYLES DES INDICES DE GLISSEMENT --- */
.category-actions { flex-shrink: 0; display: flex; gap: 5px; }
/* --- Conteneur d'affichage des catégories (S'applique à la liste de gestion et à l'aperçu) --- */
.category-info, .preview-category {
flex-grow: 1;
display: flex;
align-items: flex-start;
padding: 0;
}
/* Wrapper pour les éléments colorés (Barre verticale + Carré + Texte) */
.category-display-block {
display: flex;
flex-direction: column;
padding-left: 8px;
border-left: 4px solid transparent;
flex-grow: 1;
}
/* Wrapper pour le carré et le nom/texte */
.square-and-text-wrapper {
display: flex;
align-items: flex-start;
}
/* Carré coloré - Bordure arrondie supprimée */
.color-square {
width: 12px;
height: 12px;
border-radius: 0;
margin-right: 8px;
flex-shrink: 0;
border: 1px solid rgba(0,0,0,0.1);
margin-top: 3px;
}
/* Les titres de catégorie utilisent la couleur par défaut (gris foncé/noir) et sont en gras */
.category-name {
font-size: 1em;
font-weight: 700;
color: #343a40;
}
.category-desc { font-size: 0.8em; color: #6c757d; }
/* --- Style des étiquettes (Pills) --- */
.pill-container, .preview-pill-wrapper {
margin-top: 5px;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
/* Autorisations - Style plus subtil */
.management-permission-pill, .preview-permission-pill {
font-size: 0.7em;
font-weight: 600;
background: #e9ecef; /* Arrière-plan plus clair */
color: #495057; /* Texte plus foncé */
padding: 3px 6px;
border-radius: 4px;
text-transform: uppercase;
border: 1px solid #ced4da; /* Ajout d'une bordure subtile pour la définition */
}
.attr-pill, .preview-tag-pill { /* Balises */
font-size: 0.7em;
background: #f8f9fa; /* Arrière-plan très clair pour moins d'insistance */
color: #495057;
padding: 3px 6px;
border-radius: 4px;
display: inline-block;
border: 1px solid #e9ecef; /* Bordure subtile */
}
/* Styles des boutons Modifier/Supprimer */
.edit-btn, .delete-btn {
background: #6c757d;
color: white;
padding: 5px 8px;
font-size: 0.75em;
border-radius: 4px;
border: none;
}
.delete-btn {
background: #dc3545;
}
</style>
</head>
<body>
<h1>📋 Planificateur de Catégories et Hiérarchie Discourse</h1>
<div class="main-container">
<div class="form-section">
<h2>Ajouter/Modifier les attributs de catégorie</h2>
<form id="category-form">
<input type="hidden" id="category-id">
<div class="form-row">
<div class="form-group">
<label for="name">1. Nom</label>
<input type="text" id="name" required oninput="updateLiveFormPreview()">
</div>
<div class="form-group">
<label for="color">2. Couleur</label>
<!-- Entrée de couleur personnalisée -->
<input type="color" id="color" value="#007bff" oninput="updateLiveFormPreview()">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="description">3. Description</label>
<textarea id="description" rows="2" oninput="updateLiveFormPreview()"></textarea>
</div>
<div class="form-group">
<label for="permissions">4. Autorisations (ex : public, staff, groupe)</label>
<input type="text" id="permissions" value="Public" oninput="updateLiveFormPreview()">
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex: 2;">
<label for="tags">5. Balises (séparées par des virgules, ex : "bug, feature")</label>
<input type="text" id="tags" placeholder="Balises optionnelles pour les publications de cette catégorie" oninput="updateLiveFormPreview()">
</div>
</div>
<input type="hidden" id="parentId" value="">
<div class="form-row">
<div class="form-group" style="flex: 2;">
<button type="submit" id="submit-btn" class="action-button">Ajouter une nouvelle catégorie</button>
</div>
</div>
</form>
<div id="live-form-preview" style="padding: 10px; border: 1px dashed #ced4da; border-radius: 6px; background: #fefefe;">
<h3 style="margin-top: 0; font-size: 1em;">Aperçu</h3>
<div class="preview-category" style="padding: 10px; border: none; align-items: flex-start;">
<div class="category-display-block" id="preview-display-block" style="border-left-color: #007bff; padding-left: 8px;">
<div class="square-and-text-wrapper">
<div class="color-square" id="preview-color-bar-field" style="background-color: #007bff;"></div>
<div class="category-name" id="preview-name-field">[Nom de la catégorie]</div>
</div>
<div class="category-desc" id="preview-desc-field" style="color: #6c757d;">[La description va ici]</div>
<div class="preview-pill-wrapper" id="preview-pills-field">
<!-- Les étiquettes seront insérées ici par JS -->
</div>
</div>
</div>
</div>
<div class="utility-buttons">
<button id="export-btn" class="action-button">Exporter (CSV)</button>
<button id="import-btn" class="action-button">Importer (CSV)</button>
<input type="file" id="csv-file-input" accept=".csv" style="display: none;">
</div>
</div>
<div class="list-section">
<h2>Gestion des catégories (Glisser pour réorganiser)</h2>
<p style="font-size: 0.85em; color: #6c757d;">
Glissez un élément pour le réorganiser. Glissez vers la **droite** pour créer une sous-catégorie (indice vert), glissez vers la **gauche** pour la promouvoir (indice rouge).
</p>
<ul id="category-list">
<!-- Les catégories seront rendues ici par JavaScript -->
</ul>
<ul id="live-preview-list" style="display: none;"></ul>
</div>
</div>
<script>
let categories = [];
let dragStartX = 0;
const form = document.getElementById('category-form');
const list = document.getElementById('category-list');
const previewList = document.getElementById('live-preview-list');
const submitBtn = document.getElementById('submit-btn');
const exportBtn = document.getElementById('export-btn');
const importBtn = document.getElementById('import-btn');
const csvFileInput = document.getElementById('csv-file-input');
// --- Gestionnaires d'export/import CSV ---
function arrayToCsv(data) {
const headers = ['id', 'name', 'description', 'color', 'permissions', 'tags', 'parentId'];
let csv = headers.join(',') + '\n';
data.forEach(row => {
const values = headers.map(header => {
let value = row[header] || '';
// Échapper les virgules et les guillemets pour le format CSV
if (typeof value === 'string') {
value = value.replace(/"/g, '""');
if (value.includes(',') || value.includes('\n')) {
value = `"${value}"`;
}
}
return value;
});
csv += values.join(',') + '\n';
});
return csv;
}
function csvToArray(csv) {
const lines = csv.split('\n').filter(line => line.trim() !== '');
if (lines.length < 2) return [];
const headers = lines[0].split(',');
const data = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].match(/(".*?"|[^",\s]+)(?=\s*,|\s*$)/g) || lines[i].split(',');
if (values.length === headers.length) {
let row = {};
for (let j = 0; j < headers.length; j++) {
let value = values[j].trim();
if (value.startsWith('"') && value.endsWith('"')) {
value = value.substring(1, value.length - 1).replace(/""/g, '"');
}
row[headers[j]] = value;
}
data.push(row);
}
}
return data;
}
exportBtn.addEventListener('click', () => {
const csvData = arrayToCsv(categories);
const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
if (link.download !== undefined) {
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', 'discourse_categories.csv');
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
alert('L\'export n\'est pas supporté dans ce navigateur.');
}
});
importBtn.addEventListener('click', () => {
csvFileInput.click();
});
csvFileInput.addEventListener('change', (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const csvContent = e.target.result;
const importedData = csvToArray(csvContent);
if (importedData.length > 0) {
categories = importedData;
renderAll();
alert(`${importedData.length} catégories importées avec succès.`);
} else {
alert('Erreur lors de l\'import du CSV : Les données sont vides ou mal formatées.');
}
};
reader.readAsText(file);
});
// --- Gestion des données principales et rendu ---
const findParent = (parentId) => categories.find(c => c.id === parentId);
// Fonction récursive pour trouver tous les descendants d'un ID de catégorie
function getDescendantIds(parentId) {
let descendants = [];
const children = categories.filter(c => c.parentId === parentId);
children.forEach(child => {
descendants.push(child.id);
descendants = descendants.concat(getDescendantIds(child.id));
});
return descendants;
}
// Fonction pour mettre à jour la boîte d'aperçu simple à côté du formulaire
function updateLiveFormPreview() {
const name = document.getElementById('name').value || '[Nom de la catégorie]';
const description = document.getElementById('description').value || '[La description va ici]';
const color = document.getElementById('color').value;
const permissions = document.getElementById('permissions').value;
const tagsInput = document.getElementById('tags').value;
// Appliquer la couleur au carré et à la bordure gauche
document.getElementById('preview-color-bar-field').style.backgroundColor = color;
document.getElementById('preview-display-block').style.borderLeftColor = color;
document.getElementById('preview-name-field').textContent = name;
document.getElementById('preview-desc-field').textContent = description;
// Générer le HTML des étiquettes
let pillsHtml = `<span class="preview-permission-pill">${permissions}</span>`;
if (tagsInput) {
const tagsArray = tagsInput.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0);
tagsArray.forEach(tag => {
pillsHtml += `<span class="preview-tag-pill">${tag}</span>`;
});
}
document.getElementById('preview-pills-field').innerHTML = pillsHtml;
}
// Fonction pour rendre la liste de gestion
function renderAll() {
list.innerHTML = '';
previewList.innerHTML = '';
let finalOrder = [];
const parentCategories = categories.filter(c => !c.parentId);
const subCategories = categories.filter(c => c.parentId);
parentCategories.sort((a, b) => categories.indexOf(a) - categories.indexOf(b)).forEach(parent => {
finalOrder.push(parent);
const children = subCategories.filter(c => c.parentId === parent.id);
children.sort((a, b) => categories.indexOf(a) - categories.indexOf(b)).forEach(child => {
finalOrder.push(child);
});
});
subCategories.filter(c => !findParent(c.parentId)).forEach(orphan => {
finalOrder.push(orphan);
});
finalOrder.forEach(cat => {
const isSub = !!cat.parentId;
// --- 1. Rendre l'élément de la liste de gestion (déplaçable) ---
const managementItem = document.createElement('li');
managementItem.className = 'category-item';
if (isSub) {
managementItem.classList.add('is-sub');
}
managementItem.setAttribute('data-id', cat.id);
managementItem.setAttribute('draggable', true);
// Construire le HTML des balises
const tagsHtml = cat.tags ? cat.tags.split(',').map(tag => `<span class="attr-pill">${tag.trim()}</span>`).join('') : '';
managementItem.innerHTML = `
<div class="category-info">
<div class="category-display-block" style="border-left-color: ${cat.color};">
<div class="square-and-text-wrapper">
<span class="color-square" style="background-color: ${cat.color};"></span>
<div class="category-name">${cat.name} ${isSub ? '— Sous-catégorie' : ''}</div>
</div>
<div class="category-desc">${cat.description || ''}</div>
<div class="pill-container">
<span class="management-permission-pill">${cat.permissions}</span>
${tagsHtml}
</div>
</div>
</div>
<div class="category-actions">
<button class="edit-btn" onclick="editCategory('${cat.id}')">Modifier</button>
<button class="delete-btn" onclick="deleteCategory('${cat.id}')">Supprimer</button>
</div>
`;
list.appendChild(managementItem);
});
addDragDropListeners();
}
// Fonction pour gérer la soumission du formulaire (Ajouter ou Mettre à jour)
form.addEventListener('submit', (e) => {
e.preventDefault();
const idField = document.getElementById('category-id');
const id = idField.value;
const newCategory = {
id: id || 'cat_' + Date.now(),
name: document.getElementById('name').value,
description: document.getElementById('description').value,
color: document.getElementById('color').value,
permissions: document.getElementById('permissions').value,
tags: document.getElementById('tags').value.trim(),
parentId: '',
};
if (id) {
const index = categories.findIndex(c => c.id === id);
if (index !== -1) {
newCategory.parentId = categories[index].parentId;
categories[index] = newCategory;
}
} else {
categories.push(newCategory);
}
form.reset();
idField.value = '';
submitBtn.textContent = 'Ajouter une nouvelle catégorie';
updateLiveFormPreview();
renderAll();
});
// Fonction pour remplir le formulaire pour la modification
function editCategory(id) {
const cat = categories.find(c => c.id === id);
if (!cat) return;
document.getElementById('category-id').value = cat.id;
document.getElementById('name').value = cat.name;
document.getElementById('description').value = cat.description;
document.getElementById('color').value = cat.color;
document.getElementById('permissions').value = cat.permissions;
document.getElementById('tags').value = cat.tags || '';
submitBtn.textContent = 'Mettre à jour la catégorie';
updateLiveFormPreview();
window.scrollTo(0, 0);
}
// Fonction pour supprimer une catégorie
function deleteCategory(id) {
// Appel confirm() supprimé pour permettre l\'exécution dans un environnement sandboxé
categories = categories.filter(c => c.id !== id && c.parentId !== id);
renderAll();
}
// --- Logique de glisser-déposer pour le réarrangement et l\'imbrication ---
let draggedItem = null;
function removeAllDragHints() {
document.querySelectorAll('.category-item').forEach(li => {
li.classList.remove('indent-hint', 'outdent-hint', 'drag-over');
const cat = categories.find(c => c.id === li.dataset.id);
if (cat) {
li.style.marginLeft = cat.parentId ? '30px' : '0';
}
});
}
function addDragDropListeners() {
document.querySelectorAll('.category-item').forEach(item => {
item.addEventListener('dragstart', (e) => {
draggedItem = item;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', item.dataset.id);
dragStartX = e.clientX; // Capturer la position X de départ
setTimeout(() => item.classList.add('dragging'), 0);
});
item.addEventListener('dragend', () => {
draggedItem.classList.remove('dragging');
draggedItem = null;
removeAllDragHints();
});
item.addEventListener('dragleave', (e) => {
e.currentTarget.classList.remove('indent-hint', 'outdent-hint', 'drag-over');
});
item.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const dropTargetElement = e.currentTarget;
const dropTargetId = dropTargetElement.dataset.id;
const dropTargetCat = categories.find(c => c.id === dropTargetId);
const draggedCat = categories.find(c => c.id === draggedItem.dataset.id);
if (draggedItem === dropTargetElement) return;
removeAllDragHints();
dropTargetElement.classList.add('drag-over');
const INDENT_THRESHOLD_PX = 40;
const OUTDENT_SHIFT_PX = 50;
const currentX = e.clientX;
const horizontalShift = currentX - dragStartX;
// --- LOGIQUE D'APPLICATION DES INDICES ---
// 1. Vérifier l'INDENTATION (Glisser à droite)
if (horizontalShift > INDENT_THRESHOLD_PX) {
// Vérifier si la cible peut accepter des enfants (c\'est-à-dire qu\'elle n\'est pas déjà un enfant)
if (!dropTargetCat.parentId) {
dropTargetElement.classList.add('indent-hint');
dropTargetElement.classList.remove('drag-over');
}
}
// 2. Vérifier la DÉ-INDENTATION (Glisser à gauche)
else if (draggedCat.parentId && horizontalShift < -OUTDENT_SHIFT_PX) {
dropTargetElement.classList.add('outdent-hint');
dropTargetElement.classList.remove('drag-over');
}
// 3. Zone intermédiaire : Aucun indice signifie que seul le réarrangement vertical se produit lors du dépôt.
});
item.addEventListener('drop', (e) => {
e.preventDefault();
if (draggedItem !== e.currentTarget) {
const dropTargetElement = e.currentTarget;
const dropTargetId = dropTargetElement.dataset.id;
const draggedId = draggedItem.dataset.id;
let draggedIndex = categories.findIndex(c => c.id === draggedId);
let dropIndex = categories.findIndex(c => c.id === dropTargetId);
// 1. Ré-enchaîner le tableau des catégories (Ordre linéaire)
if (draggedIndex !== -1 && dropIndex !== -1) {
const [temp] = categories.splice(draggedIndex, 1);
categories.splice(dropIndex, 0, temp);
}
// Re-trouver les indices après ré-enchaînement
const newDraggedIndex = categories.findIndex(c => c.id === draggedId);
const draggedCat = categories[newDraggedIndex];
// 2. Déterminer le nouvel ID parent basé sur l\'indice actif
let newParentId = draggedCat.parentId; // Par défaut : Garder le statut hiérarchique actuel
let updateDescendants = false;
if (dropTargetElement.classList.contains('indent-hint')) {
// Promotion en enfant : Le nouveau parent de la catégorie glissée est la cible de dépôt.
newParentId = dropTargetId;
updateDescendants = true;
} else if (dropTargetElement.classList.contains('outdent-hint')) {
// Promotion en parent : Le nouveau parent de la catégorie glissée est null/vide.
newParentId = '';
updateDescendants = true;
}
draggedCat.parentId = newParentId;
// 3. Mettre à jour les descendants si la hiérarchie a changé pour le parent glissé
if (updateDescendants) {
const descendantIds = getDescendantIds(draggedId);
descendantIds.forEach(descendantId => {
const descendantCat = categories.find(c => c.id === descendantId);
if (descendantCat) {
// Les descendants suivent le nouveau statut du parent glissé
descendantCat.parentId = newParentId;
}
});
}
removeAllDragHints();
renderAll();
}
});
});
}
// --- Chargement initial (Catégories mises à jour) ---
categories.push({
id: 'cat_001', name: 'Général', description: 'Discussion communautaire générale et aide.', color: '#007bff', permissions: 'Public', parentId: '', tags: 'tag 1, tag 2'
});
categories.push({
id: 'cat_004', name: 'Exemple de sous-catégorie', description: '', color: '#007bff', permissions: 'Public', parentId: 'cat_001', tags: 'tag 3'
});
categories.push({
id: 'cat_002', name: 'Staff', description: 'Espace privé pour la modération et l\'administration.', color: '#ffc107', permissions: 'Staff uniquement', parentId: '', tags: ''
});
categories.push({
id: 'cat_003', name: 'Retours sur le site', description: 'Discuter du forum lui-même et suggérer des améliorations.', color: '#28a745', permissions: 'Public', parentId: '', tags: 'site, retours'
});
renderAll();
updateLiveFormPreview();
</script>
</body>
</html>