fix: explicit debugging logic on paginations, filters, Zod xss, and UI constraints
This commit is contained in:
parent
3647602413
commit
682d6620b9
@ -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}`);
|
||||
|
||||
@ -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"
|
||||
>
|
||||
« 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 »
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user