From 36476024139fe02f48bdcec3ea38d5018843eca8 Mon Sep 17 00:00:00 2001 From: "Carlos (Almeira)" Date: Sat, 28 Mar 2026 14:34:04 -0500 Subject: [PATCH] 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 --- package.json | 3 + src/app/[slug]/page.tsx | 8 +- .../dashboard/MediaGridSelector.tsx | 254 ++++++++++++++++++ src/components/dashboard/RichTextEditor.tsx | 97 ++++++- src/components/publications/PostCard.tsx | 14 +- .../publications/PublicationLayout.tsx | 26 +- .../publications/PublicationsList.tsx | 3 + src/types/publications.ts | 1 + 8 files changed, 386 insertions(+), 20 deletions(-) create mode 100644 src/components/dashboard/MediaGridSelector.tsx diff --git a/package.json b/package.json index a900e50..20b0529 100644 --- a/package.json +++ b/package.json @@ -13,18 +13,21 @@ "@tiptap/extension-color": "^3.21.0", "@tiptap/extension-font-family": "^3.21.0", "@tiptap/extension-highlight": "^3.21.0", + "@tiptap/extension-image": "^3.21.0", "@tiptap/extension-link": "^3.21.0", "@tiptap/extension-text-align": "^3.21.0", "@tiptap/extension-text-style": "^3.21.0", "@tiptap/extension-underline": "^3.21.0", "@tiptap/react": "^3.21.0", "@tiptap/starter-kit": "^3.21.0", + "@tiptap/extension-youtube": "^3.21.0", "@types/dompurify": "^3.0.5", "@types/video.js": "^7.3.58", "appwrite": "^23.0.0", "axios": "^1.13.6", "clsx": "^2.1.1", "dompurify": "^3.3.3", + "isomorphic-dompurify": "^2.20.0", "lucide-react": "^0.577.0", "next": "16.1.6", "node-appwrite": "^22.1.3", diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx index 10f8f6c..0c41031 100644 --- a/src/app/[slug]/page.tsx +++ b/src/app/[slug]/page.tsx @@ -5,6 +5,7 @@ import { GlobalSidebar, hasSidebarContent } from '@/components/AdWrappers'; import { Calendar, User, ArrowLeft } from 'lucide-react'; import Link from 'next/link'; import { decodeHtmlEntities } from '@/lib/utils'; +import DOMPurify from 'isomorphic-dompurify'; async function fetchPostData(slugOrId: string) { 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 prose-headings:font-outfit prose-headings:font-black prose-headings:text-white 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'] + }) + }} /> diff --git a/src/components/dashboard/MediaGridSelector.tsx b/src/components/dashboard/MediaGridSelector.tsx new file mode 100644 index 0000000..ef296e9 --- /dev/null +++ b/src/components/dashboard/MediaGridSelector.tsx @@ -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([]); + const [searchTerm, setSearchTerm] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isUploading, setIsUploading] = useState(false); + + const fileInputRef = useRef(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) => { + 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 = ( +
+
+ +
+ {/* Header */} +
+
+
+ +
+
+

Biblioteca de Medios

+

Selecciona o sube un archivo

+
+
+ +
+ + {/* Sub-header (Search + Upload) */} +
+
+ + 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" + /> +
+
+ + +
+
+ + {/* Content Grid */} +
+ {isLoading ? ( +
+ + Cargando Galería... +
+ ) : filteredItems.length > 0 ? ( +
+ {filteredItems.map((item) => { + const url = getAppwriteImageUrl(item.file_id, 300, 300); + return ( +
{ + 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' + }`} + > + {item.titulo} +
+ {item.titulo} +
+ {value === item.file_id && ( +
+ +
+ )} +
+ ); + })} +
+ ) : ( +
+ +

Sin Resultados

+
+ )} +
+ +
+ {filteredItems.length} Archivos Disponibles +
+
+
+ ); + return typeof document !== 'undefined' ? createPortal(popup, document.body) : popup; + }; + + if (mode === 'modal') { + return renderModal(); + } + + // FIELD MODE RENDER (Inline usage in forms) + return ( +
+
+ + {value && ( + + )} +
+ +
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 ? ( + <> + Selected Preview +
+ + Cambiar Imagen ↔ + +
+
+ Activa +
+ + ) : ( + <> +
+ +
+ Explorar Biblioteca de Medios + + )} +
+ + {renderModal()} +
+ ); +}; + +export default MediaGridSelector; diff --git a/src/components/dashboard/RichTextEditor.tsx b/src/components/dashboard/RichTextEditor.tsx index 421ec1a..40fbe27 100644 --- a/src/components/dashboard/RichTextEditor.tsx +++ b/src/components/dashboard/RichTextEditor.tsx @@ -8,13 +8,17 @@ import { TextStyle } from '@tiptap/extension-text-style'; import { Color } from '@tiptap/extension-color'; import TextAlign from '@tiptap/extension-text-align'; import Highlight from '@tiptap/extension-highlight'; +import Image from '@tiptap/extension-image'; +import Youtube from '@tiptap/extension-youtube'; import { Bold, Italic, Underline as UnderlineIcon, Link as LinkIcon, AlignLeft, AlignCenter, - AlignRight, Highlighter, Type, - List, ListOrdered, Quote, Undo, Redo + AlignRight, AlignJustify, Highlighter, Type, + List, ListOrdered, Quote, Undo, Redo, + ImageIcon, Youtube as YoutubeIcon, Palette } from 'lucide-react'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; +import { MediaGridSelector } from './MediaGridSelector'; interface RichTextEditorProps { value: string; @@ -23,6 +27,8 @@ interface RichTextEditorProps { } const MenuBar = ({ editor }: { editor: any }) => { + const [isMediaModalOpen, setIsMediaModalOpen] = useState(false); + if (!editor) return null; 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) => { + editor.chain().focus().setColor(e.target.value).run(); + }; + return ( -
+ <> +
+
@@ -116,6 +152,38 @@ const MenuBar = ({ editor }: { editor: any }) => { > + +
+ + +
+ +
+ + +
@@ -134,6 +202,13 @@ const MenuBar = ({ editor }: { editor: any }) => {
+ setIsMediaModalOpen(false)} + onChange={handleMediaInsert} + /> + ); }; @@ -150,8 +225,22 @@ export const RichTextEditor = ({ value, onChange, label }: RichTextEditorProps) }), TextStyle, 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({ types: ['heading', 'paragraph'], + alignments: ['left', 'center', 'right', 'justify'], }), Highlight.configure({ multicolor: true }), ], diff --git a/src/components/publications/PostCard.tsx b/src/components/publications/PostCard.tsx index e876d1d..fa47b3f 100644 --- a/src/components/publications/PostCard.tsx +++ b/src/components/publications/PostCard.tsx @@ -61,6 +61,7 @@ export const PostCard: React.FC = ({ onDelete, appwriteEndpoint, appwriteProjectId, + globalSettings, }) => { const imageUrl = resolveImageUrl(post, appwriteEndpoint, appwriteProjectId); @@ -88,6 +89,7 @@ export const PostCard: React.FC = ({ const handleCardDoubleClick = (e: React.MouseEvent) => { e.preventDefault(); + e.stopPropagation(); 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') { @@ -95,6 +97,8 @@ export const PostCard: React.FC = ({ } }; + const showDate = globalSettings?.show_news_date !== false; + return (
= ({ {/* Footer */}
{/* Fecha */} -
- - {fechaFormatted} -
+ {showDate && ( +
+ + {fechaFormatted} +
+ )} {/* Acciones */}
diff --git a/src/components/publications/PublicationLayout.tsx b/src/components/publications/PublicationLayout.tsx index 7f22f30..ce14fc9 100644 --- a/src/components/publications/PublicationLayout.tsx +++ b/src/components/publications/PublicationLayout.tsx @@ -30,6 +30,7 @@ import FormSection from './FormSection'; import RelationSelectField from './RelationSelectField'; import PermissionGuard from './PermissionGuard'; import { RichTextEditor } from '../dashboard/RichTextEditor'; +import { MediaGridSelector } from '../dashboard/MediaGridSelector'; // ───────────────────────────────────────────── // FIELD COMPONENTS @@ -95,6 +96,11 @@ export const PublicationLayout: React.FC = ({ const [slug, setSlug] = useState(initialData?.slug || ''); const [isAutoSlug, setIsAutoSlug] = useState(!initialData?.slug); 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>({}); // Populate relation cache in the store on mount @@ -242,6 +248,10 @@ export const PublicationLayout: React.FC = ({ > ↺ + {/* Inject initial Author if it's a new post (Autocompletion UX) */} + {!initialData?.$id && user?.$id && ( + + )}
@@ -391,17 +401,11 @@ export const PublicationLayout: React.FC = ({ {/* SECCIÓN: Multimedia */}
} columns={1}> - setImagenDestacadaId(val)} + label="Imagen Destacada" + mode="field" /> diff --git a/src/components/publications/PublicationsList.tsx b/src/components/publications/PublicationsList.tsx index 0d17d40..45d0b62 100644 --- a/src/components/publications/PublicationsList.tsx +++ b/src/components/publications/PublicationsList.tsx @@ -41,6 +41,7 @@ interface PublicationsListProps { projectId: string; databaseId: string; }; + globalSettings?: Record; entity: string; } @@ -56,6 +57,7 @@ export const PublicationsList: React.FC = ({ relationData, submitEditAction, appwriteConfig, + globalSettings, entity, }) => { const router = useRouter(); @@ -252,6 +254,7 @@ export const PublicationsList: React.FC = ({ onDelete={handleDelete} appwriteEndpoint={appwriteConfig.endpoint} appwriteProjectId={appwriteConfig.projectId} + globalSettings={globalSettings} /> ))}
diff --git a/src/types/publications.ts b/src/types/publications.ts index 455487f..1ee4f76 100644 --- a/src/types/publications.ts +++ b/src/types/publications.ts @@ -96,6 +96,7 @@ export interface PostCardProps { /** URL base del endpoint de Appwrite para construir URLs de imagen */ appwriteEndpoint: string; appwriteProjectId: string; + globalSettings?: Record; } /** Props del PermissionGuard HOC */