Création de catégories plus conviviale

Quelqu’un a fait remarquer que la création de catégories et d’étiquettes semble fastidieuse, et que cette friction pourrait décourager les nouveaux administrateurs d’adopter Discourse.

J’ai demandé à Gemini de générer un planificateur de catégories. Le résultat est plutôt joli. Je ne pense pas qu’il doive ou puisse être ajouté à Discourse tel quel, pour de nombreuses (bonnes) raisons, mais il reste intéressant à examiner. Cela pourrait susciter des idées pour l’avenir de Discourse.

Contexte : https://meta.discourse.org/t/how-to-add-multiple-tags-up-front/390796?u=canapin

Déplacer une catégorie en dehors d’un parent est un peu bizarre. Il faut la faire glisser sur le parent, puis vers la gauche. Pas intuitif, mais encore une fois, c’est plus un concept d’interface qu’une solution réelle.

chrome_Zl3VkfeCTI

L’interface d’administration de Discourse peut sembler austère au premier abord. Le logiciel est complexe, plein de paramètres, et est devenu encore plus complexe au fil des ans. Une refonte de certaines parties de l’administration en les rendant plus visuelles, interactives et ludiques, en particulier les zones que les gens rencontrent immédiatement lorsqu’ils essaient de configurer les options de base de leur communauté, pourrait être utile.

Je me demande combien de personnes se détournent de Discourse après avoir été intimidées par l’interface actuelle. :thinking:


J’ai décidé de créer ce sujet suite à cette réaction à la création de Gemini :

14 « J'aime »

Nous prévoyons actuellement d’améliorer la création de catégories, y compris toutes les diverses fonctionnalités que les catégories peuvent englober (vote, solutions, etc.) — tous vos commentaires sont très pertinents !

14 « J'aime »

Wow, c’est incroyable, je l’utilise maintenant et c’est vraiment le genre de chose à laquelle je pensais. Juste une question à ce stade, pour les permissions, est-ce que « group » signifie tous les membres ? Si je veux donner des permissions à un sous-groupe (par exemple, « vets »), est-ce que j’écris juste « vets » et c’est tout ?

Ah, aussi : est-ce que le fichier .csv est importable dans Discourse à ce stade ou non ? (ce n’est pas grave si ce n’est pas le cas, c’est déjà merveilleux d’avoir une interface où je peux jouer avec ma structure)

4 « J'aime »

Par défaut, c’est tout le monde à moins que vous ne spécifiiez des groupes. Lorsque vous cliquez sur Ajouter un groupe, il devrait détecter vos groupes existants.

Je pensais qu’il y avait un moyen d’ajouter en masse via csv, mais je ne le vois pas. Vous seriez surpris du nombre que vous pouvez copier/coller directement dans le champ à partir d’un csv.

1 « J'aime »

Je pense que nous avons une confusion, je pose des questions sur l’interface utilisateur que @Canapin vient de construire… pour le moment, je n’ai aucun groupe ou quoi que ce soit configuré dans Discourse, j’essaie juste de concevoir la structure !

1 « J'aime »

Cet outil est né de ma question à Gemini : « Comment imagineriez-vous une interface plus conviviale pour gérer les catégories dans Discourse ? »

C’est vraiment juste une maquette interactive pour s’amuser :slightly_smiling_face:.
Les boutons d’importation/exportation n’ont pas été testés, et les exportations ne peuvent certainement pas être importées dans Discourse.

À propos des groupes : dans Discourse, il existe trois autorisations (voir, répondre et créer) qui peuvent être attribuées à des groupes intégrés ou personnalisés. Dans cet outil, le champ des autorisations est comme le reste, juste un espace réservé arbitraire.

Considérez ceci comme un tableau blanc élaboré pour planifier des catégories plutôt que quelque chose de connecté à Discourse lui-même :slightly_smiling_face:.

3 « J'aime »

Vraiment impressionnant, c’est déjà tout à fait utilisable ! J’ai exporté un tas de versions de ce que j’ai fait, mais je n’ai pas encore essayé d’importer (je n’ai pas osé rafraîchir l’onglet, hehehe).

Merci pour la clarification concernant le champ des permissions dans la « maquette interactive déjà utile » :slight_smile:

C’est déjà bien pour moi tel quel !

Question cependant : y a-t-il un moyen d’importer une liste de catégories dans Discourse, comme il y a un moyen d’importer des étiquettes (tags) depuis un CSV ?

1 « J'aime »

Il existe de nombreux scripts de migration de forums entiers, mais je n’ai jamais entendu parler d’un outil servant uniquement à configurer des catégories vides.

Si l’idée de créer vos catégories via l’interface vous semble être beaucoup de travail, vous avez peut-être trop de catégories… :sweat_smile:

Après en avoir créé quelques-unes, on prend le coup de main. Je ne l’ai pas fait depuis un moment, et je viens d’ajouter une nouvelle catégorie avec les paramètres de couleur, de position et de permission de base avec un sujet de bienvenue mis à jour en environ 90 secondes : (vidéo)

