376 lines
20 KiB
HTML
376 lines
20 KiB
HTML
<!-- Vista: Gestión de Páginas de Documentación -->
|
|
<!-- EasyMDE para editor Markdown -->
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
|
|
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
|
|
<!-- Marked.js para preview de Markdown en la vista de lectura -->
|
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
|
|
<div x-data="paginasApp()" x-init="init()" @keydown.escape="closeModal()" class="bg-white rounded-lg shadow">
|
|
<div class="container mx-auto p-6 w-full">
|
|
<h1 class="text-2xl font-bold mb-1">Páginas de Documentación</h1>
|
|
<p class="text-sm text-gray-500 mb-4">Crea y gestiona el contenido de documentación para cada producto SaaS.</p>
|
|
|
|
<!-- Filtros -->
|
|
<div class="flex flex-col md:flex-row flex-wrap gap-3 mb-4">
|
|
<input type="text" x-model="search" @input="load(1)" placeholder="Buscar título..."
|
|
class="border border-gray-300 rounded p-2 text-sm w-full md:w-48">
|
|
<select x-model.number="filterSaas" @change="load(1)"
|
|
class="border border-gray-300 rounded p-2 text-sm w-full md:w-48">
|
|
<option :value="0">— Todos los SaaS —</option>
|
|
<template x-for="s in saasList" :key="s.ID">
|
|
<option :value="s.ID" x-text="s.nombre"></option>
|
|
</template>
|
|
</select>
|
|
<select x-model.number="filterCat" @change="load(1)"
|
|
class="border border-gray-300 rounded p-2 text-sm w-full md:w-48">
|
|
<option :value="0">— Todas las categorías —</option>
|
|
<template x-for="c in catList" :key="c.ID">
|
|
<option :value="c.ID" x-text="c.nombre"></option>
|
|
</template>
|
|
</select>
|
|
<div class="flex-1"></div>
|
|
<button @click="openAdd()" class="bg-[#8eb02f] text-white px-4 py-2 rounded text-sm whitespace-nowrap">+ Nueva Página</button>
|
|
</div>
|
|
|
|
<!-- Tabla -->
|
|
<div class="overflow-x-auto">
|
|
<table class="table-auto w-full text-sm">
|
|
<thead class="border-b border-gray-200 text-left font-semibold text-gray-600">
|
|
<tr>
|
|
<th class="py-2 px-4 border-b">Título</th>
|
|
<th class="py-2 px-4 border-b">SaaS</th>
|
|
<th class="py-2 px-4 border-b">Categoría</th>
|
|
<th class="py-2 px-4 border-b">Tipo</th>
|
|
<th class="py-2 px-4 border-b">Visibilidad</th>
|
|
<th class="py-2 px-4 border-b w-20">Estado</th>
|
|
<th class="py-2 px-4 border-b w-20"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="text-gray-500">
|
|
<template x-if="items.length === 0">
|
|
<tr><td colspan="7" class="py-4 text-center text-gray-400">Sin páginas registradas</td></tr>
|
|
</template>
|
|
<template x-for="item in items" :key="item.ID">
|
|
<tr class="hover:bg-gray-50 border-b border-gray-100">
|
|
<td class="py-2 px-4">
|
|
<span class="font-medium text-gray-800" x-text="item.titulo"></span>
|
|
<span class="block font-mono text-xs text-gray-400" x-text="item.slug"></span>
|
|
</td>
|
|
<td class="py-2 px-4 text-xs" x-text="item.saas ? item.saas.nombre : '—'"></td>
|
|
<td class="py-2 px-4 text-xs" x-text="item.categoria ? item.categoria.nombre : '—'"></td>
|
|
<td class="py-2 px-4">
|
|
<span :class="item.tipo_contenido === 'markdown' ? 'bg-blue-100 text-blue-700' : 'bg-purple-100 text-purple-700'"
|
|
class="text-xs px-2 py-0.5 rounded-full" x-text="item.tipo_contenido"></span>
|
|
</td>
|
|
<td class="py-2 px-4">
|
|
<span :class="item.visibilidad === 'public' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'"
|
|
class="text-xs px-2 py-0.5 rounded-full" x-text="item.visibilidad"></span>
|
|
</td>
|
|
<td class="py-2 px-4">
|
|
<span :class="item.publicado ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
|
|
class="text-xs px-2 py-0.5 rounded-full" x-text="item.publicado ? 'Publicado' : 'Borrador'"></span>
|
|
</td>
|
|
<td class="py-2 px-4 flex gap-2 justify-end">
|
|
<a :href="item.saas ? (item.visibilidad === 'private' ? `/app/docs/${item.saas.slug}/${item.slug}` : `/docs/${item.saas.slug}/${item.slug}`) : '#'" target="_blank" title="Ver página" class="text-green-500 hover:text-green-700">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
|
</svg>
|
|
</a>
|
|
<button @click="openEdit(item.ID)" title="Editar" class="text-blue-500 hover:text-blue-700">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
|
</svg>
|
|
</button>
|
|
<button @click="confirmDelete(item.ID)" title="Eliminar" class="text-red-400 hover:text-red-600">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
|
</svg>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Paginación -->
|
|
<div class="flex items-center justify-between mt-4 text-sm">
|
|
<span class="text-gray-500">Total: <strong x-text="total"></strong></span>
|
|
<div class="flex gap-1">
|
|
<template x-for="p in totalPages" :key="p">
|
|
<button @click="load(p)"
|
|
:class="p === page ? 'bg-[#8eb02f] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
|
class="px-3 py-1 rounded text-xs" x-text="p"></button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ─── Modal Editor (ocupa pantalla completa) ─────────────────────────── -->
|
|
<div x-cloak x-show="showModal" x-transition
|
|
class="fixed inset-0 bg-gray-900 bg-opacity-80 flex z-40 justify-center items-start pt-6 px-4 text-sm overflow-y-auto">
|
|
<div class="bg-white rounded shadow-lg w-full max-w-4xl mb-6">
|
|
<div class="flex items-center justify-between p-4 border-b">
|
|
<h2 class="text-lg font-bold" x-text="editId ? 'Editar Página' : 'Nueva Página'"></h2>
|
|
<button @click="closeModal()" class="text-gray-400 hover:text-gray-600 text-xl font-bold">✕</button>
|
|
</div>
|
|
|
|
<form @submit.prevent="submitForm()" class="p-4 space-y-4">
|
|
<!-- Fila 1: Título + Slug -->
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label class="block text-xs font-medium mb-1">Título *</label>
|
|
<input type="text" x-model="form.titulo" @input="autoSlug()" required
|
|
class="border border-gray-300 rounded w-full p-2 text-sm">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium mb-1">Slug *</label>
|
|
<input type="text" x-model="form.slug" required
|
|
class="border border-gray-300 rounded w-full p-2 text-sm font-mono">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Fila 2: SaaS + Categoría -->
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label class="block text-xs font-medium mb-1">Producto SaaS *</label>
|
|
<select x-model.number="form.saas_id" required class="border border-gray-300 rounded w-full p-2 text-sm">
|
|
<option :value="0" disabled>Seleccionar SaaS...</option>
|
|
<template x-for="s in saasList" :key="s.ID">
|
|
<option :value="s.ID" x-text="s.nombre"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium mb-1">Categoría *</label>
|
|
<select x-model.number="form.categoria_id" required class="border border-gray-300 rounded w-full p-2 text-sm">
|
|
<option :value="0" disabled>Seleccionar categoría...</option>
|
|
<template x-for="c in catList" :key="c.ID">
|
|
<option :value="c.ID" x-text="c.nombre"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Fila 3: Tipo contenido + Visibilidad + Orden + Publicado -->
|
|
<div class="grid grid-cols-4 gap-3">
|
|
<div>
|
|
<label class="block text-xs font-medium mb-1">Tipo de Contenido</label>
|
|
<select x-model="form.tipo_contenido" @change="toggleEditor()"
|
|
class="border border-gray-300 rounded w-full p-2 text-sm">
|
|
<option value="markdown">Markdown</option>
|
|
<option value="html">HTML (builder)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium mb-1">Visibilidad</label>
|
|
<select x-model="form.visibilidad" class="border border-gray-300 rounded w-full p-2 text-sm">
|
|
<option value="public">Pública</option>
|
|
<option value="private">Privada</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium mb-1">Orden</label>
|
|
<input type="number" x-model.number="form.orden" min="0"
|
|
class="border border-gray-300 rounded w-full p-2 text-sm">
|
|
</div>
|
|
<div class="flex items-center gap-2 mt-4">
|
|
<input type="checkbox" id="publicado" x-model="form.publicado" class="w-4 h-4">
|
|
<label for="publicado" class="text-xs font-medium">Publicado</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Roles (sólo cuando visibilidad = private) -->
|
|
<div x-show="form.visibilidad === 'private'" class="space-y-1">
|
|
<label class="block text-xs font-medium mb-1">Roles con acceso <span class="text-gray-400 font-normal">(dejar vacío = todos los usuarios logueados)</span></label>
|
|
<div class="flex flex-wrap gap-2">
|
|
<template x-for="r in rolesList" :key="r.ID">
|
|
<label class="flex items-center gap-1 bg-gray-100 px-2 py-1 rounded cursor-pointer text-xs">
|
|
<input type="checkbox" :value="r.ID"
|
|
:checked="form.rol_ids.includes(r.ID)"
|
|
@change="toggleRol(r.ID)">
|
|
<span x-text="r.name"></span>
|
|
</label>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Editor Markdown -->
|
|
<div x-show="form.tipo_contenido === 'markdown'">
|
|
<label class="block text-xs font-medium mb-1">Contenido (Markdown)</label>
|
|
<textarea id="mdEditor"></textarea>
|
|
</div>
|
|
|
|
<!-- Editor HTML -->
|
|
<div x-show="form.tipo_contenido === 'html'">
|
|
<label class="block text-xs font-medium mb-1">Contenido (HTML)</label>
|
|
<textarea x-model="form.contenido" rows="16"
|
|
class="border border-gray-300 rounded w-full p-2 text-sm font-mono"
|
|
placeholder="Escribe HTML aquí..."></textarea>
|
|
</div>
|
|
|
|
<div class="flex justify-between pt-2 border-t">
|
|
<button type="submit" class="bg-[#8eb02f] text-white px-5 py-2 rounded text-sm">Guardar</button>
|
|
<button type="button" @click="closeModal()" class="bg-gray-500 text-white px-4 py-2 rounded text-sm">Cancelar</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Confirmar Eliminación -->
|
|
<div x-cloak x-show="showDeleteModal" x-transition
|
|
class="fixed inset-0 bg-gray-800 bg-opacity-75 flex z-50 justify-center items-center text-sm">
|
|
<div class="bg-white p-6 rounded shadow-lg max-w-sm w-full">
|
|
<h2 class="text-lg font-bold mb-3">Confirmar eliminación</h2>
|
|
<p class="text-gray-600 mb-4">¿Eliminar esta página de documentación? Esta acción no se puede deshacer.</p>
|
|
<div class="flex justify-end gap-2">
|
|
<button @click="doDelete()" class="bg-red-500 text-white px-4 py-2 rounded text-sm">Eliminar</button>
|
|
<button @click="showDeleteModal = false" class="bg-gray-500 text-white px-4 py-2 rounded text-sm">Cancelar</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let mdEditorInstance = null;
|
|
|
|
function paginasApp() {
|
|
return {
|
|
items: [], saasList: [], catList: [], rolesList: [],
|
|
total: 0, totalPages: 1, page: 1,
|
|
search: '', filterSaas: 0, filterCat: 0,
|
|
loading: false,
|
|
showModal: false, showDeleteModal: false,
|
|
editId: null, deleteId: null,
|
|
form: {
|
|
titulo: '', slug: '', saas_id: 0, categoria_id: 0,
|
|
contenido: '', tipo_contenido: 'markdown', visibilidad: 'public',
|
|
orden: 0, publicado: false, rol_ids: []
|
|
},
|
|
|
|
init() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const sid = parseInt(params.get('saas_id') || '0', 10);
|
|
if (sid) this.filterSaas = sid;
|
|
this.load(1);
|
|
},
|
|
|
|
load(p = 1) {
|
|
this.page = p; this.loading = true;
|
|
const qs = new URLSearchParams({
|
|
page: p, search: this.search,
|
|
saas_id: this.filterSaas, categoria_id: this.filterCat
|
|
});
|
|
fetch(`/app/doc/loadpaginas?${qs}`)
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
this.items = d.items || [];
|
|
this.saasList = d.saas || [];
|
|
this.catList = d.categorias || [];
|
|
this.rolesList = d.roles || [];
|
|
this.total = d.total;
|
|
this.totalPages = Array.from({ length: d.totalPages }, (_, i) => i + 1);
|
|
this.loading = false;
|
|
});
|
|
},
|
|
|
|
autoSlug() {
|
|
if (!this.editId) {
|
|
this.form.slug = this.form.titulo
|
|
.toLowerCase().trim()
|
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
}
|
|
},
|
|
|
|
toggleRol(id) {
|
|
const idx = this.form.rol_ids.indexOf(id);
|
|
if (idx === -1) this.form.rol_ids.push(id);
|
|
else this.form.rol_ids.splice(idx, 1);
|
|
},
|
|
|
|
openAdd() {
|
|
this.editId = null;
|
|
this.form = {
|
|
titulo: '', slug: '', saas_id: 0, categoria_id: 0,
|
|
contenido: '', tipo_contenido: 'markdown', visibilidad: 'public',
|
|
orden: 0, publicado: false, rol_ids: []
|
|
};
|
|
this.showModal = true;
|
|
this.$nextTick(() => this.initMde());
|
|
},
|
|
|
|
openEdit(id) {
|
|
fetch(`/app/doc/paginas/${id}`)
|
|
.then(r => r.json())
|
|
.then(item => {
|
|
this.editId = item.ID;
|
|
this.form = {
|
|
titulo: item.titulo, slug: item.slug,
|
|
saas_id: item.saas_id, categoria_id: item.categoria_id,
|
|
contenido: item.contenido, tipo_contenido: item.tipo_contenido,
|
|
visibilidad: item.visibilidad, orden: item.orden,
|
|
publicado: item.publicado,
|
|
rol_ids: (item.roles || []).map(r => r.ID)
|
|
};
|
|
this.showModal = true;
|
|
this.$nextTick(() => this.initMde(item.contenido));
|
|
});
|
|
},
|
|
|
|
initMde(val = '') {
|
|
if (mdEditorInstance) { mdEditorInstance.toTextArea(); mdEditorInstance = null; }
|
|
if (this.form.tipo_contenido !== 'markdown') return;
|
|
const ta = document.getElementById('mdEditor');
|
|
if (!ta) return;
|
|
mdEditorInstance = new EasyMDE({
|
|
element: ta,
|
|
initialValue: val || this.form.contenido,
|
|
spellChecker: false,
|
|
autosave: { enabled: false },
|
|
toolbar: ['bold','italic','heading','|','quote','code','|','unordered-list','ordered-list','|','link','image','|','preview','side-by-side','fullscreen','|','guide']
|
|
});
|
|
mdEditorInstance.codemirror.on('change', () => {
|
|
this.form.contenido = mdEditorInstance.value();
|
|
});
|
|
},
|
|
|
|
toggleEditor() {
|
|
this.$nextTick(() => {
|
|
if (this.form.tipo_contenido === 'markdown') {
|
|
this.initMde(this.form.contenido);
|
|
} else {
|
|
if (mdEditorInstance) { mdEditorInstance.toTextArea(); mdEditorInstance = null; }
|
|
}
|
|
});
|
|
},
|
|
|
|
submitForm() {
|
|
// Sincronizar contenido del editor Markdown antes de enviar
|
|
if (mdEditorInstance) this.form.contenido = mdEditorInstance.value();
|
|
|
|
const url = this.editId ? `/app/doc/paginas/${this.editId}` : '/app/doc/paginas';
|
|
const method = this.editId ? 'PUT' : 'POST';
|
|
fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.form) })
|
|
.then(r => r.json())
|
|
.then(d => { if (d.error) { alert(d.error); return; } this.closeModal(); this.load(this.page); });
|
|
},
|
|
|
|
confirmDelete(id) { this.deleteId = id; this.showDeleteModal = true; },
|
|
|
|
doDelete() {
|
|
fetch(`/app/doc/paginas/${this.deleteId}`, { method: 'DELETE' })
|
|
.then(r => r.json())
|
|
.then(() => { this.showDeleteModal = false; this.load(this.page); });
|
|
},
|
|
|
|
closeModal() {
|
|
if (mdEditorInstance) { mdEditorInstance.toTextArea(); mdEditorInstance = null; }
|
|
this.showModal = false;
|
|
this.showDeleteModal = false;
|
|
}
|
|
};
|
|
}
|
|
</script>
|