Barra de ayuda contextualizada

This commit is contained in:
2026-04-28 14:24:57 -05:00
parent d145cc376e
commit 381149e276
8 changed files with 299 additions and 134 deletions

View File

@@ -14,6 +14,8 @@ class HelpArticleBase(BaseModel):
file_url: Optional[str] = None
file_size: Optional[int] = None
mime_type: Optional[str] = None
context_path: Optional[str] = None
tags: Optional[str] = None
class HelpArticleCreate(HelpArticleBase):
pass
@@ -29,6 +31,8 @@ class HelpArticleUpdate(BaseModel):
file_url: Optional[str] = None
file_size: Optional[int] = None
mime_type: Optional[str] = None
context_path: Optional[str] = None
tags: Optional[str] = None
class HelpArticleInDB(HelpArticleBase):
uuid: UUID
@@ -50,6 +54,8 @@ class HelpSyncRequest(BaseModel):
client_file_url: Optional[str] = None
client_file_size: Optional[int] = None
client_mime_type: Optional[str] = None
client_context_path: Optional[str] = None
client_tags: Optional[str] = None
class HelpSyncResponse(BaseModel):
status: str
@@ -63,4 +69,6 @@ class HelpSyncResponse(BaseModel):
server_file_url: Optional[str] = None
server_file_size: Optional[int] = None
server_mime_type: Optional[str] = None
server_context_path: Optional[str] = None
server_tags: Optional[str] = None
message: str

View File

@@ -22,6 +22,8 @@ class HelpCenterService:
article.file_url = metadata.get("file_url")
article.file_size = metadata.get("file_size")
article.mime_type = metadata.get("mime_type")
article.context_path = metadata.get("context_path")
article.tags = metadata.get("tags")
# Remove metadata from content for clean display if needed,
# but usually better to leave it and let parser handle it or hide it here.
# For now, we just set the attributes.
@@ -32,6 +34,8 @@ class HelpCenterService:
article.file_url = None
article.file_size = None
article.mime_type = None
article.context_path = None
article.tags = None
return article
@@ -44,7 +48,9 @@ class HelpCenterService:
"content_type": data.get("content_type", "article"),
"file_url": data.get("file_url"),
"file_size": data.get("file_size"),
"mime_type": data.get("mime_type")
"mime_type": data.get("mime_type"),
"context_path": data.get("context_path"),
"tags": data.get("tags")
}
# Only append if there's something meaningful beyond "article"
@@ -82,7 +88,7 @@ class HelpCenterService:
# Move metadata into content
data["content"] = HelpCenterService._extract_metadata(data["content"], data)
# Remove virtual fields from data to avoid SQLAlchemy errors
virtual_fields = ["content_type", "file_url", "file_size", "mime_type"]
virtual_fields = ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]
for f in virtual_fields:
if f in data:
del data[f]
@@ -105,16 +111,18 @@ class HelpCenterService:
update_data = article_data.model_dump(exclude_unset=True)
# Handle metadata update
if "content" in update_data or any(f in update_data for f in ["content_type", "file_url", "file_size", "mime_type"]):
if "content" in update_data or any(f in update_data for f in ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]):
# Merge existing metadata with new updates
current_meta = {
"content_type": getattr(db_article, "content_type", "article"),
"file_url": getattr(db_article, "file_url", None),
"file_size": getattr(db_article, "file_size", None),
"mime_type": getattr(db_article, "mime_type", None)
"mime_type": getattr(db_article, "mime_type", None),
"context_path": getattr(db_article, "context_path", None),
"tags": getattr(db_article, "tags", None)
}
# Update with new data if present
for f in ["content_type", "file_url", "file_size", "mime_type"]:
for f in ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]:
if f in update_data:
current_meta[f] = update_data[f]
@@ -123,7 +131,7 @@ class HelpCenterService:
update_data["content"] = HelpCenterService._extract_metadata(content, current_meta)
# Remove virtual fields from data
virtual_fields = ["content_type", "file_url", "file_size", "mime_type"]
virtual_fields = ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]
for f in virtual_fields:
if f in update_data:
del update_data[f]
@@ -162,7 +170,9 @@ class HelpCenterService:
"content_type": sync_data.client_content_type,
"file_url": sync_data.client_file_url,
"file_size": sync_data.client_file_size,
"mime_type": sync_data.client_mime_type
"mime_type": sync_data.client_mime_type,
"context_path": sync_data.client_context_path,
"tags": sync_data.client_tags
}
content_with_meta = HelpCenterService._extract_metadata(sync_data.client_content, client_meta)
@@ -197,7 +207,9 @@ class HelpCenterService:
"content_type": sync_data.client_content_type,
"file_url": sync_data.client_file_url,
"file_size": sync_data.client_file_size,
"mime_type": sync_data.client_mime_type
"mime_type": sync_data.client_mime_type,
"context_path": sync_data.client_context_path,
"tags": sync_data.client_tags
}
db_article.content = HelpCenterService._extract_metadata(sync_data.client_content, client_meta)
db_article.title = sync_data.client_title
@@ -232,6 +244,8 @@ class HelpCenterService:
server_file_url=getattr(db_article, "file_url", None),
server_file_size=getattr(db_article, "file_size", None),
server_mime_type=getattr(db_article, "mime_type", None),
server_context_path=getattr(db_article, "context_path", None),
server_tags=getattr(db_article, "tags", None),
message="Client is outdated. Update required."
)