(Je noterai que plusieurs des catégories dans le menu visibles ici sont limitées à un groupe ou à un autre. En tant qu’administrateur, je vois tout, mais la plupart des membres ne voient que 6 ou 7 catégories dans leur liste.)

1 « J'aime »

@Canapin alors, après avoir joué un peu avec, j’ai accidentellement actualisé la fenêtre du navigateur, ce qui m’a donné l’occasion d’essayer le bouton « importer ». Je peux vous confirmer que l’exportation fonctionne (voir fichier ci-joint), mais l’importation échoue silencieusement :joy:

discourse_categories-7.csv (3,4 Ko)

Je peux-être :sweat_smile: mais si je dois les affiner, l’interface n’est pas l’endroit qui va m’aider à les affiner :wink:

Vous savez, je pense que ce n’est pas tant le temps réel nécessaire pour créer une catégorie qui pose problème, mais la surcharge cognitive due au fait de devoir faire des allers-retours entre des écrans où seule une petite partie (une catégorie… et un sous-ensemble de paramètres pour celle-ci) de la structure globale est visible. Ce que la maquette visuelle fait, c’est me permettre de tout garder sous les yeux pendant que je joue avec les catégories et les paramètres, afin de pouvoir itérer et voir ce qui fonctionne et ce qui ne fonctionne pas.

2 « J'aime »

Oh oui, je suis tout à fait d’accord pour planifier et maquettez d’abord votre structure. Je commentais l’idée d’importer une liste de catégories dans Discourse, ce qui pourrait avoir du sens si vous êtes Procter & Gamble, mais pour la plupart d’entre nous, ce serait beaucoup d’effort pour peu de résultat. :wink:

1 « J'aime »

Si je voulais essayer de modifier l’outil pour au moins faire fonctionner l’importation, comment ferais-je ? Y a-t-il un moyen de faire une « copie » de l’outil que tu as créé pour y travailler ? Ta requête était-elle vraiment juste ces quelques mots ?

Je fais un suivi ici. Je suis maintenant dans Discourse en train d’essayer de nettoyer mes catégories. C’est un processus vraiment fastidieux.

  1. aller sur la page « toutes les catégories »
  2. cliquer sur la catégorie que je veux modifier
  3. cliquer sur la clé à molette, effectuer les modifications
  4. cliquer sur « enregistrer » en bas et aucun retour visuel ne m’indique que cela a réellement fonctionné
  5. ok, je suppose que j’ai terminé, où dois-je aller maintenant ? ah, « retour à la catégorie »
  6. hmmm en fait, je suis de retour sur la page de la catégorie qui ne m’intéresse plus, comment puis-je revenir à la page principale des catégories ? ah, barre latérale > toutes les catégories… je suppose que j’aurais dû cliquer dessus avant

Quelques-unes de mes attentes en cours de route :

  • lorsque je clique sur « enregistrer », j’aimerais que quelque chose sur la page confirme que j’ai bien enregistré
  • pour mon cas d’utilisation, où je parcours toutes mes catégories pour restructurer, modifier les permissions, les couleurs, les descriptions, ou quoi que ce soit, je veux (au moins) revenir à la page principale des catégories, et oui, il y a ce lien dans la barre latérale, mais j’ai continué à chercher quelque chose dans la zone de contenu principale pour « me ramener là où j’en avais besoin »
  • même si nous n’obtenons pas quelque chose d’aussi joli que la maquette visuelle présentée dans ce fil de discussion, il serait tellement plus facile d’avoir un grand tableau avec toutes les catégories et les paramètres importants dedans que je peux modifier, afin de pouvoir effectuer cette configuration/cet entretien initial des catégories sans avoir à cliquer à travers (littéralement) des dizaines d’écrans encore et encore à mesure que la stratégie de structure de la communauté évolue, sans une bonne vue d’ensemble.
2 « J'aime »

Il pourrait être utile d’utiliser le menu déroulant pour naviguer vers la catégorie suivante si vous souhaitez la modifier.

Je ne suis pas non plus sûr de ce que vous attendez en cliquant sur enregistrer. Sur mon forum, le bouton passe à « sauvegarde » et une fois cela terminé, il redevient « enregistrer ».

oh tu as raison, quelqu’un (toi ?) l’a déjà mentionné mais j’avais oublié.

Si c’est ce qui se passe (c’est possible !) cela se produit si vite que je ne vois rien. De plus, je dois dire qu’une fois que mon cerveau a donné l’ordre à mon doigt de cliquer, mes yeux sont déjà ailleurs. Je m’attendrais à un petit quelque chose en haut de la page indiquant « enregistré » – comme sur WordPress, je suppose :wink:

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>
2 « J'aime »

Nous avons ajouté des retours d’information lors de l’enregistrement à de plus en plus d’endroits, et cela semble être un ajout raisonnable. Vous verrez quelque chose comme ceci lors de la prochaine mise à jour de Discourse

3 « J'aime »

Il est maintenant également possible de définir des espaces réservés de titre pour chaque catégorie :

4 « J'aime »