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 { 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}`);

View File

@ -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 && (
<div className="flex justify-center items-center gap-4 mt-8">
<Link
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'}`}
>
ANTERIOR
</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>
<Link
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'}`}
>
SIGUIENTE
</Link>
</div>
)}
{total > limit && (() => {
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
href={`/dashboard/${entity}?page=${page - 1}${q ? `&q=${q}` : ''}${sort ? `&sort=${sort}` : ''}${dir ? `&dir=${dir}` : ''}`}
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
</Link>
<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>
<Link
href={`/dashboard/${entity}?page=${page + 1}${q ? `&q=${q}` : ''}${sort ? `&sort=${sort}` : ''}${dir ? `&dir=${dir}` : ''}`}
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
</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>
);
}

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" />
<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">
<span className="text-[10px] font-black text-white uppercase truncate drop-shadow-lg">{item.titulo}</span>
</div>
<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" />
{value === item.file_id && (
<div className="absolute top-2 right-2 bg-cyan-500 text-white rounded-full p-1 shadow-lg">
<CheckCircle2 size={16} />

View File

@ -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 (
<div className="flex flex-col gap-2">
<label className="text-sm font-bold tracking-widest text-slate-300 uppercase">{label}</label>
<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">
<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} />
<EditorContent editor={editor} />
<div className="overflow-y-auto custom-scrollbar flex-1">
<EditorContent editor={editor} />
</div>
</div>
{/* Hidden input for form submission if needed, though we use controlled value */}
<input type="hidden" name={label.toLowerCase().replace(/ /g, '_')} value={value} />

View File

@ -266,8 +266,8 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
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' }}
/>
</FieldWrapper>
</FormSection>