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>