Nuevo frontend y start cambiado

This commit is contained in:
2026-02-24 11:35:46 -06:00
parent 09c806b4f5
commit c9f934a0da
9 changed files with 383 additions and 147 deletions

View File

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

View File

@@ -1,3 +1,5 @@
import json
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional from typing import List, Optional
from uuid import UUID from uuid import UUID
@@ -6,32 +8,90 @@ from .models import HelpArticle
from .schemas import HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse from .schemas import HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse
class HelpCenterService: class HelpCenterService:
@staticmethod
def _inject_metadata(article: HelpArticle) -> HelpArticle:
if not article or not article.content:
return article
# Look for <!-- a76_metadata: { ... } -->
match = re.search(r'<!-- a76_metadata: (.*?) -->', article.content, re.DOTALL)
if match:
try:
metadata = json.loads(match.group(1))
article.content_type = metadata.get("content_type", "article")
article.file_url = metadata.get("file_url")
article.file_size = metadata.get("file_size")
article.mime_type = metadata.get("mime_type")
# 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.
except Exception:
pass
else:
article.content_type = "article"
article.file_url = None
article.file_size = None
article.mime_type = None
return article
@staticmethod
def _extract_metadata(content: str, data: dict) -> str:
# Remove existing metadata block if any
content = re.sub(r'\n\n<!-- a76_metadata: .*? -->', '', content, flags=re.DOTALL)
metadata = {
"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")
}
# Only append if there's something meaningful beyond "article"
if metadata["content_type"] != "article" or metadata["file_url"]:
content += f"\n\n<!-- a76_metadata: {json.dumps(metadata)} -->"
return content
@staticmethod @staticmethod
def get_all(db: Session) -> List[HelpArticle]: def get_all(db: Session) -> List[HelpArticle]:
return db.query(HelpArticle).all() articles = db.query(HelpArticle).all()
return [HelpCenterService._inject_metadata(a) for a in articles]
@staticmethod @staticmethod
def get_by_uuid(db: Session, article_uuid: UUID) -> Optional[HelpArticle]: def get_by_uuid(db: Session, article_uuid: UUID) -> Optional[HelpArticle]:
return db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first() article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
return HelpCenterService._inject_metadata(article)
@staticmethod @staticmethod
def get_by_slug(db: Session, slug: str) -> Optional[HelpArticle]: def get_by_slug(db: Session, slug: str) -> Optional[HelpArticle]:
return db.query(HelpArticle).filter(HelpArticle.slug == slug).first() article = db.query(HelpArticle).filter(HelpArticle.slug == slug).first()
return HelpCenterService._inject_metadata(article)
@staticmethod @staticmethod
def get_modifications(db: Session, since: datetime) -> List[HelpArticle]: def get_modifications(db: Session, since: datetime) -> List[HelpArticle]:
# Ensure timezone awareness # Ensure timezone awareness
if since.tzinfo is None: if since.tzinfo is None:
since = since.replace(tzinfo=timezone.utc) since = since.replace(tzinfo=timezone.utc)
return db.query(HelpArticle).filter(HelpArticle.updated_at > since).all() articles = db.query(HelpArticle).filter(HelpArticle.updated_at > since).all()
return [HelpCenterService._inject_metadata(a) for a in articles]
@staticmethod @staticmethod
def create(db: Session, article: HelpArticleCreate) -> HelpArticle: def create(db: Session, article: HelpArticleCreate) -> HelpArticle:
db_article = HelpArticle(**article.model_dump()) data = article.model_dump()
# 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"]
for f in virtual_fields:
if f in data:
del data[f]
db_article = HelpArticle(**data)
db.add(db_article) db.add(db_article)
db.commit() db.commit()
db.refresh(db_article) db.refresh(db_article)
return db_article return HelpCenterService._inject_metadata(db_article)
@staticmethod @staticmethod
def update(db: Session, article_uuid: UUID, article_data: HelpArticleUpdate) -> Optional[HelpArticle]: def update(db: Session, article_uuid: UUID, article_data: HelpArticleUpdate) -> Optional[HelpArticle]:
@@ -39,13 +99,41 @@ class HelpCenterService:
if not db_article: if not db_article:
return None return None
# Inject metadata to existing article to get current virtual fields
db_article = HelpCenterService._inject_metadata(db_article)
update_data = article_data.model_dump(exclude_unset=True) 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"]):
# 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)
}
# Update with new data if present
for f in ["content_type", "file_url", "file_size", "mime_type"]:
if f in update_data:
current_meta[f] = update_data[f]
# Use current content or new content
content = update_data.get("content", db_article.content)
update_data["content"] = HelpCenterService._extract_metadata(content, current_meta)
# Remove virtual fields from data
virtual_fields = ["content_type", "file_url", "file_size", "mime_type"]
for f in virtual_fields:
if f in update_data:
del update_data[f]
for key, value in update_data.items(): for key, value in update_data.items():
setattr(db_article, key, value) setattr(db_article, key, value)
db.commit() db.commit()
db.refresh(db_article) db.refresh(db_article)
return db_article return HelpCenterService._inject_metadata(db_article)
@staticmethod @staticmethod
def delete(db: Session, article_uuid: UUID) -> bool: def delete(db: Session, article_uuid: UUID) -> bool:
@@ -74,22 +162,34 @@ class HelpCenterService:
if not db_article: if not db_article:
# Caso A: Artículo nuevo desde el cliente # Caso A: Artículo nuevo desde el cliente
# Store metadata in content
client_meta = {
"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
}
content_with_meta = HelpCenterService._extract_metadata(sync_data.client_content, client_meta)
new_article = HelpArticle( new_article = HelpArticle(
uuid=sync_data.article_uuid, uuid=sync_data.article_uuid,
slug=sync_data.client_slug, slug=sync_data.client_slug,
title=sync_data.client_title, title=sync_data.client_title,
content=sync_data.client_content, content=content_with_meta,
updated_at=client_updated_at, updated_at=client_updated_at,
last_editor=sync_data.last_editor, last_editor=sync_data.last_editor,
category=sync_data.client_category, 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.add(new_article)
db.commit() db.commit()
# Download assets if needed (Images in content and main file)
from .utils import download_file_from_hub, sync_assets_from_content
if sync_data.client_file_url:
download_file_from_hub(sync_data.client_file_url)
sync_assets_from_content(sync_data.client_content)
return HelpSyncResponse(status="OK", message="Article created on server.") return HelpSyncResponse(status="OK", message="Article created on server.")
server_updated_at = db_article.updated_at server_updated_at = db_article.updated_at
@@ -98,22 +198,33 @@ class HelpCenterService:
# Caso A: Cliente es más nuevo # Caso A: Cliente es más nuevo
if client_updated_at > server_updated_at: if client_updated_at > server_updated_at:
db_article.content = sync_data.client_content client_meta = {
"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_article.content = HelpCenterService._extract_metadata(sync_data.client_content, client_meta)
db_article.title = sync_data.client_title db_article.title = sync_data.client_title
db_article.slug = sync_data.client_slug db_article.slug = sync_data.client_slug
db_article.updated_at = client_updated_at db_article.updated_at = client_updated_at
db_article.last_editor = sync_data.last_editor db_article.last_editor = sync_data.last_editor
db_article.category = sync_data.client_category db_article.category = sync_data.client_category
db_article.order = sync_data.client_order 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() db.commit()
# Download assets if needed (Images in content)
from .utils import download_file_from_hub, sync_assets_from_content
if sync_data.client_file_url:
download_file_from_hub(sync_data.client_file_url)
sync_assets_from_content(sync_data.client_content)
return HelpSyncResponse(status="OK", message="Server updated with client data.") return HelpSyncResponse(status="OK", message="Server updated with client data.")
# Caso B: Servidor es más nuevo # Caso B: Servidor es más nuevo
elif server_updated_at > client_updated_at: elif server_updated_at > client_updated_at:
# Inject metadata for response
db_article = HelpCenterService._inject_metadata(db_article)
return HelpSyncResponse( return HelpSyncResponse(
status="UPDATE_REQUIRED", status="UPDATE_REQUIRED",
server_updated_at=server_updated_at, server_updated_at=server_updated_at,
@@ -122,10 +233,10 @@ class HelpCenterService:
server_slug=db_article.slug, server_slug=db_article.slug,
server_category=db_article.category, server_category=db_article.category,
server_order=db_article.order, server_order=db_article.order,
server_content_type=db_article.content_type, server_content_type=getattr(db_article, "content_type", "article"),
server_file_url=db_article.file_url, server_file_url=getattr(db_article, "file_url", None),
server_file_size=db_article.file_size, server_file_size=getattr(db_article, "file_size", None),
server_mime_type=db_article.mime_type, server_mime_type=getattr(db_article, "mime_type", None),
message="Client is outdated. Update required." message="Client is outdated. Update required."
) )

View File

@@ -144,6 +144,12 @@ def sync_single_article(article_uuid):
article.updated_at = result.server_updated_at article.updated_at = result.server_updated_at
db.commit() db.commit()
logger.info(f"Article {article.uuid} updated from server.") logger.info(f"Article {article.uuid} updated from server.")
# Download assets if needed
from .utils import download_file_from_hub, sync_assets_from_content
if result.server_file_url:
download_file_from_hub(result.server_file_url)
sync_assets_from_content(result.server_content)
else: else:
logger.info(f"Article {article.uuid} sync OK: {result.message}") logger.info(f"Article {article.uuid} sync OK: {result.message}")
else: else:
@@ -234,6 +240,14 @@ def sync_from_hub_task():
local_article.order = art_data.get('order', 0) local_article.order = art_data.get('order', 0)
db.commit() db.commit()
# Download assets after bulk update (Polling)
from .utils import download_file_from_hub, sync_assets_from_content
for art_data in articles_data:
if art_data.get('file_url'):
download_file_from_hub(art_data['file_url'])
sync_assets_from_content(art_data.get('content', ''))
logger.info("Polling sync completed successfully.") logger.info("Polling sync completed successfully.")
else: else:

View File

@@ -0,0 +1,73 @@
import os
import re
import httpx
import logging
import uuid
from pathlib import Path
from core.config import settings
logger = logging.getLogger(__name__)
def download_file_from_hub(relative_path: str) -> bool:
"""
Downloads a file from the Hub to the local storage.
relative_path: e.g., 'uploads/help/pdfs/myfile.pdf' or '/api/uploads/help/image.png'
"""
if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""':
return False
# Clean the path
clean_path = relative_path.replace("/api/uploads/", "uploads/")
if clean_path.startswith("/"):
clean_path = clean_path[1:]
# Check if it starts with uploads
if not clean_path.startswith("uploads/"):
# If it doesn't start with uploads, it might just be the filename or a subpath
# We assume it's relative to /app/
pass
local_path = Path(clean_path)
if local_path.exists():
return True
# Ensure directories exist
local_path.parent.mkdir(parents=True, exist_ok=True)
# Resolve Hub Base URL
# CENTRAL_SERVER_URL is usually http://hub:8000/api/v1/core/help-center/sync/
# We want http://hub:8000/api/
base_url = settings.CENTRAL_SERVER_URL.split("/v1/")[0]
hub_file_url = f"{base_url}/uploads/{clean_path.replace('uploads/', '')}"
logger.info(f"Downloading asset from Hub: {hub_file_url} -> {local_path}")
try:
with httpx.Client() as client:
response = client.get(hub_file_url, timeout=30.0)
if response.status_code == 200:
with open(local_path, "wb") as f:
f.write(response.content)
logger.info(f"Successfully downloaded {clean_path}")
return True
else:
logger.warning(f"Failed to download {clean_path}: Status {response.status_code}")
return False
except Exception as e:
logger.error(f"Error downloading {clean_path}: {str(e)}")
return False
def sync_assets_from_content(content: str):
"""
Parses markdown content for image URLs and downloads them if they are local references.
Example: ![alt text](/api/uploads/help/uuid.png)
"""
if not content:
return
# Regex for markdown images: ![...](/api/uploads/...)
image_pattern = r'!\[.*?\]\((/api/uploads/.*?)\)'
matches = re.findall(image_pattern, content)
for asset_url in matches:
download_file_from_hub(asset_url)

View File

@@ -8,7 +8,6 @@ services:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_APP_PASSWORD:-postgres} POSTGRES_PASSWORD: ${POSTGRES_APP_PASSWORD:-postgres}
POSTGRES_INITDB_ARGS: "--encoding=UTF8" POSTGRES_INITDB_ARGS: "--encoding=UTF8"
PGDATA: /var/lib/postgresql/data
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
@@ -38,7 +37,7 @@ services:
# PostgreSQL - Base de datos Keycloak # PostgreSQL - Base de datos Keycloak
postgres-keycloak: postgres-keycloak:
image: postgres:16-alpine image: postgres:18-alpine
container_name: anexo76-postgres-keycloak container_name: anexo76-postgres-keycloak
environment: environment:
POSTGRES_DB: keycloak POSTGRES_DB: keycloak
@@ -81,10 +80,10 @@ services:
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
KC_DB: postgres KC_DB: postgres
KC_DB_URL_HOST: anexo76-postgres-keycloak KC_DB_URL_HOST: postgres-keycloak
KC_DB_URL_PORT: "5432" KC_DB_URL_PORT: "5432"
KC_DB_URL_DATABASE: keycloak KC_DB_URL_DATABASE: keycloak
KC_DB_URL: jdbc:postgresql://anexo76-postgres-keycloak:5432/keycloak KC_DB_URL: jdbc:postgresql://postgres-keycloak:5432/keycloak
KC_DB_USERNAME: postgres KC_DB_USERNAME: postgres
KC_DB_PASSWORD: ${POSTGRES_KEYCLOAK_PASSWORD:-postgres} KC_DB_PASSWORD: ${POSTGRES_KEYCLOAK_PASSWORD:-postgres}
KC_DB_SCHEMA: public KC_DB_SCHEMA: public
@@ -102,7 +101,7 @@ services:
- start-dev - start-dev
- --http-relative-path=/kcauth - --http-relative-path=/kcauth
- --db=postgres - --db=postgres
- --db-url-host=anexo76-postgres-keycloak - --db-url-host=postgres-keycloak
- --db-url-port=5432 - --db-url-port=5432
- --db-url-database=keycloak - --db-url-database=keycloak
- --db-username=postgres - --db-username=postgres
@@ -128,17 +127,13 @@ services:
"CMD-SHELL", "CMD-SHELL",
"exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /kcauth/health/ready HTTP/1.1\r "exec 3<>/dev/tcp/127.0.0.1/9000; echo -e 'GET /kcauth/health/ready HTTP/1.1\r
Host: localhost\r
\ host: 127.0.0.1\r Connection: close\r
\r
\ Connection: close\r ' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1"
\ \r
\ ' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1"
] ]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
@@ -247,6 +242,7 @@ services:
- KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master}
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend}
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-zRU5NuvUFtBSOuh7Kdc372AItoWGLgz9} - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-zRU5NuvUFtBSOuh7Kdc372AItoWGLgz9}
- VITE_HUB_MODE=${VITE_HUB_MODE:-true}
ports: ports:
- "5173:5173" - "5173:5173"
depends_on: depends_on:
@@ -348,7 +344,16 @@ volumes:
networks: networks:
backend-net: backend-net:
driver: bridge driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
auth-net: auth-net:
driver: bridge driver: bridge
ipam:
config:
- subnet: 172.21.0.0/16
frontend-net: frontend-net:
driver: bridge driver: bridge
ipam:
config:
- subnet: 172.22.0.0/16

