Criação de categoria mais amigável

Alguém observou que criar categorias e tags parece complicado, e que esse atrito pode desencorajar novos administradores a adotarem o Discourse.

Pedi ao Gemini para gerar um planejador de categorias. O resultado parece bem legal. Eu não acho que deva ou possa ser adicionado ao Discourse como está, por muitas (boas) razões, mas ainda é interessante de se ver. Pode inspirar ideias para o futuro do Discourse.

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

Mover uma categoria para fora de um pai é um pouco estranho. Você precisa arrastá-la para o pai e depois para a esquerda. Não é intuitivo, mas novamente, este é mais um conceito de interface do que uma solução real.

chrome_Zl3VkfeCTI

A interface de administração do Discourse pode parecer dura no início. O software é complexo, cheio de configurações, e se tornou ainda mais complexo ao longo dos anos. Uma reformulação de algumas partes da administração, tornando-as mais visuais, interativas e lúdicas, especialmente nas áreas que as pessoas encontram logo ao tentar configurar as opções básicas de sua comunidade, pode ser útil.

Eu me pergunto quantas pessoas desistem do Discourse depois de se sentirem intimidadas pela interface atual. :thinking:


Decidi criar este tópico após esta reação à criação do Gemini:

14 Curtiram

Atualmente, estamos planejando melhorar a criação de categorias, incluindo todos os vários recursos que as categorias podem abranger (votação, soluções, etc.) — todo o seu feedback é muito verdadeiro!

14 Curtiram

Uau, isto é incrível, estou usando agora e é realmente o tipo de coisa que eu estava pensando. Apenas uma pergunta nesta fase, para permissões, “grupo” significa todos os membros? Se eu quiser dar permissões a um subgrupo (por exemplo, “veterinários”), eu apenas escrevo “veterinários” e é isso?

Ah, também: a importação de .csv para o Discourse é possível neste momento ou não? (não é um grande problema se não for, já é maravilhoso ter uma interface onde posso brincar com minha estrutura)

4 Curtiram

Por padrão, é todo mundo, a menos que você especifique grupos. Quando você clica em Adicionar um Grupo, ele deve detectar seus grupos existentes.

Eu pensei que havia uma maneira de adicionar em massa via csv, mas não estou vendo. Você ficaria surpreso com quantos você pode copiar/colar no campo diretamente de um csv.

1 Curtiu

Acho que estamos com uma confusão, estou perguntando sobre a interface do usuário que o @Canapin acabou de construir… por enquanto não tenho grupos ou qualquer coisa configurada no Discourse, estou apenas tentando projetar a estrutura!

1 Curtiu

Esta ferramenta surgiu quando perguntei ao Gemini: “como você imaginaria uma interface mais amigável para gerenciar Categorias no Discourse?”

É realmente apenas um mockup interativo para brincar :slightly_smiling_face:.
Os botões de importação/exportação não foram testados, e as exportações definitivamente não podem ser importadas para o Discourse.

Sobre grupos: no Discourse real, existem três permissões (ver, responder e criar) que podem ser atribuídas a grupos internos ou personalizados. Nesta ferramenta, o campo de permissões é como o resto, apenas um espaço reservado arbitrário.

Pense nisso como um quadro branco elaborado para planejar categorias do que qualquer coisa conectada ao próprio Discourse :slightly_smiling_face:.

3 Curtiram

Realmente impressionante, já está bem utilizável! Eu exportei um monte de versões do que fiz, mas ainda não tentei importar (não ousei atualizar a aba, hehehe).

Obrigado pelo esclarecimento sobre o campo de permissões na “maquete interativa que já é útil” :slight_smile:

Para mim já está bom assim!

Uma pergunta, no entanto: existe alguma forma de importar uma lista de categorias para o Discourse, assim como existe uma forma de importar tags de um CSV?

1 Curtiu

Existem muitos scripts de migração de fóruns inteiros, mas nunca ouvi falar de uma ferramenta apenas para configurar categorias vazias.

