feat: soporte para videos de biblioteca de medios en publicaciones

This commit is contained in:
Carlos (Almeira) 2026-07-01 08:31:37 -05:00
parent 74e0563349
commit 92a57d01b8
6 changed files with 93 additions and 30 deletions

View File

@ -182,8 +182,8 @@ export default async function NoticiaDetail({ params }: { params: Promise<{ slug
prose-strong:text-cyan-400 prose-p:empty:before:content-['\\00a0']" prose-strong:text-cyan-400 prose-p:empty:before:content-['\\00a0']"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(formatTextToHtml(post.contenido), { __html: DOMPurify.sanitize(formatTextToHtml(post.contenido), {
ADD_TAGS: ['iframe'], ADD_TAGS: ['iframe', 'video', 'source'],
ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling', 'style', 'class'] ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling', 'style', 'class', 'controls', 'src', 'type', 'muted', 'loop', 'autoplay', 'preload']
}) })
}} }}
/> />

View File

@ -38,8 +38,8 @@ export default async function RootLayout({
<Navbar entity={entity} /> <Navbar entity={entity} />
<div className="pt-20 lg:pt-24"> <div className="pt-20 lg:pt-24">
<NewsTicker />
<GlobalTopBanner /> <GlobalTopBanner />
<NewsTicker />
{children} {children}
</div> </div>
<GlobalPlayer initialConfig={entity} /> <GlobalPlayer initialConfig={entity} />

View File

@ -1,6 +1,7 @@
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite'; import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
import { createAdminClient, getLoggedInUser } from '@/lib/appwrite.server'; import { createAdminClient, getLoggedInUser } from '@/lib/appwrite.server';
import { Query } from 'node-appwrite'; import { Query } from 'node-appwrite';
import { cookies } from 'next/headers';
import { NewsCard } from '@/components/NewsCard'; import { NewsCard } from '@/components/NewsCard';
import { GlobalSidebar, hasSidebarContent } from '@/components/AdWrappers'; import { GlobalSidebar, hasSidebarContent } from '@/components/AdWrappers';
import { RadioTower, Sparkles, ChevronDown } from 'lucide-react'; import { RadioTower, Sparkles, ChevronDown } from 'lucide-react';
@ -106,8 +107,14 @@ export default async function Home(props: { searchParams: Promise<{ [key: string
const { posts, cardSettings, totalPages } = await fetchHomeData(page, isAdmin, adminMonth); const { posts, cardSettings, totalPages } = await fetchHomeData(page, isAdmin, adminMonth);
const hasSidebar = await hasSidebarContent(); const hasSidebar = await hasSidebarContent();
const cookieStore = await cookies();
const hasVisited = cookieStore.has('almeira_visited');
return ( return (
<main className="animate-fade-in relative"> <main className="animate-fade-in relative">
{!hasVisited && (
<>
<script dangerouslySetInnerHTML={{ __html: `document.cookie = "almeira_visited=true; path=/; max-age=31536000";` }} />
{/* Sci-Fi Hero Section */} {/* Sci-Fi Hero Section */}
<section className="relative flex flex-col items-center justify-center min-h-screen text-center px-4 overflow-hidden mesh-bg"> <section className="relative flex flex-col items-center justify-center min-h-screen text-center px-4 overflow-hidden mesh-bg">
{/* Dynamic Orbs background */} {/* Dynamic Orbs background */}
@ -146,12 +153,15 @@ export default async function Home(props: { searchParams: Promise<{ [key: string
<ChevronDown size={24} className="text-cyan-500" /> <ChevronDown size={24} className="text-cyan-500" />
</a> </a>
</section> </section>
</>
)}
{/* Feed Section */} {/* Feed Section */}
<section id="noticias" className="container mx-auto px-4 py-24 relative z-10 w-full"> <section id="noticias" className={`container mx-auto px-4 relative z-10 w-full ${hasVisited ? 'pt-8 pb-24' : 'py-24'}`}>
<div className="flex flex-col lg:flex-row gap-8 w-full items-start"> <div className="flex flex-col lg:flex-row gap-8 w-full items-start">
<div className="flex-1 w-full relative"> <div className="flex-1 w-full relative">
<div className="flex flex-col md:flex-row justify-between items-end mb-12 gap-6"> <div className={`flex flex-col md:flex-row items-end mb-12 gap-6 ${hasVisited && isAdmin ? 'justify-end' : 'justify-between'}`}>
{!hasVisited && (
<div> <div>
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="w-12 h-1 bg-gradient-to-r from-cyan-400 to-blue-600 rounded-full"></div> <div className="w-12 h-1 bg-gradient-to-r from-cyan-400 to-blue-600 rounded-full"></div>
@ -159,6 +169,7 @@ export default async function Home(props: { searchParams: Promise<{ [key: string
</div> </div>
<h2 className="text-4xl md:text-5xl font-outfit font-black text-white">Feed de <span className="text-transparent bg-clip-text bg-gradient-to-r from-white to-slate-500">Noticias</span></h2> <h2 className="text-4xl md:text-5xl font-outfit font-black text-white">Feed de <span className="text-transparent bg-clip-text bg-gradient-to-r from-white to-slate-500">Noticias</span></h2>
</div> </div>
)}
{isAdmin && ( {isAdmin && (
<form action="/" method="GET" className="flex items-center gap-3 bg-slate-800/80 p-3 rounded-xl border border-white/10"> <form action="/" method="GET" className="flex items-center gap-3 bg-slate-800/80 p-3 rounded-xl border border-white/10">
<input type="hidden" name="page" value="1" /> <input type="hidden" name="page" value="1" />

View File

@ -44,6 +44,19 @@ export async function GlobalTopBanner() {
if (!post.fecha_finalizacion) return true; if (!post.fecha_finalizacion) return true;
return new Date(post.fecha_finalizacion) >= now; return new Date(post.fecha_finalizacion) >= now;
}); });
// Resolver media
const mediaIds = banners.map(p => p.imagen_destacada_id).filter(id => id && typeof id === 'string');
if (mediaIds.length > 0) {
const mediaRes = await client.databases.listDocuments(APPWRITE_ALMEIRA_CONFIG.databaseId, 'media', [
Query.equal('$id', mediaIds)
]);
const mediaMap = new Map(mediaRes.documents.map((m: any) => [m.$id, m]));
banners = banners.map(p => ({
...p,
imagen_destacada_id: mediaMap.get(p.imagen_destacada_id) || p.imagen_destacada_id
}));
}
} }
} catch (e) { } catch (e) {
console.error("TopBanner fetch error", e); console.error("TopBanner fetch error", e);

View File

@ -7,15 +7,18 @@ import { databases, APPWRITE_ALMEIRA_CONFIG, endpoint, projectId } from '@/lib/a
import { uploadMediaDocumentServer, listMediaServer, getMediaServer } from '@/app/actions/appwrite'; import { uploadMediaDocumentServer, listMediaServer, getMediaServer } from '@/app/actions/appwrite';
/** Utility to generate frontend Appwrite image previews */ /** Utility to generate frontend Appwrite image previews */
export function getAppwriteImageUrl(fileId: string, bucketId?: string, width = 400, height = 250) { export function getAppwriteImageUrl(fileId: string, bucketId?: string, width = 400, height = 250, isVideo = false) {
if (!fileId) return ''; if (!fileId) return '';
const finalBucketId = bucketId || APPWRITE_ALMEIRA_CONFIG.mediaBucketId; const finalBucketId = bucketId || APPWRITE_ALMEIRA_CONFIG.mediaBucketId;
if (isVideo) {
return `${endpoint}/storage/buckets/${finalBucketId}/files/${fileId}/view?project=${projectId}`;
}
return `${endpoint}/storage/buckets/${finalBucketId}/files/${fileId}/preview?project=${projectId}&width=${width}&height=${height}&quality=80`; return `${endpoint}/storage/buckets/${finalBucketId}/files/${fileId}/preview?project=${projectId}&width=${width}&height=${height}&quality=80`;
} }
interface MediaGridSelectorProps { interface MediaGridSelectorProps {
value?: string; value?: string;
onChange: (fileId: string, url?: string) => void; onChange: (fileId: string, url?: string, tipo?: string) => void;
label?: string; label?: string;
mode?: 'field' | 'modal'; mode?: 'field' | 'modal';
isOpen?: boolean; isOpen?: boolean;
@ -112,8 +115,9 @@ export const MediaGridSelector = ({
const doc = res.data[0]; const doc = res.data[0];
setMediaItems(prev => [doc, ...prev]); setMediaItems(prev => [doc, ...prev]);
const url = getAppwriteImageUrl(doc.file_id, doc.bucket_id, 1200, 800); const isVideo = doc.tipo === 'video';
onChange(doc.$id, url); const url = getAppwriteImageUrl(doc.file_id, doc.bucket_id, 1200, 800, isVideo);
onChange(doc.$id, url, doc.tipo);
closeModal(); closeModal();
} catch (error) { } catch (error) {
console.error("Error upload:", error); console.error("Error upload:", error);
@ -168,7 +172,7 @@ export const MediaGridSelector = ({
/> />
</div> </div>
<div className="w-full sm:w-1/3 flex justify-end"> <div className="w-full sm:w-1/3 flex justify-end">
<input type="file" accept="image/*" className="hidden" ref={fileInputRef} onChange={handleUpload}/> <input type="file" accept="image/*,video/*" className="hidden" ref={fileInputRef} onChange={handleUpload}/>
<button <button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={isUploading} disabled={isUploading}
@ -190,13 +194,14 @@ export const MediaGridSelector = ({
) : filteredItems.length > 0 ? ( ) : filteredItems.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{filteredItems.map((item) => { {filteredItems.map((item) => {
const url = getAppwriteImageUrl(item.file_id, item.bucket_id, 300, 300); const isVideo = item.tipo === 'video';
const url = getAppwriteImageUrl(item.file_id, item.bucket_id, 300, 300, isVideo);
return ( return (
<div <div
key={item.$id} key={item.$id}
onClick={() => { onClick={() => {
const selectedUrl = getAppwriteImageUrl(item.file_id, item.bucket_id, 1200, 800); const selectedUrl = getAppwriteImageUrl(item.file_id, item.bucket_id, 1200, 800, isVideo);
onChange(item.$id, selectedUrl); onChange(item.$id, selectedUrl, item.tipo);
if (mode !== 'modal') setIsOpen(false); if (mode !== 'modal') setIsOpen(false);
closeModal(); closeModal();
}} }}
@ -204,7 +209,11 @@ export const MediaGridSelector = ({
value === item.file_id ? 'border-cyan-500 shadow-xl shadow-cyan-500/20' : 'border-slate-800 hover:border-slate-600 shadow-lg' value === item.file_id ? 'border-cyan-500 shadow-xl shadow-cyan-500/20' : 'border-slate-800 hover:border-slate-600 shadow-lg'
}`} }`}
> >
{isVideo ? (
<video src={url} title={item.titulo} className="w-full h-full object-cover transition-transform group-hover:scale-110" onMouseEnter={e => e.currentTarget.play()} onMouseLeave={e => e.currentTarget.pause()} muted loop />
) : (
<img src={url} alt={item.titulo} title={item.titulo} className="w-full h-full object-cover transition-transform group-hover:scale-110" loading="lazy" /> <img src={url} alt={item.titulo} title={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-cyan-500/20 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" /> <div className="absolute inset-0 bg-gradient-to-t from-cyan-500/20 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
{value === item.$id && ( {value === item.$id && (
<div className="absolute top-2 right-2 bg-cyan-500 text-white rounded-full p-1 shadow-lg"> <div className="absolute top-2 right-2 bg-cyan-500 text-white rounded-full p-1 shadow-lg">
@ -274,11 +283,12 @@ export const MediaGridSelector = ({
const selectedDoc = mediaItems.find(m => m.$id === value); const selectedDoc = mediaItems.find(m => m.$id === value);
// Si aún no está en la lista (cargando), mostramos loader genérico // Si aún no está en la lista (cargando), mostramos loader genérico
if (!selectedDoc && mediaItems.length > 0) return null; if (!selectedDoc && mediaItems.length > 0) return null;
const fileIdToRender = selectedDoc ? selectedDoc.file_id : value; const isVideo = selectedDoc?.tipo === 'video';
const bucketIdToRender = selectedDoc ? selectedDoc.bucket_id : undefined; const fileUrl = getAppwriteImageUrl(fileIdToRender, bucketIdToRender, 800, 450, isVideo);
return isVideo ? (
return ( <video src={fileUrl} className="w-full h-full object-cover" muted loop autoPlay />
<img src={getAppwriteImageUrl(fileIdToRender, bucketIdToRender, 800, 450)} alt="Selected Preview" className="w-full h-full object-cover" /> ) : (
<img src={fileUrl} 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"> <div className="absolute inset-0 bg-slate-900/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEditor, EditorContent } from '@tiptap/react'; import { useEditor, EditorContent } from '@tiptap/react';
import { Node, mergeAttributes } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline'; import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link'; import Link from '@tiptap/extension-link';
@ -26,6 +27,29 @@ interface RichTextEditorProps {
label: string; label: string;
} }
const VideoNode = Node.create({
name: 'videoNode',
group: 'block',
selectable: true,
draggable: true,
atom: true,
addAttributes() {
return {
src: { default: null },
controls: { default: 'true' },
}
},
parseHTML() {
return [{ tag: 'video' }]
},
renderHTML({ HTMLAttributes }) {
return ['video', mergeAttributes(HTMLAttributes, { class: 'w-full aspect-video rounded-xl shadow-lg my-6', controls: 'true', preload: 'metadata' })]
},
});
const MenuBar = ({ editor }: { editor: any }) => { const MenuBar = ({ editor }: { editor: any }) => {
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false); const [isMediaModalOpen, setIsMediaModalOpen] = useState(false);
@ -49,10 +73,14 @@ const MenuBar = ({ editor }: { editor: any }) => {
} }
}; };
const handleMediaInsert = (fileId: string, url?: string) => { const handleMediaInsert = (fileId: string, url?: string, tipo?: string) => {
if (url) { if (url) {
if (tipo === 'video') {
editor.chain().focus().insertContent({ type: 'videoNode', attrs: { src: url } }).run();
} else {
editor.chain().focus().setImage({ src: url }).run(); editor.chain().focus().setImage({ src: url }).run();
} }
}
setIsMediaModalOpen(false); setIsMediaModalOpen(false);
}; };
@ -232,6 +260,7 @@ export const RichTextEditor = ({ value, onChange, label }: RichTextEditorProps)
class: 'rounded-xl shadow-lg max-w-full h-auto object-cover my-6' class: 'rounded-xl shadow-lg max-w-full h-auto object-cover my-6'
} }
}), }),
VideoNode,
Youtube.configure({ Youtube.configure({
controls: true, controls: true,
HTMLAttributes: { HTMLAttributes: {