feat: complete rx editor, media library grid, UX logic, and XSS protection

- Added @tiptap/extension-youtube and extension-image
- Added MediaGridSelector component with robust grid UI and inline uploads
- Integrated MediaGridSelector into PublicationLayout for featured images
- Integrated inline image module in RichTextEditor linked to MediaGridSelector
- Rendered date conditionally on PostCard and disabled bubble propagation
- Added isomorphic-dompurify to mitigate XSS in the public article viewer
This commit is contained in:
Carlos (Almeira) 2026-03-28 14:34:04 -05:00
parent 97a3d162e0
commit 3647602413
8 changed files with 386 additions and 20 deletions

View File

@ -13,18 +13,21 @@
"@tiptap/extension-color": "^3.21.0", "@tiptap/extension-color": "^3.21.0",
"@tiptap/extension-font-family": "^3.21.0", "@tiptap/extension-font-family": "^3.21.0",
"@tiptap/extension-highlight": "^3.21.0", "@tiptap/extension-highlight": "^3.21.0",
"@tiptap/extension-image": "^3.21.0",
"@tiptap/extension-link": "^3.21.0", "@tiptap/extension-link": "^3.21.0",
"@tiptap/extension-text-align": "^3.21.0", "@tiptap/extension-text-align": "^3.21.0",
"@tiptap/extension-text-style": "^3.21.0", "@tiptap/extension-text-style": "^3.21.0",
"@tiptap/extension-underline": "^3.21.0", "@tiptap/extension-underline": "^3.21.0",
"@tiptap/react": "^3.21.0", "@tiptap/react": "^3.21.0",
"@tiptap/starter-kit": "^3.21.0", "@tiptap/starter-kit": "^3.21.0",
"@tiptap/extension-youtube": "^3.21.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/video.js": "^7.3.58", "@types/video.js": "^7.3.58",
"appwrite": "^23.0.0", "appwrite": "^23.0.0",
"axios": "^1.13.6", "axios": "^1.13.6",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"isomorphic-dompurify": "^2.20.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next": "16.1.6", "next": "16.1.6",
"node-appwrite": "^22.1.3", "node-appwrite": "^22.1.3",

View File