Se a perspectiva de criar suas categorias pela interface parece muito trabalho, você poderia ter muitas categorias… :sweat_smile:

Depois de criar algumas, você pega o jeito. Eu não faço isso há um tempo, e acabei de adicionar uma nova categoria com cor básica, posição e configurações de permissão com um tópico de boas-vindas atualizado em cerca de 90 segundos: (vídeo)

(Observo que várias das categorias no menu visto ali são limitadas a um grupo ou outro. Como administrador, eu vejo tudo, mas a maioria dos membros vê apenas 6 ou 7 categorias na lista deles.)

1 Curtiu

@Canapin então, depois de brincar um pouco com ele, eu atualizei a janela do navegador por engano, o que me deu a oportunidade de testar o botão “importar”. Posso relatar que a exportação funciona (veja o arquivo anexado), mas a importação falha silenciosamente :joy:

discourse_categories-7.csv (3,4 KB)

Eu poooode :sweat_smile: mas se eu for restringi-las, a interface não é o lugar que vai me ajudar a restringi-las :wink:

Sabe, acho que o problema não é tanto o tempo real necessário para criar uma categoria, mas a sobrecarga cognitiva devido a ter que ir e voltar entre telas onde apenas uma pequena parte (uma categoria… e um subconjunto de configurações para ela) da estrutura geral é visível. O que a maquete visual faz é me permitir manter tudo à vista enquanto estou mexendo nas categorias e configurações, para que eu possa iterar e ver o que funciona e o que não funciona.

2 Curtiram

Ah sim, concordo plenamente em relação a planejar e esboçar sua estrutura primeiro. Eu estava comentando sobre a ideia de importar uma lista de categorias para o Discourse, o que pode fazer sentido se você for Proctor & Gamble, mas para a maioria de nós seria muito esforço para pouco resultado. :wink:

1 Curtiu

Se eu quisesse tentar modificar a ferramenta para pelo menos fazer a importação funcionar, como eu faria isso? Existe uma maneira de eu fazer uma “cópia” da ferramenta que você fez para trabalhar nela? Seu prompt foi realmente apenas essas poucas palavras?

Dando seguimento aqui. Estou agora no Discourse tentando organizar minhas categorias. É um processo realmente complicado.

  1. ir para a página “todas as categorias”
  2. clicar na categoria que quero modificar
  3. clicar na chave inglesa, fazer modificações
  4. clicar em “salvar” na parte inferior e nenhum feedback visível me diz que realmente fez algo
  5. ok, acho que terminei, para onde eu vou agora? ah, “voltar para a categoria”
  6. hmmm, no momento estou de volta à página da categoria que não me interessa mais, como volto para a página principal de categorias? ah, barra lateral > todas as categorias… acho que deveria ter clicado nisso antes

Algumas das minhas expectativas ao longo do caminho:

  • quando clico em “salvar”, gostaria que algo na página confirmasse que eu realmente salvei
  • para o meu caso de uso, onde estou percorrendo todas as minhas categorias para reestruturá-las, ou alterar permissões, ou cores, ou descrições, ou o que for, eu quero (pelo menos) voltar para a página principal de categorias, e sim, existe aquele link na barra lateral, mas continuei procurando por algo na área de conteúdo principal para “me levar de volta para onde eu precisava”
  • mesmo que não consigamos algo tão bonito quanto o mockup visual apresentado neste tópico, seria muito mais fácil ter uma tabela grande com todas as categorias e configurações importantes nela que eu possa modificar, para que eu possa fazer essa configuração/organização inicial de categorias sem ter que clicar em (literalmente) dezenas de telas repetidamente, à medida que a estratégia para a estrutura da comunidade evolui, sem uma boa visão geral.
2 Curtiram

Pode ser útil usar o menu suspenso para navegar para a próxima categoria se você quiser editá-la.