View File

@@ -39,6 +39,8 @@ export interface HelpArticle {
file_url?: string;
file_size?: number;
mime_type?: string;
context_path?: string;
tags?: string;
}
export const helpApi = {

View File

@@ -1,17 +1,28 @@
<script lang="ts">
import { authStore, currentUser } from '$lib/auth';
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 } from 'lucide-svelte';
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';
// 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';
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);
@@ -20,9 +31,34 @@
let editTitle = $state('');
let isLoading = $state(false);
let isCreating = $state(false);
let searchTerm = $state('');
// Temporalmente habilitado para todos los usuarios por petición
const isAdmin = $derived(true);
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;
@@ -62,28 +98,19 @@
updated_at: '',
last_editor: '',
content_type: 'markdown'
}; // Mock for UI
}
function generateSlug(text: string): string {
return text
.toLowerCase()
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '-');
} as any;
}
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');
toast.success('Artículo creado con éxito');
} else if (selectedArticle) {
const updated = await helpApi.updateArticle(selectedArticle.uuid, {
content: editContent,
@@ -91,13 +118,13 @@
last_editor: $currentUser?.username || 'unknown'
});
selectedArticle = updated;
toast.success('Cambios guardados y sincronizados');
toast.success('Cambios guardados');
}
isEditing = false;
isCreating = false;
loadArticles();
} catch (e) {
toast.error(isCreating ? 'Error al crear artículo' : 'Error al guardar cambios');
toast.error('Error al guardar cambios');
}
}
@@ -107,114 +134,183 @@
}
});
// 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
// Fallback simple pero limpio
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(/^# (.*$)/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.Trigger
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} />
</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}
<span>Base de Conocimientos</span>
</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" /> <span>Agregar Nuevo</span>
</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 (article.uuid)}
<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: {article.updated_at ? article.updated_at.split('T')[0] : '...'}
</span>
</button>
{/each}
</div>
<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>
{: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;
}}
<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"
>
<X size={16} class="mr-2" /> <span>Cancelar</span>
</Button>
<Button onclick={saveChanges}>
<Save size={16} class="mr-2" /> <span>Guardar Cambios</span>
</Button>
<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="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" /> <span>Editar</span>
</Button>
{/if}
<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 max-w-none border-t pt-4 dark:prose-invert">
<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}
@@ -224,11 +320,17 @@
</div>
{/if}
</div>
{/if}
</div>
</div>
{/if}
</div>
</Sheet.Content>
</Sheet.Root>
<style>
/* Estilos adicionales si son necesarios */
@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>