@ -5,6 +5,7 @@ import { GlobalSidebar, hasSidebarContent } from '@/components/AdWrappers';
import { Calendar, User, ArrowLeft } from 'lucide-react'; import { Calendar, User, ArrowLeft } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { decodeHtmlEntities } from '@/lib/utils'; import { decodeHtmlEntities } from '@/lib/utils';
import DOMPurify from 'isomorphic-dompurify';
async function fetchPostData(slugOrId: string) { async function fetchPostData(slugOrId: string) {
if (!slugOrId || typeof slugOrId !== 'string' || slugOrId.includes('.')) return null; if (!slugOrId || typeof slugOrId !== 'string' || slugOrId.includes('.')) return null;
@ -110,7 +111,12 @@ export default async function NoticiaDetail({ params }: { params: Promise<{ slug
className="prose prose-invert prose-cyan max-w-none text-slate-300 leading-relaxed text-lg className="prose prose-invert prose-cyan max-w-none text-slate-300 leading-relaxed text-lg
prose-headings:font-outfit prose-headings:font-black prose-headings:text-white prose-headings:font-outfit prose-headings:font-black prose-headings:text-white
prose-p:mb-6 prose-strong:text-cyan-400" prose-p:mb-6 prose-strong:text-cyan-400"
dangerouslySetInnerHTML={{ __html: post.contenido }} dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(post.contenido, {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling']
})
}}
/> />
</article> </article>

View File

@ -0,0 +1,254 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { ImageIcon, X, Search, CheckCircle2, UploadCloud, Loader2 } from 'lucide-react';
import { databases, storage, APPWRITE_ALMEIRA_CONFIG, endpoint, projectId } from '@/lib/appwrite';
import { Query, ID } from 'appwrite';
/** Utility to generate frontend Appwrite image previews */
export function getAppwriteImageUrl(fileId: string, width = 400, height = 250) {
if (!fileId) return '';
return `${endpoint}/storage/buckets/media/files/${fileId}/preview?project=${projectId}&width=${width}&height=${height}&quality=80`;
}
interface MediaGridSelectorProps {
value?: string;
onChange: (fileId: string, url?: string) => void;
label?: string;
mode?: 'field' | 'modal';
isOpen?: boolean;
onClose?: () => void;
}
export const MediaGridSelector = ({
value,
onChange,
label = 'Seleccionar Medio',
mode = 'field',
isOpen: externalOpen,
onClose
}: MediaGridSelectorProps) => {
const [isOpen, setIsOpen] = useState(mode === 'modal' ? !!externalOpen : false);
const [mediaItems, setMediaItems] = useState<any[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (mode === 'modal' && externalOpen !== undefined) setIsOpen(externalOpen);
}, [mode, externalOpen]);
useEffect(() => {
if (isOpen) fetchMedia();
}, [isOpen]);
const fetchMedia = async () => {
setIsLoading(true);
try {
const res = await databases.listDocuments(APPWRITE_ALMEIRA_CONFIG.databaseId, 'media', [
Query.limit(100), Query.orderDesc('$createdAt')
]);
setMediaItems(res.documents);
} catch (error) {
console.error('Error fetching media:', error);
} finally {
setIsLoading(false);
}
};
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
// 1. Upload to storage bucket
const uploadedFile = await storage.createFile('media', ID.unique(), file);
// 2. Create document in media collection
const doc = await databases.createDocument(APPWRITE_ALMEIRA_CONFIG.databaseId, 'media', ID.unique(), {
file_id: uploadedFile.$id,
bucket_id: 'media',
titulo: file.name,
alt_text: file.name.split('.')[0],
tipo: file.type.startsWith('image/') ? 'imagen' : 'video' // mostly images in this bucket
});
// 3. Update state
setMediaItems(prev => [doc, ...prev]);
const url = getAppwriteImageUrl(uploadedFile.$id, 1200, 800);
onChange(uploadedFile.$id, url);
closeModal();
} catch (error) {
console.error("Error upload:", error);
alert("Error al subir el archivo.");
} finally {
setIsUploading(false);
}
};
const closeModal = useCallback(() => {
setIsOpen(false);
if (onClose) onClose();
}, [onClose]);
const filteredItems = mediaItems.filter(item => item.titulo.toLowerCase().includes(searchTerm.toLowerCase()));
// Render modal structure
const renderModal = () => {
if (!isOpen) return null;
const popup = (
<div className="fixed inset-0 z-[99999] flex items-center justify-center p-4 md:p-8 animate-fade-in">
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-md" onClick={closeModal}></div>
<div className="relative w-full max-w-5xl max-h-[85vh] bg-slate-900 border border-slate-800 rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="p-6 border-b border-slate-800 flex items-center justify-between bg-slate-900/50">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-500/10 rounded-xl text-purple-400 border border-purple-500/20">
<ImageIcon size={20} />
</div>
<div>
<h3 className="text-lg font-black uppercase tracking-tighter text-white">Biblioteca de Medios</h3>
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Selecciona o sube un archivo</p>
</div>
</div>
<button onClick={closeModal} className="p-2 hover:bg-slate-800 rounded-xl text-slate-500 hover:text-white transition-all">
<X size={20} />
</button>
</div>
{/* Sub-header (Search + Upload) */}
<div className="p-6 pb-2 flex flex-col sm:flex-row gap-4 items-center justify-between">
<div className="relative group w-full sm:w-2/3">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-cyan-500 transition-colors" size={18} />
<input
type="text"
placeholder="Buscar imagen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-2xl pl-12 pr-6 py-4 text-white focus:outline-none focus:border-cyan-500 transition-all font-medium"
/>
</div>
<div className="w-full sm:w-1/3 flex justify-end">
<input type="file" accept="image/*" className="hidden" ref={fileInputRef} onChange={handleUpload}/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="w-full sm:w-auto flex items-center justify-center gap-2 bg-gradient-to-r from-purple-600 to-pink-500 hover:from-purple-500 hover:to-pink-400 text-white px-6 py-4 rounded-2xl font-black uppercase tracking-widest text-xs transition-all shadow-[0_0_20px_rgba(168,85,247,0.3)] disabled:opacity-50"
>
{isUploading ? <Loader2 size={16} className="animate-spin" /> : <UploadCloud size={16} />}
{isUploading ? 'Subiendo...' : 'Subir Nuevo'}
</button>
</div>
</div>
{/* Content Grid */}
<div className="flex-1 overflow-y-auto p-6 pt-2 custom-scrollbar">
{isLoading ? (
<div className="h-64 flex flex-col items-center justify-center gap-4">
<Loader2 size={32} className="text-cyan-500 animate-spin" />
<span className="text-xs font-black uppercase tracking-widest text-slate-500">Cargando Galería...</span>
</div>
) : filteredItems.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{filteredItems.map((item) => {
const url = getAppwriteImageUrl(item.file_id, 300, 300);
return (
<div
key={item.$id}
onClick={() => {
onChange(item.file_id, getAppwriteImageUrl(item.file_id, 1200, 800));
if (mode !== 'modal') setIsOpen(false);
closeModal();
}}
className={`group relative aspect-square rounded-2xl border-2 transition-all cursor-pointer overflow-hidden bg-slate-950 ${
value === item.file_id ? 'border-cyan-500 shadow-xl shadow-cyan-500/20' : 'border-slate-800 hover:border-slate-600 shadow-lg'
}`}
>
<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>
{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} />
</div>
)}
</div>
);
})}
</div>
) : (
<div className="h-48 flex flex-col items-center justify-center text-slate-500 border-2 border-dashed border-slate-800 rounded-3xl gap-2">
<ImageIcon size={32} className="opacity-50" />
<p className="font-bold uppercase tracking-widest text-sm text-slate-400 mt-2">Sin Resultados</p>
</div>
)}
</div>
<div className="p-4 bg-slate-950 border-t border-slate-800 text-center">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">{filteredItems.length} Archivos Disponibles</span>
</div>
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(popup, document.body) : popup;
};
if (mode === 'modal') {
return renderModal();
}
// FIELD MODE RENDER (Inline usage in forms)
return (
<div className="space-y-2 w-full">
<div className="flex justify-between items-center mb-1">
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500">
{label}
</label>
{value && (
<button type="button" onClick={() => onChange('', '')} className="text-[9px] font-bold text-pink-500 hover:text-pink-400 transition-colors uppercase">
Quitar Media
</button>
)}
</div>
<div
onClick={() => setIsOpen(true)}
className={`relative aspect-video rounded-2xl border-2 border-dashed transition-all cursor-pointer flex flex-col items-center justify-center gap-2 overflow-hidden bg-slate-950/50 group ${
value ? 'border-cyan-500/50 hover:border-cyan-400 shadow-[0_0_20px_rgba(6,182,212,0.1)]' : 'border-slate-800 hover:border-slate-700 hover:bg-slate-900'
}`}
>
{value ? (
<>
<img src={getAppwriteImageUrl(value, 800, 450)} alt="Selected Preview" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-slate-900/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<span className="bg-slate-950/80 px-4 py-2 rounded-full text-[10px] font-black uppercase tracking-widest text-cyan-400 border border-cyan-500/30">
Cambiar Imagen ↔
</span>
</div>
<div className="absolute top-3 left-3 bg-cyan-500 text-slate-950 text-[9px] font-black uppercase tracking-widest px-2 py-1 rounded shadow-lg">
Activa
</div>
</>
) : (
<>
<div className="p-4 bg-slate-900 rounded-2xl text-slate-600 group-hover:text-cyan-500 transition-colors border border-slate-800 group-hover:border-cyan-500/30 shadow-inner">
<ImageIcon size={28} />
</div>
<span className="text-[10px] font-black text-slate-500 uppercase tracking-widest mt-1">Explorar Biblioteca de Medios</span>
</>
)}
</div>
<input type="hidden" name="imagen_destacada_id" value={value || ''} />
{renderModal()}
</div>
);
};
export default MediaGridSelector;

