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:
parent
b2daaa2108
commit
97a3d162e0
@ -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");
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 ──────────────────────────────────
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user