Creación de categorías más amigable

Alguien señaló que crear categorías y etiquetas resulta engorroso, y que esta fricción podría disuadir a los nuevos administradores de adoptar Discourse.

Le pedí a Gemini que generara un planificador de categorías. El resultado parece bastante bueno. No creo que deba ni pueda añadirse a Discourse tal cual, por muchas (buenas) razones, pero sigue siendo interesante de ver. Podría inspirar ideas para el futuro de Discourse.

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

Mover una categoría fuera de un padre es un poco raro. Tienes que arrastrarla sobre el padre y luego hacia la izquierda. No es intuitivo, pero de nuevo, esto es más un concepto de interfaz que una solución real.

chrome_Zl3VkfeCTI

La interfaz de administración de Discourse puede parecer dura al principio. El software es complejo, lleno de configuraciones, y se ha vuelto aún más complejo con los años. Una revisión de algunas partes de la administración haciéndolas más visuales, interactivas y lúdicas, especialmente las áreas que la gente encuentra de inmediato al intentar configurar las opciones básicas de su comunidad, podría valer la pena.

Me pregunto cuántas personas se alejan de Discourse después de sentirse intimidadas por la interfaz actual. :thinking:


Decidí crear este tema después de esta reacción a la creación de Gemini:

12 Me gusta

Actualmente estamos planeando mejorar la creación de categorías, incluyendo todas las diversas características que las categorías pueden abarcar (votación, soluciones, etc.) — ¡todos sus comentarios son muy ciertos!

13 Me gusta

Vaya, esto es increíble, lo estoy usando ahora y es realmente el tipo de cosa que estaba pensando. Solo una pregunta en esta etapa, para los permisos, ¿“group” (grupo) significa todos los miembros? Si quiero dar permisos a un subgrupo (por ejemplo, “vets” - veterinarios), ¿simplemente escribo “vets” y ya está?

Ah, también: ¿el archivo .csv es importable en Discourse en este momento o no? (no es un gran problema si no lo es, ya es maravilloso tener una interfaz donde puedo jugar con mi estructura)

4 Me gusta

Por defecto es todo el mundo a menos que especifiques grupos. Cuando haces clic en Añadir un grupo, debería detectar tus grupos existentes.

Pensé que había una forma de añadir masivamente a través de csv, pero no lo estoy viendo. Te sorprendería cuántos puedes copiar/pegar directamente en el campo desde un csv.

1 me gusta

Creo que tenemos una confusión, estoy preguntando sobre la interfaz de usuario que @Canapin acaba de construir… por el momento no tengo grupos ni nada configurado en Discourse, ¡solo estoy tratando de diseñar la estructura!

1 me gusta

Esta herramienta surgió cuando le pregunté a Gemini: “¿cómo imaginarías una interfaz más amigable para gestionar Categorías en Discourse?”

Es realmente solo una maqueta interactiva para jugar un rato :slightly_smiling_face:.
Los botones de importar/exportar no fueron probados, y las exportaciones definitivamente no se pueden importar en Discourse.

Sobre los grupos: en Discourse real, hay tres permisos (ver, responder y crear) que se pueden asignar a grupos integrados o personalizados. En esta herramienta, el campo de permisos es como el resto, solo un marcador de posición arbitrario.

Piensa en esto como una pizarra elaborada para planificar categorías más que en algo conectado con Discourse en sí mismo :slightly_smiling_face:.

2 Me gusta

¡Bastante impresionante, de verdad, ya es bastante utilizable! He exportado un montón de versiones de lo que he hecho, pero todavía no he intentado importar (no me he atrevido a refrescar la pestaña, jejeje).

Gracias por la aclaración sobre el campo de permisos en el “mockup interactivo que ya es útil” :slight_smile:

¡Para mí ya está bien tal como está!

Aunque una pregunta: ¿hay alguna forma de importar una lista de categorías a Discourse, como hay una forma de importar etiquetas desde un CSV?

1 me gusta

Hay muchos scripts de migración de foros completos, pero nunca he oído hablar de una herramienta solo para configurar categorías vacías.

Si la perspectiva de crear tus categorías a través de la interfaz te parece mucho trabajo, podrías tener demasiadas categorías… :sweat_smile:

