Nueva funcionalidad
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}')>"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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 . .
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
67
frontend/pnpm-lock.yaml
generated
67
frontend/pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
153
frontend/src/routes/dashboard/help-center/[uuid]/+page.svelte
Normal file
153
frontend/src/routes/dashboard/help-center/[uuid]/+page.svelte
Normal 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>
|
||||
@@ -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\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>
|
||||
@@ -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 "$@"
|
||||
|
||||
Reference in New Issue
Block a user