almeira-web/src/app/[slug]/page.tsx

325 lines
16 KiB
TypeScript

import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
import { createAdminClient } from '@/lib/appwrite.server';
import { Query } from 'node-appwrite';
import { GlobalSidebar, hasSidebarContent } from '@/components/AdWrappers';
import { Calendar, User, ArrowLeft, Newspaper, Tag } from 'lucide-react';
import Link from 'next/link';
import { decodeHtmlEntities } from '@/lib/utils';
import DOMPurify from 'isomorphic-dompurify';
import { Metadata, ResolvingMetadata } from 'next';
function formatTextToHtml(text: string) {
if (!text) return '';
// Normalize any video preview URLs to view URLs for legacy content
const normalizedText = text
.replace(/(<video[^>]*src=["'])([^"']*)\/preview\?([^"']*["'])/gi, '$1$2/view?$3')
.replace(/(<source[^>]*src=["'])([^"']*)\/preview\?([^"']*["'])/gi, '$1$2/view?$3');
// Si ya contiene etiquetas estructurales de HTML (párrafos, saltos, divs, listas), lo devolvemos tal cual
if (/<\/?(?:p|br|div|h[1-6]|ul|ol|li|table)[^>]*>/i.test(normalizedText)) {
return normalizedText;
}
// Convertimos texto plano con saltos de línea a párrafos y <br/>, permitiendo que etiquetas inline como <strong> o <iframe> se mantengan.
return normalizedText.split(/\n\s*\n/)
.filter(p => p.trim() !== '')
.map(p => `<p>${p.replace(/\n/g, '<br />')}</p>`)
.join('');
}
function extractMedia(content: string, videoUrlField?: string) {
let videoHtml: string | null = null;
let cleanContent = content;
// 1. Check if there's a video_url field (YouTube/Vimeo)
if (videoUrlField) {
if (videoUrlField.includes('youtube.com') || videoUrlField.includes('youtu.be')) {
let videoId = '';
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
const match = videoUrlField.match(regExp);
if (match && match[2].length === 11) {
videoId = match[2];
}
if (videoId) {
videoHtml = `<iframe src="https://www.youtube.com/embed/${videoId}" class="w-full h-full absolute inset-0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
}
} else if (videoUrlField.includes('vimeo.com')) {
const match = videoUrlField.match(/vimeo\.com\/(\d+)/);
if (match) {
const videoId = match[1];
videoHtml = `<iframe src="https://player.vimeo.com/video/${videoId}" class="w-full h-full absolute inset-0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
}
} else {
videoHtml = `<video src="${videoUrlField}" controls preload="metadata" class="w-full h-full object-cover"></video>`;
}
}
// 2. Look for video tag inside content (first occurrence)
if (!videoHtml && content) {
const videoRegex = /<video[^>]*>([\s\S]*?)<\/video>/i;
const match = videoRegex.exec(content);
if (match) {
videoHtml = match[0];
cleanContent = content.replace(videoRegex, '');
}
}
// 3. Look for youtube iframe/div inside content (first occurrence)
if (!videoHtml && content) {
const ytRegex = /<div[^>]*data-youtube-video[^>]*>([\s\S]*?)<\/div>/i;
const ytMatch = ytRegex.exec(content);
if (ytMatch) {
videoHtml = ytMatch[0];
cleanContent = content.replace(ytRegex, '');
} else {
const iframeRegex = /<iframe[^>]*src=["'][^"']*(?:youtube\.com|youtu\.be|vimeo\.com)[^"']*["'][^>]*>([\s\S]*?)<\/iframe>/i;
const iframeMatch = iframeRegex.exec(content);
if (iframeMatch) {
videoHtml = iframeMatch[0];
cleanContent = content.replace(iframeRegex, '');
}
}
}
return { videoHtml, cleanContent };
}
function styleHeroMedia(html: string, posterUrl?: string): string {
if (!html) return '';
if (html.includes('<video')) {
let styled = html
.replace(/class=["'][^"']*["']/gi, '')
.replace(/width=["'][^"']*["']/gi, '')
.replace(/height=["'][^"']*["']/gi, '');
let attributes = 'class="w-full h-full object-cover rounded-3xl"';
if (posterUrl && !styled.includes('poster=')) {
attributes += ` poster="${posterUrl}"`;
}
styled = styled.replace('<video', `<video ${attributes}`);
return styled;
}
if (html.includes('<iframe')) {
let styled = html
.replace(/class=["'][^"']*["']/gi, '')
.replace(/width=["'][^"']*["']/gi, '')
.replace(/height=["'][^"']*["']/gi, '');
styled = styled.replace('<iframe', '<iframe class="w-full h-full absolute inset-0 rounded-3xl"');
return styled;
}
if (html.includes('data-youtube-video')) {
const iframeMatch = html.match(/<iframe[^>]*>[\s\S]*?<\/iframe>/i);
if (iframeMatch) {
return styleHeroMedia(iframeMatch[0]);
}
}
return html;
}
async function fetchPostData(slugOrId: string) {
if (!slugOrId || typeof slugOrId !== 'string' || slugOrId.includes('.')) return null;
try {
const client = await createAdminClient();
const dbId = APPWRITE_ALMEIRA_CONFIG.databaseId;
// 1. Try by slug
let res = await client.databases.listDocuments(dbId, 'posts', [
Query.equal('slug', slugOrId),
Query.limit(1)
]);
if (res.documents.length > 0) return res.documents[0];
// 2. Try by $id (fallback)
try {
return await client.databases.getDocument(dbId, 'posts', slugOrId);
} catch (e) {
return null;
}
} catch (e) {
if (!slugOrId.includes('favicon')) {
console.error("Post detail fetch error for slug:", slugOrId, e);
}
return null;
}
}
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> },
parent: ResolvingMetadata
): Promise<Metadata> {
const { slug } = await params;
const post = await fetchPostData(slug);
if (!post) {
return {
title: 'No encontrado - Almeira Web',
description: 'La publicación solicitada no existe o ha sido eliminada.'
};
}
const title = decodeHtmlEntities(post.titulo);
const description = post.resumen_redes
? decodeHtmlEntities(post.resumen_redes)
: (post.contenido?.replace(/<[^>]*>?/gm, '').substring(0, 160) + '...');
const PROJECT_ID = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || '6aef63d9002c07371b40';
const ENDPOINT = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://nyc.cloud.appwrite.io/v1';
let imagenUrl = post.og_image_url || null;
if (post.imagen_destacada_id) {
try {
const client = await createAdminClient();
const mediaRes = await client.databases.getDocument(APPWRITE_ALMEIRA_CONFIG.databaseId, 'media', post.imagen_destacada_id);
if (mediaRes) {
imagenUrl = `${ENDPOINT}/storage/buckets/${mediaRes.bucket_id}/files/${mediaRes.file_id}/preview?project=${PROJECT_ID}&width=1200&height=630&quality=90`;
}
} catch(e) {}
}
const previousImages = (await parent).openGraph?.images || [];
const images = imagenUrl ? [imagenUrl, ...previousImages] : previousImages;
return {
title: title,
description: description,
openGraph: {
title: title,
description: description,
images: images,
type: 'article',
publishedTime: post.fecha_publicacion || post.$createdAt,
},
twitter: {
card: 'summary_large_image',
title: title,
description: description,
images: images,
}
};
}
export default async function NoticiaDetail({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await fetchPostData(slug);
const hasSidebar = await hasSidebarContent();
if (!post) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-slate-950 text-white p-4">
<h1 className="text-4xl font-black font-outfit mb-4">404 - SEÑAL PERDIDA</h1>
<p className="text-slate-400 mb-8 text-center">No hemos podido localizar la transmisión solicitada en el núcleo central.</p>
<Link href="/" className="px-6 py-3 rounded-full bg-cyan-600 font-bold hover:bg-cyan-500 transition-colors">VOLVER AL FEED</Link>
</div>
);
}
const PROJECT_ID = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || '6aef63d9002c07371b40';
const ENDPOINT = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://nyc.cloud.appwrite.io/v1';
let imagenUrl = post.og_image_url || null;
const isSquare = post.tipo === 'evento' || post.tipo === 'aviso' ||
(typeof post.tipo_publicacion_id === 'object' && (post.tipo_publicacion_id.codigo === 'EVENTO' || post.tipo_publicacion_id.codigo === 'CLASIFICADO'));
const imgHeight = isSquare ? 640 : 360;
if (post.imagen_destacada_id) {
try {
const client = await createAdminClient();
const mediaRes = await client.databases.getDocument(APPWRITE_ALMEIRA_CONFIG.databaseId, 'media', post.imagen_destacada_id);
if (mediaRes) {
imagenUrl = `${ENDPOINT}/storage/buckets/${mediaRes.bucket_id}/files/${mediaRes.file_id}/preview?project=${PROJECT_ID}&width=640&height=${imgHeight}&quality=90`;
}
} catch(e) {}
}
const fechaObj = new Date(post.fecha_publicacion || post.$createdAt);
const fechaFormateada = fechaObj.toLocaleDateString('es-ES', { day: '2-digit', month: 'long', year: 'numeric' });
// Extract video media if present
const rawContent = post.contenido || '';
const { videoHtml, cleanContent } = extractMedia(rawContent, post.video_url);
const styledHeroMedia = videoHtml ? styleHeroMedia(videoHtml, imagenUrl || undefined) : null;
return (
<main className="animate-fade-in relative min-h-screen pt-24 pb-20">
<div className="container mx-auto px-4 lg:px-8">
<div className="flex flex-col lg:flex-row gap-12">
<article className="flex-1 max-w-4xl overflow-hidden">
<Link href="/" className="inline-flex items-center gap-2 text-cyan-400 font-bold hover:text-cyan-300 transition-colors mb-8 group">
<ArrowLeft size={18} className="transition-transform group-hover:-translate-x-1" />
<span>Volver a la portada</span>
</Link>
<header className="mb-10 space-y-6">
<div className="flex flex-wrap items-center gap-4 text-xs font-bold uppercase tracking-wider text-slate-400">
<span className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">
<Tag size={14} />
{typeof post.tipo_publicacion_id === 'object' ? post.tipo_publicacion_id.descripcion : (post.tipo || 'Noticia')}
</span>
<span className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-slate-800/60 border border-white/5">
<Calendar size={14} />
{fechaFormateada}
</span>
</div>
<h1 className="text-3xl md:text-5xl font-black font-outfit text-white tracking-tight leading-tight">
{decodeHtmlEntities(post.titulo)}
</h1>
{post.resumen_redes && (
<p className="text-xl text-slate-400 font-medium leading-relaxed italic border-l-4 border-cyan-500 pl-6 py-2">
{decodeHtmlEntities(post.resumen_redes)}
</p>
)}
</header>
{styledHeroMedia ? (
<div className="relative aspect-video w-full rounded-3xl overflow-hidden mb-12 border border-white/10 shadow-2xl bg-black">
<div className="w-full h-full" dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(styledHeroMedia, {
ADD_TAGS: ['iframe', 'video', 'source'],
ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling', 'style', 'class', 'controls', 'src', 'type', 'muted', 'loop', 'autoplay', 'preload', 'poster']
})
}} />
</div>
) : imagenUrl ? (
<div className={`relative ${isSquare ? 'aspect-square max-w-xl mx-auto' : 'aspect-video'} w-full rounded-3xl overflow-hidden mb-12 border border-white/10 shadow-2xl bg-black`}>
<img src={imagenUrl} alt={post.titulo} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/50 to-transparent"></div>
</div>
) : (
<div className={`relative ${isSquare ? 'aspect-square max-w-xl mx-auto' : 'aspect-video'} w-full rounded-3xl overflow-hidden mb-12 border border-white/10 shadow-2xl bg-gradient-to-br from-slate-900 to-slate-950 flex flex-col items-center justify-center gap-4`}>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--accent-glow))] opacity-5 pointer-events-none"></div>
<Newspaper size={48} className="text-cyan-500/40 animate-pulse" />
<span className="text-slate-500 font-bold uppercase tracking-widest text-xs font-outfit">Almeira Web</span>
</div>
)}
<div
className="prose prose-invert prose-cyan max-w-none text-slate-300 leading-relaxed text-lg whitespace-pre-wrap break-words
prose-headings:font-outfit prose-headings:font-black prose-headings:text-white
prose-strong:text-cyan-400 prose-p:empty:before:content-['\\00a0']"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(formatTextToHtml(cleanContent), {
ADD_TAGS: ['iframe', 'video', 'source'],
ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling', 'style', 'class', 'controls', 'src', 'type', 'muted', 'loop', 'autoplay', 'preload']
})
}}
/>
</article>
{hasSidebar && (
<aside className="w-full lg:w-[320px] xl:w-[360px] shrink-0 sticky top-24 h-fit">
<GlobalSidebar />
</aside>
)}
</div>
</div>
</main>
);
}