Nuevo forntend con datos multimedia
This commit is contained in:
@@ -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}')>"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
9
start.sh
9
start.sh
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user