View File

@@ -5,6 +5,7 @@
import { GLOBAL_NAV } from '$lib/config/shortcuts';
import { shortcutStore, activeShortcutsList } from '$lib/stores/shortcut-store';
import { focusStore, interactionMode } from '$lib/stores/focus-store';
import { helpStore } from '$lib/stores/help.svelte';
import ShortcutsHelpModal from './ShortcutsHelpModal.svelte';
let showHelp = $state(false);
@@ -334,6 +335,16 @@
return;
}
// 1.5 HELP DRAWER: F12
if (key === 'F12') {
if (!authenticated) {
return;
}
event.preventDefault();
helpStore.toggle();
return;
}
// 2. ESCAPE
if (key === 'Escape') {
if (showHelp) {

View File

@@ -18,6 +18,7 @@ import {
Ship,
Truck,
Users,
Book,
} from '@lucide/svelte';
import { m } from '$lib/i18n/messages';
import { Title } from '../ui/alert';
@@ -571,9 +572,9 @@ export function getSidebarData(): SidebarData {
icon: Settings2,
},
{
name: m["sidebar.reference_data.ayuda"](),
name: "Manuales del Sistema",
url: "/dashboard/help-center",
icon: Frame,
icon: Book,
},
],
};

View File

@@ -56,7 +56,7 @@
order: 0
});
// Derived state: Grouped by Category
// Derived state: Grouped by Category with prioritization for Manuals
const groupedArticles = $derived.by(() => {
const filtered = articles
.filter(
@@ -65,7 +65,14 @@
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
.sort((a, b) => {
// Prioritize Manuales category
const catA = (a.category || 'General').toLowerCase();
const catB = (b.category || 'General').toLowerCase();
if (catA.includes('manual') && !catB.includes('manual')) return -1;
if (!catA.includes('manual') && catB.includes('manual')) return 1;
return (a.order || 0) - (b.order || 0);
});
const groups: Record<string, HelpArticle[]> = {};
filtered.forEach((article) => {

View File

@@ -21,7 +21,8 @@
FileText,
Video as VideoIcon,
FileCode,
Upload
Upload,
HelpCircle
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { marked } from 'marked';
@@ -56,6 +57,8 @@
let file_url = $state('');
let file_size = $state<number | undefined>(undefined);
let mime_type = $state('');
let context_path = $state('');
let tags = $state('');
// Preview State
let showPreview = $state(true);
@@ -88,6 +91,8 @@
file_url = article.file_url || '';
file_size = article.file_size ?? undefined;
mime_type = article.mime_type || '';
context_path = article.context_path || '';
tags = article.tags || '';
}
} catch (e: any) {
toast.error('Error al cargar artículo: ' + e.message);
@@ -109,7 +114,9 @@
content_type,
file_url,
file_size,
mime_type
mime_type,
context_path,
tags
};
// Auto-generate slug if missing
@@ -300,6 +307,19 @@
<Label for="order">Orden</Label>
<Input id="order" type="number" bind:value={order} />
</div>
<div class="grid gap-2 border-t pt-4">
<Label for="context_path" class="flex items-center gap-1">
Ruta Contextual (RegEx)
<HelpCircle size={12} class="text-muted-foreground" title="Regex o ruta donde aparecerá este artículo como recomendado (ej: /dashboard/invoices/.*)" />
</Label>
<Input id="context_path" bind:value={context_path} placeholder="Ej: /dashboard/invoices/.*" />
<p class="text-[10px] text-muted-foreground">Expresión regular para activar este artículo en pantallas específicas.</p>
</div>
<div class="grid gap-2 border-t pt-4">
<Label for="tags">Etiquetas</Label>
<Input id="tags" bind:value={tags} placeholder="Ej: facturas, mermas, immex" />
<p class="text-[10px] text-muted-foreground">Separadas por comas. Ayudan a la búsqueda y contextualización.</p>
</div>
</div>
</aside>