'use client'; /** * @file PostCard.tsx * @description Pure presentation component for displaying a Post in the dashboard. * * - Receives all data via props (no internal data fetching). * - Uses `PermissionGuard` to conditionally show edit/delete controls. * - XSS-safe: all text content uses textContent rendering (no dangerouslySetInnerHTML). * - The `onEdit` callback is injected by the parent — this component stays pure. */ import React from 'react'; import Link from 'next/link'; import { Edit2, Trash2, Eye, Calendar, Tag } from 'lucide-react'; import { Post, PostCardProps, UserRole } from '@/types/publications'; import PermissionGuard from './PermissionGuard'; import { decodeHtmlEntities } from '@/lib/utils'; /** Derives a plain text excerpt from raw HTML content — XSS safe */ function getPlainTextExcerpt(html: string, maxLength = 120): string { // Remove all HTML tags using regex (safe for server, no DOM parsing needed) const plain = html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); return plain.length > maxLength ? plain.substring(0, maxLength) + '…' : plain; } /** Resolves the image URL from a Post's imagen_destacada_id (string or MediaItem object) */ function resolveImageUrl( post: Post, endpoint: string, projectId: string ): string | null { if (post.og_image_url) return post.og_image_url; if (!post.imagen_destacada_id) return null; if (typeof post.imagen_destacada_id === 'object') { const { bucket_id, file_id } = post.imagen_destacada_id; return `${endpoint}/storage/buckets/${bucket_id}/files/${file_id}/preview?project=${projectId}&width=600&height=400&quality=80`; } // Fallback: just an ID, use default 'media' bucket return `${endpoint}/storage/buckets/media/files/${post.imagen_destacada_id}/preview?project=${projectId}&width=600&height=400&quality=80`; } /** Maps PostStatus to a visual badge style */ const statusBadge: Record = { Publicado: { label: 'Publicado', className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' }, Borrador: { label: 'Borrador', className: 'bg-slate-500/10 text-slate-400 border-slate-500/30' }, Pendiente: { label: 'Pendiente', className: 'bg-amber-500/10 text-amber-400 border-amber-500/30' }, Archivado: { label: 'Archivado', className: 'bg-red-500/10 text-red-400 border-red-500/30' }, }; /** * PostCard — Dashboard card for a single Post. */ export const PostCard: React.FC = ({ post, userRole, onEdit, onDelete, appwriteEndpoint, appwriteProjectId, }) => { const imageUrl = resolveImageUrl(post, appwriteEndpoint, appwriteProjectId); // Safe text rendering — no raw HTML const safeTitle = decodeHtmlEntities(post.titulo); const safeExcerpt = getPlainTextExcerpt(post.resumen_redes || post.contenido || ''); const badge = statusBadge[post.estado] ?? statusBadge['Borrador']; const fechaFormatted = new Date(post.fecha_publicacion || post.$createdAt).toLocaleDateString( 'es-CO', { day: '2-digit', month: 'short', year: 'numeric' } ); const postType = typeof post.tipo_publicacion_id === 'object' ? post.tipo_publicacion_id.descripcion : post.tipo_publicacion_id; return (
{/* Imagen Destacada */}
{imageUrl ? ( // eslint-disable-next-line @next/next/no-img-element {safeTitle} ) : (
)} {/* Estado Badge */}
{badge.label}
{/* Edit Overlay — solo visible si el usuario tiene permisos */}
onEdit?.(post)} role="button" aria-label={`Editar: ${safeTitle}`} > Editar
{/* Contenido */}
{/* Tipo de publicación */} {postType && ( {postType} )} {/* Título — safe text */}

{safeTitle}

{/* Resumen — safe, no HTML */}

{safeExcerpt}

{/* Footer */}
{/* Fecha */}
{fechaFormatted}
{/* Acciones */}
{/* Ver publicación (siempre visible) */} {/* Editar — solo para editor/admin */} {/* Eliminar — solo para admin */}
); }; export default PostCard;