Files
plantillas-proyectos/frontend/src/lib/components/help/HelpDrawer.svelte

337 lines
12 KiB
Svelte

<script lang="ts">
import { 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,
BookOpen,
ExternalLink,
Search,
Sparkles,
FileCode,
Upload
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { helpApi, type HelpArticle } from '$lib/api/help';
import { helpStore } from '$lib/stores/help.svelte';
import { browser } from '$app/environment';
import { page } from '$app/state';
import { Badge } from '$lib/components/ui/badge';
import { Label } from '$lib/components/ui/label';
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);
let searchTerm = $state('');
const isAdmin = $derived($currentUser?.roles?.includes('admin') || false);
const currentPath = $derived(page.url.pathname);
// Filtrar artículos contextuales basados en la ruta actual
const contextualArticles = $derived(
articles.filter((a) => {
if (!a.context_path) return false;
try {
const regex = new RegExp(a.context_path);
return regex.test(currentPath);
} catch {
return currentPath.includes(a.context_path);
}
})
);
// Filtrar búsqueda manual
const filteredArticles = $derived(
articles.filter(
(a) =>
a.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
(a.category || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(a.tags || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(a.content || '').toLowerCase().includes(searchTerm.toLowerCase())
)
);
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: '',
content_type: 'markdown'
} as any;
}
async function saveChanges() {
try {
if (isCreating) {
const newArticle = await helpApi.createArticle({
title: editTitle,
content: editContent,
last_editor: $currentUser?.username || 'unknown'
});
selectedArticle = newArticle;
toast.success('Artículo 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');
}
isEditing = false;
isCreating = false;
loadArticles();
} catch (e) {
toast.error('Error al guardar cambios');
}
}
$effect(() => {
if (helpStore.isOpen && articles.length === 0 && !isLoading) {
loadArticles();
}
});
function renderMarkdown(content: string) {
// Fallback simple pero limpio
return content
.replace(/^# (.*$)/gim, '<h1 class="text-2xl font-bold mb-4">$1</h1>')
.replace(/^## (.*$)/gim, '<h2 class="text-xl font-bold mb-3 mt-6">$1</h2>')
.replace(/^### (.*$)/gim, '<h3 class="text-lg font-bold mb-2 mt-4">$1</h3>')
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
.replace(/\*(.*)\*/gim, '<em>$1</em>')
.replace(/\n/gim, '<br />');
}
</script>
<Sheet.Root bind:open={helpStore.isOpen}>
<Sheet.Content side="right" class="w-[450px] sm:w-[600px] border-l bg-background/95 backdrop-blur-md p-0 shadow-2xl flex flex-col">
<!-- Header con Glassmorphism -->
<div class="px-6 py-8 border-b bg-primary/5">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class="p-2 rounded-lg bg-primary/10 text-primary">
<Sparkles size={20} />
</div>
<h2 class="text-xl font-bold tracking-tight">Centro de Ayuda</h2>
</div>
</div>
<p class="text-sm text-muted-foreground">Manuales y guías interactivas del sistema.</p>
</div>
<div class="flex-1 flex flex-col overflow-hidden">
{#if !selectedArticle}
<!-- Vista de Lista -->
<div class="p-6 flex-1 overflow-y-auto space-y-8">
<!-- Search Box -->
<div class="relative group">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground group-focus-within:text-primary transition-colors" />
<input
type="text"
placeholder="Buscar ayuda..."
bind:value={searchTerm}
class="w-full pl-10 pr-4 py-2.5 rounded-xl border bg-muted/50 focus:bg-background focus:ring-2 focus:ring-primary/20 outline-none transition-all"
/>
</div>
<!-- Sección Contextual -->
{#if contextualArticles.length > 0 && !searchTerm}
<div class="space-y-3">
<div class="flex items-center gap-2 px-1">
<Badge variant="secondary" class="bg-primary/10 text-primary border-primary/20">Recomendado para esta pantalla</Badge>
</div>
<div class="grid gap-3">
{#each contextualArticles as article}
<button
onclick={() => selectArticle(article)}
class="flex items-start gap-4 p-4 rounded-xl border bg-card hover:bg-accent/50 hover:border-primary/30 transition-all text-left shadow-sm hover:shadow-md"
>
<div class="p-2 rounded-lg bg-muted flex-shrink-0">
<BookOpen size={18} class="text-primary" />
</div>
<div class="space-y-1">
<h4 class="font-semibold text-sm leading-tight">{article.title}</h4>
<p class="text-xs text-muted-foreground line-clamp-1">{article.category || 'General'}</p>
</div>
</button>
{/each}
</div>
</div>
{/if}
<!-- Todos los artículos -->
<div class="space-y-3">
<h3 class="text-sm font-medium text-muted-foreground px-1">Guías Disponibles</h3>
{#if isLoading}
<div class="flex justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
{:else if filteredArticles.length === 0}
<div class="text-center py-12 px-4 rounded-xl border border-dashed">
<Search size={32} class="mx-auto mb-3 text-muted-foreground opacity-20" />
<p class="text-sm text-muted-foreground">No encontramos nada que coincida con tu búsqueda.</p>
</div>
{:else}
<div class="grid gap-2">
{#each filteredArticles as article}
<button
onclick={() => selectArticle(article)}
class="flex items-center justify-between p-3.5 rounded-lg border border-transparent hover:border-border hover:bg-muted/30 transition-all text-left group"
>
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-md bg-muted flex items-center justify-center text-muted-foreground group-hover:text-primary transition-colors shrink-0">
<BookOpen size={16} />
</div>
<div class="flex flex-col">
<span class="text-sm font-medium">{article.title}</span>
{#if article.tags}
<div class="flex flex-wrap gap-1 mt-1">
{#each article.tags.split(',') as tag}
<span class="text-[9px] text-muted-foreground bg-muted/50 px-1 rounded border border-border/50">{tag.trim()}</span>
{/each}
</div>
{/if}
</div>
</div>
<Badge variant="outline" class="text-[10px] opacity-60 uppercase tracking-tighter shrink-0 ml-2">{article.category || 'Gral'}</Badge>
</button>
{/each}
</div>
{/if}
</div>
</div>
<!-- Footer Actions -->
<div class="p-6 border-t bg-muted/20">
<div class="flex flex-col gap-3">
<Button href="/dashboard/help-center" variant="outline" class="w-full bg-background rounded-xl h-11 border-primary/20 text-primary hover:bg-primary/5">
<ExternalLink size={16} class="mr-2" /> Ir al Catálogo de Manuales
</Button>
{#if isAdmin}
<Button onclick={startCreate} variant="ghost" class="w-full rounded-xl h-11 text-muted-foreground hover:text-foreground">
<Plus size={16} class="mr-2" /> Crear nuevo artículo
</Button>
{/if}
</div>
</div>
{:else}
<!-- Vista de Artículo -->
<div class="flex-1 flex flex-col overflow-hidden">
<div class="px-6 py-4 flex items-center justify-between border-b bg-background">
<Button variant="ghost" size="sm" onclick={() => (selectedArticle = null)} class="rounded-lg h-9">
<ChevronLeft size={18} class="mr-1" /> Volver
</Button>
{#if isAdmin && !isEditing}
<Button variant="outline" size="sm" onclick={startEdit} class="h-9 rounded-lg">
<Edit2 size={16} class="mr-2" /> Editar
</Button>
{/if}
</div>
<div class="flex-1 overflow-y-auto p-8">
{#if isEditing}
<div class="space-y-6">
<div class="space-y-2">
<Label class="text-xs uppercase tracking-widest text-muted-foreground">Título</Label>
<input
bind:value={editTitle}
class="w-full bg-transparent border-b border-muted py-2 text-2xl font-bold outline-none focus:border-primary transition-colors"
placeholder="Título del artículo"
/>
</div>
<div class="space-y-2">
<Label class="text-xs uppercase tracking-widest text-muted-foreground">Contenido (Markdown)</Label>
<textarea
bind:value={editContent}
class="min-h-[500px] w-full bg-muted/20 rounded-xl border p-6 font-mono text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
placeholder="Escribe el contenido..."
></textarea>
</div>
<div class="flex justify-end gap-3 pt-4">
<Button variant="outline" onclick={() => {
isEditing = false;
if (isCreating) selectedArticle = null;
isCreating = false;
}} class="rounded-xl">Cancelar</Button>
<Button onclick={saveChanges} class="rounded-xl px-8 shadow-lg shadow-primary/20">Guardar Cambios</Button>
</div>
</div>
{:else}
<div class="max-w-2xl mx-auto space-y-6">
<div class="space-y-2">
<Badge variant="outline" class="bg-primary/5 text-primary border-primary/20">{selectedArticle.category || 'General'}</Badge>
<h1 class="text-4xl font-extrabold tracking-tight">{selectedArticle.title}</h1>
<div class="flex items-center gap-4 text-xs text-muted-foreground pt-1 border-b pb-6">
<span>Por <strong>{selectedArticle.last_editor}</strong></span>
<span></span>
<span>Actualizado: {new Date(selectedArticle.updated_at).toLocaleDateString()}</span>
</div>
</div>
<div class="prose prose-sm dark:prose-invert max-w-none prose-headings:text-foreground prose-p:text-muted-foreground prose-strong:text-foreground leading-relaxed">
{#if browser}
{@html renderMarkdown(selectedArticle.content)}
{:else}
<div class="whitespace-pre-wrap">{selectedArticle.content}</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
</Sheet.Content>
</Sheet.Root>
<style>
@reference "../../../app.css";
:global(.prose h1) { @apply text-3xl font-bold mb-6 text-foreground; }
:global(.prose h2) { @apply text-2xl font-semibold mb-4 mt-8 text-foreground; }
:global(.prose p) { @apply mb-4 text-muted-foreground; }
:global(.prose strong) { @apply font-bold text-foreground; }
</style>