View File

@ -8,13 +8,17 @@ import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color'; import { Color } from '@tiptap/extension-color';
import TextAlign from '@tiptap/extension-text-align'; import TextAlign from '@tiptap/extension-text-align';
import Highlight from '@tiptap/extension-highlight'; import Highlight from '@tiptap/extension-highlight';
import Image from '@tiptap/extension-image';
import Youtube from '@tiptap/extension-youtube';
import { import {
Bold, Italic, Underline as UnderlineIcon, Bold, Italic, Underline as UnderlineIcon,
Link as LinkIcon, AlignLeft, AlignCenter, Link as LinkIcon, AlignLeft, AlignCenter,
AlignRight, Highlighter, Type, AlignRight, AlignJustify, Highlighter, Type,
List, ListOrdered, Quote, Undo, Redo List, ListOrdered, Quote, Undo, Redo,
ImageIcon, Youtube as YoutubeIcon, Palette
} from 'lucide-react'; } from 'lucide-react';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { MediaGridSelector } from './MediaGridSelector';
interface RichTextEditorProps { interface RichTextEditorProps {
value: string; value: string;
@ -23,6 +27,8 @@ interface RichTextEditorProps {
} }
const MenuBar = ({ editor }: { editor: any }) => { const MenuBar = ({ editor }: { editor: any }) => {
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false);
if (!editor) return null; if (!editor) return null;
const setLink = () => { const setLink = () => {
@ -32,8 +38,31 @@ const MenuBar = ({ editor }: { editor: any }) => {
} }
}; };
const addYoutubeVideo = () => {
const url = window.prompt('URL de YouTube:');
if (url) {
editor.commands.setYoutubeVideo({
src: url,
width: 640,
height: 480,
});
}
};
const handleMediaInsert = (fileId: string, url?: string) => {
if (url) {
editor.chain().focus().setImage({ src: url }).run();
}
setIsMediaModalOpen(false);
};
const setColor = (e: React.ChangeEvent<HTMLInputElement>) => {
editor.chain().focus().setColor(e.target.value).run();
};
return ( return (
<div className="flex flex-wrap gap-1 p-2 bg-slate-900 border-b border-slate-800 sticky top-0 z-10"> <>
<div className="flex flex-wrap gap-1 p-2 bg-slate-900 border-b border-slate-800 sticky top-0 z-10 items-center">
<button <button
type="button" type="button"
onClick={() => editor.chain().focus().toggleBold().run()} onClick={() => editor.chain().focus().toggleBold().run()}
@ -82,6 +111,13 @@ const MenuBar = ({ editor }: { editor: any }) => {
> >
<AlignRight size={18} /> <AlignRight size={18} />
</button> </button>
<button
type="button"
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive({ textAlign: 'justify' }) ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
>
<AlignJustify size={18} />
</button>
<div className="w-px h-6 bg-slate-800 mx-1 self-center" /> <div className="w-px h-6 bg-slate-800 mx-1 self-center" />
@ -117,6 +153,38 @@ const MenuBar = ({ editor }: { editor: any }) => {
<Highlighter size={18} /> <Highlighter size={18} />
</button> </button>
<div className="relative group flex items-center">
<label htmlFor="colorPicker" className="p-2 cursor-pointer text-slate-400 hover:bg-slate-800 rounded transition-colors group-hover:text-cyan-400" title="Color del texto">
<Palette size={18} />
</label>
<input
id="colorPicker"
type="color"
onChange={setColor}
value={editor.getAttributes('textStyle').color || '#ffffff'}
className="absolute opacity-0 w-0 h-0"
/>
</div>
<div className="w-px h-6 bg-slate-800 mx-1 self-center" />
<button
type="button"
onClick={() => setIsMediaModalOpen(true)}
className="p-2 rounded hover:bg-slate-800 text-slate-400 hover:text-cyan-400 transition-colors"
title="Insertar Imagen de Biblioteca"
>
<ImageIcon size={18} />
</button>
<button
type="button"
onClick={addYoutubeVideo}
className="p-2 rounded hover:bg-slate-800 text-slate-400 hover:text-red-400 transition-colors"
title="Insertar Video YouTube"
>
<YoutubeIcon size={18} />
</button>
<div className="flex-grow" /> <div className="flex-grow" />
<button <button
@ -134,6 +202,13 @@ const MenuBar = ({ editor }: { editor: any }) => {
<Redo size={18} /> <Redo size={18} />
</button> </button>
</div> </div>
<MediaGridSelector
mode="modal"
isOpen={isMediaModalOpen}
onClose={() => setIsMediaModalOpen(false)}
onChange={handleMediaInsert}
/>
</>
); );
}; };
@ -150,8 +225,22 @@ export const RichTextEditor = ({ value, onChange, label }: RichTextEditorProps)
}), }),
TextStyle, TextStyle,
Color, Color,
Image.configure({
inline: true,
allowBase64: true,
HTMLAttributes: {
class: 'rounded-xl shadow-lg max-w-full h-auto object-cover my-6'
}
}),
Youtube.configure({
controls: true,
HTMLAttributes: {
class: 'w-full aspect-video rounded-xl shadow-lg my-6'
}
}),
TextAlign.configure({ TextAlign.configure({
types: ['heading', 'paragraph'], types: ['heading', 'paragraph'],
alignments: ['left', 'center', 'right', 'justify'],
}), }),
Highlight.configure({ multicolor: true }), Highlight.configure({ multicolor: true }),
], ],

