Nuevo forntend con datos multimedia

This commit is contained in:
2026-02-23 17:44:33 -06:00
parent 168a5fdf1c
commit 09c806b4f5
10 changed files with 505 additions and 141 deletions

View File

@@ -21,6 +21,12 @@ class HelpArticle(Base):
# Library Mode Fields
category = Column(String(255), nullable=True, default="General")
order = Column(Integer, nullable=True, default=0)
# Multimedia Fields
content_type = Column(String(50), nullable=False, default="article") # article, pdf, video, image, document
file_url = Column(String(512), nullable=True) # URL to the uploaded asset
file_size = Column(Integer, nullable=True) # Size in bytes
mime_type = Column(String(100), nullable=True) # e.g. application/pdf
def __repr__(self):
return f"<HelpArticle(title='{self.title}', slug='{self.slug}')>"

View File

@@ -97,6 +97,40 @@ def upload_help_image(file: UploadFile = File(...)):
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/upload-asset/")
def upload_help_asset(file: UploadFile = File(...)):
"""Sube cualquier tipo de archivo (PDF, Video, etc.) para la biblioteca."""
try:
file_ext = os.path.splitext(file.filename)[1].lower()
new_filename = f"{uuid.uuid4()}{file_ext}"
# Guardar en una carpeta segun el tipo o general
folder = "uploads/help/assets"
if file_ext in ['.pdf']:
folder = "uploads/help/pdfs"
elif file_ext in ['.mp4', '.mov', '.avi']:
folder = "uploads/help/videos"
file_location = f"{folder}/{new_filename}"
# Ensure directory exists
os.makedirs(folder, exist_ok=True)
with open(file_location, "wb+") as buffer:
shutil.copyfileobj(file.file, buffer)
# Get file size
file_size = os.path.getsize(file_location)
return {
"url": f"/api/{file_location}",
"filename": file.filename,
"size": file_size,
"mime_type": file.content_type
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/articles/", response_model=List[HelpArticleInDB])
def list_articles(db: Session = Depends(get_core_db)):
"""Lista todos los artículos de ayuda."""

View File

@@ -10,6 +10,10 @@ class HelpArticleBase(BaseModel):
last_editor: str
category: Optional[str] = "General"
order: Optional[int] = 0
content_type: str = "article"
file_url: Optional[str] = None
file_size: Optional[int] = None
mime_type: Optional[str] = None
class HelpArticleCreate(HelpArticleBase):
pass
@@ -21,6 +25,10 @@ class HelpArticleUpdate(BaseModel):
last_editor: Optional[str] = None
category: Optional[str] = None
order: Optional[int] = None
content_type: Optional[str] = None
file_url: Optional[str] = None
file_size: Optional[int] = None
mime_type: Optional[str] = None
class HelpArticleInDB(HelpArticleBase):
uuid: UUID
@@ -38,6 +46,10 @@ class HelpSyncRequest(BaseModel):
last_editor: str
client_category: Optional[str] = "General"
client_order: Optional[int] = 0
client_content_type: str = "article"
client_file_url: Optional[str] = None
client_file_size: Optional[int] = None
client_mime_type: Optional[str] = None
origin_client_uuid: Optional[UUID] = None
class HelpSyncResponse(BaseModel):
@@ -48,4 +60,8 @@ class HelpSyncResponse(BaseModel):
server_slug: Optional[str] = None
server_category: Optional[str] = None
server_order: Optional[int] = None
server_content_type: Optional[str] = None
server_file_url: Optional[str] = None
server_file_size: Optional[int] = None
server_mime_type: Optional[str] = None
message: str

View File

@@ -82,7 +82,11 @@ class HelpCenterService:
updated_at=client_updated_at,
last_editor=sync_data.last_editor,
category=sync_data.client_category,
order=sync_data.client_order
order=sync_data.client_order,
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
)
db.add(new_article)
db.commit()
@@ -101,6 +105,10 @@ class HelpCenterService:
db_article.last_editor = sync_data.last_editor
db_article.category = sync_data.client_category
db_article.order = sync_data.client_order
db_article.content_type = sync_data.client_content_type
db_article.file_url = sync_data.client_file_url
db_article.file_size = sync_data.client_file_size
db_article.mime_type = sync_data.client_mime_type
db.commit()
return HelpSyncResponse(status="OK", message="Server updated with client data.")
@@ -114,6 +122,10 @@ class HelpCenterService:
server_slug=db_article.slug,
server_category=db_article.category,
server_order=db_article.order,
server_content_type=db_article.content_type,
server_file_url=db_article.file_url,
server_file_size=db_article.file_size,
server_mime_type=db_article.mime_type,
message="Client is outdated. Update required."
)

