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 :

12 « 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 !

13 « 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:.

2 « 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.

1 « 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.

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:

I gave Gemini the main attributes of a category (name, color, tags, etc) and asked: “Can you output a js/html5 tool to create and organize categories with these attributes? We should also be able to quickly edit and rearrange”.

The produced tool was already great, but I fine-tuned it by asking to remove/add attributes and features as well as tweaking the design.

Probably. You can send the code to Gemini (use the Canvas mode) and ask it to tweak it like you want:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Discourse Category Planner</title>
    <style>
        /* Base Styling */
        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; }
        
        /* Layout */
        .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); }

        /* Form Controls */
        .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; }
        
        /* Buttons */
        .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; }


        /* --- Color Input Styling --- */
        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;
        }
        /* --- End Color Input Styling --- */
        
        /* Management List Styles (Draggable) */
        #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; }
        
        /* Subcategory Indentation in Management List (Middle Column) */
        .category-item.is-sub {
            margin-left: 30px;
            border-left: 3px solid #ced4da; 
            background-color: #f7f7f7;
        }

        /* --- DRAG HINT STYLES --- */
        .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;
        }
        /* --- END DRAG HINT STYLES --- */


        .category-actions { flex-shrink: 0; display: flex; gap: 5px; }

        /* --- Category Display Container (Applies to both Management List and Preview) --- */
        .category-info, .preview-category { 
            flex-grow: 1; 
            display: flex;
            align-items: flex-start; 
            padding: 0; 
        }
        
        /* Wrapper for the colored elements (Vertical Bar + Square + Text) */
        .category-display-block {
            display: flex;
            flex-direction: column;
            padding-left: 8px; 
            border-left: 4px solid transparent; 
            flex-grow: 1;
        }

        /* Wrapper for the Square and Name/Text */
        .square-and-text-wrapper {
            display: flex;
            align-items: flex-start;
        }

        /* Colored Square - Removed border-radius */
        .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;
        }

        /* Category titles use default color (dark gray/black) and are bold */
        .category-name { 
            font-size: 1em; 
            font-weight: 700;
            color: #343a40; 
        } 
        .category-desc { font-size: 0.8em; color: #6c757d; }

        /* --- Pill Styling --- */
        .pill-container, .preview-pill-wrapper { 
            margin-top: 5px; 
            display: flex; 
            flex-wrap: wrap; 
            gap: 6px; 
            align-items: center; 
        }

        /* Permissions - Subtler styling */
        .management-permission-pill, .preview-permission-pill { 
            font-size: 0.7em; 
            font-weight: 600; 
            background: #e9ecef; /* Lighter background */
            color: #495057; /* Darker text */
            padding: 3px 6px; 
            border-radius: 4px;
            text-transform: uppercase;
            border: 1px solid #ced4da; /* Add a subtle border for definition */
        }

        .attr-pill, .preview-tag-pill { /* Tags */
            font-size: 0.7em; 
            background: #f8f9fa; /* Very light background for less emphasis */
            color: #495057;
            padding: 3px 6px; 
            border-radius: 4px;
            display: inline-block;
            border: 1px solid #e9ecef; /* Subtle border */
        }
        
        /* Edit/Delete Button Styles */
        .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>📋 Discourse Category & Hierarchy Planner</h1>
    
    <div class="main-container">
        
        <div class="form-section">
            <h2>Add/Edit Category Attributes</h2>
            <form id="category-form">
                <input type="hidden" id="category-id">
                <div class="form-row">
                    <div class="form-group">
                        <label for="name">1. Name</label> 
                        <input type="text" id="name" required oninput="updateLiveFormPreview()">
                    </div>
                    <div class="form-group">
                        <label for="color">2. Color</label>
                        <!-- Custom styled color input -->
                        <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. Permissions (e.g., public, staff, group)</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. Tags (Comma separated, e.g., "bug, feature")</label>
                        <input type="text" id="tags" placeholder="Optional tags for posts in this category" 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">Add New Category</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;">Preview</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">[Category Name]</div>
                        </div>
                        <div class="category-desc" id="preview-desc-field" style="color: #6c757d;">[Description goes here]</div>
                        <div class="preview-pill-wrapper" id="preview-pills-field">
                            <!-- Pills will be inserted here by JS -->
                        </div>
                    </div>
                </div>
            </div>
            
            <div class="utility-buttons">
                <button id="export-btn" class="action-button">Export (CSV)</button>
                <button id="import-btn" class="action-button">Import (CSV)</button>
                <input type="file" id="csv-file-input" accept=".csv" style="display: none;">
            </div>
        </div>

        <div class="list-section">
            <h2>Category Management (Drag to Reorder)</h2>
            <p style="font-size: 0.85em; color: #6c757d;">
                Drag an element to reorder. Drag to the **right** to create a subcategory (Green hint), drag to the **left** to promote (Red hint).
            </p>
            <ul id="category-list">
                <!-- Categories will be rendered here by 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');
        
        // --- CSV Export/Import Handlers ---

        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] || '';
                    // Escape commas and quotes for CSV format
                    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('Export not supported in this browser.');
            }
        });

        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(`Successfully imported ${importedData.length} categories.`);
                } else {
                    alert('Error importing CSV: Data is empty or incorrectly formatted.');
                }
            };
            reader.readAsText(file);
        });

        // --- Core Data Management & Rendering ---

        const findParent = (parentId) => categories.find(c => c.id === parentId);

        // Recursive function to find all descendants of a category ID
        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;
        }

        // Function to update the simple preview box next to the form
        function updateLiveFormPreview() {
            const name = document.getElementById('name').value || '[Category Name]';
            const description = document.getElementById('description').value || '[Description goes here]';
            const color = document.getElementById('color').value;
            const permissions = document.getElementById('permissions').value;
            const tagsInput = document.getElementById('tags').value;

            // Apply color to the square and left border
            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;
            
            // Generate Pills HTML
            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;
        }


        // Function to render the management list
        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. Render Management List Item (Draggable) ---
                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);

                // Build tags HTML
                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 ? '— Subcategory' : ''}</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}')">Edit</button>
                        <button class="delete-btn" onclick="deleteCategory('${cat.id}')">Delete</button>
                    </div>
                `;
                list.appendChild(managementItem);
            });
            
            addDragDropListeners();
        }

        // Function to handle form submission (Add or Update)
        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 = 'Add New Category';
            updateLiveFormPreview();
            renderAll();
        });

        // Function to populate the form for editing
        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 = 'Update Category';
            updateLiveFormPreview();
            window.scrollTo(0, 0); 
        }

        // Function to delete a category
        function deleteCategory(id) {
            // Removed confirm() call to allow execution in sandboxed environment
            categories = categories.filter(c => c.id !== id && c.parentId !== id); 
            renderAll();
        }


        // --- Drag and Drop Logic for Rearrangement and Nesting ---

        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; // Capture starting X position
                    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; 
                    
                    // --- LOGIC FOR HINT APPLICATION ---

                    // 1. Check for INDENT (Drag Right)
                    if (horizontalShift > INDENT_THRESHOLD_PX) {
                        // Check if the target can accept children (i.e., is not already a child)
                        if (!dropTargetCat.parentId) {
                            dropTargetElement.classList.add('indent-hint');
                            dropTargetElement.classList.remove('drag-over');
                        }
                    } 
                    
                    // 2. Check for OUTDENT (Drag Left)
                    else if (draggedCat.parentId && horizontalShift < -OUTDENT_SHIFT_PX) {
                        dropTargetElement.classList.add('outdent-hint');
                        dropTargetElement.classList.remove('drag-over');
                    }
                    
                    // 3. Middle Ground: No hint means only vertical reordering occurs on drop.
                });

                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. Re-sequence the categories array (Linear Order)
                        if (draggedIndex !== -1 && dropIndex !== -1) {
                            const [temp] = categories.splice(draggedIndex, 1);
                            categories.splice(dropIndex, 0, temp);
                        }

                        // Re-find indices after resequencing
                        const newDraggedIndex = categories.findIndex(c => c.id === draggedId);
                        const draggedCat = categories[newDraggedIndex];
                        
                        // 2. Determine New Parent ID based on the active hint
                        
                        let newParentId = draggedCat.parentId; // Default: Keep current hierarchy status
                        let updateDescendants = false;

                        if (dropTargetElement.classList.contains('indent-hint')) {
                            // Promoting to child: The dragged category's new parent is the drop target.
                            newParentId = dropTargetId;
                            updateDescendants = true; 
                        } else if (dropTargetElement.classList.contains('outdent-hint')) {
                            // Promoting to parent: The dragged category's new parent is null/empty.
                            newParentId = '';
                            updateDescendants = true; 
                        }
                        
                        draggedCat.parentId = newParentId;

                        // 3. Update Descendants if hierarchy changed for the dragged parent
                        if (updateDescendants) {
                            const descendantIds = getDescendantIds(draggedId);
                            descendantIds.forEach(descendantId => {
                                const descendantCat = categories.find(c => c.id === descendantId);
                                if (descendantCat) {
                                    // Descendants follow the dragged parent's new status
                                    descendantCat.parentId = newParentId;
                                }
                            });
                        }

                        removeAllDragHints(); 
                        renderAll(); 
                    }
                });
            });
        }

        // --- Initial Load (Updated Categories) ---

        categories.push({
            id: 'cat_001', name: 'General', description: 'General community discussion and help.', color: '#007bff', permissions: 'Public', parentId: '', tags: 'tag 1, tag 2'
        });
        categories.push({
            id: 'cat_004', name: 'Subcategory Example', description: '', color: '#007bff', permissions: 'Public', parentId: 'cat_001', tags: 'tag 3'
        });
        categories.push({
            id: 'cat_002', name: 'Staff', description: 'Private area for moderation and administration.', color: '#ffc107', permissions: 'Staff Only', parentId: '', tags: ''
        });
        categories.push({
            id: 'cat_003', name: 'Site Feedback', description: 'Discuss the forum itself and suggest improvements.', color: '#28a745', permissions: 'Public', parentId: '', tags: 'site, feedback'
        });

        renderAll();
        updateLiveFormPreview();
    </script>
</body>
</html>
1 « J'aime »