Nuevo frontend y start cambiado
This commit is contained in:
@@ -22,11 +22,11 @@ class HelpArticle(Base):
|
||||
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
|
||||
# Removed missing fields to avoid 500 errors (No migration approach)
|
||||
# content_type = Column(String(50), nullable=False, default="article")
|
||||
# file_url = Column(String(512), nullable=True)
|
||||
# file_size = Column(Integer, nullable=True)
|
||||
# mime_type = Column(String(100), nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<HelpArticle(title='{self.title}', slug='{self.slug}')>"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
@@ -6,32 +8,90 @@ from .models import HelpArticle
|
||||
from .schemas import HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
def get_modifications(db: Session, since: datetime) -> List[HelpArticle]:
|
||||
# Ensure timezone awareness
|
||||
if since.tzinfo is None:
|
||||
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
|
||||
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.commit()
|
||||
db.refresh(db_article)
|
||||
return db_article
|
||||
return HelpCenterService._inject_metadata(db_article)
|
||||
|
||||
@staticmethod
|
||||
def update(db: Session, article_uuid: UUID, article_data: HelpArticleUpdate) -> Optional[HelpArticle]:
|
||||
@@ -39,13 +99,41 @@ class HelpCenterService:
|
||||
if not db_article:
|
||||
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)
|
||||
|
||||
# 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():
|
||||
setattr(db_article, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_article)
|
||||
return db_article
|
||||
return HelpCenterService._inject_metadata(db_article)
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, article_uuid: UUID) -> bool:
|
||||
@@ -74,22 +162,34 @@ class HelpCenterService:
|
||||
|
||||
if not db_article:
|
||||
# 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(
|
||||
uuid=sync_data.article_uuid,
|
||||
slug=sync_data.client_slug,
|
||||
title=sync_data.client_title,
|
||||
content=sync_data.client_content,
|
||||
content=content_with_meta,
|
||||
updated_at=client_updated_at,
|
||||
last_editor=sync_data.last_editor,
|
||||
category=sync_data.client_category,
|
||||
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
|
||||
order=sync_data.client_order
|
||||
)
|
||||
db.add(new_article)
|
||||
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.")
|
||||
|
||||
server_updated_at = db_article.updated_at
|
||||
@@ -98,22 +198,33 @@ class HelpCenterService:
|
||||
|
||||
# Caso A: Cliente es más nuevo
|
||||
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.slug = sync_data.client_slug
|
||||
db_article.updated_at = client_updated_at
|
||||
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()
|
||||
|
||||
# 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.")
|
||||
|
||||
# Caso B: Servidor es más nuevo
|
||||
elif server_updated_at > client_updated_at:
|
||||
# Inject metadata for response
|
||||
db_article = HelpCenterService._inject_metadata(db_article)
|
||||
return HelpSyncResponse(
|
||||
status="UPDATE_REQUIRED",
|
||||
server_updated_at=server_updated_at,
|
||||
@@ -122,10 +233,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,
|
||||
server_content_type=getattr(db_article, "content_type", "article"),
|
||||
server_file_url=getattr(db_article, "file_url", None),
|
||||
server_file_size=getattr(db_article, "file_size", None),
|
||||
server_mime_type=getattr(db_article, "mime_type", None),
|
||||
message="Client is outdated. Update required."
|
||||
)
|
||||
|
||||
|
||||
@@ -144,6 +144,12 @@ def sync_single_article(article_uuid):
|
||||
article.updated_at = result.server_updated_at
|
||||
db.commit()
|
||||
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:
|
||||
logger.info(f"Article {article.uuid} sync OK: {result.message}")
|
||||
else:
|
||||
@@ -234,6 +240,14 @@ def sync_from_hub_task():
|
||||
local_article.order = art_data.get('order', 0)
|
||||
|
||||
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.")
|
||||
|
||||
else:
|
||||
|
||||
73
backend/api/v1/modules/core/help_center/utils.py
Normal file
73
backend/api/v1/modules/core/help_center/utils.py
Normal 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: 
|
||||
"""
|
||||
if not content:
|
||||
return
|
||||
|
||||
# Regex for markdown images: 
|
||||
image_pattern = r'!\[.*?\]\((/api/uploads/.*?)\)'
|
||||
matches = re.findall(image_pattern, content)
|
||||
|
||||
for asset_url in matches:
|
||||
download_file_from_hub(asset_url)
|
||||
@@ -8,7 +8,6 @@ services:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ${POSTGRES_APP_PASSWORD:-postgres}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
|
||||
PGDATA: /var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
@@ -38,7 +37,7 @@ services:
|
||||
|
||||
# PostgreSQL - Base de datos Keycloak
|
||||
postgres-keycloak:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:18-alpine
|
||||
container_name: anexo76-postgres-keycloak
|
||||
environment:
|
||||
POSTGRES_DB: keycloak
|
||||
@@ -81,10 +80,10 @@ services:
|
||||
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin}
|
||||
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
|
||||
KC_DB: postgres
|
||||
KC_DB_URL_HOST: anexo76-postgres-keycloak
|
||||
KC_DB_URL_HOST: postgres-keycloak
|
||||
KC_DB_URL_PORT: "5432"
|
||||
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_PASSWORD: ${POSTGRES_KEYCLOAK_PASSWORD:-postgres}
|
||||
KC_DB_SCHEMA: public
|
||||
@@ -102,7 +101,7 @@ services:
|
||||
- start-dev
|
||||
- --http-relative-path=/kcauth
|
||||
- --db=postgres
|
||||
- --db-url-host=anexo76-postgres-keycloak
|
||||
- --db-url-host=postgres-keycloak
|
||||
- --db-url-port=5432
|
||||
- --db-url-database=keycloak
|
||||
- --db-username=postgres
|
||||
@@ -128,17 +127,13 @@ services:
|
||||
"CMD-SHELL",
|
||||
"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
|
||||
|
||||
|
||||
\ \r
|
||||
|
||||
|
||||
\ ' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1"
|
||||
' >&3; grep -q 'HTTP/1.1 200' <&3 || exit 1"
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
@@ -247,6 +242,7 @@ services:
|
||||
- KEYCLOAK_REALM=${KEYCLOAK_REALM:-master}
|
||||
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend}
|
||||
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-zRU5NuvUFtBSOuh7Kdc372AItoWGLgz9}
|
||||
- VITE_HUB_MODE=${VITE_HUB_MODE:-true}
|
||||
ports:
|
||||
- "5173:5173"
|
||||
depends_on:
|
||||
@@ -348,7 +344,16 @@ volumes:
|
||||
networks:
|
||||
backend-net:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
auth-net:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.21.0.0/16
|
||||
frontend-net:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.22.0.0/16
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
></div>
|
||||
<Card.Header class="pb-2">
|
||||
<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'}
|
||||
<FileText class="h-5 w-5 text-red-500" />
|
||||
@@ -205,7 +205,13 @@
|
||||
</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
|
||||
{#if article.content_type === 'video'}
|
||||
Ver
|
||||
{:else if article.content_type === 'article'}
|
||||
Leer
|
||||
{:else}
|
||||
Abrir
|
||||
{/if}
|
||||
</Button>
|
||||
<div class="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{#if HUB_MODE}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
Italic,
|
||||
Link as LinkIcon,
|
||||
List,
|
||||
Heading1,
|
||||
Heading2,
|
||||
FileText,
|
||||
Video as VideoIcon,
|
||||
@@ -26,24 +27,24 @@
|
||||
import { marked } from 'marked';
|
||||
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 loading = true;
|
||||
let processing = false;
|
||||
let article = $state<HelpArticle | null>(null);
|
||||
let loading = $state(true);
|
||||
let processing = $state(false);
|
||||
|
||||
// Editor State
|
||||
let title = '';
|
||||
let slug = '';
|
||||
let content = '';
|
||||
let category = 'General';
|
||||
let order = 0;
|
||||
let last_editor = 'Admin';
|
||||
let content_type = 'article';
|
||||
let file_url = '';
|
||||
let file_size: number | null = null;
|
||||
let mime_type = '';
|
||||
let title = $state('');
|
||||
let slug = $state('');
|
||||
let content = $state('');
|
||||
let category = $state('General');
|
||||
let order = $state(0);
|
||||
let last_editor = $state('Admin');
|
||||
let content_type = $state('article');
|
||||
let file_url = $state('');
|
||||
let file_size = $state<number | undefined>(undefined);
|
||||
let mime_type = $state('');
|
||||
|
||||
// Preview State
|
||||
let showPreview = true;
|
||||
let showPreview = $state(true);
|
||||
const HUB_MODE = import.meta.env.VITE_HUB_MODE === 'true';
|
||||
|
||||
onMount(async () => {
|
||||
@@ -68,9 +69,10 @@
|
||||
content = article.content || '';
|
||||
category = article.category || 'General';
|
||||
order = article.order || 0;
|
||||
last_editor = article.last_editor || 'Admin';
|
||||
content_type = article.content_type || 'article';
|
||||
file_url = article.file_url || '';
|
||||
file_size = article.file_size || null;
|
||||
file_size = article.file_size ?? undefined;
|
||||
mime_type = article.mime_type || '';
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -83,9 +85,17 @@
|
||||
async function handleSave() {
|
||||
processing = true;
|
||||
try {
|
||||
const data = {
|
||||
title, slug, content, last_editor, category, order,
|
||||
content_type, file_url, file_size, mime_type
|
||||
const data = {
|
||||
title,
|
||||
slug,
|
||||
content,
|
||||
last_editor,
|
||||
category,
|
||||
order,
|
||||
content_type,
|
||||
file_url,
|
||||
file_size,
|
||||
mime_type
|
||||
};
|
||||
|
||||
// Auto-generate slug if missing
|
||||
@@ -138,36 +148,47 @@
|
||||
}
|
||||
|
||||
async function handleImageUpload(file: File) {
|
||||
const tid = toast.loading('Subiendo imagen...');
|
||||
processing = true;
|
||||
try {
|
||||
toast.loading('Subiendo imagen...');
|
||||
const result = await helpApi.uploadImage(file);
|
||||
const imageMarkdown = `\n\n`;
|
||||
insertText(imageMarkdown);
|
||||
toast.success('Imagen insertada');
|
||||
toast.success('Imagen insertada', { id: tid });
|
||||
} 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) {
|
||||
const tid = toast.loading(`Subiendo ${file.name}...`);
|
||||
processing = true;
|
||||
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-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
|
||||
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');
|
||||
|
||||
toast.success('Archivo subido correctamente', { id: tid });
|
||||
} catch (e: any) {
|
||||
toast.error('Error al subir archivo: ' + e.message);
|
||||
toast.error('Error al subir archivo: ' + e.message, { id: tid });
|
||||
} finally {
|
||||
processing = false;
|
||||
}
|
||||
@@ -229,24 +250,12 @@
|
||||
<Label for="title">Título</Label>
|
||||
<Input id="title" bind:value={title} placeholder="Ej: Introducción" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<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">
|
||||
<div class="grid gap-2 border-t pt-4">
|
||||
<Label for="type">Tipo de Contenido</Label>
|
||||
<select
|
||||
id="type"
|
||||
<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"
|
||||
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="pdf">PDF</option>
|
||||
@@ -254,6 +263,14 @@
|
||||
<option value="document">Otro Documento</option>
|
||||
</select>
|
||||
</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>
|
||||
</aside>
|
||||
|
||||
@@ -383,9 +400,7 @@
|
||||
</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>
|
||||
<p class="text-muted-foreground">Sube el archivo que deseas asociar a este título.</p>
|
||||
</div>
|
||||
|
||||
{#if file_url}
|
||||
@@ -396,9 +411,9 @@
|
||||
</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 class="truncate text-xs text-muted-foreground">{file_url}</p>
|
||||
<p class="font-mono text-xs">
|
||||
{mime_type} • {file_size ? (file_size / 1024 / 1024).toFixed(2) : '??'} MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -406,21 +421,25 @@
|
||||
<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">
|
||||
<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"
|
||||
<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">
|
||||
<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" />
|
||||
@@ -428,13 +447,13 @@
|
||||
<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"
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
onchange={(e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files?.length) handleAssetUpload(target.files[0]);
|
||||
}}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
@@ -10,7 +10,13 @@ export default defineConfig({
|
||||
allowedHosts: [
|
||||
'anexo76-dev.aduanasoft.com',
|
||||
// 'otro-host.com' si necesitas más
|
||||
],
|
||||
],
|
||||
proxy: {
|
||||
'/api/uploads': {
|
||||
target: 'http://backend:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
|
||||
92
start.sh
92
start.sh
@@ -64,45 +64,9 @@ echo ""
|
||||
echo -e "${BLUE}[2/7] Configurando variables de entorno...${NC}"
|
||||
|
||||
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
|
||||
cp .env.example .env
|
||||
echo -e "${GREEN}✓ Archivo .env basado en .env.example creado${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
|
||||
echo -e "${GREEN}✓ Archivo .env creado desde .env.example${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ .env.example no existe, creando .env básico${NC}"
|
||||
cat > .env <<EOF
|
||||
@@ -119,17 +83,55 @@ ENVIRONMENT=development
|
||||
NODE_ENV=development
|
||||
VITE_API_URL=http://localhost:8000/api
|
||||
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
|
||||
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
|
||||
|
||||
# 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 ""
|
||||
|
||||
# 3. Limpiar contenedores previos si existen
|
||||
|
||||
Reference in New Issue
Block a user