diff --git a/src/app/actions/entities.ts b/src/app/actions/entities.ts index cbd402e..e406fe1 100644 --- a/src/app/actions/entities.ts +++ b/src/app/actions/entities.ts @@ -4,6 +4,16 @@ import { collectionMap, SCHEMA_MAP } from '@/lib/schema'; import { createDocumentServer, updateDocumentServer } from './appwrite'; import { redirect } from 'next/navigation'; import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import DOMPurify from 'isomorphic-dompurify'; + +// Re-usable rich text validator +const safeHtmlSchema = z.string().transform(html => + DOMPurify.sanitize(html, { + ADD_TAGS: ['iframe'], + ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'] + }) +); /** * Centralized action to create a new entity document @@ -31,6 +41,11 @@ export async function saveEntityAction(entity: string, formData: FormData) { data.estado = data.estado.toLowerCase(); } + // Zod XSS Sanitization & Strict Types + if (entity === 'posts' && data.contenido) { + data.contenido = safeHtmlSchema.parse(data.contenido); + } + const res = await createDocumentServer(collectionId, data); if (res.success) { revalidatePath(`/dashboard/${entity}`); @@ -66,6 +81,11 @@ export async function updateEntityAction(entity: string, id: string, formData: F data.estado = data.estado.toLowerCase(); } + // Zod XSS Sanitization & Strict Types + if (entity === 'posts' && data.contenido) { + data.contenido = safeHtmlSchema.parse(data.contenido); + } + const res = await updateDocumentServer(collectionId, id, data); if (res.success) { revalidatePath(`/dashboard/${entity}`); diff --git a/src/app/dashboard/[entity]/page.tsx b/src/app/dashboard/[entity]/page.tsx index bcecf13..04d110a 100644 --- a/src/app/dashboard/[entity]/page.tsx +++ b/src/app/dashboard/[entity]/page.tsx @@ -22,8 +22,8 @@ export default async function EntityPage({ const q = sParams.q || ''; const page = Number(sParams.page) || 1; const sort = sParams.sort || '$createdAt'; - const dir = sParams.dir === 'asc' ? 'asc' : 'desc'; - const limit = entity === 'posts' ? 12 : 15; + const dir: 'asc' | 'desc' = sParams.dir === 'asc' ? 'asc' : 'desc'; + const limit: number = entity === 'posts' ? 12 : 15; const offset = (page - 1) * limit; const collectionId = collectionMap[entity]; @@ -169,25 +169,44 @@ export default async function EntityPage({ /> )} - {total > limit && ( -
- - ANTERIOR - - - Página {page} de {Math.ceil(total / limit)} - - = Math.ceil(total / limit) ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`} - > - SIGUIENTE - -
- )} + {total > limit && (() => { + const totalPages = Math.ceil(total / limit); + return ( +
+ + « INICIO + + + ANTERIOR + + + + Página {page} de {totalPages} + + + = totalPages ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`} + > + SIGUIENTE + + = totalPages ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`} + title="Ir a la última página" + > + FINAL » + +
+ ); + })()} ); } diff --git a/src/components/dashboard/MediaGridSelector.tsx b/src/components/dashboard/MediaGridSelector.tsx index ef296e9..eab21f3 100644 --- a/src/components/dashboard/MediaGridSelector.tsx +++ b/src/components/dashboard/MediaGridSelector.tsx @@ -171,9 +171,7 @@ export const MediaGridSelector = ({ }`} > {item.titulo} -
- {item.titulo} -
+
{value === item.file_id && (
diff --git a/src/components/dashboard/RichTextEditor.tsx b/src/components/dashboard/RichTextEditor.tsx index 40fbe27..3c2ff5e 100644 --- a/src/components/dashboard/RichTextEditor.tsx +++ b/src/components/dashboard/RichTextEditor.tsx @@ -250,11 +250,17 @@ export const RichTextEditor = ({ value, onChange, label }: RichTextEditorProps) }, editorProps: { attributes: { - class: 'prose prose-invert max-w-none focus:outline-none min-h-[300px] p-6 text-slate-200 leading-relaxed', + class: 'prose prose-invert max-w-none focus:outline-none min-h-[300px] p-6 text-slate-200 leading-relaxed custom-scrollbar', }, }, }); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + // Keep content synced if value changes externally useEffect(() => { if (editor && value !== editor.getHTML()) { @@ -262,12 +268,23 @@ export const RichTextEditor = ({ value, onChange, label }: RichTextEditorProps) } }, [value, editor]); + if (!mounted) { + return ( +
+ +
+
+ ); + } + return (
-
+
- +
+ +
{/* Hidden input for form submission if needed, though we use controlled value */} diff --git a/src/components/publications/PublicationLayout.tsx b/src/components/publications/PublicationLayout.tsx index ce14fc9..aeb077e 100644 --- a/src/components/publications/PublicationLayout.tsx +++ b/src/components/publications/PublicationLayout.tsx @@ -266,8 +266,8 @@ export const PublicationLayout: React.FC = ({ defaultValue={initialData?.resumen_redes || ''} placeholder="Resumen corto para compartir en redes sociales..." maxLength={500} - className={textareaClass} - style={{ minHeight: '80px' }} + className={`${textareaClass} custom-scrollbar resize-none`} + style={{ minHeight: '80px', maxHeight: '150px', overflowY: 'auto' }} />