View File

@@ -128,13 +128,17 @@ services:
"CMD-SHELL",
"exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /kcauth/health/ready HTTP/1.1\r
host: 127.0.0.1\r
Connection: close\r
\ host: 127.0.0.1\r
\r
' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1"
\ Connection: close\r
\ \r
\ ' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1"
]
interval: 10s
timeout: 5s
@@ -297,6 +301,27 @@ services:
networks:
- backend-net
celery_beat:
build: ./backend
container_name: celery_beat
command: celery -A core.celery_app beat --loglevel=info
environment:
- VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0}
- CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""}
- SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production}
- CLIENT_UUID=${CLIENT_UUID:-""}
- SPOKE_URLS=${SPOKE_URLS:-""}
- CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76}
- CORE_DB_PORT=${CORE_DB_PORT:-5432}
- CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core}
- CORE_DB_USER=${CORE_DB_USER:-postgres}
- CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres}
depends_on:
- backend
- valkey
networks:
- backend-net
valkey:
image: valkey/valkey:7.2
container_name: valkey

View File

@@ -21,6 +21,10 @@ export interface HelpArticle {
last_editor: string;
category?: string;
order?: number;
content_type: string;
file_url?: string;
file_size?: number;
mime_type?: string;
}
export const helpApi = {
@@ -46,7 +50,7 @@ export const helpApi = {
return response.json();
},
async createArticle(data: { title: string; content: string; slug: string; last_editor: string; category?: string; order?: number }): Promise<HelpArticle> {
async createArticle(data: Partial<HelpArticle>): Promise<HelpArticle> {
const response = await fetch(`${BASE_URL}/articles/`, {
method: 'POST',
headers: getHeaders(),
@@ -82,5 +86,20 @@ export const helpApi = {
});
if (!response.ok) throw new Error('Failed to upload image');
return response.json();
},
async uploadAsset(file: File): Promise<{ url: string, filename: string, size: number, mime_type: string }> {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${BASE_URL}/upload-asset/`, {
method: 'POST',
headers: {
...(get(authStore).token ? { 'Authorization': `Bearer ${get(authStore).token}` } : {})
},
body: formData
});
if (!response.ok) throw new Error('Failed to upload asset');
return response.json();
}
};

View File

@@ -8,12 +8,24 @@
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, Book, Image as ImageIcon } from 'lucide-svelte';
import {
Plus,
Trash2,
Edit,
Loader2,
Search,
Book,
Image as ImageIcon,
FileText,
Video,
FileCode
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
let articles: HelpArticle[] = $state([]);
let loading = $state(true);
let searchTerm = $state('');
const HUB_MODE = import.meta.env.VITE_HUB_MODE === 'true';
// Modals state
let showCreateModal = $state(false);
@@ -108,10 +120,12 @@
<p class="text-muted-foreground">Manuales, Guías y Documentación del Sistema.</p>
</div>
<div class="flex items-center gap-2">
<Button href="/dashboard/help-center/editor/new" class="shadow-lg">
<Plus class="mr-2 h-4 w-4" />
Nuevo Capítulo
</Button>
{#if HUB_MODE}
<Button href="/dashboard/help-center/editor/new" class="shadow-lg">
<Plus class="mr-2 h-4 w-4" />
Nuevo Capítulo
</Button>
{/if}
</div>
</div>
@@ -158,8 +172,17 @@
></div>
<Card.Header class="pb-2">
<Card.Title
class="line-clamp-2 text-lg transition-colors group-hover:text-primary"
class="line-clamp-2 text-lg transition-colors group-hover:text-primary flex items-center gap-2"
>
{#if article.content_type === 'pdf'}
<FileText class="h-5 w-5 text-red-500" />
{:else if article.content_type === 'video'}
<Video class="h-5 w-5 text-blue-500" />
{:else if article.content_type === 'article'}
<Book class="h-5 w-5 text-primary" />
{:else}
<FileCode class="h-5 w-5 text-muted-foreground" />
{/if}
{article.title}
</Card.Title>
<Card.Description class="text-xs">
@@ -167,31 +190,42 @@
</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>
{#if article.content_type === 'article'}
<div class="prose prose-sm line-clamp-3 text-sm text-muted-foreground">
{@html (article.content || '').replace(/<[^>]*>?/gm, '').substring(0, 150)}...
</div>
{:else}
<div class="flex flex-col gap-1 text-xs text-muted-foreground">
<p>Formato: {article.mime_type || 'Desconocido'}</p>
{#if article.file_size}
<p>Tamaño: {(article.file_size / 1024 / 1024).toFixed(2)} MB</p>
{/if}
</div>
{/if}
</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>
{#if HUB_MODE}
<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>
{/if}
</div>
</Card.Footer>
</Card.Root>

View File

@@ -3,7 +3,16 @@
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 {
Loader2,
ArrowLeft,
Calendar,
User,
BookOpen,
Download,
FileText,
ExternalLink
} from 'lucide-svelte';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
@@ -124,11 +133,72 @@
</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>
{#if article.content_type === 'article'}
<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>
{:else if article.content_type === 'pdf'}
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between rounded-lg border bg-muted/30 p-4">
<div class="flex items-center gap-3">
<FileText class="h-8 w-8 text-red-500" />
<div>
<p class="font-medium">Documento PDF</p>
<p class="text-xs text-muted-foreground">
{article.file_size ? (article.file_size / 1024 / 1024).toFixed(2) : '??'} MB
</p>
</div>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" href={article.file_url} target="_blank">
<ExternalLink class="mr-2 h-4 w-4" />
Abrir en pestaña
</Button>
<Button size="sm" href={article.file_url} download={article.title}>
<Download class="mr-2 h-4 w-4" />
Descargar
</Button>
</div>
</div>
<!-- PDF Embed -->
<iframe
src={article.file_url}
title={article.title}
class="h-[800px] w-full rounded-xl border shadow-inner"
></iframe>
</div>
{:else if article.content_type === 'video'}
<div class="flex flex-col gap-4">
<video controls class="w-full rounded-xl border shadow-lg">
<source src={article.file_url} type={article.mime_type} />
Tu navegador no soporta videos.
</video>
<div class="rounded-lg border bg-muted/30 p-4">
<p class="text-sm font-medium">Información del archivo</p>
<p class="text-xs text-muted-foreground">{article.mime_type}</p>
</div>
</div>
{:else}
<div
class="flex flex-col items-center justify-center gap-6 rounded-2xl border border-dashed py-20"
>
<div class="rounded-full bg-primary/10 p-6">
<Download class="h-12 w-12 text-primary" />
</div>
<div class="text-center">
<h3 class="text-xl font-semibold">Archivo para descargar</h3>
<p class="text-muted-foreground truncate max-w-md">
Este archivo no tiene vista previa directa.
</p>
</div>
<Button href={article.file_url} download={article.title} class="px-8 shadow-lg">
<Download class="mr-2 h-4 w-4" />
Descargar ahora
</Button>
</div>
{/if}
</div>
</div>

View File

@@ -16,8 +16,11 @@
Italic,
Link as LinkIcon,
List,
Heading1,
Heading2
Heading2,
FileText,
Video as VideoIcon,
FileCode,
Upload
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { marked } from 'marked';
@@ -33,12 +36,23 @@
let content = '';
let category = 'General';
let order = 0;
let last_editor = 'Admin'; // Could pull from auth store
let last_editor = 'Admin';
let content_type = 'article';
let file_url = '';
let file_size: number | null = null;
let mime_type = '';
// Preview State
let showPreview = true;
const HUB_MODE = import.meta.env.VITE_HUB_MODE === 'true';
onMount(async () => {
// Proteccion: Solo el Hub puede editar/crear
if (!HUB_MODE) {
window.location.href = '/dashboard/help-center';
return;
}
const uuid = $page.params.uuid as string;
if (uuid === 'new') {
loading = false;
@@ -51,9 +65,13 @@
if (article) {
title = article.title;
slug = article.slug;
content = article.content;
content = article.content || '';
category = article.category || 'General';
order = article.order || 0;
content_type = article.content_type || 'article';
file_url = article.file_url || '';
file_size = article.file_size || null;
mime_type = article.mime_type || '';
}
} catch (e: any) {
toast.error('Error al cargar artículo: ' + e.message);
@@ -65,7 +83,10 @@
async function handleSave() {
processing = true;
try {
const data = { title, slug, content, last_editor, category, order };
const data = {
title, slug, content, last_editor, category, order,
content_type, file_url, file_size, mime_type
};
// Auto-generate slug if missing
if (!data.slug) {
@@ -128,6 +149,30 @@
}
}
async function handleAssetUpload(file: File) {
try {
processing = true;
toast.loading(`Subiendo ${file.name}...`);
const result = await helpApi.uploadAsset(file);
file_url = result.url;
file_size = result.size;
mime_type = result.mime_type;
// Auto-infer content type if not set or article
if (content_type === 'article') {
if (mime_type === 'application/pdf') content_type = 'pdf';
else if (mime_type.startsWith('video/')) content_type = 'video';
else content_type = 'document';
}
toast.success('Archivo subido correctamente');
} catch (e: any) {
toast.error('Error al subir archivo: ' + e.message);
} finally {
processing = false;
}
}
function handleDrop(e: DragEvent) {
e.preventDefault();
const files = e.dataTransfer?.files;
@@ -139,11 +184,12 @@
}
}
import { browser } from '$app/environment';
function renderMarkdown(text: string) {
// @ts-ignore
return DOMPurify.sanitize(marked.parse(text || '') as string);
const html = marked.parse(text || '') as string;
if (browser) {
return DOMPurify.sanitize(html);
}
return html;
}
</script>
@@ -195,110 +241,203 @@
<Label for="order">Orden</Label>
<Input id="order" type="number" bind:value={order} />
</div>
<div class="grid gap-2 pt-4 border-t">
<Label for="type">Tipo de Contenido</Label>
<select
id="type"
bind:value={content_type}
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="article">Artículo (Markdown)</option>
<option value="pdf">PDF</option>
<option value="video">Video</option>
<option value="document">Otro Documento</option>
</select>
</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"
{#if content_type === 'article'}
<!-- 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"
>
<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>
<p class="font-medium text-primary">Arrastra imágenes aquí</p>
</div>
</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>
<!-- 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}
{:else}
<!-- Asset Upload View -->
<div class="flex flex-1 flex-col items-center justify-center bg-muted/5 p-12">
<div class="w-full max-w-2xl space-y-8 text-center">
<div class="flex flex-col items-center gap-4">
{#if content_type === 'pdf'}
<div class="rounded-full bg-red-100 p-6 dark:bg-red-900/20">
<FileText class="h-16 w-16 text-red-500" />
</div>
<h2 class="text-2xl font-bold">Configuración de PDF</h2>
{:else if content_type === 'video'}
<div class="rounded-full bg-blue-100 p-6 dark:bg-blue-900/20">
<VideoIcon class="h-16 w-16 text-blue-500" />
</div>
<h2 class="text-2xl font-bold">Configuración de Video</h2>
{:else}
<div class="rounded-full bg-primary/10 p-6">
<FileCode class="h-16 w-16 text-primary" />
</div>
<h2 class="text-2xl font-bold">Configuración de Documento</h2>
{/if}
<p class="text-muted-foreground">
Sube el archivo que deseas asociar a este título.
</p>
</div>
<!-- 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)}
{#if file_url}
<div class="relative flex flex-col gap-4 rounded-xl border bg-card p-6 shadow-sm">
<div class="flex items-center gap-4">
<div class="rounded bg-muted p-2">
<FileCode class="h-6 w-6" />
</div>
<div class="flex-1 text-left">
<p class="font-medium">Archivo Cargado</p>
<p class="text-xs text-muted-foreground truncate">{file_url}</p>
<p class="text-xs font-mono">
{mime_type}{(file_size ? (file_size / 1024 / 1024).toFixed(2) : '??')} MB
</p>
</div>
</div>
<div class="flex gap-2">
<Button variant="outline" class="flex-1" href={file_url} target="_blank">
Ver Archivo
</Button>
<label class="flex flex-1 cursor-pointer items-center justify-center rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground">
Cambiar Archivo
<input
type="file"
class="hidden"
onchange={(e) => {
const target = e.target as HTMLInputElement;
if (target.files?.length) handleAssetUpload(target.files[0]);
}}
/>
</label>
</div>
</div>
{:else}
<label class="flex h-64 w-full cursor-pointer flex-col items-center justify-center gap-4 rounded-2xl border-2 border-dashed border-muted-foreground/20 bg-muted/20 transition-colors hover:border-primary/50 hover:bg-primary/5">
<div class="flex flex-col items-center gap-2">
<div class="rounded-full bg-background p-4 shadow-sm">
<Upload class="h-8 w-8 text-primary" />
</div>
<span class="font-medium">Selecciona un archivo</span>
<span class="text-xs text-muted-foreground">O arrastra y suelta aquí</span>
</div>
<input
type="file"
class="hidden"
onchange={(e) => {
const target = e.target as HTMLInputElement;
if (target.files?.length) handleAssetUpload(target.files[0]);
}}
/>
</label>
{/if}
</div>
</div>
{/if}

View File

@@ -77,8 +77,10 @@ if [ ! -f .env ]; then
CENTRAL_URL=""
CLIENT_ID=""
SPOKE_URLS=""
HUB_MODE="true"
else
echo -e "${GREEN}Configurando como CLIENTE...${NC}"
HUB_MODE="false"
read -p "Ingrese la URL del Hub [http://100.78.6.108:8001/api/v1/core/help-center/sync/]: " CENTRAL_URL
CENTRAL_URL=${CENTRAL_URL:-http://100.78.6.108:8001/api/v1/core/help-center/sync/}
read -p "Ingrese el UUID de este cliente (presione Enter para generar uno): " CLIENT_ID
@@ -95,6 +97,12 @@ if [ ! -f .env ]; then
sed -i "s|SPOKE_URLS=.*|SPOKE_URLS=$SPOKE_URLS|" .env
sed -i "s|CLIENT_UUID=.*|CLIENT_UUID=$CLIENT_ID|" .env
sed -i "s|SYNC_SECRET_TOKEN=.*|SYNC_SECRET_TOKEN=$SYNC_TOKEN|" .env
# Agregar o actualizar VITE_HUB_MODE
if grep -q "VITE_HUB_MODE=" .env; then
sed -i "s|VITE_HUB_MODE=.*|VITE_HUB_MODE=$HUB_MODE|" .env
else
echo "VITE_HUB_MODE=$HUB_MODE" >> .env
fi
else
echo -e "${YELLOW}⚠ .env.example no existe, creando .env básico${NC}"
cat > .env <<EOF
@@ -115,6 +123,7 @@ CENTRAL_SERVER_URL=$CENTRAL_URL
SPOKE_URLS=$SPOKE_URLS
CLIENT_UUID=$CLIENT_ID
SYNC_SECRET_TOKEN=$SYNC_TOKEN
VITE_HUB_MODE=$HUB_MODE
EOF
fi
echo -e "${GREEN}✓ Configuración de sincronización aplicada al .env${NC}"