Nueva funcionalidad

This commit is contained in:
2026-02-19 11:34:03 -06:00
parent f00a32ddb3
commit bcc758953f
14 changed files with 729 additions and 220 deletions

View File

@@ -39,3 +39,12 @@ VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend
SITAR_API_URL=http://api.sitar.aduanasoft.com
SITAR_API_USER=your_sitar_user
SITAR_API_PASSWORD=your_sitar_password
# ----- Help Center -----
CENTRAL_SERVER_URL=
CLIENT_UUID=
SYNC_SECRET_TOKEN=
SPOKE_URLS=
CORS_ORIGINS=CORS: Permitir acceso desde la IP del Hub y localhost

View File

@@ -1,6 +1,6 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import Column, String, Text, DateTime
from sqlalchemy import Column, String, Text, DateTime, Integer
from sqlalchemy.dialects.postgresql import UUID
from core.database import Base
@@ -17,6 +17,10 @@ class HelpArticle(Base):
content = Column(Text, nullable=False)
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
last_editor = Column(String(255), nullable=False)
# Library Mode Fields
category = Column(String(255), nullable=True, default="General")
order = Column(Integer, nullable=True, default=0)
def __repr__(self):
return f"<HelpArticle(title='{self.title}', slug='{self.slug}')>"

View File

@@ -1,7 +1,10 @@
import shutil
import os
import uuid
from datetime import datetime
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Header, status
from fastapi import APIRouter, Depends, HTTPException, Header, status, UploadFile, File
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.config import settings
@@ -61,6 +64,24 @@ def sync_help_article(sync_data: HelpSyncRequest, db: Session = Depends(get_core
return result
@router.post("/upload-image/")
def upload_help_image(file: UploadFile = File(...)):
"""Sube una imagen para usar en los artículos."""
try:
file_ext = os.path.splitext(file.filename)[1]
new_filename = f"{uuid.uuid4()}{file_ext}"
file_location = f"uploads/help/{new_filename}"
# Ensure directory exists
os.makedirs("uploads/help", exist_ok=True)
with open(file_location, "wb+") as buffer:
shutil.copyfileobj(file.file, buffer)
return {"url": f"/api/uploads/help/{new_filename}"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/articles/", response_model=List[HelpArticleInDB])
def list_articles(db: Session = Depends(get_core_db)):
"""Lista todos los artículos de ayuda."""

View File

@@ -8,6 +8,8 @@ class HelpArticleBase(BaseModel):
title: str
content: str
last_editor: str
category: Optional[str] = "General"
order: Optional[int] = 0
class HelpArticleCreate(HelpArticleBase):
pass
@@ -17,6 +19,8 @@ class HelpArticleUpdate(BaseModel):
title: Optional[str] = None
content: Optional[str] = None
last_editor: Optional[str] = None
category: Optional[str] = None
order: Optional[int] = None
class HelpArticleInDB(HelpArticleBase):
uuid: UUID
@@ -32,6 +36,8 @@ class HelpSyncRequest(BaseModel):
client_title: str
client_slug: str
last_editor: str
client_category: Optional[str] = "General"
client_order: Optional[int] = 0
origin_client_uuid: Optional[UUID] = None
class HelpSyncResponse(BaseModel):
@@ -40,4 +46,6 @@ class HelpSyncResponse(BaseModel):
server_content: Optional[str] = None
server_title: Optional[str] = None
server_slug: Optional[str] = None
server_category: Optional[str] = None
server_order: Optional[int] = None
message: str

View File

@@ -80,7 +80,9 @@ class HelpCenterService:
title=sync_data.client_title,
content=sync_data.client_content,
updated_at=client_updated_at,
last_editor=sync_data.last_editor
last_editor=sync_data.last_editor,
category=sync_data.client_category,
order=sync_data.client_order
)
db.add(new_article)
db.commit()
@@ -97,6 +99,8 @@ class HelpCenterService:
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.commit()
return HelpSyncResponse(status="OK", message="Server updated with client data.")
@@ -108,6 +112,8 @@ class HelpCenterService:
server_content=db_article.content,
server_title=db_article.title,
server_slug=db_article.slug,
server_category=db_article.category,
server_order=db_article.order,
message="Client is outdated. Update required."
)

View File

@@ -214,7 +214,9 @@ def sync_from_hub_task():
title=art_data['title'],
content=art_data['content'],
updated_at=server_updated_at,
last_editor=art_data['last_editor']
last_editor=art_data['last_editor'],
category=art_data.get('category', "General"),
order=art_data.get('order', 0)
)
db.add(new_article)
else:
@@ -224,6 +226,8 @@ def sync_from_hub_task():
local_article.content = art_data['content']
local_article.updated_at = server_updated_at
local_article.last_editor = art_data['last_editor']
local_article.category = art_data.get('category', "General")
local_article.order = art_data.get('order', 0)
db.commit()
logger.info("Polling sync completed successfully.")

