233 lines
6.9 KiB
Svelte
233 lines
6.9 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { authStore, currentUser } from '$lib/auth';
|
|
import * as Sheet from '$lib/components/ui/sheet';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import { HelpCircle, Edit2, Save, X, ChevronLeft, Plus } from 'lucide-svelte';
|
|
import { toast } from 'svelte-sonner';
|
|
import { helpApi, type HelpArticle } from '$lib/api/help';
|
|
import { helpStore } from '$lib/stores/help.svelte';
|
|
|
|
// Nota: Estas librerías deben ser instaladas: npm install marked dompurify @types/dompurify
|
|
// Si no están, fallará el import. El usuario debe instalarlas.
|
|
// import { marked } from 'marked';
|
|
// import DOMPurify from 'dompurify';
|
|
|
|
let articles = $state<HelpArticle[]>([]);
|
|
let selectedArticle = $state<HelpArticle | null>(null);
|
|
let isEditing = $state(false);
|
|
let editContent = $state('');
|
|
let editTitle = $state('');
|
|
let isLoading = $state(false);
|
|
let isCreating = $state(false);
|
|
|
|
// Temporalmente habilitado para todos los usuarios por petición
|
|
const isAdmin = $derived(true);
|
|
|
|
async function loadArticles() {
|
|
isLoading = true;
|
|
try {
|
|
articles = await helpApi.listArticles();
|
|
} catch (e) {
|
|
toast.error('Error al cargar artículos de ayuda');
|
|
} finally {
|
|
isLoading = false;
|
|
}
|
|
}
|
|
|
|
function selectArticle(article: HelpArticle) {
|
|
selectedArticle = article;
|
|
isEditing = false;
|
|
isCreating = false;
|
|
}
|
|
|
|
function startEdit() {
|
|
if (!selectedArticle) return;
|
|
editContent = selectedArticle.content;
|
|
editTitle = selectedArticle.title;
|
|
isEditing = true;
|
|
isCreating = false;
|
|
}
|
|
|
|
function startCreate() {
|
|
editContent = '# Nuevo Artículo\nEscribe el contenido aquí...';
|
|
editTitle = '';
|
|
isEditing = true;
|
|
isCreating = true;
|
|
selectedArticle = {
|
|
uuid: '',
|
|
slug: '',
|
|
title: '',
|
|
content: '',
|
|
updated_at: '',
|
|
last_editor: ''
|
|
}; // Mock for UI
|
|
}
|
|
|
|
function generateSlug(text: string): string {
|
|
return text
|
|
.toLowerCase()
|
|
.replace(/[^\w ]+/g, '')
|
|
.replace(/ +/g, '-');
|
|
}
|
|
|
|
async function saveChanges() {
|
|
try {
|
|
if (isCreating) {
|
|
const slug = generateSlug(editTitle);
|
|
const newArticle = await helpApi.createArticle({
|
|
title: editTitle,
|
|
content: editContent,
|
|
slug: slug,
|
|
last_editor: $currentUser?.username || 'unknown'
|
|
});
|
|
selectedArticle = newArticle;
|
|
toast.success('Capítulo creado con éxito');
|
|
} else if (selectedArticle) {
|
|
const updated = await helpApi.updateArticle(selectedArticle.uuid, {
|
|
content: editContent,
|
|
title: editTitle,
|
|
last_editor: $currentUser?.username || 'unknown'
|
|
});
|
|
selectedArticle = updated;
|
|
toast.success('Cambios guardados y sincronizados');
|
|
}
|
|
isEditing = false;
|
|
isCreating = false;
|
|
loadArticles();
|
|
} catch (e) {
|
|
toast.error(isCreating ? 'Error al crear artículo' : 'Error al guardar cambios');
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
loadArticles();
|
|
});
|
|
|
|
// Simple markdown renderer fallback if marked is not available
|
|
function renderMarkdown(content: string) {
|
|
// En una implementación real, usar marked + DOMPurify
|
|
// return DOMPurify.sanitize(marked.parse(content));
|
|
|
|
// Fallback ultra-básico para demostración
|
|
return content
|
|
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
|
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
|
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
|
.replace(/\*\*(.*)\*\*/gim, '<b>$1</b>')
|
|
.replace(/\*(.*)\*/gim, '<i>$1</i>')
|
|
.replace(/\n/gim, '<br />');
|
|
}
|
|
</script>
|
|
|
|
<Sheet.Root bind:open={helpStore.isOpen}>
|
|
<Sheet.Trigger>
|
|
{#snippet child({ props })}
|
|
<button
|
|
{...props}
|
|
class="fixed right-6 bottom-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95"
|
|
aria-label="Ayuda"
|
|
>
|
|
<HelpCircle size={28} />
|
|
</button>
|
|
{/snippet}
|
|
</Sheet.Trigger>
|
|
<Sheet.Content side="right" class="w-[400px] sm:w-[540px]">
|
|
<Sheet.Header>
|
|
<Sheet.Title class="flex items-center gap-2">
|
|
{#if selectedArticle}
|
|
<Button variant="ghost" size="icon" onclick={() => (selectedArticle = null)}>
|
|
<ChevronLeft size={20} />
|
|
</Button>
|
|
{/if}
|
|
Base de Conocimientos
|
|
</Sheet.Title>
|
|
</Sheet.Header>
|
|
|
|
<div class="mt-6 flex h-[calc(100vh-120px)] flex-col">
|
|
{#if !selectedArticle}
|
|
<div class="space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-medium">Artículos Disponibles</h3>
|
|
{#if isAdmin}
|
|
<Button variant="outline" size="sm" onclick={startCreate}>
|
|
<Plus size={16} class="mr-2" /> Agregar Nuevo
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
{#if isLoading}
|
|
<p class="py-10 text-center text-sm text-muted-foreground">Cargando...</p>
|
|
{:else if articles.length === 0}
|
|
<p class="py-10 text-center text-sm text-muted-foreground">
|
|
No hay artículos de ayuda disponibles.
|
|
</p>
|
|
{/if}
|
|
<div class="grid gap-2">
|
|
{#each articles as article}
|
|
<button
|
|
onclick={() => selectArticle(article)}
|
|
class="flex flex-col items-start rounded-lg border p-4 text-left transition-colors hover:bg-accent"
|
|
>
|
|
<span class="font-semibold">{article.title}</span>
|
|
<span class="text-xs text-muted-foreground"
|
|
>Última edición: {new Date(article.updated_at).toLocaleDateString()}</span
|
|
>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="flex flex-1 flex-col gap-4">
|
|
{#if isEditing}
|
|
<div class="space-y-4">
|
|
<input
|
|
bind:value={editTitle}
|
|
class="w-full rounded-md border bg-transparent p-2 text-xl font-bold"
|
|
placeholder="Título del artículo"
|
|
/>
|
|
<textarea
|
|
bind:value={editContent}
|
|
class="min-h-[400px] w-full flex-1 rounded-md border bg-transparent p-4 font-mono text-sm focus:ring-1 focus:ring-primary focus:outline-none"
|
|
placeholder="Escribe en Markdown..."
|
|
></textarea>
|
|
<div class="flex justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onclick={() => {
|
|
isEditing = false;
|
|
if (isCreating) selectedArticle = null;
|
|
isCreating = false;
|
|
}}
|
|
>
|
|
<X size={16} class="mr-2" /> Cancelar
|
|
</Button>
|
|
<Button onclick={saveChanges}>
|
|
<Save size={16} class="mr-2" /> Guardar Cambios
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="flex flex-col gap-4">
|
|
<div class="flex items-center justify-between">
|
|
<h2 class="text-2xl font-bold">{selectedArticle.title}</h2>
|
|
{#if isAdmin}
|
|
<Button variant="outline" size="sm" onclick={startEdit}>
|
|
<Edit2 size={16} class="mr-2" /> Editar
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
<div class="prose prose-sm max-w-none border-t pt-4 dark:prose-invert">
|
|
{@html renderMarkdown(selectedArticle.content)}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</Sheet.Content>
|
|
</Sheet.Root>
|
|
|
|
<style>
|
|
/* Estilos adicionales si son necesarios */
|
|
</style>
|