- Updated `auth.setup.ts` to handle multiple app launches in Workspace and ensure proper redirection to the dashboard. - Improved invoice creation tests in `export-flow.spec.ts` and `invoice-flow.spec.ts` to check for existing exchange rates before creating new ones, and adjusted button names for consistency. - Refactored selectors to use placeholders for invoice number inputs across multiple test files. - Enhanced error handling and visibility checks in various test scenarios to improve reliability. - Removed obsolete `setup-catalogs.spec.ts` file as its functionality is no longer needed. These changes aim to streamline the testing process and ensure more robust interactions with the application.
505 lines
20 KiB
Svelte
505 lines
20 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,
|
|
FileText,
|
|
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('');
|
|
let hasError = $state(false);
|
|
let isLoaded = $state(false);
|
|
|
|
const isAdmin = $derived($currentUser?.roles?.includes('admin') || false);
|
|
const currentPath = $derived(page.url.pathname + page.url.search);
|
|
|
|
const synonyms: Record<string, string[]> = {
|
|
'users': ['usuario', 'colaborador', 'acceso', 'perfil', 'permiso', 'roles'],
|
|
'company': ['empresa', 'compania', 'negocio', 'fiscal', 'emisor', 'razon social', 'rfc'],
|
|
'invoices': ['factura', 'cobro', 'gasto', 'cxc', 'cxp', 'comprobante', 'imp', 'exp', 'facturacion'],
|
|
'packages': ['bulto', 'embalaje', 'empaque', 'pallet', 'contenedor', 'packing'],
|
|
'audit': ['auditoria', 'bitacora', 'log', 'historial', 'evento', 'monitoreo'],
|
|
'pedimentos': ['pedimento', 'aduana', 'valida', 'despacho', 'pedimentacion'],
|
|
'parts': ['parte', 'mercancia', 'producto', 'fraccion', 'item', 'articulo', 'numero de parte'],
|
|
'goods': ['mercancia', 'producto', 'bienes', 'parte', 'item'],
|
|
'manifest': ['manifiesto', 'salida', 'embarque', 'transporte', 'carga'],
|
|
'clients': ['cliente', 'proveedor', 'vendor', 'provider', 'comercial', 'socio', 'proveedores', 'clientes'],
|
|
'transporters': ['transportista', 'fletera', 'chofer', 'conductor', 'camion', 'vehiculo', 'transporte'],
|
|
'settings': ['configuracion', 'ajuste', 'preferencia', 'perfil', 'cuenta'],
|
|
'digitalizacion': ['documento', 'archivo', 'digital', 'expediente', 'pdf', 'xml', 'e-document'],
|
|
'reports': ['reporte', 'estadistica', 'grafica', 'consulta', 'descarga', 'excel', 'kpi'],
|
|
'fractions': ['fraccion', 'arancel', 'nico', 'tarifa', 'impuesto', 'tigie'],
|
|
'reference': ['catalogo', 'fijo', 'referencia', 'maestro', 'base']
|
|
};
|
|
|
|
function getKeywords(str: string): string[] {
|
|
const isEdit = str.toLowerCase().includes('/edit') || str.toLowerCase().includes('/editor');
|
|
const isCreate = str.toLowerCase().includes('/create') || str.toLowerCase().includes('/new');
|
|
|
|
const baseKeywords = str
|
|
.toLowerCase()
|
|
.normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Quitar acentos
|
|
.replace(/[/_-]/g, ' ')
|
|
.split(/\s+/)
|
|
.filter(w => w.length > 2 && !['dashboard', 'general', 'catalogs', 'information', 'management'].includes(w));
|
|
|
|
if (isEdit) baseKeywords.push('edicion', 'editar', 'modificar', 'actualizar');
|
|
if (isCreate) baseKeywords.push('crear', 'nuevo', 'registro', 'alta');
|
|
|
|
// Expandir con sinónimos
|
|
const expanded = [...baseKeywords];
|
|
baseKeywords.forEach(kw => {
|
|
if (synonyms[kw]) expanded.push(...synonyms[kw]);
|
|
// Buscar si la palabra clave es un sinónimo de alguna categoría
|
|
Object.entries(synonyms).forEach(([key, values]) => {
|
|
if (values.includes(kw)) expanded.push(key);
|
|
});
|
|
});
|
|
return [...new Set(expanded)];
|
|
}
|
|
|
|
// Filtrar artículos contextuales basados en la ruta actual o coincidencias inteligentes
|
|
const contextualArticles = $derived(
|
|
articles.filter((a) => {
|
|
// 1. Prioridad: Ruta explícita (si existe)
|
|
if (a.context_path) {
|
|
try {
|
|
if (a.context_path.startsWith('/')) {
|
|
if (currentPath === a.context_path || currentPath.startsWith(a.context_path + '/')) return true;
|
|
} else {
|
|
const regex = new RegExp(a.context_path);
|
|
if (regex.test(currentPath)) return true;
|
|
}
|
|
} catch {
|
|
if (currentPath.includes(a.context_path)) return true;
|
|
}
|
|
}
|
|
|
|
// 2. Inteligencia de Coincidencia (Fuzzy match por palabras clave)
|
|
const editIntents = ['edicion', 'editar', 'modificar', 'actualizar', 'corregir', 'edit', 'editor'];
|
|
const createIntents = ['crear', 'nuevo', 'registro', 'alta', 'create', 'new'];
|
|
const intentionWords = [...editIntents, ...createIntents, 'baja', 'cambio'];
|
|
|
|
const pathKeywords = getKeywords(currentPath);
|
|
const moduleKeywords = pathKeywords.filter(pk => !intentionWords.includes(pk));
|
|
const pathIntentKeywords = pathKeywords.filter(pk => intentionWords.includes(pk));
|
|
|
|
const titleKeywords = getKeywords(a.title);
|
|
const contentKeywords = getKeywords(a.content.substring(0, 100));
|
|
|
|
// Si estamos en un módulo específico (ej. Pedimentos), DEBE coincidir el módulo
|
|
const matchesModule = moduleKeywords.length === 0 || moduleKeywords.some(pk =>
|
|
titleKeywords.some(tk => tk.includes(pk) || pk.includes(tk)) ||
|
|
contentKeywords.some(ck => ck.includes(pk) || pk.includes(ck))
|
|
);
|
|
|
|
if (!matchesModule) return false;
|
|
|
|
// Si hay una intención clara en la URL (crear/editar), filtramos los artículos
|
|
// que sean explícitamente de la intención OPUESTA.
|
|
if (pathIntentKeywords.length > 0) {
|
|
const pathIsEdit = pathIntentKeywords.some(pk => editIntents.includes(pk));
|
|
const pathIsCreate = pathIntentKeywords.some(pk => createIntents.includes(pk));
|
|
|
|
const articleIsEdit = titleKeywords.some(tk => editIntents.includes(tk));
|
|
const articleIsCreate = titleKeywords.some(tk => createIntents.includes(tk));
|
|
|
|
// Bloqueo cruzado: Si estoy creando, no me des manuales que son SOLO de editar.
|
|
// (Si el manual sirve para ambos o es general, pasará)
|
|
if (pathIsCreate && articleIsEdit && !articleIsCreate) return false;
|
|
if (pathIsEdit && articleIsCreate && !articleIsEdit) return false;
|
|
}
|
|
|
|
return true;
|
|
})
|
|
);
|
|
|
|
// 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 {
|
|
hasError = false;
|
|
articles = await helpApi.listArticles();
|
|
isLoaded = true;
|
|
} catch (e) {
|
|
hasError = true;
|
|
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 = 'Nueva Guía de Ayuda';
|
|
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,
|
|
context_path: currentPath, // Captura automática de la ruta real
|
|
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 && !isLoaded && !isLoading && !hasError) {
|
|
loadArticles();
|
|
}
|
|
|
|
// Reset error when drawer closes to allow retry next time it opens
|
|
if (!helpStore.isOpen) {
|
|
hasError = false;
|
|
}
|
|
});
|
|
|
|
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 />');
|
|
}
|
|
|
|
function highlightText(text: string, term: string) {
|
|
if (!term || term.length < 2) return text;
|
|
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const regex = new RegExp(`(${escapedTerm})`, 'gi');
|
|
return text.replace(regex, '<mark class="bg-primary/20 text-primary-foreground font-bold rounded-sm px-0.5">$1</mark>');
|
|
}
|
|
</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">
|
|
{#if selectedArticle}
|
|
<Button variant="ghost" size="icon" onclick={() => (selectedArticle = null)} class="mr-1 h-8 w-8 rounded-full">
|
|
<ChevronLeft size={20} />
|
|
</Button>
|
|
{/if}
|
|
<div class="p-2 rounded-lg bg-primary/10 text-primary">
|
|
<Sparkles size={20} />
|
|
</div>
|
|
<h2 class="text-xl font-bold tracking-tight">
|
|
{selectedArticle ? 'Artículo de Ayuda' : '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 (Recomendaciones) -->
|
|
{#if contextualArticles.length > 0 && !searchTerm}
|
|
<div class="space-y-3">
|
|
<div class="flex items-center gap-2 px-1">
|
|
<div class="flex items-center gap-1.5 text-xs font-semibold text-primary uppercase tracking-wider">
|
|
<Sparkles size={14} /> Recomendado para ti
|
|
</div>
|
|
</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-primary/5 border-primary/20 hover:bg-primary/10 hover:border-primary/30 transition-all text-left shadow-sm"
|
|
>
|
|
<div class="p-2 rounded-lg bg-background flex-shrink-0 shadow-sm">
|
|
{#if article.content_type === 'pdf'}
|
|
<FileText size={18} class="text-primary" />
|
|
{:else}
|
|
<BookOpen size={18} class="text-primary" />
|
|
{/if}
|
|
</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}
|
|
|
|
<!-- Catálogo Completo / Búsqueda -->
|
|
<div class="space-y-4">
|
|
<div class="flex items-center justify-between px-1">
|
|
<h3 class="text-xs font-bold text-muted-foreground uppercase tracking-widest">
|
|
{searchTerm ? 'Resultados de búsqueda' : 'Manuales del Sistema'}
|
|
</h3>
|
|
{#if articles.length > 0}
|
|
<span class="text-[10px] text-muted-foreground bg-muted px-2 py-0.5 rounded-full">{articles.length} artículos</span>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if isLoading}
|
|
<div class="flex flex-col items-center justify-center py-12 space-y-4">
|
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
|
<p class="text-xs text-muted-foreground animate-pulse">Cargando base de conocimientos...</p>
|
|
</div>
|
|
{:else if articles.length === 0}
|
|
<div class="flex flex-col items-center justify-center py-12 px-4 text-center space-y-3 bg-muted/20 rounded-2xl border border-dashed border-muted-foreground/20">
|
|
<div class="p-3 rounded-full bg-background shadow-sm">
|
|
<BookOpen size={24} class="text-muted-foreground/40" />
|
|
</div>
|
|
<div class="space-y-1">
|
|
<p class="text-sm font-medium">
|
|
{hasError ? 'Error de conexión' : 'Biblioteca vacía'}
|
|
</p>
|
|
<p class="text-xs text-muted-foreground">
|
|
{hasError
|
|
? 'No pudimos conectar con el servidor de ayuda.'
|
|
: 'No hay artículos registrados para esta sección aún.'}
|
|
</p>
|
|
</div>
|
|
{#if hasError}
|
|
<Button variant="outline" size="sm" onclick={loadArticles} class="mt-2 h-8">
|
|
Reintentar
|
|
</Button>
|
|
{:else if isAdmin}
|
|
<Button variant="outline" size="sm" onclick={startCreate} class="mt-2 h-8">
|
|
<Plus size={14} class="mr-1" /> Crear primero
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
{@const displayList = searchTerm ? filteredArticles : articles}
|
|
{#if displayList.length === 0}
|
|
<div class="text-center py-12 px-4 rounded-xl border border-dashed bg-muted/10">
|
|
<Search size={32} class="mx-auto mb-3 text-muted-foreground opacity-20" />
|
|
<p class="text-sm text-muted-foreground">No encontramos nada para "{searchTerm}"</p>
|
|
<Button variant="link" size="sm" onclick={() => searchTerm = ''}>Limpiar búsqueda</Button>
|
|
</div>
|
|
{:else}
|
|
<div class="grid gap-2">
|
|
{#each displayList as article}
|
|
<button
|
|
onclick={() => selectArticle(article)}
|
|
class="flex items-center justify-between p-3.5 rounded-xl border border-transparent hover:border-border hover:bg-muted/50 transition-all text-left group"
|
|
>
|
|
<div class="flex items-center gap-3 overflow-hidden">
|
|
<div class="w-9 h-9 rounded-lg bg-muted flex items-center justify-center text-muted-foreground group-hover:text-primary group-hover:bg-primary/10 transition-all shrink-0">
|
|
{#if article.content_type === 'pdf'}
|
|
<FileText size={16} />
|
|
{:else}
|
|
<BookOpen size={16} />
|
|
{/if}
|
|
</div>
|
|
<div class="flex flex-col overflow-hidden">
|
|
<span class="text-sm font-medium truncate group-hover:text-primary transition-colors">
|
|
{@html highlightText(article.title, searchTerm)}
|
|
</span>
|
|
<span class="text-[10px] text-muted-foreground uppercase">{article.category || 'General'}</span>
|
|
</div>
|
|
</div>
|
|
<ChevronLeft class="rotate-180 opacity-0 group-hover:opacity-40 transition-all w-4 h-4 shrink-0" />
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{/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 selectedArticle.content_type === 'pdf' && selectedArticle.file_url}
|
|
<div class="rounded-2xl overflow-hidden border bg-muted/30 h-[calc(100vh-350px)] min-h-[500px] shadow-sm">
|
|
<iframe
|
|
src={selectedArticle.file_url}
|
|
class="w-full h-full border-none"
|
|
title={selectedArticle.title}
|
|
></iframe>
|
|
</div>
|
|
{:else}
|
|
{#if browser}
|
|
{@html renderMarkdown(selectedArticle.content)}
|
|
{:else}
|
|
<div class="whitespace-pre-wrap">{selectedArticle.content}</div>
|
|
{/if}
|
|
{/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>
|