Después de crear un par, le coges el truco. No lo he hecho en un tiempo, y acabo de añadir una nueva categoría con color básico, posición y ajustes de permisos con un tema de bienvenida actualizado en unos 90 segundos: (video)

(Señalaré que varias de las categorías en el menú que se ven allí están limitadas a un grupo u otro. Como administrador lo veo todo, pero la mayoría de los miembros solo ven 6 o 7 categorías en su lista.)

1 me gusta

@Canapin así que, después de jugar un poco con él, refresqué la ventana del navegador por error, lo que me dio la oportunidad de probar el botón de “importar”. Puedo informar que la exportación funciona (ver archivo adjunto), pero la importación falla silenciosamente :joy:

discourse_categories-7.csv (3.4 KB)

Puede que :sweat_smile: pero si voy a reducirlas, la interfaz no es el lugar que me va a ayudar a reducirlas :wink:

Sabes, creo que el problema no es tanto el tiempo real necesario para crear una categoría, sino la sobrecarga cognitiva debida a tener que ir y venir entre pantallas donde solo se ve una pequeña parte (una categoría… y un subconjunto de configuraciones para ella) de la estructura general. Lo que hace la maqueta visual es permitirme mantener todo a la vista mientras estoy jugueteando con categorías y configuraciones, para poder iterar y ver qué funciona y qué no.

1 me gusta

Oh sí, estoy totalmente de acuerdo con respecto a planificar y esbozar su estructura primero. Estaba comentando sobre la idea de importar una lista de categorías a Discourse, lo que podría tener sentido si usted es Proctor & Gamble, pero para la mayoría de nosotros sería mucho esfuerzo para poco resultado. :wink:

1 me gusta

Si quisiera intentar modificar la herramienta para al menos hacer que la importación funcione, ¿cómo lo haría? ¿Hay alguna manera de hacer una “copia” de la herramienta que hiciste para trabajar en ella? ¿Tu indicación fue realmente solo esas pocas palabras?

Dando seguimiento a esto. Ahora estoy en Discourse tratando de limpiar mis categorías. Es un proceso realmente engorroso.

  1. Ir a la página de “todas las categorías”
  2. Hacer clic en la categoría que quiero modificar
  3. Hacer clic en la llave inglesa, hacer modificaciones
  4. Hacer clic en “guardar” en la parte inferior y no hay ninguna indicación visible de que realmente haya hecho algo
  5. De acuerdo, supongo que terminé, ¿a dónde voy ahora? Ah, “volver a la categoría”
  6. Mmm, ahora estoy de vuelta en la página de la categoría que ya no me interesa, ¿cómo vuelvo a la página principal de categorías? Ah, barra lateral > todas las categorías… supongo que debería haber hecho clic en eso antes

Algunas de mis expectativas durante el proceso:

  • cuando hago clic en “guardar”, me gustaría que algo en la página confirmara que realmente guardé
  • para mi caso de uso, donde estoy recorriendo todas mis categorías para reestructurarlas, o cambiar permisos, o colores, o descripciones, o lo que sea, quiero (al menos) volver a la página principal de categorías, y sí, está ese enlace en la barra lateral, pero seguí buscando algo en el área de contenido principal para “llevarme de vuelta a donde necesitaba”
  • incluso si no obtenemos algo tan elegante como la maqueta visual presentada en este hilo, sería mucho más fácil tener una tabla grande con todas las categorías y configuraciones importantes allí que pueda modificar, para poder hacer esta configuración/mantenimiento inicial de categorías sin tener que hacer clic en (literalmente) docenas de pantallas una y otra vez a medida que evoluciona la estrategia para la estructura de la comunidad, sin una buena visión general.

Puede ser útil usar el menú desplegable para navegar a la siguiente categoría si desea editarla.

Tampoco estoy seguro de lo que espera al hacer clic en guardar. En mi foro, el botón cambia a “guardando” y una vez que termina, vuelve a cambiar a “guardar”.

oh, tienes razón, alguien (¿tú?) ya lo mencionó pero lo había olvidado.

Si está haciendo eso (¡podría ser!) sucede tan rápido que no veo nada. Además, tengo que decir que una vez que mi cerebro ha dado la orden a mi dedo de hacer clic, mis ojos ya están en otro sitio. Esperaría algo en la parte superior de la página que diga “guardado”, como en WordPress, supongo :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 me gusta