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:

14 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!

14 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:.

3 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.

2 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.
2 Me gusta

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:

Le di a Gemini los atributos principales de una categoría (nombre, color, etiquetas, etc.) y le pregunté: “¿Puedes generar una herramienta en js/html5 para crear y organizar categorías con estos atributos? También debería ser posible editar y reorganizar rápidamente”.

La herramienta generada ya era muy buena, pero la ajusté pidiéndole que eliminara/añadiera atributos y funciones, así como modificando el diseño.

Probablemente. Puedes enviar el código a Gemini (usando el modo Canvas) y pedirle que lo ajuste como quieras:

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Planificador de Categorías de Discourse</title>
    <style>
        /* Estilos 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; }
        
        /* Disposición */
        .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); }

        /* Controles de Formulario */
        .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; }
        
        /* Botones */
        .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; }


        /* --- Estilos de Entrada de Color --- */
        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 Estilos de Entrada de Color --- */
        
        /* Estilos de Lista de Gestión (Arrastrables) */
        #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; }
        
        /* Sangría de Subcategorías en Lista de Gestión (Columna Central) */
        .category-item.is-sub {
            margin-left: 30px;
            border-left: 3px solid #ced4da; 
            background-color: #f7f7f7;
        }

        /* --- ESTILOS DE INDICACIONES DE ARRASTRE --- */
        .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 ESTILOS DE INDICACIONES DE ARRASTRE --- */


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

        /* --- Contenedor de Visualización de Categoría (Aplica a Lista de Gestión y Vista Previa) --- */
        .category-info, .preview-category { 
            flex-grow: 1; 
            display: flex;
            align-items: flex-start; 
            padding: 0; 
        }
        
        /* Envoltorio para los elementos coloreados (Barra Vertical + Cuadrado + Texto) */
        .category-display-block {
            display: flex;
            flex-direction: column;
            padding-left: 8px; 
            border-left: 4px solid transparent; 
            flex-grow: 1;
        }

        /* Envoltorio para el Cuadrado y Nombre/Texto */
        .square-and-text-wrapper {
            display: flex;
            align-items: flex-start;
        }

        /* Cuadrado Coloreado - Sin borde redondeado */
        .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;
        }

        /* Los títulos de categoría usan el color predeterminado (gris oscuro/negro) y son negritas */
        .category-name { 
            font-size: 1em; 
            font-weight: 700;
            color: #343a40; 
        } 
        .category-desc { font-size: 0.8em; color: #6c757d; }

        /* --- Estilos de Etiquetas (Pills) --- */
        .pill-container, .preview-pill-wrapper { 
            margin-top: 5px; 
            display: flex; 
            flex-wrap: wrap; 
            gap: 6px; 
            align-items: center; 
        }

        /* Permisos - Estilo más sutil */
        .management-permission-pill, .preview-permission-pill { 
            font-size: 0.7em; 
            font-weight: 600; 
            background: #e9ecef; /* Fondo más claro */
            color: #495057; /* Texto más oscuro */
            padding: 3px 6px; 
            border-radius: 4px;
            text-transform: uppercase;
            border: 1px solid #ced4da; /* Añadir un borde sutil para definición */
        }

        .attr-pill, .preview-tag-pill { /* Etiquetas */
            font-size: 0.7em; 
            background: #f8f9fa; /* Fondo muy claro para menos énfasis */
            color: #495057;
            padding: 3px 6px; 
            border-radius: 4px;
            display: inline-block;
            border: 1px solid #e9ecef; /* Borde sutil */
        }
        
        /* Estilos de Botones Editar/Eliminar */
        .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>📋 Planificador de Categorías y Jerarquía de Discourse</h1>
    
    <div class="main-container">
        
        <div class="form-section">
            <h2>Añadir/Editar Atributos de Categoría</h2>
            <form id="category-form">
                <input type="hidden" id="category-id">
                <div class="form-row">
                    <div class="form-group">
                        <label for="name">1. Nombre</label> 
                        <input type="text" id="name" required oninput="updateLiveFormPreview()">
                    </div>
                    <div class="form-group">
                        <label for="color">2. Color</label>
                        <!-- Entrada de color con estilo personalizado -->
                        <input type="color" id="color" value="#007bff" oninput="updateLiveFormPreview()">
                    </div>
                </div>
                <div class="form-row">
                    <div class="form-group">
                        <label for="description">3. Descripción</label>
                        <textarea id="description" rows="2" oninput="updateLiveFormPreview()"></textarea>
                    </div>
                    <div class="form-group">
                        <label for="permissions">4. Permisos (ej. público, personal, grupo)</label> 
                        <input type="text" id="permissions" value="Público" oninput="updateLiveFormPreview()">
                    </div>
                </div>
                <div class="form-row">
                    <div class="form-group" style="flex: 2;">
                        <label for="tags">5. Etiquetas (Separadas por comas, ej. "error, función")</label>
                        <input type="text" id="tags" placeholder="Etiquetas opcionales para publicaciones en esta categoría" 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">Añadir Nueva Categoría</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;">Vista Previa</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">[Nombre de Categoría]</div>
                        </div>
                        <div class="category-desc" id="preview-desc-field" style="color: #6c757d;">[Aquí va la descripción]</div>
                        <div class="preview-pill-wrapper" id="preview-pills-field">
                            <!-- Las etiquetas se insertarán aquí por JS -->
                        </div>
                    </div>
                </div>
            </div>
            
            <div class="utility-buttons">
                <button id="export-btn" class="action-button">Exportar (CSV)</button>
                <button id="import-btn" class="action-button">Importar (CSV)</button>
                <input type="file" id="csv-file-input" accept=".csv" style="display: none;">
            </div>
        </div>

        <div class="list-section">
            <h2>Gestión de Categorías (Arrastrar para Reordenar)</h2>
            <p style="font-size: 0.85em; color: #6c757d;">
                Arrastra un elemento para reordenar. Arrastra hacia la **derecha** para crear una subcategoría (indicación verde), arrastra hacia la **izquierda** para promover (indicación roja).
            </p>
            <ul id="category-list">
                <!-- Las categorías se renderizarán aquí por 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');
        
        // --- Manejadores de Exportación/Importación 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] || '';
                    // Escapar comas y comillas para formato 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('La exportación no está soportada en este navegador.');
            }
        });

        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(`Se importaron correctamente ${importedData.length} categorías.`);
                } else {
                    alert('Error al importar CSV: Los datos están vacíos o tienen un formato incorrecto.');
                }
            };
            reader.readAsText(file);
        });

        // --- Gestión de Datos Central y Renderizado ---

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

        // Función recursiva para encontrar todos los descendientes de un ID de categoría
        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;
        }

        // Función para actualizar la caja de vista previa simple junto al formulario
        function updateLiveFormPreview() {
            const name = document.getElementById('name').value || '[Nombre de Categoría]';
            const description = document.getElementById('description').value || '[Aquí va la descripción]';
            const color = document.getElementById('color').value;
            const permissions = document.getElementById('permissions').value;
            const tagsInput = document.getElementById('tags').value;

            // Aplicar color al cuadrado y borde izquierdo
            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;
            
            // Generar HTML de Etiquetas
            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;
        }


        // Función para renderizar la lista de gestión
        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. Renderizar Elemento de Lista de Gestión (Arrastrable) ---
                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);

                // Construir HTML de etiquetas
                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 ? '— Subcategoría' : ''}</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}')">Editar</button>
                        <button class="delete-btn" onclick="deleteCategory('${cat.id}')">Eliminar</button>
                    </div>
                `;
                list.appendChild(managementItem);
            });
            
            addDragDropListeners();
        }

        // Función para manejar el envío del formulario (Añadir o Actualizar)
        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 = 'Añadir Nueva Categoría';
            updateLiveFormPreview();
            renderAll();
        });

        // Función para poblar el formulario para edición
        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 = 'Actualizar Categoría';
            updateLiveFormPreview();
            window.scrollTo(0, 0); 
        }

        // Función para eliminar una categoría
        function deleteCategory(id) {
            // Llamada a confirm() eliminada para permitir ejecución en entorno aislado
            categories = categories.filter(c => c.id !== id && c.parentId !== id); 
            renderAll();
        }


        // --- Lógica de Arrastrar y Soltar para Reordenación y Anidamiento ---

        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; // Capturar posición X inicial
                    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; 
                    
                    // --- LÓGICA PARA APLICACIÓN DE INDICACIONES ---

                    // 1. Verificar INDENTACIÓN (Arrastrar a la Derecha)
                    if (horizontalShift > INDENT_THRESHOLD_PX) {
                        // Verificar si el objetivo puede aceptar hijos (es decir, no es ya un hijo)
                        if (!dropTargetCat.parentId) {
                            dropTargetElement.classList.add('indent-hint');
                            dropTargetElement.classList.remove('drag-over');
                        }
                    } 
                    
                    // 2. Verificar DESINDENTACIÓN (Arrastrar a la Izquierda)
                    else if (draggedCat.parentId && horizontalShift < -OUTDENT_SHIFT_PX) {
                        dropTargetElement.classList.add('outdent-hint');
                        dropTargetElement.classList.remove('drag-over');
                    }
                    
                    // 3. Punto Medio: Sin indicación significa que solo ocurre reordenación vertical al soltar.
                });

                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-secuenciar el array de categorías (Orden Lineal)
                        if (draggedIndex !== -1 && dropIndex !== -1) {
                            const [temp] = categories.splice(draggedIndex, 1);
                            categories.splice(dropIndex, 0, temp);
                        }

                        // Re-encontrar índices después de la re-secuenciación
                        const newDraggedIndex = categories.findIndex(c => c.id === draggedId);
                        const draggedCat = categories[newDraggedIndex];
                        
                        // 2. Determinar Nuevo ID de Padre basado en la indicación activa
                        
                        let newParentId = draggedCat.parentId; // Predeterminado: Mantener estado jerárquico actual
                        let updateDescendants = false;

                        if (dropTargetElement.classList.contains('indent-hint')) {
                            // Promover a hijo: El nuevo padre de la categoría arrastrada es el objetivo.
                            newParentId = dropTargetId;
                            updateDescendants = true; 
                        } else if (dropTargetElement.classList.contains('outdent-hint')) {
                            // Promover a padre: El nuevo padre de la categoría arrastrada es nulo/vacío.
                            newParentId = '';
                            updateDescendants = true; 
                        }
                        
                        draggedCat.parentId = newParentId;

                        // 3. Actualizar Descendientes si la jerarquía cambió para el padre arrastrado
                        if (updateDescendants) {
                            const descendantIds = getDescendantIds(draggedId);
                            descendantIds.forEach(descendantId => {
                                const descendantCat = categories.find(c => c.id === descendantId);
                                if (descendantCat) {
                                    // Los descendientes siguen el nuevo estado del padre arrastrado
                                    descendantCat.parentId = newParentId;
                                }
                            });
                        }

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

        // --- Carga Inicial (Categorías Actualizadas) ---

        categories.push({
            id: 'cat_001', name: 'General', description: 'Discusión y ayuda general de la comunidad.', color: '#007bff', permissions: 'Público', parentId: '', tags: 'etiqueta 1, etiqueta 2'
        });
        categories.push({
            id: 'cat_004', name: 'Ejemplo de Subcategoría', description: '', color: '#007bff', permissions: 'Público', parentId: 'cat_001', tags: 'etiqueta 3'
        });
        categories.push({
            id: 'cat_002', name: 'Personal', description: 'Área privada para moderación y administración.', color: '#ffc107', permissions: 'Solo Personal', parentId: '', tags: ''
        });
        categories.push({
            id: 'cat_003', name: 'Comentarios del Sitio', description: 'Discutir el foro en sí y sugerir mejoras.', color: '#28a745', permissions: 'Público', parentId: '', tags: 'sitio, comentarios'
        });

        renderAll();
        updateLiveFormPreview();
    </script>
</body>
</html>
2 Me gusta

Hemos estado añadiendo confirmación al guardar en más lugares, y este parece un caso razonable. Verás algo como esto la próxima vez que se actualice Discourse

3 Me gusta

Ahora también existe la posibilidad de establecer marcadores de posición de título para cada categoría:

4 Me gusta