View File

@@ -172,7 +172,7 @@
></div> ></div>
<Card.Header class="pb-2"> <Card.Header class="pb-2">
<Card.Title <Card.Title
class="line-clamp-2 text-lg transition-colors group-hover:text-primary flex items-center gap-2" class="line-clamp-2 flex items-center gap-2 text-lg transition-colors group-hover:text-primary"
> >
{#if article.content_type === 'pdf'} {#if article.content_type === 'pdf'}
<FileText class="h-5 w-5 text-red-500" /> <FileText class="h-5 w-5 text-red-500" />
@@ -205,7 +205,13 @@
</Card.Content> </Card.Content>
<Card.Footer class="flex items-center justify-between border-t bg-muted/5 pt-4"> <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}`}> <Button variant="ghost" size="sm" href={`/dashboard/help-center/${article.uuid}`}>
Leer {#if article.content_type === 'video'}
Ver
{:else if article.content_type === 'article'}
Leer
{:else}
Abrir
{/if}
</Button> </Button>
<div class="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div class="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{#if HUB_MODE} {#if HUB_MODE}

View File

@@ -16,6 +16,7 @@
Italic, Italic,
Link as LinkIcon, Link as LinkIcon,
List, List,
Heading1,
Heading2, Heading2,
FileText, FileText,
Video as VideoIcon, Video as VideoIcon,
@@ -26,24 +27,24 @@
import { marked } from 'marked'; import { marked } from 'marked';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
let article: HelpArticle | null = null; // $state equivalent in Svelte 5 logic handled manually or via store, using standard let for simplest adaptation let article = $state<HelpArticle | null>(null);
let loading = true; let loading = $state(true);
let processing = false; let processing = $state(false);
// Editor State // Editor State
let title = ''; let title = $state('');
let slug = ''; let slug = $state('');
let content = ''; let content = $state('');
let category = 'General'; let category = $state('General');
let order = 0; let order = $state(0);
let last_editor = 'Admin'; let last_editor = $state('Admin');
let content_type = 'article'; let content_type = $state('article');
let file_url = ''; let file_url = $state('');
let file_size: number | null = null; let file_size = $state<number | undefined>(undefined);
let mime_type = ''; let mime_type = $state('');
// Preview State // Preview State
let showPreview = true; let showPreview = $state(true);
const HUB_MODE = import.meta.env.VITE_HUB_MODE === 'true'; const HUB_MODE = import.meta.env.VITE_HUB_MODE === 'true';
onMount(async () => { onMount(async () => {
@@ -68,9 +69,10 @@
content = article.content || ''; content = article.content || '';
category = article.category || 'General'; category = article.category || 'General';
order = article.order || 0; order = article.order || 0;
last_editor = article.last_editor || 'Admin';
content_type = article.content_type || 'article'; content_type = article.content_type || 'article';
file_url = article.file_url || ''; file_url = article.file_url || '';
file_size = article.file_size || null; file_size = article.file_size ?? undefined;
mime_type = article.mime_type || ''; mime_type = article.mime_type || '';
} }
} catch (e: any) { } catch (e: any) {
@@ -83,9 +85,17 @@
async function handleSave() { async function handleSave() {
processing = true; processing = true;
try { try {
const data = { const data = {
title, slug, content, last_editor, category, order, title,
content_type, file_url, file_size, mime_type slug,
content,
last_editor,
category,
order,
content_type,
file_url,
file_size,
mime_type
}; };
// Auto-generate slug if missing // Auto-generate slug if missing
@@ -138,36 +148,47 @@
} }
async function handleImageUpload(file: File) { async function handleImageUpload(file: File) {
const tid = toast.loading('Subiendo imagen...');
processing = true;
try { try {
toast.loading('Subiendo imagen...');
const result = await helpApi.uploadImage(file); const result = await helpApi.uploadImage(file);
const imageMarkdown = `\n![${file.name}](${result.url})\n`; const imageMarkdown = `\n![${file.name}](${result.url})\n`;
insertText(imageMarkdown); insertText(imageMarkdown);
toast.success('Imagen insertada'); toast.success('Imagen insertada', { id: tid });
} catch (e: any) { } catch (e: any) {
toast.error('Error al subir: ' + e.message); toast.error('Error al subir: ' + e.message, { id: tid });
} finally {
processing = false;
} }
} }
async function handleAssetUpload(file: File) { async function handleAssetUpload(file: File) {
const tid = toast.loading(`Subiendo ${file.name}...`);
processing = true;
try { try {
processing = true;
toast.loading(`Subiendo ${file.name}...`);
const result = await helpApi.uploadAsset(file); const result = await helpApi.uploadAsset(file);
file_url = result.url; file_url = result.url;
file_size = result.size; file_size = result.size;
mime_type = result.mime_type; mime_type = result.mime_type;
// Auto-set title if empty
if (!title) {
title = file.name.split('.').shift() || 'Sin Título';
}
if (!slug) {
slug = title.toLowerCase().replace(/[^\w]+/g, '-');
}
// Auto-infer content type if not set or article // Auto-infer content type if not set or article
if (content_type === 'article') { if (content_type === 'article') {
if (mime_type === 'application/pdf') content_type = 'pdf'; if (mime_type === 'application/pdf') content_type = 'pdf';
else if (mime_type.startsWith('video/')) content_type = 'video'; else if (mime_type.startsWith('video/')) content_type = 'video';
else content_type = 'document'; else content_type = 'document';
} }
toast.success('Archivo subido correctamente'); toast.success('Archivo subido correctamente', { id: tid });
} catch (e: any) { } catch (e: any) {
toast.error('Error al subir archivo: ' + e.message); toast.error('Error al subir archivo: ' + e.message, { id: tid });
} finally { } finally {
processing = false; processing = false;
} }
@@ -229,24 +250,12 @@
<Label for="title">Título</Label> <Label for="title">Título</Label>
<Input id="title" bind:value={title} placeholder="Ej: Introducción" /> <Input id="title" bind:value={title} placeholder="Ej: Introducción" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2 border-t pt-4">
<Label for="slug">Slug / URL</Label>
<Input id="slug" bind:value={slug} class="font-mono text-xs" />
</div>
<div class="grid gap-2">
<Label for="category">Categoría</Label>
<Input id="category" bind:value={category} placeholder="Ej: General" />
</div>
<div class="grid gap-2">
<Label for="order">Orden</Label>
<Input id="order" type="number" bind:value={order} />
</div>
<div class="grid gap-2 pt-4 border-t">
<Label for="type">Tipo de Contenido</Label> <Label for="type">Tipo de Contenido</Label>
<select <select
id="type" id="type"
bind:value={content_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" 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:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
> >
<option value="article">Artículo (Markdown)</option> <option value="article">Artículo (Markdown)</option>
<option value="pdf">PDF</option> <option value="pdf">PDF</option>
@@ -254,6 +263,14 @@
<option value="document">Otro Documento</option> <option value="document">Otro Documento</option>
</select> </select>
</div> </div>
<div class="grid gap-2 border-t pt-4">
<Label for="category">Categoría</Label>
<Input id="category" bind:value={category} placeholder="Ej: General" />
</div>
<div class="grid gap-2 border-t pt-4">
<Label for="order">Orden</Label>
<Input id="order" type="number" bind:value={order} />
</div>
</div> </div>
</aside> </aside>
@@ -383,9 +400,7 @@
</div> </div>
<h2 class="text-2xl font-bold">Configuración de Documento</h2> <h2 class="text-2xl font-bold">Configuración de Documento</h2>
{/if} {/if}
<p class="text-muted-foreground"> <p class="text-muted-foreground">Sube el archivo que deseas asociar a este título.</p>
Sube el archivo que deseas asociar a este título.
</p>
</div> </div>
{#if file_url} {#if file_url}
@@ -396,9 +411,9 @@
</div> </div>
<div class="flex-1 text-left"> <div class="flex-1 text-left">
<p class="font-medium">Archivo Cargado</p> <p class="font-medium">Archivo Cargado</p>
<p class="text-xs text-muted-foreground truncate">{file_url}</p> <p class="truncate text-xs text-muted-foreground">{file_url}</p>
<p class="text-xs font-mono"> <p class="font-mono text-xs">
{mime_type}{(file_size ? (file_size / 1024 / 1024).toFixed(2) : '??')} MB {mime_type}{file_size ? (file_size / 1024 / 1024).toFixed(2) : '??'} MB
</p> </p>
</div> </div>
</div> </div>
@@ -406,21 +421,25 @@
<Button variant="outline" class="flex-1" href={file_url} target="_blank"> <Button variant="outline" class="flex-1" href={file_url} target="_blank">
Ver Archivo Ver Archivo
</Button> </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"> <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 Cambiar Archivo
<input <input
type="file" type="file"
class="hidden" class="hidden"
onchange={(e) => { onchange={(e) => {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
if (target.files?.length) handleAssetUpload(target.files[0]); if (target.files?.length) handleAssetUpload(target.files[0]);
}} }}
/> />
</label> </label>
</div> </div>
</div> </div>
{:else} {: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"> <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="flex flex-col items-center gap-2">
<div class="rounded-full bg-background p-4 shadow-sm"> <div class="rounded-full bg-background p-4 shadow-sm">
<Upload class="h-8 w-8 text-primary" /> <Upload class="h-8 w-8 text-primary" />
@@ -428,13 +447,13 @@
<span class="font-medium">Selecciona un archivo</span> <span class="font-medium">Selecciona un archivo</span>
<span class="text-xs text-muted-foreground">O arrastra y suelta aquí</span> <span class="text-xs text-muted-foreground">O arrastra y suelta aquí</span>
</div> </div>
<input <input
type="file" type="file"
class="hidden" class="hidden"
onchange={(e) => { onchange={(e) => {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
if (target.files?.length) handleAssetUpload(target.files[0]); if (target.files?.length) handleAssetUpload(target.files[0]);
}} }}
/> />
</label> </label>
{/if} {/if}

View File

@@ -10,7 +10,13 @@ export default defineConfig({
allowedHosts: [ allowedHosts: [
'anexo76-dev.aduanasoft.com', 'anexo76-dev.aduanasoft.com',
// 'otro-host.com' si necesitas más // 'otro-host.com' si necesitas más
], ],
proxy: {
'/api/uploads': {
target: 'http://backend:8000',
changeOrigin: true
}
}
}, },
plugins: [ plugins: [
tailwindcss(), tailwindcss(),

View File

@@ -64,45 +64,9 @@ echo ""
echo -e "${BLUE}[2/7] Configurando variables de entorno...${NC}" echo -e "${BLUE}[2/7] Configurando variables de entorno...${NC}"
if [ ! -f .env ]; then if [ ! -f .env ]; then
echo -e "${YELLOW}¿Cómo desea configurar este servidor?${NC}"
echo "1) Hub (Servidor Central)"
echo "2) Cliente (Sucursal/Spoke)"
read -p "Seleccione una opción [1-2]: " SERVER_ROLE
# Valores por defecto comunes
SYNC_TOKEN=$(openssl rand -hex 16 2>/dev/null || echo "dev-sync-token-$(date +%s)")
if [ "$SERVER_ROLE" == "1" ]; then
echo -e "${GREEN}Configurando como HUB...${NC}"
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
CLIENT_ID=${CLIENT_ID:-$(cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "client-$(date +%s)")}
SPOKE_URLS=""
fi
if [ -f .env.example ]; then if [ -f .env.example ]; then
cp .env.example .env cp .env.example .env
echo -e "${GREEN}✓ Archivo .env basado en .env.example creado${NC}" echo -e "${GREEN}✓ Archivo .env creado desde .env.example${NC}"
# Aplicar configuraciones de rol al .env copiado
sed -i "s|CENTRAL_SERVER_URL=.*|CENTRAL_SERVER_URL=$CENTRAL_URL|" .env
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 else
echo -e "${YELLOW}⚠ .env.example no existe, creando .env básico${NC}" echo -e "${YELLOW}⚠ .env.example no existe, creando .env básico${NC}"
cat > .env <<EOF cat > .env <<EOF
@@ -119,17 +83,55 @@ ENVIRONMENT=development
NODE_ENV=development NODE_ENV=development
VITE_API_URL=http://localhost:8000/api VITE_API_URL=http://localhost:8000/api
VITE_KEYCLOAK_URL=http://localhost:8080/kcauth VITE_KEYCLOAK_URL=http://localhost:8080/kcauth
CENTRAL_SERVER_URL=$CENTRAL_URL
SPOKE_URLS=$SPOKE_URLS
CLIENT_UUID=$CLIENT_ID
SYNC_SECRET_TOKEN=$SYNC_TOKEN
VITE_HUB_MODE=$HUB_MODE
EOF EOF
fi fi
echo -e "${GREEN}✓ Configuración de sincronización aplicada al .env${NC}"
else
echo -e "${YELLOW}⚠ .env ya existe, se usarán las variables actuales${NC}"
fi fi
# Preguntar por el rol si no está definido o si el usuario quiere reconfigurar
if ! grep -q "VITE_HUB_MODE=" .env || [ "${RECONFIGURE:-false}" == "true" ]; then
echo -e "${YELLOW}Configuración de Rol (Hub vs Cliente)${NC}"
echo "1) Hub (Servidor Central)"
echo "2) Cliente (Sucursal/Spoke)"
read -p "Seleccione una opción [1-2, Enter para omitir]: " SERVER_ROLE
if [ "$SERVER_ROLE" == "1" ] || [ "$SERVER_ROLE" == "2" ]; then
SYNC_TOKEN=$(openssl rand -hex 16 2>/dev/null || echo "dev-sync-token-$(date +%s)")
if [ "$SERVER_ROLE" == "1" ]; then
echo -e "${GREEN}Configurando como HUB...${NC}"
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
CLIENT_ID=${CLIENT_ID:-$(cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "client-$(date +%s)")}
SPOKE_URLS=""
fi
# Aplicar configuraciones
sed -i "s|^CENTRAL_SERVER_URL=.*|CENTRAL_SERVER_URL=$CENTRAL_URL|" .env 2>/dev/null || echo "CENTRAL_SERVER_URL=$CENTRAL_URL" >> .env
sed -i "s|^SPOKE_URLS=.*|SPOKE_URLS=$SPOKE_URLS|" .env 2>/dev/null || echo "SPOKE_URLS=$SPOKE_URLS" >> .env
sed -i "s|^CLIENT_UUID=.*|CLIENT_UUID=$CLIENT_ID|" .env 2>/dev/null || echo "CLIENT_UUID=$CLIENT_ID" >> .env
if ! grep -q "SYNC_SECRET_TOKEN=" .env; then
echo "SYNC_SECRET_TOKEN=$SYNC_TOKEN" >> .env
fi
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
echo -e "${GREEN}✓ Configuración de rol aplicada al .env${NC}"
fi
else
echo -e "${YELLOW}⚠ .env ya contiene configuración de rol. Para cambiarlo, ejecuta con RECONFIGURE=true${NC}"
fi
echo "" echo ""
# 3. Limpiar contenedores previos si existen # 3. Limpiar contenedores previos si existen