View File

@@ -15,7 +15,7 @@ RUN npm config set strict-ssl false
RUN npm install -g pnpm
# Instalar dependencias
RUN pnpm install --frozen-lockfile
RUN pnpm install
# Copiar código
COPY . .

View File

@@ -57,8 +57,12 @@
"vitest-browser-svelte": "^1.1.0"
},
"dependencies": {
"@types/dompurify": "^3.2.0",
"@types/marked": "^6.0.0",
"dompurify": "^3.0.9",
"keycloak-js": "^26.2.1",
"lucide-svelte": "^0.553.0",
"marked": "^12.0.0",
"svelte-sonner": "^1.0.7"
}
}
}

View File

@@ -8,12 +8,24 @@ importers:
.:
dependencies:
'@types/dompurify':
specifier: ^3.2.0
version: 3.2.0
'@types/marked':
specifier: ^6.0.0
version: 6.0.0
dompurify:
specifier: ^3.0.9
version: 3.3.1
keycloak-js:
specifier: ^26.2.1
version: 26.2.1
lucide-svelte:
specifier: ^0.553.0
version: 0.553.0(svelte@5.40.2)
marked:
specifier: ^12.0.0
version: 12.0.2
svelte-sonner:
specifier: ^1.0.7
version: 1.0.7(svelte@5.40.2)
@@ -505,56 +517,67 @@ packages:
resolution: {integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.52.4':
resolution: {integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.52.4':
resolution: {integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.52.4':
resolution: {integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.52.4':
resolution: {integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.52.4':
resolution: {integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.52.4':
resolution: {integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.52.4':
resolution: {integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.52.4':
resolution: {integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.52.4':
resolution: {integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.52.4':
resolution: {integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.52.4':
resolution: {integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==}
@@ -675,24 +698,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.14':
resolution: {integrity: sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.14':
resolution: {integrity: sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.14':
resolution: {integrity: sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.14':
resolution: {integrity: sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==}
@@ -758,18 +785,29 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/dompurify@3.2.0':
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/marked@6.0.0':
resolution: {integrity: sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA==}
deprecated: This is a stub types definition. marked provides its own type definitions, so you do not need this installed.
'@types/node@20.19.22':
resolution: {integrity: sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ==}
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@typescript-eslint/eslint-plugin@8.46.1':
resolution: {integrity: sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1054,6 +1092,9 @@ packages:
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
dompurify@3.3.1:
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
enhanced-resolve@5.18.3:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
@@ -1368,24 +1409,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
@@ -1432,6 +1477,11 @@ packages:
magic-string@0.30.19:
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
marked@12.0.2:
resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==}
engines: {node: '>= 18'}
hasBin: true
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@@ -2547,16 +2597,27 @@ snapshots:
'@types/deep-eql@4.0.2': {}
'@types/dompurify@3.2.0':
dependencies:
dompurify: 3.3.1
'@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}
'@types/marked@6.0.0':
dependencies:
marked: 12.0.2
'@types/node@20.19.22':
dependencies:
undici-types: 6.21.0
'@types/resolve@1.20.2': {}
'@types/trusted-types@2.0.7':
optional: true
'@typescript-eslint/eslint-plugin@8.46.1(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.1
@@ -2853,6 +2914,10 @@ snapshots:
dom-accessibility-api@0.5.16: {}
dompurify@3.3.1:
optionalDependencies:
'@types/trusted-types': 2.0.7
enhanced-resolve@5.18.3:
dependencies:
graceful-fs: 4.2.11
@@ -3212,6 +3277,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@12.0.2: {}
merge2@1.4.1: {}
micromatch@4.0.8:

View File

@@ -19,6 +19,8 @@ export interface HelpArticle {
content: string;
updated_at: string;
last_editor: string;
category?: string;
order?: number;
}
export const helpApi = {
@@ -44,7 +46,7 @@ export const helpApi = {
return response.json();
},
async createArticle(data: { title: string; content: string; slug: string; last_editor: string }): Promise<HelpArticle> {
async createArticle(data: { title: string; content: string; slug: string; last_editor: string; category?: string; order?: number }): Promise<HelpArticle> {
const response = await fetch(`${BASE_URL}/articles/`, {
method: 'POST',
headers: getHeaders(),
@@ -64,5 +66,21 @@ export const helpApi = {
async triggerSync(): Promise<void> {
// Opcional: endpoint para forzar sync desde UI si es necesario
},
async uploadImage(file: File): Promise<{ url: string }> {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${BASE_URL}/upload-image/`, {
method: 'POST',
// No Content-Type header for FormData, browser sets it with boundary
headers: {
...(get(authStore).token ? { 'Authorization': `Bearer ${get(authStore).token}` } : {})
},
body: formData
});
if (!response.ok) throw new Error('Failed to upload image');
return response.json();
}
};

View File

@@ -8,7 +8,7 @@
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 } from 'lucide-svelte';
import { Plus, Trash2, Edit, Loader2, Search, Book, Image as ImageIcon } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
let articles: HelpArticle[] = $state([]);
@@ -28,16 +28,30 @@
title: '',
slug: '',
content: '',
last_editor: 'Admin'
last_editor: 'Admin',
category: 'General',
order: 0
});
const filteredArticles = $derived(
articles.filter(
(a) =>
a.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
a.content.toLowerCase().includes(searchTerm.toLowerCase())
).sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
);
// Derived state: Grouped by Category
const groupedArticles = $derived.by(() => {
const filtered = articles
.filter(
(a) =>
a.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
a.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
(a.category || 'General').toLowerCase().includes(searchTerm.toLowerCase())
)
.sort((a, b) => (a.order || 0) - (b.order || 0)); // Sort by order first
const groups: Record<string, HelpArticle[]> = {};
filtered.forEach((article) => {
const cat = article.category || 'General';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(article);
});
return groups;
});
onMount(async () => {
await loadArticles();
@@ -54,50 +68,12 @@
}
}
function resetForm() {
formValues = {
title: '',
slug: '',
content: '',
last_editor: 'Admin'
};
function handleCreate() {
window.location.href = '/dashboard/help-center/editor/new';
}
async function handleCreate() {
processing = true;
try {
// Generate slug if empty
if (!formValues.slug) {
formValues.slug = formValues.title
.toLowerCase()
.replace(/ /g, '-')
.replace(/[^\w-]+/g, '');
}
await helpApi.createArticle(formValues);
toast.success('Artículo creado correctamente');
showCreateModal = false;
resetForm();
await loadArticles();
} catch (e: any) {
toast.error('Error: ' + e.message);
} finally {
processing = false;
}
}
async function handleUpdate() {
if (!selectedArticle) return;
processing = true;
try {
await helpApi.updateArticle(selectedArticle.uuid, formValues);
toast.success('Artículo actualizado');
showEditModal = false;
await loadArticles();
} catch (e: any) {
toast.error('Error: ' + e.message);
} finally {
processing = false;
}
function openEdit(article: HelpArticle) {
window.location.href = `/dashboard/help-center/editor/${article.uuid}`;
}
async function handleDelete() {
@@ -105,7 +81,7 @@
processing = true;
try {
await helpApi.deleteArticle(selectedArticle.uuid);
toast.success('Artículo eliminado');
toast.success('Capítulo eliminado');
showDeleteDialog = false;
await loadArticles();
} catch (e: any) {
@@ -115,196 +91,125 @@
}
}
function openEdit(article: HelpArticle) {
selectedArticle = article;
formValues = {
title: article.title,
slug: article.slug,
content: article.content,
last_editor: article.last_editor
};
showEditModal = true;
}
function openDelete(article: HelpArticle) {
selectedArticle = article;
showDeleteDialog = true;
}
</script>
<div class="flex h-full flex-col space-y-6">
<div class="flex h-full flex-col space-y-6 rounded-xl bg-muted/10 p-4">
<!-- Page Header -->
<div class="flex flex-col gap-4 px-2 md:flex-row md:items-center md:justify-between">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">Centro de Ayuda</h1>
<p class="text-muted-foreground">Gestiona la base de conocimientos distribuida.</p>
<h1 class="flex items-center gap-2 text-3xl font-bold tracking-tight text-primary">
<Book class="h-8 w-8" />
Biblioteca de Conocimiento
</h1>
<p class="text-muted-foreground">Manuales, Guías y Documentación del Sistema.</p>
</div>
<div class="flex items-center gap-2">
<Button
onclick={() => {
resetForm();
showCreateModal = true;
}}
>
<Button href="/dashboard/help-center/editor/new" class="shadow-lg">
<Plus class="mr-2 h-4 w-4" />
Nuevo Artículo
Nuevo Capítulo
</Button>
</div>
</div>
<!-- Filters & Tools -->
<div class="flex items-center gap-2 px-2">
<div class="relative max-w-sm flex-1">
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
<Input type="search" placeholder="Buscar artículos..." class="pl-8" bind:value={searchTerm} />
</div>
<!-- Search -->
<div class="relative max-w-lg">
<Search class="absolute top-3 left-3 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Buscar en la biblioteca..."
class="h-10 border-muted-foreground/20 bg-background pl-10"
bind:value={searchTerm}
/>
</div>
<!-- Content -->
<div class="flex-1 overflow-auto p-2">
<!-- Content: Shelves -->
<div class="flex-1 space-y-8 overflow-auto pr-2">
{#if loading}
<div class="flex h-32 items-center justify-center">
<Loader2 class="h-8 w-8 animate-spin text-primary" />
<div class="flex h-40 items-center justify-center">
<Loader2 class="h-10 w-10 animate-spin text-primary" />
</div>
{:else if Object.keys(groupedArticles).length === 0}
<div class="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Book class="mb-4 h-16 w-16 opacity-20" />
<p>La biblioteca está vacía.</p>
</div>
{:else if filteredArticles.length === 0}
<Card.Root>
<Card.Content class="flex flex-col items-center justify-center py-10">
<p class="text-muted-foreground">No se encontraron artículos.</p>
</Card.Content>
</Card.Root>
{:else}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each filteredArticles as article}
<Card.Root class="flex h-full flex-col transition-colors hover:border-primary/50">
<Card.Header>
<div class="flex items-start justify-between gap-2">
<Card.Title class="line-clamp-2 text-lg">{article.title}</Card.Title>
<div class="flex shrink-0 gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={() => openEdit(article)}
>
<Edit class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
onclick={() => openDelete(article)}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
<Card.Description class="text-xs">
Slug: /{article.slug}
</Card.Description>
</Card.Header>
<Card.Content class="flex-grow">
<div class="prose prose-sm line-clamp-4 max-w-none text-sm text-muted-foreground">
{@html article.content}
</div>
</Card.Content>
<Card.Footer
class="flex justify-between bg-muted/30 pt-3 text-[10px] tracking-wider text-muted-foreground uppercase"
{#each Object.entries(groupedArticles) as [category, groupArticles]}
<div class="space-y-4">
<h2
class="flex items-center gap-2 border-b pb-2 text-xl font-semibold text-foreground/80"
>
<span
class="rounded bg-primary/10 px-2 py-1 text-sm tracking-wide text-primary uppercase"
>{category}</span
>
<span>Edito: {article.last_editor}</span>
<span>{new Date(article.updated_at).toLocaleDateString()}</span>
</Card.Footer>
</Card.Root>
{/each}
</div>
</h2>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each groupArticles as article}
<Card.Root
class="group relative flex h-full flex-col overflow-hidden border-muted-foreground/10 bg-background transition-all duration-300 hover:border-primary/50 hover:shadow-xl"
>
<div
class="absolute top-0 left-0 h-full w-1 bg-primary/0 transition-all group-hover:bg-primary"
></div>
<Card.Header class="pb-2">
<Card.Title
class="line-clamp-2 text-lg transition-colors group-hover:text-primary"
>
{article.title}
</Card.Title>
<Card.Description class="text-xs">
{new Date(article.updated_at).toLocaleDateString()}
</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>
</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>
</div>
</Card.Footer>
</Card.Root>
{/each}
</div>
</div>
{/each}
{/if}
</div>
</div>
<!-- Create Modal -->
<Dialog.Root bind:open={showCreateModal}>
<Dialog.Content class="sm:max-w-[600px]">
<Dialog.Header>
<Dialog.Title>Crear Artículo</Dialog.Title>
<Dialog.Description>
Completa los campos para añadir un nuevo artículo a la base de conocimientos.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label for="title">Título</Label>
<Input id="title" bind:value={formValues.title} placeholder="Ej: Cómo subir facturas" />
</div>
<div class="grid gap-2">
<Label for="slug">Slug (URL)</Label>
<Input id="slug" bind:value={formValues.slug} placeholder="ej-como-subir-facturas" />
</div>
<div class="grid gap-2">
<Label for="content">Contenido (HTML permitido)</Label>
<Textarea
id="content"
bind:value={formValues.content}
rows={10}
placeholder="Contenido del artículo..."
/>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (showCreateModal = false)}>Cancelar</Button>
<Button
onclick={handleCreate}
disabled={processing || !formValues.title || !formValues.content}
>
{#if processing}<Loader2 class="mr-2 h-4 w-4 animate-spin" />{/if}
Guardar Artículo
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Edit Modal -->
<Dialog.Root bind:open={showEditModal}>
<Dialog.Content class="sm:max-w-[600px]">
<Dialog.Header>
<Dialog.Title>Editar Artículo</Dialog.Title>
<Dialog.Description>
Modifica el contenido del artículo. Los cambios se sincronizarán automáticamente.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label for="edit-title">Título</Label>
<Input id="edit-title" bind:value={formValues.title} />
</div>
<div class="grid gap-2">
<Label for="edit-slug">Slug (URL)</Label>
<Input id="edit-slug" bind:value={formValues.slug} />
</div>
<div class="grid gap-2">
<Label for="edit-content">Contenido</Label>
<Textarea id="edit-content" bind:value={formValues.content} rows={10} />
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (showEditModal = false)}>Cancelar</Button>
<Button
onclick={handleUpdate}
disabled={processing || !formValues.title || !formValues.content}
>
{#if processing}<Loader2 class="mr-2 h-4 w-4 animate-spin" />{/if}
Actualizar Cambios
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Delete Confirmation -->
<AlertDialog.Root bind:open={showDeleteDialog}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>¿Estás seguro?</AlertDialog.Title>
<AlertDialog.Title>¿Eliminar capítulo?</AlertDialog.Title>
<AlertDialog.Description>
Esta acción eliminará el artículo "{selectedArticle?.title}" de forma permanente. Esta
acción no se puede deshacer.
Se eliminará permanentemente "{selectedArticle?.title}".
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
@@ -314,7 +219,7 @@
onclick={handleDelete}
>
{#if processing}<Loader2 class="mr-2 h-4 w-4 animate-spin" />{/if}
Eliminar Artículo
Eliminar
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>

View File

@@ -0,0 +1,153 @@
<script lang="ts">
import { page } from '$app/stores';
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 { marked } from 'marked';
import DOMPurify from 'dompurify';
let article: HelpArticle | null = null; // Removed $state() as it's not reactive by itself in Svelte 5 standard store usage unless wrapped
let loading = true; // Use standard value, reactive via variable assignment in Svelte 5 runes if enabled
let error: string | null = null;
let toc: { id: string; text: string; level: number }[] = [];
onMount(async () => {
const uuid = $page.params.uuid;
try {
article = await helpApi.getArticle(uuid);
if (article) {
parseTOC(article.content);
}
} catch (e: any) {
error = e.message;
} finally {
loading = false;
}
});
function parseTOC(content: string) {
const lines = content.split('\n');
toc = [];
let inCodeBlock = false; // Simple check to avoid parsing headers inside code blocks
lines.forEach((line) => {
if (line.trim().startsWith('```')) {
inCodeBlock = !inCodeBlock;
return;
}
if (inCodeBlock) return;
const match = line.match(/^(#{1,3})\s+(.*)$/);
if (match) {
const level = match[1].length;
const text = match[2];
const id = text.toLowerCase().replace(/[^\w]+/g, '-');
toc = [...toc, { id, text, level }];
}
});
}
function renderMarkdown(content: string) {
const rawHtml = marked.parse(content || '') as string;
// Add IDs to headers for TOC navigation
const htmlWithIds = rawHtml.replace(/<h([1-3])>(.*?)<\/h\1>/g, (match, level, text) => {
const id = text.toLowerCase().replace(/[^\w]+/g, '-');
return `<h${level} id="${id}">${text}</h${level}>`;
});
return DOMPurify.sanitize(htmlWithIds);
}
function scrollToHeader(id: string) {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
</script>
<div class="flex h-full flex-col bg-background">
<!-- Toolbar -->
<div
class="sticky top-0 z-10 flex items-center gap-4 border-b bg-card/50 px-6 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
<Button variant="ghost" size="sm" href="/dashboard/help-center" class="gap-2">
<ArrowLeft class="h-4 w-4" />
Volver a la Biblioteca
</Button>
<div class="mx-2 h-4 w-px bg-border"></div>
{#if article}
<span class="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<BookOpen class="h-4 w-4" />
{article.category || 'General'}
</span>
{/if}
</div>
<div class="relative flex-1 overflow-hidden">
{#if loading}
<div class="flex h-full items-center justify-center">
<Loader2 class="h-8 w-8 animate-spin text-primary" />
</div>
{:else if error}
<div class="flex h-full flex-col items-center justify-center gap-2 text-destructive">
<p class="text-lg font-medium">Error al cargar el capítulo</p>
<p class="text-sm text-muted-foreground">{error}</p>
</div>
{:else if article}
<div class="flex h-full">
<!-- Article Content -->
<div class="flex-1 overflow-y-auto scroll-smooth px-8 py-10 md:px-12 lg:px-16">
<div class="mx-auto max-w-4xl pb-20">
<!-- Header -->
<div class="mb-8 border-b pb-6">
<h1 class="mb-4 text-4xl font-bold tracking-tight text-foreground">
{article.title}
</h1>
<div class="flex items-center gap-6 text-sm text-muted-foreground">
<span class="flex items-center gap-1.5">
<User class="h-4 w-4" />
{article.last_editor}
</span>
<span class="flex items-center gap-1.5">
<Calendar class="h-4 w-4" />
{new Date(article.updated_at).toLocaleDateString(undefined, {
dateStyle: 'long'
})}
</span>
</div>
</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>
</div>
</div>
<!-- TOC Sidebar (Desktop) -->
{#if toc.length > 0}
<div class="hidden w-72 overflow-y-auto border-l bg-muted/5 p-6 xl:block">
<h4 class="mb-4 text-sm font-semibold tracking-wider text-muted-foreground uppercase">
En este capitulo
</h4>
<nav class="space-y-1">
{#each toc as item}
<button
class="block w-full py-1 text-left text-sm text-muted-foreground transition-colors hover:text-foreground
{item.level === 1 ? 'font-medium' : 'pl-' + item.level * 2}
"
onclick={() => scrollToHeader(item.id)}
>
{item.text}
</button>
{/each}
</nav>
</div>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,304 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount, tick } from 'svelte';
import { helpApi, type HelpArticle } from '$lib/api/help';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import {
Loader2,
Save,
ArrowLeft,
Image as ImageIcon,
Bold,
Italic,
Link as LinkIcon,
List,
Heading1,
Heading2
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
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;
// Editor State
let title = '';
let slug = '';
let content = '';
let category = 'General';
let order = 0;
let last_editor = 'Admin'; // Could pull from auth store
// Preview State
let showPreview = true;
onMount(async () => {
const uuid = $page.params.uuid as string;
if (uuid === 'new') {
loading = false;
// Defaults for new article
return;
}
try {
article = await helpApi.getArticle(uuid);
if (article) {
title = article.title;
slug = article.slug;
content = article.content;
category = article.category || 'General';
order = article.order || 0;
}
} catch (e: any) {
toast.error('Error al cargar artículo: ' + e.message);
} finally {
loading = false;
}
});
async function handleSave() {
processing = true;
try {
const data = { title, slug, content, last_editor, category, order };
// Auto-generate slug if missing
if (!data.slug) {
data.slug = data.title
.toLowerCase()
.replace(/ /g, '-')
.replace(/[^\w-]+/g, '');
slug = data.slug;
}
const uuid = $page.params.uuid as string;
if (uuid === 'new') {
const newArticle = await helpApi.createArticle(data);
toast.success('Capítulo creado');
// Redirect to edit mode or list? For now, stay here but update URL would be ideal.
// simpler to just go back to list or reload.
// Let's redirect to edit mode of this new UUID to avoid duplicates on re-save
window.location.href = `/dashboard/help-center/editor/${newArticle.uuid}`;
} else {
await helpApi.updateArticle(uuid, data);
toast.success('Cambios guardados');
}
} catch (e: any) {
toast.error('Error al guardar: ' + e.message);
} finally {
processing = false;
}
}
function insertText(prefix: string, suffix: string = '') {
const textarea = document.getElementById('markdown-editor') as HTMLTextAreaElement;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selection = text.substring(start, end);
const before = text.substring(0, start);
const after = text.substring(end);
content = `${before}${prefix}${selection}${suffix}${after}`;
// Restore focus and selection
tick().then(() => {
textarea.focus();
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
});
}
async function handleImageUpload(file: File) {
try {
toast.loading('Subiendo imagen...');
const result = await helpApi.uploadImage(file);
const imageMarkdown = `\n![${file.name}](${result.url})\n`;
insertText(imageMarkdown);
toast.success('Imagen insertada');
} catch (e: any) {
toast.error('Error al subir: ' + e.message);
}
}
function handleDrop(e: DragEvent) {
e.preventDefault();
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const file = files[0];
if (file.type.startsWith('image/')) {
handleImageUpload(file);
}
}
}
function renderMarkdown(text: string) {
// @ts-ignore
return DOMPurify.sanitize(marked.parse(text || '') as string);
}
</script>
<div class="flex h-screen flex-col overflow-hidden bg-background">
<!-- Top Bar -->
<header class="z-10 flex items-center justify-between border-b bg-card px-6 py-3">
<div class="flex items-center gap-4">
<Button variant="ghost" size="sm" href="/dashboard/help-center">
<ArrowLeft class="mr-2 h-4 w-4" />
Volver
</Button>
<h1 class="max-w-md truncate text-lg font-semibold">
{title || 'Sin Título'}
</h1>
</div>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" onclick={() => (showPreview = !showPreview)}>
{showPreview ? 'Ocultar Vista Previa' : 'Ver Vista Previa'}
</Button>
<Button onclick={handleSave} disabled={processing || !title}>
{#if processing}<Loader2 class="mr-2 h-4 w-4 animate-spin" />{/if}
<Save class="mr-2 h-4 w-4" />
Guardar
</Button>
</div>
</header>
<!-- Main Editor Area -->
<div class="flex flex-1 overflow-hidden">
<!-- Metadata Sidebar (Left) - Collapsible or Fixed width -->
<aside class="hidden w-64 overflow-y-auto border-r bg-muted/10 p-4 lg:block">
<h3 class="mb-4 text-sm font-semibold tracking-wider text-muted-foreground uppercase">
Configuración
</h3>
<div class="space-y-4">
<div class="grid gap-2">
<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>
</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"
>
<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"
>
<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)}
</div>
</div>
{/if}
</main>
</div>
</div>

View File

@@ -40,5 +40,11 @@ echo "=========================================="
echo "Iniciando aplicación SvelteKit..."
echo "=========================================="
# Instalar dependencias nuevas si package.json ha cambiado
if [ "$NODE_ENV" = "development" ]; then
echo "Instalando dependencias (development mode)..."
pnpm install
fi
# Ejecutar el comando que se pasó al contenedor
exec "$@"