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>