fix: explicit debugging logic on paginations, filters, Zod xss, and UI constraints

This commit is contained in:
Carlos (Almeira) 2026-03-28 15:22:05 -05:00
parent 3647602413
commit 682d6620b9
5 changed files with 83 additions and 29 deletions

View File

@ -4,6 +4,16 @@ import { collectionMap, SCHEMA_MAP } from '@/lib/schema';
import { createDocumentServer, updateDocumentServer } from './appwrite'; import { createDocumentServer, updateDocumentServer } from './appwrite';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache'; 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 * 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(); 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); const res = await createDocumentServer(collectionId, data);
if (res.success) { if (res.success) {
revalidatePath(`/dashboard/${entity}`); revalidatePath(`/dashboard/${entity}`);
@ -66,6 +81,11 @@ export async function updateEntityAction(entity: string, id: string, formData: F
data.estado = data.estado.toLowerCase(); 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); const res = await updateDocumentServer(collectionId, id, data);
if (res.success) { if (res.success) {
revalidatePath(`/dashboard/${entity}`); revalidatePath(`/dashboard/${entity}`);

View File

@ -22,8 +22,8 @@ export default async function EntityPage({
const q = sParams.q || ''; const q = sParams.q || '';
const page = Number(sParams.page) || 1; const page = Number(sParams.page) || 1;
const sort = sParams.sort || '$createdAt'; const sort = sParams.sort || '$createdAt';
const dir = sParams.dir === 'asc' ? 'asc' : 'desc'; const dir: 'asc' | 'desc' = sParams.dir === 'asc' ? 'asc' : 'desc';
const limit = entity === 'posts' ? 12 : 15; const limit: number = entity === 'posts' ? 12 : 15;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const collectionId = collectionMap[entity]; const collectionId = collectionMap[entity];
@ -169,25 +169,44 @@ export default async function EntityPage({
/> />
)} )}
{total > limit && ( {total > limit && (() => {
<div className="flex justify-center items-center gap-4 mt-8"> const totalPages = Math.ceil(total / limit);
return (
<div className="flex justify-center items-center gap-2 mt-8 flex-wrap">
<Link
href={`/dashboard/${entity}?page=1${q ? `&q=${q}` : ''}${sort ? `&sort=${sort}` : ''}${dir ? `&dir=${dir}` : ''}`}
className={`px-4 py-2 rounded-xl font-bold text-xs transition-all border border-slate-800 ${page <= 1 ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`}
title="Ir a la primera página"
>
&laquo; INICIO
</Link>
<Link <Link
href={`/dashboard/${entity}?page=${page - 1}${q ? `&q=${q}` : ''}${sort ? `&sort=${sort}` : ''}${dir ? `&dir=${dir}` : ''}`} href={`/dashboard/${entity}?page=${page - 1}${q ? `&q=${q}` : ''}${sort ? `&sort=${sort}` : ''}${dir ? `&dir=${dir}` : ''}`}
className={`px-6 py-2 rounded-full font-bold text-sm transition-all border border-slate-800 ${page <= 1 ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`} className={`px-5 py-2 rounded-xl font-bold text-sm transition-all border border-slate-800 ${page <= 1 ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`}
> >
ANTERIOR ANTERIOR
</Link> </Link>
<span className="text-slate-400 font-bold tracking-widest text-xs uppercase text-center px-4">
Página {page} de {Math.ceil(total / limit)} <span className="text-slate-400 font-bold tracking-widest text-[10px] md:text-xs uppercase text-center px-2 md:px-4 bg-slate-900 py-2 rounded-xl border border-slate-800">
Página {page} de {totalPages}
</span> </span>
<Link <Link
href={`/dashboard/${entity}?page=${page + 1}${q ? `&q=${q}` : ''}${sort ? `&sort=${sort}` : ''}${dir ? `&dir=${dir}` : ''}`} href={`/dashboard/${entity}?page=${page + 1}${q ? `&q=${q}` : ''}${sort ? `&sort=${sort}` : ''}${dir ? `&dir=${dir}` : ''}`}
className={`px-6 py-2 rounded-full font-bold text-sm transition-all border border-slate-800 ${page >= Math.ceil(total / limit) ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`} className={`px-5 py-2 rounded-xl font-bold text-sm transition-all border border-slate-800 ${page >= totalPages ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`}
> >
SIGUIENTE SIGUIENTE
</Link> </Link>
<Link
href={`/dashboard/${entity}?page=${totalPages}${q ? `&q=${q}` : ''}${sort ? `&sort=${sort}` : ''}${dir ? `&dir=${dir}` : ''}`}
className={`px-4 py-2 rounded-xl font-bold text-xs transition-all border border-slate-800 ${page >= 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 &raquo;
</Link>
</div> </div>
)} );
})()}
</div> </div>
); );
} }

View File

@ -171,9 +171,7 @@ export const MediaGridSelector = ({
}`} }`}
> >
<img src={url} alt={item.titulo} className="w-full h-full object-cover transition-transform group-hover:scale-110" loading="lazy" /> <img src={url} alt={item.titulo} className="w-full h-full object-cover transition-transform group-hover:scale-110" loading="lazy" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/90 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity p-3 flex flex-col justify-end"> <div className="absolute inset-0 bg-gradient-to-t from-cyan-500/20 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
<span className="text-[10px] font-black text-white uppercase truncate drop-shadow-lg">{item.titulo}</span>
</div>
{value === item.file_id && ( {value === item.file_id && (
<div className="absolute top-2 right-2 bg-cyan-500 text-white rounded-full p-1 shadow-lg"> <div className="absolute top-2 right-2 bg-cyan-500 text-white rounded-full p-1 shadow-lg">
<CheckCircle2 size={16} /> <CheckCircle2 size={16} />

View File

@ -250,11 +250,17 @@ export const RichTextEditor = ({ value, onChange, label }: RichTextEditorProps)
}, },
editorProps: { editorProps: {
attributes: { 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 // Keep content synced if value changes externally
useEffect(() => { useEffect(() => {
if (editor && value !== editor.getHTML()) { if (editor && value !== editor.getHTML()) {
@ -262,13 +268,24 @@ export const RichTextEditor = ({ value, onChange, label }: RichTextEditorProps)
} }
}, [value, editor]); }, [value, editor]);
if (!mounted) {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="text-sm font-bold tracking-widest text-slate-300 uppercase">{label}</label> <label className="text-sm font-bold tracking-widest text-slate-300 uppercase">{label}</label>
<div className="bg-slate-950 border border-slate-800 rounded-2xl overflow-hidden focus-within:border-cyan-500/50 transition-all shadow-xl"> <div className="h-[400px] bg-slate-900 animate-pulse rounded-2xl border border-slate-800" />
</div>
);
}
return (
<div className="flex flex-col gap-2">
<label className="text-sm font-bold tracking-widest text-slate-300 uppercase">{label}</label>
<div className="bg-slate-950 border border-slate-800 rounded-2xl overflow-hidden focus-within:border-cyan-500/50 transition-all shadow-xl flex flex-col max-h-[600px]">
<MenuBar editor={editor} /> <MenuBar editor={editor} />
<div className="overflow-y-auto custom-scrollbar flex-1">
<EditorContent editor={editor} /> <EditorContent editor={editor} />
</div> </div>
</div>
{/* Hidden input for form submission if needed, though we use controlled value */} {/* Hidden input for form submission if needed, though we use controlled value */}
<input type="hidden" name={label.toLowerCase().replace(/ /g, '_')} value={value} /> <input type="hidden" name={label.toLowerCase().replace(/ /g, '_')} value={value} />
</div> </div>

View File

@ -266,8 +266,8 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
defaultValue={initialData?.resumen_redes || ''} defaultValue={initialData?.resumen_redes || ''}
placeholder="Resumen corto para compartir en redes sociales..." placeholder="Resumen corto para compartir en redes sociales..."
maxLength={500} maxLength={500}
className={textareaClass} className={`${textareaClass} custom-scrollbar resize-none`}
style={{ minHeight: '80px' }} style={{ minHeight: '80px', maxHeight: '150px', overflowY: 'auto' }}
/> />
</FieldWrapper> </FieldWrapper>
</FormSection> </FormSection>