Nueva funcionalidad

This commit is contained in:
2026-02-19 11:34:03 -06:00
parent f00a32ddb3
commit bcc758953f
14 changed files with 729 additions and 220 deletions

View File

@@ -19,6 +19,8 @@ export interface HelpArticle {
content: string;
updated_at: string;
last_editor: string;
category?: string;
order?: number;
}
export const helpApi = {
@@ -44,7 +46,7 @@ export const helpApi = {
return response.json();
},
async createArticle(data: { title: string; content: string; slug: string; last_editor: string }): Promise<HelpArticle> {
async createArticle(data: { title: string; content: string; slug: string; last_editor: string; category?: string; order?: number }): Promise<HelpArticle> {
const response = await fetch(`${BASE_URL}/articles/`, {
method: 'POST',
headers: getHeaders(),
@@ -64,5 +66,21 @@ export const helpApi = {
async triggerSync(): Promise<void> {
// Opcional: endpoint para forzar sync desde UI si es necesario
},
async uploadImage(file: File): Promise<{ url: string }> {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${BASE_URL}/upload-image/`, {
method: 'POST',
// No Content-Type header for FormData, browser sets it with boundary
headers: {
...(get(authStore).token ? { 'Authorization': `Bearer ${get(authStore).token}` } : {})
},
body: formData
});
if (!response.ok) throw new Error('Failed to upload image');
return response.json();
}
};

View File

@@ -8,7 +8,7 @@
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Plus, Trash2, Edit, Loader2, Search } from 'lucide-svelte';
import { Plus, Trash2, Edit, Loader2, Search, Book, Image as ImageIcon } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
let articles: HelpArticle[] = $state([]);
@@ -28,16 +28,30 @@
title: '',
slug: '',
content: '',
last_editor: 'Admin'
last_editor: 'Admin',
category: 'General',
order: 0
});
const filteredArticles = $derived(
articles.filter(
(a) =>
a.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
a.content.toLowerCase().includes(searchTerm.toLowerCase())
).sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
);
// Derived state: Grouped by Category
const groupedArticles = $derived.by(() => {
const filtered = articles
.filter(
(a) =>
a.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
a.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
(a.category || 'General').toLowerCase().includes(searchTerm.toLowerCase())
)
.sort((a, b) => (a.order || 0) - (b.order || 0)); // Sort by order first
const groups: Record<string, HelpArticle[]> = {};
filtered.forEach((article) => {
const cat = article.category || 'General';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(article);
});
return groups;
});
onMount(async () => {
await loadArticles();
@@ -54,50 +68,12 @@
}
}
function resetForm() {
formValues = {
title: '',
slug: '',
content: '',
last_editor: 'Admin'
};
function handleCreate() {
window.location.href = '/dashboard/help-center/editor/new';
}
async function handleCreate() {
processing = true;
try {
// Generate slug if empty
if (!formValues.slug) {
formValues.slug = formValues.title
.toLowerCase()
.replace(/ /g, '-')
.replace(/[^\w-]+/g, '');
}
await helpApi.createArticle(formValues);
toast.success('Artículo creado correctamente');
showCreateModal = false;
resetForm();
await loadArticles();
} catch (e: any) {
toast.error('Error: ' + e.message);
} finally {
processing = false;
}
}
async function handleUpdate() {
if (!selectedArticle) return;
processing = true;
try {
await helpApi.updateArticle(selectedArticle.uuid, formValues);
toast.success('Artículo actualizado');
showEditModal = false;
await loadArticles();
} catch (e: any) {
toast.error('Error: ' + e.message);
} finally {
processing = false;
}
function openEdit(article: HelpArticle) {
window.location.href = `/dashboard/help-center/editor/${article.uuid}`;
}
async function handleDelete() {
@@ -105,7 +81,7 @@
processing = true;
try {
await helpApi.deleteArticle(selectedArticle.uuid);
toast.success('Artículo eliminado');
toast.success('Capítulo eliminado');
showDeleteDialog = false;
await loadArticles();
} catch (e: any) {
@@ -115,196 +91,125 @@
}
}
function openEdit(article: HelpArticle) {
selectedArticle = article;
formValues = {
title: article.title,
slug: article.slug,
content: article.content,
last_editor: article.last_editor
};
showEditModal = true;
}
function openDelete(article: HelpArticle) {
selectedArticle = article;
showDeleteDialog = true;
}
</script>
<div class="flex h-full flex-col space-y-6">
<div class="flex h-full flex-col space-y-6 rounded-xl bg-muted/10 p-4">
<!-- Page Header -->
<div class="flex flex-col gap-4 px-2 md:flex-row md:items-center md:justify-between">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">Centro de Ayuda</h1>
<p class="text-muted-foreground">Gestiona la base de conocimientos distribuida.</p>
<h1 class="flex items-center gap-2 text-3xl font-bold tracking-tight text-primary">
<Book class="h-8 w-8" />
Biblioteca de Conocimiento
</h1>
<p class="text-muted-foreground">Manuales, Guías y Documentación del Sistema.</p>
</div>
<div class="flex items-center gap-2">
<Button
onclick={() => {
resetForm();
showCreateModal = true;
}}
>
<Button href="/dashboard/help-center/editor/new" class="shadow-lg">
<Plus class="mr-2 h-4 w-4" />
Nuevo Artículo
Nuevo Capítulo
</Button>
</div>
</div>
<!-- Filters & Tools -->
<div class="flex items-center gap-2 px-2">
<div class="relative max-w-sm flex-1">
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
<Input type="search" placeholder="Buscar artículos..." class="pl-8" bind:value={searchTerm} />
</div>
<!-- Search -->
<div class="relative max-w-lg">
<Search class="absolute top-3 left-3 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Buscar en la biblioteca..."
class="h-10 border-muted-foreground/20 bg-background pl-10"
bind:value={searchTerm}
/>
</div>
<!-- Content -->
<div class="flex-1 overflow-auto p-2">
<!-- Content: Shelves -->
<div class="flex-1 space-y-8 overflow-auto pr-2">
{#if loading}
<div class="flex h-32 items-center justify-center">
<Loader2 class="h-8 w-8 animate-spin text-primary" />
<div class="flex h-40 items-center justify-center">
<Loader2 class="h-10 w-10 animate-spin text-primary" />
</div>
{:else if Object.keys(groupedArticles).length === 0}
<div class="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Book class="mb-4 h-16 w-16 opacity-20" />
<p>La biblioteca está vacía.</p>
</div>
{:else if filteredArticles.length === 0}
<Card.Root>
<Card.Content class="flex flex-col items-center justify-center py-10">
<p class="text-muted-foreground">No se encontraron artículos.</p>
</Card.Content>
</Card.Root>
{:else}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each filteredArticles as article}
<Card.Root class="flex h-full flex-col transition-colors hover:border-primary/50">
<Card.Header>
<div class="flex items-start justify-between gap-2">
<Card.Title class="line-clamp-2 text-lg">{article.title}</Card.Title>
<div class="flex shrink-0 gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => openEdit(article)}
>
<Edit class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
onclick={() => openDelete(article)}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
<Card.Description class="text-xs">
Slug: /{article.slug}
</Card.Description>
</Card.Header>
<Card.Content class="flex-grow">
<div class="prose prose-sm line-clamp-4 max-w-none text-sm text-muted-foreground">
{@html article.content}
</div>
</Card.Content>
<Card.Footer
class="flex justify-between bg-muted/30 pt-3 text-[10px] tracking-wider text-muted-foreground uppercase"
{#each Object.entries(groupedArticles) as [category, groupArticles]}
<div class="space-y-4">
<h2
class="flex items-center gap-2 border-b pb-2 text-xl font-semibold text-foreground/80"
>
<span
class="rounded bg-primary/10 px-2 py-1 text-sm tracking-wide text-primary uppercase"
>{category}</span
>
<span>Edito: {article.last_editor}</span>
<span>{new Date(article.updated_at).toLocaleDateString()}</span>
</Card.Footer>
</Card.Root>
{/each}
</div>
</h2>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each groupArticles as article}
<Card.Root
class="group relative flex h-full flex-col overflow-hidden border-muted-foreground/10 bg-background transition-all duration-300 hover:border-primary/50 hover:shadow-xl"
>
<div
class="absolute top-0 left-0 h-full w-1 bg-primary/0 transition-all group-hover:bg-primary"
></div>
<Card.Header class="pb-2">
<Card.Title
class="line-clamp-2 text-lg transition-colors group-hover:text-primary"
>
{article.title}
</Card.Title>
<Card.Description class="text-xs">
{new Date(article.updated_at).toLocaleDateString()}
</Card.Description>
</Card.Header>
<Card.Content class="flex-grow pt-2">
<div class="prose prose-sm line-clamp-3 text-sm text-muted-foreground">
{@html article.content.replace(/<[^>]*>?/gm, '').substring(0, 150)}...
</div>
</Card.Content>
<Card.Footer class="flex items-center justify-between border-t bg-muted/5 pt-4">
<Button variant="ghost" size="sm" href={`/dashboard/help-center/${article.uuid}`}>
Leer
</Button>
<div class="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
href={`/dashboard/help-center/editor/${article.uuid}`}
>
<Edit class="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive"
onclick={() => openDelete(article)}
>
<Trash2 class="h-3 w-3" />
</Button>
</div>
</Card.Footer>
</Card.Root>
{/each}
</div>
</div>
{/each}
{/if}
</div>
</div>
<!-- Create Modal -->
<Dialog.Root bind:open={showCreateModal}>
<Dialog.Content class="sm:max-w-[600px]">
<Dialog.Header>
<Dialog.Title>Crear Artículo</Dialog.Title>
<Dialog.Description>
Completa los campos para añadir un nuevo artículo a la base de conocimientos.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label for="title">Título</Label>
<Input id="title" bind:value={formValues.title} placeholder="Ej: Cómo subir facturas" />
</div>
<div class="grid gap-2">
<Label for="slug">Slug (URL)</Label>
<Input id="slug" bind:value={formValues.slug} placeholder="ej-como-subir-facturas" />
</div>
<div class="grid gap-2">
<Label for="content">Contenido (HTML permitido)</Label>
<Textarea
id="content"
bind:value={formValues.content}
rows={10}
placeholder="Contenido del artículo..."
/>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (showCreateModal = false)}>Cancelar</Button>
<Button
onclick={handleCreate}
disabled={processing || !formValues.title || !formValues.content}
>
{#if processing}<Loader2 class="mr-2 h-4 w-4 animate-spin" />{/if}
Guardar Artículo
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Edit Modal -->
<Dialog.Root bind:open={showEditModal}>
<Dialog.Content class="sm:max-w-[600px]">
<Dialog.Header>
<Dialog.Title>Editar Artículo</Dialog.Title>
<Dialog.Description>
Modifica el contenido del artículo. Los cambios se sincronizarán automáticamente.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label for="edit-title">Título</Label>
<Input id="edit-title" bind:value={formValues.title} />
</div>
<div class="grid gap-2">
<Label for="edit-slug">Slug (URL)</Label>
<Input id="edit-slug" bind:value={formValues.slug} />
</div>
<div class="grid gap-2">
<Label for="edit-content">Contenido</Label>
<Textarea id="edit-content" bind:value={formValues.content} rows={10} />
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (showEditModal = false)}>Cancelar</Button>
<Button
onclick={handleUpdate}
disabled={processing || !formValues.title || !formValues.content}
>
{#if processing}<Loader2 class="mr-2 h-4 w-4 animate-spin" />{/if}
Actualizar Cambios
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Delete Confirmation -->
<AlertDialog.Root bind:open={showDeleteDialog}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>¿Estás seguro?</AlertDialog.Title>
<AlertDialog.Title>¿Eliminar capítulo?</AlertDialog.Title>
<AlertDialog.Description>
Esta acción eliminará el artículo "{selectedArticle?.title}" de forma permanente. Esta
acción no se puede deshacer.
Se eliminará permanentemente "{selectedArticle?.title}".
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
@@ -314,7 +219,7 @@
onclick={handleDelete}
>
{#if processing}<Loader2 class="mr-2 h-4 w-4 animate-spin" />{/if}
Eliminar Artículo
Eliminar
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>

View File

@@ -0,0 +1,153 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { helpApi, type HelpArticle } from '$lib/api/help';
import { Button } from '$lib/components/ui/button/index.js';
import { Loader2, ArrowLeft, Calendar, User, BookOpen } from 'lucide-svelte';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
let article: HelpArticle | null = null; // Removed $state() as it's not reactive by itself in Svelte 5 standard store usage unless wrapped
let loading = true; // Use standard value, reactive via variable assignment in Svelte 5 runes if enabled
let error: string | null = null;
let toc: { id: string; text: string; level: number }[] = [];
onMount(async () => {
const uuid = $page.params.uuid;
try {
article = await helpApi.getArticle(uuid);
if (article) {
parseTOC(article.content);
}
} catch (e: any) {
error = e.message;
} finally {
loading = false;
}
});
function parseTOC(content: string) {
const lines = content.split('\n');
toc = [];
let inCodeBlock = false; // Simple check to avoid parsing headers inside code blocks
lines.forEach((line) => {
if (line.trim().startsWith('```')) {
inCodeBlock = !inCodeBlock;
return;
}
if (inCodeBlock) return;
const match = line.match(/^(#{1,3})\s+(.*)$/);
if (match) {
const level = match[1].length;
const text = match[2];
const id = text.toLowerCase().replace(/[^\w]+/g, '-');
toc = [...toc, { id, text, level }];
}
});
}
function renderMarkdown(content: string) {
const rawHtml = marked.parse(content || '') as string;
// Add IDs to headers for TOC navigation
const htmlWithIds = rawHtml.replace(/<h([1-3])>(.*?)<\/h\1>/g, (match, level, text) => {
const id = text.toLowerCase().replace(/[^\w]+/g, '-');
return `<h${level} id="${id}">${text}</h${level}>`;
});
return DOMPurify.sanitize(htmlWithIds);
}
function scrollToHeader(id: string) {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
</script>
<div class="flex h-full flex-col bg-background">
<!-- Toolbar -->
<div
class="sticky top-0 z-10 flex items-center gap-4 border-b bg-card/50 px-6 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
<Button variant="ghost" size="sm" href="/dashboard/help-center" class="gap-2">
<ArrowLeft class="h-4 w-4" />
Volver a la Biblioteca
</Button>
<div class="mx-2 h-4 w-px bg-border"></div>
{#if article}
<span class="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<BookOpen class="h-4 w-4" />
{article.category || 'General'}
</span>
{/if}
</div>
<div class="relative flex-1 overflow-hidden">
{#if loading}
<div class="flex h-full items-center justify-center">
<Loader2 class="h-8 w-8 animate-spin text-primary" />
</div>
{:else if error}
<div class="flex h-full flex-col items-center justify-center gap-2 text-destructive">
<p class="text-lg font-medium">Error al cargar el capítulo</p>
<p class="text-sm text-muted-foreground">{error}</p>
</div>
{:else if article}
<div class="flex h-full">
<!-- Article Content -->
<div class="flex-1 overflow-y-auto scroll-smooth px-8 py-10 md:px-12 lg:px-16">
<div class="mx-auto max-w-4xl pb-20">
<!-- Header -->
<div class="mb-8 border-b pb-6">
<h1 class="mb-4 text-4xl font-bold tracking-tight text-foreground">
{article.title}
</h1>
<div class="flex items-center gap-6 text-sm text-muted-foreground">
<span class="flex items-center gap-1.5">
<User class="h-4 w-4" />
{article.last_editor}
</span>
<span class="flex items-center gap-1.5">
<Calendar class="h-4 w-4" />
{new Date(article.updated_at).toLocaleDateString(undefined, {
dateStyle: 'long'
})}
</span>
</div>
</div>
<!-- Content -->
<article
class="prose max-w-none prose-slate dark:prose-invert prose-headings:scroll-mt-20 prose-img:rounded-lg prose-img:shadow-md"
>
{@html renderMarkdown(article.content)}
</article>
</div>
</div>
<!-- TOC Sidebar (Desktop) -->
{#if toc.length > 0}
<div class="hidden w-72 overflow-y-auto border-l bg-muted/5 p-6 xl:block">
<h4 class="mb-4 text-sm font-semibold tracking-wider text-muted-foreground uppercase">
En este capitulo
</h4>
<nav class="space-y-1">
{#each toc as item}
<button
class="block w-full py-1 text-left text-sm text-muted-foreground transition-colors hover:text-foreground
{item.level === 1 ? 'font-medium' : 'pl-' + item.level * 2}
"
onclick={() => scrollToHeader(item.id)}
>
{item.text}
</button>
{/each}
</nav>
</div>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,304 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount, tick } from 'svelte';
import { helpApi, type HelpArticle } from '$lib/api/help';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import {
Loader2,
Save,
ArrowLeft,
Image as ImageIcon,
Bold,
Italic,
Link as LinkIcon,
List,
Heading1,
Heading2
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
let article: HelpArticle | null = null; // $state equivalent in Svelte 5 logic handled manually or via store, using standard let for simplest adaptation
let loading = true;
let processing = false;
// Editor State
let title = '';
let slug = '';
let content = '';
let category = 'General';
let order = 0;
let last_editor = 'Admin'; // Could pull from auth store
// Preview State
let showPreview = true;
onMount(async () => {
const uuid = $page.params.uuid as string;
if (uuid === 'new') {
loading = false;
// Defaults for new article
return;
}
try {
article = await helpApi.getArticle(uuid);
if (article) {
title = article.title;
slug = article.slug;
content = article.content;
category = article.category || 'General';
order = article.order || 0;
}
} catch (e: any) {
toast.error('Error al cargar artículo: ' + e.message);
} finally {
loading = false;
}
});
async function handleSave() {
processing = true;
try {
const data = { title, slug, content, last_editor, category, order };
// Auto-generate slug if missing
if (!data.slug) {
data.slug = data.title
.toLowerCase()
.replace(/ /g, '-')
.replace(/[^\w-]+/g, '');
slug = data.slug;
}
const uuid = $page.params.uuid as string;
if (uuid === 'new') {
const newArticle = await helpApi.createArticle(data);
toast.success('Capítulo creado');
// Redirect to edit mode or list? For now, stay here but update URL would be ideal.
// simpler to just go back to list or reload.
// Let's redirect to edit mode of this new UUID to avoid duplicates on re-save
window.location.href = `/dashboard/help-center/editor/${newArticle.uuid}`;
} else {
await helpApi.updateArticle(uuid, data);
toast.success('Cambios guardados');
}
} catch (e: any) {
toast.error('Error al guardar: ' + e.message);
} finally {
processing = false;
}
}
function insertText(prefix: string, suffix: string = '') {
const textarea = document.getElementById('markdown-editor') as HTMLTextAreaElement;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selection = text.substring(start, end);
const before = text.substring(0, start);
const after = text.substring(end);
content = `${before}${prefix}${selection}${suffix}${after}`;
// Restore focus and selection
tick().then(() => {
textarea.focus();
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
});
}
async function handleImageUpload(file: File) {
try {
toast.loading('Subiendo imagen...');
const result = await helpApi.uploadImage(file);
const imageMarkdown = `\n![${file.name}](${result.url})\n`;
insertText(imageMarkdown);
toast.success('Imagen insertada');
} catch (e: any) {
toast.error('Error al subir: ' + e.message);
}
}
function handleDrop(e: DragEvent) {
e.preventDefault();
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const file = files[0];
if (file.type.startsWith('image/')) {
handleImageUpload(file);
}
}
}
function renderMarkdown(text: string) {
// @ts-ignore
return DOMPurify.sanitize(marked.parse(text || '') as string);
}
</script>
<div class="flex h-screen flex-col overflow-hidden bg-background">
<!-- Top Bar -->
<header class="z-10 flex items-center justify-between border-b bg-card px-6 py-3">
<div class="flex items-center gap-4">
<Button variant="ghost" size="sm" href="/dashboard/help-center">
<ArrowLeft class="mr-2 h-4 w-4" />
Volver
</Button>
<h1 class="max-w-md truncate text-lg font-semibold">
{title || 'Sin Título'}
</h1>
</div>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" onclick={() => (showPreview = !showPreview)}>
{showPreview ? 'Ocultar Vista Previa' : 'Ver Vista Previa'}
</Button>
<Button onclick={handleSave} disabled={processing || !title}>
{#if processing}<Loader2 class="mr-2 h-4 w-4 animate-spin" />{/if}
<Save class="mr-2 h-4 w-4" />
Guardar
</Button>
</div>
</header>
<!-- Main Editor Area -->
<div class="flex flex-1 overflow-hidden">
<!-- Metadata Sidebar (Left) - Collapsible or Fixed width -->
<aside class="hidden w-64 overflow-y-auto border-r bg-muted/10 p-4 lg:block">
<h3 class="mb-4 text-sm font-semibold tracking-wider text-muted-foreground uppercase">
Configuración
</h3>
<div class="space-y-4">
<div class="grid gap-2">
<Label for="title">Título</Label>
<Input id="title" bind:value={title} placeholder="Ej: Introducción" />
</div>
<div class="grid gap-2">
<Label for="slug">Slug / URL</Label>
<Input id="slug" bind:value={slug} class="font-mono text-xs" />
</div>
<div class="grid gap-2">
<Label for="category">Categoría</Label>
<Input id="category" bind:value={category} placeholder="Ej: General" />
</div>
<div class="grid gap-2">
<Label for="order">Orden</Label>
<Input id="order" type="number" bind:value={order} />
</div>
</div>
</aside>
<!-- Split View -->
<main class="flex flex-1 overflow-hidden">
<!-- Editor -->
<div class="group relative flex flex-1 flex-col border-r">
<!-- Toolbar -->
<div class="flex items-center gap-1 border-b bg-background p-2">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => insertText('**', '**')}
title="Negrita"
>
<Bold class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => insertText('*', '*')}
title="Cursiva"
>
<Italic class="h-4 w-4" />
</Button>
<div class="mx-1 h-4 w-px bg-border"></div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => insertText('# ')}
title="Título 1"
>
<Heading1 class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => insertText('## ')}
title="Título 2"
>
<Heading2 class="h-4 w-4" />
</Button>
<div class="mx-1 h-4 w-px bg-border"></div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => insertText('- ')}
title="Lista"
>
<List class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => insertText('[texto](url)')}
title="Enlace"
>
<LinkIcon class="h-4 w-4" />
</Button>
<label
class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
title="Subir Imagen"
>
<ImageIcon class="h-4 w-4" />
<input
type="file"
accept="image/*"
class="hidden"
onchange={(e) => {
const target = e.target as HTMLInputElement;
if (target.files?.length) handleImageUpload(target.files[0]);
}}
/>
</label>
</div>
<textarea
id="markdown-editor"
bind:value={content}
class="w-full flex-1 resize-none bg-background p-8 font-mono text-sm leading-relaxed outline-none"
placeholder="# Empieza a escribir aquí..."
ondrop={handleDrop}
ondragover={(e) => e.preventDefault()}
></textarea>
<!-- Dropping Hint -->
<div
class="pointer-events-none absolute inset-0 m-4 hidden items-center justify-center rounded-lg border-2 border-dashed border-primary bg-primary/10 opacity-0 transition-opacity group-hover:flex"
>
<p class="font-medium text-primary">Arrastra imágenes aquí</p>
</div>
</div>
<!-- Preview -->
{#if showPreview}
<div class="w-1/2 flex-1 overflow-y-auto border-l bg-muted/5 p-8">
<div class="prose max-w-none prose-slate dark:prose-invert">
{@html renderMarkdown(content)}
</div>
</div>
{/if}
</main>
</div>
</div>