View File

@ -61,6 +61,7 @@ export const PostCard: React.FC<PostCardProps> = ({
onDelete, onDelete,
appwriteEndpoint, appwriteEndpoint,
appwriteProjectId, appwriteProjectId,
globalSettings,
}) => { }) => {
const imageUrl = resolveImageUrl(post, appwriteEndpoint, appwriteProjectId); const imageUrl = resolveImageUrl(post, appwriteEndpoint, appwriteProjectId);
@ -88,6 +89,7 @@ export const PostCard: React.FC<PostCardProps> = ({
const handleCardDoubleClick = (e: React.MouseEvent) => { const handleCardDoubleClick = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
if ((e.target as HTMLElement).closest('button, a, [role="button"]')) return; if ((e.target as HTMLElement).closest('button, a, [role="button"]')) return;
// Double click: abre edición si es admin/editor // Double click: abre edición si es admin/editor
if (userRole === 'admin' || userRole === 'editor') { if (userRole === 'admin' || userRole === 'editor') {
@ -95,6 +97,8 @@ export const PostCard: React.FC<PostCardProps> = ({
} }
}; };
const showDate = globalSettings?.show_news_date !== false;
return ( return (
<article <article
onClick={handleCardClick} onClick={handleCardClick}
@ -154,10 +158,12 @@ export const PostCard: React.FC<PostCardProps> = ({
{/* Footer */} {/* Footer */}
<div className="mt-4 pt-4 border-t border-white/8 flex items-center justify-between gap-2"> <div className="mt-4 pt-4 border-t border-white/8 flex items-center justify-between gap-2">
{/* Fecha */} {/* Fecha */}
<div className="flex items-center gap-1.5 text-slate-500 text-xs font-medium"> {showDate && (
<Calendar size={12} /> <div className="flex items-center gap-1.5 text-slate-500 text-xs font-medium">
<span>{fechaFormatted}</span> <Calendar size={12} />
</div> <span>{fechaFormatted}</span>
</div>
)}
{/* Acciones */} {/* Acciones */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View File

@ -30,6 +30,7 @@ import FormSection from './FormSection';
import RelationSelectField from './RelationSelectField'; import RelationSelectField from './RelationSelectField';
import PermissionGuard from './PermissionGuard'; import PermissionGuard from './PermissionGuard';
import { RichTextEditor } from '../dashboard/RichTextEditor'; import { RichTextEditor } from '../dashboard/RichTextEditor';
import { MediaGridSelector } from '../dashboard/MediaGridSelector';
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// FIELD COMPONENTS // FIELD COMPONENTS
@ -95,6 +96,11 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
const [slug, setSlug] = useState(initialData?.slug || ''); const [slug, setSlug] = useState(initialData?.slug || '');
const [isAutoSlug, setIsAutoSlug] = useState(!initialData?.slug); const [isAutoSlug, setIsAutoSlug] = useState(!initialData?.slug);
const [contenido, setContenido] = useState(initialData?.contenido || ''); const [contenido, setContenido] = useState(initialData?.contenido || '');
const [imagenDestacadaId, setImagenDestacadaId] = useState(
typeof initialData?.imagen_destacada_id === 'object'
? initialData.imagen_destacada_id.$id
: initialData?.imagen_destacada_id || ''
);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({}); const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
// Populate relation cache in the store on mount // Populate relation cache in the store on mount
@ -242,6 +248,10 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
> >
↺ ↺
</button> </button>
{/* Inject initial Author if it's a new post (Autocompletion UX) */}
{!initialData?.$id && user?.$id && (
<input type="hidden" name="autor_id" value={user.$id} />
)}
</div> </div>
</FieldWrapper> </FieldWrapper>
@ -391,17 +401,11 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
{/* SECCIÓN: Multimedia */} {/* SECCIÓN: Multimedia */}
<div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6"> <div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6">
<FormSection title="Multimedia" icon={<ImageIcon size={16} />} columns={1}> <FormSection title="Multimedia" icon={<ImageIcon size={16} />} columns={1}>
<RelationSelectField <MediaGridSelector
name="imagen_destacada_id" value={imagenDestacadaId}
label="Imagen Destacada" onChange={(val) => setImagenDestacadaId(val)}
options={relationData.imagen_destacada_id || []} label="Imagen Destacada"
labelKey="titulo" mode="field"
value={
typeof initialData?.imagen_destacada_id === 'object'
? initialData.imagen_destacada_id.$id
: initialData?.imagen_destacada_id || ''
}
relationCollection="media"
/> />
<FieldWrapper label="URL de Video (YouTube/Vimeo)" error={validationErrors.video_url}> <FieldWrapper label="URL de Video (YouTube/Vimeo)" error={validationErrors.video_url}>

View File

@ -41,6 +41,7 @@ interface PublicationsListProps {
projectId: string; projectId: string;
databaseId: string; databaseId: string;
}; };
globalSettings?: Record<string, any>;
entity: string; entity: string;
} }
@ -56,6 +57,7 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
relationData, relationData,
submitEditAction, submitEditAction,
appwriteConfig, appwriteConfig,
globalSettings,
entity, entity,
}) => { }) => {
const router = useRouter(); const router = useRouter();
@ -252,6 +254,7 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
onDelete={handleDelete} onDelete={handleDelete}
appwriteEndpoint={appwriteConfig.endpoint} appwriteEndpoint={appwriteConfig.endpoint}
appwriteProjectId={appwriteConfig.projectId} appwriteProjectId={appwriteConfig.projectId}
globalSettings={globalSettings}
/> />
))} ))}
</div> </div>

View File

@ -96,6 +96,7 @@ export interface PostCardProps {
/** URL base del endpoint de Appwrite para construir URLs de imagen */ /** URL base del endpoint de Appwrite para construir URLs de imagen */
appwriteEndpoint: string; appwriteEndpoint: string;
appwriteProjectId: string; appwriteProjectId: string;
globalSettings?: Record<string, any>;
} }
/** Props del PermissionGuard HOC */ /** Props del PermissionGuard HOC */