Também não tenho certeza do que você espera ao clicar em salvar. No meu fórum, o botão muda para “salvando” e, assim que isso termina, ele volta a ser “salvar”.

Ah, você está certo, alguém (você?) já mencionou isso, mas eu tinha esquecido.

Se estiver fazendo isso (pode estar!), acontece tão rápido que não estou vendo nada. Além disso, tenho que dizer que, assim que meu cérebro dá a ordem para o meu dedo clicar, meus olhos já estão em outro lugar. Eu esperaria algo no topo da página dizendo “salvo” – como no WordPress, eu acho :wink:

Eu dei ao Gemini os principais atributos de uma categoria (nome, cor, tags, etc.) e perguntei: “Você pode gerar uma ferramenta em js/html5 para criar e organizar categorias com esses atributos? Também devemos poder editar e reorganizar rapidamente”.

A ferramenta gerada já era ótima, mas eu ajustei-a pedindo para remover/adicionar atributos e recursos, além de refinar o design.

Provavelmente. Você pode enviar o código para o Gemini (use o modo Canvas) e pedir para ajustá-lo como desejar:

<!DOCTYPE html>
<html lang="pt-BR">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Planejador de Categorias do Discourse</title>
    <style>
        /* Estilização 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; }
        
        /* 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); }

        /* Controles de Formulário */
        .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; }
        
        /* Botões */
        .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; }


        /* --- Estilização da Entrada de Cor --- */
        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;
        }
        /* --- Fim da Estilização da Entrada de Cor --- */
        
        /* Estilos da Lista de Gestão (Arrastável) */
        #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; }
        
        /* Indentação de Subcategorias na Lista de Gestão (Coluna do Meio) */
        .category-item.is-sub {
            margin-left: 30px;
            border-left: 3px solid #ced4da; 
            background-color: #f7f7f7;
        }

        /* --- ESTILOS DE DICAS DE ARRASTE --- */
        .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;
        }
        /* --- FIM DOS ESTILOS DE DICAS DE ARRASTE --- */


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

        /* --- Contêiner de Exibição de Categoria (Aplica-se tanto à Lista de Gestão quanto à Pré-visualização) --- */
        .category-info, .preview-category { 
            flex-grow: 1; 
            display: flex;
            align-items: flex-start; 
            padding: 0; 
        }
        
        /* Wrapper para os elementos coloridos (Barra Vertical + Quadrado + Texto) */
        .category-display-block {
            display: flex;
            flex-direction: column;
            padding-left: 8px; 
            border-left: 4px solid transparent; 
            flex-grow: 1;
        }

        /* Wrapper para o Quadrado e Nome/Texto */
        .square-and-text-wrapper {
            display: flex;
            align-items: flex-start;
        }

        /* Quadrado Colorido - Removido 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;
        }

        /* Títulos de categoria usam cor padrão (cinza escuro/preto) e estão em negrito */
        .category-name { 
            font-size: 1em; 
            font-weight: 700;
            color: #343a40; 
        } 
        .category-desc { font-size: 0.8em; color: #6c757d; }

        /* --- Estilização de Pílulas --- */
        .pill-container, .preview-pill-wrapper { 
            margin-top: 5px; 
            display: flex; 
            flex-wrap: wrap; 
            gap: 6px; 
            align-items: center; 
        }

        /* Permissões - Estilização mais sutil */
        .management-permission-pill, .preview-permission-pill { 
            font-size: 0.7em; 
            font-weight: 600; 
            background: #e9ecef; /* Fundo mais claro */
            color: #495057; /* Texto mais escuro */
            padding: 3px 6px; 
            border-radius: 4px;
            text-transform: uppercase;
            border: 1px solid #ced4da; /* Adicionar uma borda sutil para definição */
        }

        .attr-pill, .preview-tag-pill { /* Tags */
            font-size: 0.7em; 
            background: #f8f9fa; /* Fundo muito claro para menos ênfase */
            color: #495057;
            padding: 3px 6px; 
            border-radius: 4px;
            display: inline-block;
            border: 1px solid #e9ecef; /* Borda sutil */
        }
        
        /* Estilos de Botões Editar/Excluir */
        .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>📋 Planejador de Categorias e Hierarquia do Discourse</h1>
    
    <div class="main-container">
        
        <div class="form-section">
            <h2>Adicionar/Editar Atributos de Categoria</h2>
            <form id="category-form">
                <input type="hidden" id="category-id">
                <div class="form-row">
                    <div class="form-group">
                        <label for="name">1. Nome</label> 
                        <input type="text" id="name" required oninput="updateLiveFormPreview()">
                    </div>
                    <div class="form-group">
                        <label for="color">2. Cor</label>
                        <!-- Entrada de cor estilizada customizada -->
                        <input type="color" id="color" value="#007bff" oninput="updateLiveFormPreview()">
                    </div>
                </div>
                <div class="form-row">
                    <div class="form-group">
                        <label for="description">3. Descrição</label>
                        <textarea id="description" rows="2" oninput="updateLiveFormPreview()"></textarea>
                    </div>
                    <div class="form-group">
                        <label for="permissions">4. Permissões (ex: público, equipe, 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. Tags (Separadas por vírgula, ex: "bug, recurso")</label>
                        <input type="text" id="tags" placeholder="Tags opcionais para publicações nesta categoria" 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">Adicionar Nova Categoria</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;">Pré-visualização</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">[Nome da Categoria]</div>
                        </div>
                        <div class="category-desc" id="preview-desc-field" style="color: #6c757d;">[A descrição vai aqui]</div>
                        <div class="preview-pill-wrapper" id="preview-pills-field">
                            <!-- Pílulas serão inseridas aqui pelo 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>Gestão de Categorias (Arraste para Reordenar)</h2>
            <p style="font-size: 0.85em; color: #6c757d;">
                Arraste um elemento para reordenar. Arraste para a **direita** para criar uma subcategoria (dica verde), arraste para a **esquerda** para promover (dica vermelha).
            </p>
            <ul id="category-list">
                <!-- Categorias serão renderizadas aqui pelo 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');
        
        // --- Manipuladores de Exportação/Importação 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 vírgulas e aspas 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('Exportação não suportada neste 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(`Importadas ${importedData.length} categorias com sucesso.`);
                } else {
                    alert('Erro ao importar CSV: Os dados estão vazios ou formatados incorretamente.');
                }
            };
            reader.readAsText(file);
        });

        // --- Gestão de Dados Principal e Renderização ---

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

        // Função recursiva para encontrar todos os descendentes de um ID de categoria
        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;
        }

        // Função para atualizar a caixa de pré-visualização simples ao lado do formulário
        function updateLiveFormPreview() {
            const name = document.getElementById('name').value || '[Nome da Categoria]';
            const description = document.getElementById('description').value || '[A descrição vai aqui]';
            const color = document.getElementById('color').value;
            const permissions = document.getElementById('permissions').value;
            const tagsInput = document.getElementById('tags').value;

            // Aplicar cor ao quadrado e borda esquerda
            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;
            
            // Gerar HTML das Pílulas
            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;
        }


        // Função para renderizar a lista de gestão
        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 Item da Lista de Gestão (Arrastável) ---
                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 das tags
                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 ? '— Subcategoria' : ''}</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}')">Excluir</button>
                    </div>
                `;
                list.appendChild(managementItem);
            });
            
            addDragDropListeners();
        }

        // Função para lidar com envio do formulário (Adicionar ou Atualizar)
        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 = 'Adicionar Nova Categoria';
            updateLiveFormPreview();
            renderAll();
        });

        // Função para preencher o formulário para edição
        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 = 'Atualizar Categoria';
            updateLiveFormPreview();
            window.scrollTo(0, 0); 
        }

        // Função para excluir uma categoria
        function deleteCategory(id) {
            // Chamada confirm() removida para permitir execução em ambiente sandboxed
            categories = categories.filter(c => c.id !== id && c.parentId !== id); 
            renderAll();
        }


        // --- Lógica de Arrastar e Soltar para Reordenação e Aninhamento ---

        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 posição 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 APLICAÇÃO DE DICAS ---

                    // 1. Verificar INDENT (Arrastar para a Direita)
                    if (horizontalShift > INDENT_THRESHOLD_PX) {
                        // Verificar se o alvo pode aceitar filhos (ou seja, não é já um filho)
                        if (!dropTargetCat.parentId) {
                            dropTargetElement.classList.add('indent-hint');
                            dropTargetElement.classList.remove('drag-over');
                        }
                    } 
                    
                    // 2. Verificar OUTDENT (Arrastar para a Esquerda)
                    else if (draggedCat.parentId && horizontalShift < -OUTDENT_SHIFT_PX) {
                        dropTargetElement.classList.add('outdent-hint');
                        dropTargetElement.classList.remove('drag-over');
                    }
                    
                    // 3. Meio Termo: Sem dica significa que apenas reordenação vertical ocorre ao 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-sequenciar a matriz de categorias (Ordem Linear)
                        if (draggedIndex !== -1 && dropIndex !== -1) {
                            const [temp] = categories.splice(draggedIndex, 1);
                            categories.splice(dropIndex, 0, temp);
                        }

                        // Re-encontrar índices após resequenciamento
                        const newDraggedIndex = categories.findIndex(c => c.id === draggedId);
                        const draggedCat = categories[newDraggedIndex];
                        
                        // 2. Determinar Novo ID de Pai com base na dica ativa
                        
                        let newParentId = draggedCat.parentId; // Padrão: Manter status de hierarquia atual
                        let updateDescendants = false;

                        if (dropTargetElement.classList.contains('indent-hint')) {
                            // Promover para filho: O novo pai da categoria arrastada é o alvo.
                            newParentId = dropTargetId;
                            updateDescendants = true; 
                        } else if (dropTargetElement.classList.contains('outdent-hint')) {
                            // Promover para pai: O novo pai da categoria arrastada é nulo/vazio.
                            newParentId = '';
                            updateDescendants = true; 
                        }
                        
                        draggedCat.parentId = newParentId;

                        // 3. Atualizar Descendentes se a hierarquia mudou para o pai arrastado
                        if (updateDescendants) {
                            const descendantIds = getDescendantIds(draggedId);
                            descendantIds.forEach(descendantId => {
                                const descendantCat = categories.find(c => c.id === descendantId);
                                if (descendantCat) {
                                    // Descendentes seguem o novo status do pai arrastado
                                    descendantCat.parentId = newParentId;
                                }
                            });
                        }

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

        // --- Carregamento Inicial (Categorias Atualizadas) ---

        categories.push({
            id: 'cat_001', name: 'Geral', description: 'Discussão e ajuda geral da comunidade.', color: '#007bff', permissions: 'Público', parentId: '', tags: 'tag 1, tag 2'
        });
        categories.push({
            id: 'cat_004', name: 'Exemplo de Subcategoria', description: '', color: '#007bff', permissions: 'Público', parentId: 'cat_001', tags: 'tag 3'
        });
        categories.push({
            id: 'cat_002', name: 'Equipe', description: 'Área privada para moderação e administração.', color: '#ffc107', permissions: 'Apenas Equipe', parentId: '', tags: ''
        });
        categories.push({
            id: 'cat_003', name: 'Feedback do Site', description: 'Discuta o próprio fórum e sugira melhorias.', color: '#28a745', permissions: 'Público', parentId: '', tags: 'site, feedback'
        });

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

Temos adicionado feedback ao salvar em mais lugares, e este parece ser um local razoável. Você verá algo como isto na próxima vez que o Discourse for atualizado

3 Curtiram

Agora também existe a capacidade de definir espaços reservados para títulos para cada categoria:

4 Curtiram