325 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|