feat: implement rich text, media previews, interactions, and fix state sync

- Added RichTextEditor to PublicationLayout's 'contenido'
- Added MediaThumbnail to RelationSelectField for dynamic image previews
- Replaced PostCard edit overlay with single click (view) and double click (edit) mechanics
- Fixed persistence/state bugs by adding revalidatePath to entity server actions
- Replaced hard reload in PublicationsList with router.refresh() for optimistic syncing
This commit is contained in:
Carlos (Almeira) 2026-03-28 14:06:38 -05:00
parent b2daaa2108
commit 97a3d162e0
5 changed files with 68 additions and 27 deletions

View File

@ -3,6 +3,7 @@
import { collectionMap, SCHEMA_MAP } from '@/lib/schema';
import { createDocumentServer, updateDocumentServer } from './appwrite';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
/**
* Centralized action to create a new entity document
@ -32,6 +33,7 @@ export async function saveEntityAction(entity: string, formData: FormData) {
const res = await createDocumentServer(collectionId, data);
if (res.success) {
revalidatePath(`/dashboard/${entity}`);
redirect(`/dashboard/${entity}`);
} else {
throw new Error(res.error || "Error desconocido al crear documento");
@ -66,6 +68,7 @@ export async function updateEntityAction(entity: string, id: string, formData: F
const res = await updateDocumentServer(collectionId, id, data);
if (res.success) {
revalidatePath(`/dashboard/${entity}`);
redirect(`/dashboard/${entity}`);
} else {
throw new Error(res.error || "Error desconocido al actualizar documento");

View File

@ -80,11 +80,34 @@ export const PostCard: React.FC<PostCardProps> = ({
? post.tipo_publicacion_id.descripcion
: post.tipo_publicacion_id;
const handleCardClick = (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button, a, [role="button"]')) return;
// Single click: abre vista de detalle
window.open(`/${post.slug || post.$id}`, '_blank');
};
const handleCardDoubleClick = (e: React.MouseEvent) => {
e.preventDefault();
if ((e.target as HTMLElement).closest('button, a, [role="button"]')) return;
// Double click: abre edición si es admin/editor
if (userRole === 'admin' || userRole === 'editor') {
onEdit?.(post);
}
};
return (
<article
className="group relative flex flex-col bg-slate-900/60 border border-white/8 rounded-2xl overflow-hidden transition-all duration-300 hover:border-cyan-500/40 hover:shadow-[0_8px_30px_rgba(6,182,212,0.1)] hover:-translate-y-1"
onClick={handleCardClick}
onDoubleClick={handleCardDoubleClick}
className="group relative flex flex-col bg-slate-900/60 border border-white/8 rounded-2xl overflow-hidden transition-all duration-300 hover:border-cyan-500/40 hover:shadow-[0_8px_30px_rgba(6,182,212,0.1)] hover:-translate-y-1 cursor-pointer"
aria-label={`Publicación: ${safeTitle}`}
>
{/* Tooltip instructivo para admins */}
{(userRole === 'admin' || userRole === 'editor') && (
<div className="absolute -top-10 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-[10px] py-1 px-3 rounded-full opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-xl border border-slate-700">
Doble click para editar
</div>
)}
{/* Imagen Destacada */}
<div className="relative aspect-[16/9] bg-slate-800 overflow-hidden">
{imageUrl ? (
@ -107,20 +130,6 @@ export const PostCard: React.FC<PostCardProps> = ({
</span>
</div>
{/* Edit Overlay — solo visible si el usuario tiene permisos */}
<PermissionGuard allowedRoles={['editor', 'admin']} userRole={userRole}>
<div
className="absolute inset-0 bg-slate-950/70 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-3 cursor-pointer"
onClick={() => onEdit?.(post)}
role="button"
aria-label={`Editar: ${safeTitle}`}
>
<span className="flex items-center gap-2 bg-cyan-500 text-slate-950 font-black text-xs px-4 py-2 rounded-full uppercase tracking-widest shadow-lg">
<Edit2 size={14} />
Editar
</span>
</div>
</PermissionGuard>
</div>
{/* Contenido */}

View File

@ -29,6 +29,7 @@ import { usePublicationStore } from '@/store/usePublicationStore';
import FormSection from './FormSection';
import RelationSelectField from './RelationSelectField';
import PermissionGuard from './PermissionGuard';
import { RichTextEditor } from '../dashboard/RichTextEditor';
// ─────────────────────────────────────────────
// FIELD COMPONENTS
@ -93,6 +94,7 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
const [titulo, setTitulo] = useState(initialData?.titulo || '');
const [slug, setSlug] = useState(initialData?.slug || '');
const [isAutoSlug, setIsAutoSlug] = useState(!initialData?.slug);
const [contenido, setContenido] = useState(initialData?.contenido || '');
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
// Populate relation cache in the store on mount
@ -116,6 +118,7 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
e.preventDefault();
const formData = new FormData(e.currentTarget);
formData.set('contenido', contenido); // Ensure rich text state is attached
// Client-side Zod validation
const parseResult = PostSchema.safeParse(Object.fromEntries(formData.entries()));
@ -264,13 +267,10 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
<div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6">
<FormSection title="Contenido Principal" icon={<Eye size={16} />} columns={1}>
<FieldWrapper label="Contenido Completo" required error={validationErrors.contenido}>
<textarea
name="contenido"
defaultValue={initialData?.contenido || ''}
placeholder="Escribe el contenido completo de la publicación..."
required
className={`${textareaClass}`}
style={{ minHeight: '250px' }}
<RichTextEditor
label="Contenido"
value={contenido}
onChange={(val) => setContenido(val)}
/>
</FieldWrapper>
</FormSection>

View File

@ -19,6 +19,7 @@
*/
import React, { useState, useCallback, useMemo, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Search, Plus, Filter, Loader2, FileText } from 'lucide-react';
import Link from 'next/link';
import type { Post, AuthUser, UserRole } from '@/types/publications';
@ -57,6 +58,7 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
appwriteConfig,
entity,
}) => {
const router = useRouter();
const toast = useToast();
const [isPending, startTransition] = useTransition();
@ -150,10 +152,8 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
const handleEditSuccess = useCallback(() => {
setDrawerPost(null);
toast.success('Publicación actualizada correctamente.');
// Note: For a full refresh of the list, the parent Server Component should
// call revalidatePath. Here we rely on Next.js router refresh.
window.location.reload();
}, [toast]);
router.refresh();
}, [toast, router]);
// ── RENDER ──────────────────────────────────

View File

@ -22,7 +22,7 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { PlusCircle, ChevronDown, MousePointerClick } from 'lucide-react';
import { PlusCircle, ChevronDown, MousePointerClick, Image as ImageIcon } from 'lucide-react';
import { RelationSelectFieldProps } from '@/types/publications';
import { usePublicationStore } from '@/store/usePublicationStore';
import { collectionMap } from '@/lib/schema';
@ -177,6 +177,30 @@ function QuickCreatePortal({ collection, fieldName, onClose }: QuickCreatePortal
return createPortal(modal, document.body);
}
// ─────────────────────────────────────────────
// MEDIA PREVIEW THUMBNAIL
// ─────────────────────────────────────────────
function MediaThumbnail({ fileId }: { fileId: string }) {
if (!fileId) return null;
const endpoint = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://appwrite.almeira.com.co/v1';
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || '69b84df5003bd8957c5c';
const url = `${endpoint}/storage/buckets/media/files/${fileId}/preview?project=${projectId}&width=400&height=250&quality=80`;
return (
<div className="mt-3 rounded-xl overflow-hidden border border-slate-800 bg-slate-900 aspect-video relative group animate-fade-in shadow-lg">
<img src={url} alt="Recurso Seleccionado" className="w-full h-full object-cover opacity-90 group-hover:opacity-100 transition-opacity" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3 pointer-events-none">
<div className="flex items-center gap-2 text-cyan-400">
<ImageIcon size={14} />
<span className="text-[9px] font-black uppercase tracking-widest">Previsualización Activa</span>
</div>
</div>
</div>
);
}
// ─────────────────────────────────────────────
// RELATION SELECT FIELD
// ─────────────────────────────────────────────
@ -282,6 +306,11 @@ export const RelationSelectField: React.FC<RelationSelectFieldProps> = ({
/>
</div>
{/* Media Preview Thumbnail */}
{relationCollection === 'media' && selectedValue && (
<MediaThumbnail fileId={selectedValue} />
)}
{/* Quick Create Portal */}
{isModalOpen && (
<QuickCreatePortal