fix: safe content rendering, media library server-side fetch, robots & sitemap
This commit is contained in:
parent
45da8c1d46
commit
9680d8788f
Binary file not shown.
@ -46,6 +46,23 @@ if [ -f ".env.local" ]; then
|
|||||||
cp .env.local "$DEPLOY_DIR/.env.production.example"
|
cp .env.local "$DEPLOY_DIR/.env.production.example"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 7.5. Modificar package.json para evitar que Hostinger intente hacer build
|
||||||
|
echo "Configurando package.json para producción en Hostinger..."
|
||||||
|
node -e "
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const pkgPath = path.join('$DEPLOY_DIR', 'package.json');
|
||||||
|
if (fs.existsSync(pkgPath)) {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||||
|
pkg.scripts = {
|
||||||
|
...pkg.scripts,
|
||||||
|
build: 'echo \"Application is already built standalone. Skipping build.\"',
|
||||||
|
start: 'NODE_ENV=production node server.js'
|
||||||
|
};
|
||||||
|
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
|
||||||
|
}
|
||||||
|
"
|
||||||
|
|
||||||
# 8. Comprimir en un archivo zip
|
# 8. Comprimir en un archivo zip
|
||||||
ZIP_FILE="almeira-web-deploy.zip"
|
ZIP_FILE="almeira-web-deploy.zip"
|
||||||
echo "Comprimiendo aplicación en $ZIP_FILE..."
|
echo "Comprimiendo aplicación en $ZIP_FILE..."
|
||||||
|
|||||||
@ -6,6 +6,20 @@ 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';
|
import DOMPurify from 'isomorphic-dompurify';
|
||||||
|
import { Metadata, ResolvingMetadata } from 'next';
|
||||||
|
|
||||||
|
function formatTextToHtml(text: string) {
|
||||||
|
if (!text) return '';
|
||||||
|
// 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(text)) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
// Convertimos texto plano con saltos de línea a párrafos y <br/>, permitiendo que etiquetas inline como <strong> o <iframe> se mantengan.
|
||||||
|
return text.split(/\n\s*\n/)
|
||||||
|
.filter(p => p.trim() !== '')
|
||||||
|
.map(p => `<p>${p.replace(/\n/g, '<br />')}</p>`)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
@ -36,6 +50,61 @@ async function fetchPostData(slugOrId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 || '69b84df5003bd8957c5c';
|
||||||
|
const ENDPOINT = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://appwrite.almeira.com.co/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 }> }) {
|
export default async function NoticiaDetail({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const post = await fetchPostData(slug);
|
const post = await fetchPostData(slug);
|
||||||
@ -110,9 +179,9 @@ export default async function NoticiaDetail({ params }: { params: Promise<{ slug
|
|||||||
<div
|
<div
|
||||||
className="prose prose-invert prose-cyan max-w-none text-slate-300 leading-relaxed text-lg whitespace-pre-wrap break-words
|
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-headings:font-outfit prose-headings:font-black prose-headings:text-white
|
||||||
prose-p:mb-6 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(post.contenido, {
|
__html: DOMPurify.sanitize(formatTextToHtml(post.contenido), {
|
||||||
ADD_TAGS: ['iframe'],
|
ADD_TAGS: ['iframe'],
|
||||||
ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling', 'style', 'class']
|
ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling', 'style', 'class']
|
||||||
})
|
})
|
||||||
|
|||||||
@ -126,3 +126,40 @@ export async function uploadMediaDocumentServer(formData: FormData) {
|
|||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listMediaServer(search?: string, offset: number = 0) {
|
||||||
|
try {
|
||||||
|
const client = getAdminClient();
|
||||||
|
const databases = new Databases(client);
|
||||||
|
|
||||||
|
const { Query } = require('node-appwrite');
|
||||||
|
const queries = [
|
||||||
|
Query.limit(20),
|
||||||
|
Query.offset(offset),
|
||||||
|
Query.orderDesc('$createdAt')
|
||||||
|
];
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
queries.push(Query.contains('titulo', search));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await databases.listDocuments(DATABASE_ID, 'media', queries);
|
||||||
|
// Sanitizar para Next.js
|
||||||
|
return { success: true, data: res.documents.map(doc => ({ ...doc })) };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching media:', error);
|
||||||
|
return { success: false, data: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMediaServer(documentId: string) {
|
||||||
|
try {
|
||||||
|
const client = getAdminClient();
|
||||||
|
const databases = new Databases(client);
|
||||||
|
const doc = await databases.getDocument(DATABASE_ID, 'media', documentId);
|
||||||
|
return { success: true, data: { ...doc } };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching media document:', error);
|
||||||
|
return { success: false, data: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
14
src/app/robots.ts
Normal file
14
src/app/robots.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://almeira.com.co';
|
||||||
|
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: '*',
|
||||||
|
allow: '/',
|
||||||
|
disallow: ['/dashboard/', '/api/'],
|
||||||
|
},
|
||||||
|
sitemap: `${baseUrl}/sitemap.xml`,
|
||||||
|
};
|
||||||
|
}
|
||||||
41
src/app/sitemap.ts
Normal file
41
src/app/sitemap.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
import { createAdminClient } from '@/lib/appwrite.server';
|
||||||
|
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
|
||||||
|
import { Query } from 'node-appwrite';
|
||||||
|
|
||||||
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://almeira.com.co';
|
||||||
|
|
||||||
|
// Static routes
|
||||||
|
const routes = ['', '/clasificados', '/eventos', '/tv', '/radio'].map((route) => ({
|
||||||
|
url: `${baseUrl}${route}`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: 'daily' as const,
|
||||||
|
priority: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Dynamic posts
|
||||||
|
let postRoutes: MetadataRoute.Sitemap = [];
|
||||||
|
try {
|
||||||
|
const client = await createAdminClient();
|
||||||
|
const dbId = APPWRITE_ALMEIRA_CONFIG.databaseId;
|
||||||
|
|
||||||
|
// Fetch all published posts
|
||||||
|
const postsRes = await client.databases.listDocuments(dbId, 'posts', [
|
||||||
|
Query.equal('estado', 'Publicado'),
|
||||||
|
Query.limit(1000), // Get up to 1000 posts. For more, pagination is needed.
|
||||||
|
Query.orderDesc('fecha_publicacion')
|
||||||
|
]);
|
||||||
|
|
||||||
|
postRoutes = postsRes.documents.map((post) => ({
|
||||||
|
url: `${baseUrl}/${post.slug || post.$id}`,
|
||||||
|
lastModified: new Date(post.$updatedAt || post.fecha_publicacion || post.$createdAt),
|
||||||
|
changeFrequency: 'weekly' as const,
|
||||||
|
priority: 0.7,
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error generating sitemap for posts:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...routes, ...postRoutes];
|
||||||
|
}
|
||||||
@ -118,7 +118,7 @@ export const EntityForm = ({
|
|||||||
|
|
||||||
// Ensure featured image ID is set if present
|
// Ensure featured image ID is set if present
|
||||||
if (featuredImageId) {
|
if (featuredImageId) {
|
||||||
formData.set('imagen_destacada_id', featuredImageId);
|
formData.set('logotipo', featuredImageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@ -127,7 +127,7 @@ export const EntityForm = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Organize fields into Main Content and Sidebar Metadata
|
// Organize fields into Main Content and Sidebar Metadata
|
||||||
const sidebarFieldNames = ['estado', 'fecha_publicacion', 'fecha_finalizacion', 'slug', 'imagen_destacada_id', 'categoria_id', 'tipo_publicacion_id'];
|
const sidebarFieldNames = ['estado', 'fecha_publicacion', 'fecha_finalizacion', 'slug', 'logotipo', 'categoria_id', 'tipo_publicacion_id'];
|
||||||
const mainFields = fields.filter(f => !sidebarFieldNames.includes(f.name));
|
const mainFields = fields.filter(f => !sidebarFieldNames.includes(f.name));
|
||||||
const sidebarFields = fields.filter(f => sidebarFieldNames.includes(f.name));
|
const sidebarFields = fields.filter(f => sidebarFieldNames.includes(f.name));
|
||||||
|
|
||||||
@ -136,8 +136,9 @@ export const EntityForm = ({
|
|||||||
|
|
||||||
// Set smart defaults for new items
|
// Set smart defaults for new items
|
||||||
if (!initialData) {
|
if (!initialData) {
|
||||||
if (field.name === 'estado') return 'borrador';
|
if (field.name === 'logotipo') {
|
||||||
if (field.name === 'fecha_publicacion' || field.type === 'date') {
|
return featuredImageId;
|
||||||
|
} if (field.name === 'fecha_publicacion' || field.type === 'date') {
|
||||||
// Get local ISO string for datetime-local input
|
// Get local ISO string for datetime-local input
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||||
|
|||||||
@ -4,8 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { ImageIcon, X, Search, CheckCircle2, UploadCloud, Loader2 } from 'lucide-react';
|
import { ImageIcon, X, Search, CheckCircle2, UploadCloud, Loader2 } from 'lucide-react';
|
||||||
import { databases, APPWRITE_ALMEIRA_CONFIG, endpoint, projectId } from '@/lib/appwrite';
|
import { databases, APPWRITE_ALMEIRA_CONFIG, endpoint, projectId } from '@/lib/appwrite';
|
||||||
import { Query } from 'appwrite';
|
import { uploadMediaDocumentServer, listMediaServer, getMediaServer } from '@/app/actions/appwrite';
|
||||||
import { uploadMediaDocumentServer } 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) {
|
||||||
@ -52,25 +51,18 @@ export const MediaGridSelector = ({
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const currentOffset = more ? mediaItems.length : 0;
|
const currentOffset = more ? mediaItems.length : 0;
|
||||||
const queries = [
|
const res = await listMediaServer(search, currentOffset);
|
||||||
Query.limit(20),
|
|
||||||
Query.offset(currentOffset),
|
|
||||||
Query.orderDesc('$createdAt')
|
|
||||||
];
|
|
||||||
|
|
||||||
if (search) {
|
if (res.success && res.data) {
|
||||||
queries.push(Query.contains('titulo', search));
|
if (more) {
|
||||||
}
|
setMediaItems(prev => [...prev, ...res.data]);
|
||||||
|
} else {
|
||||||
const res = await databases.listDocuments(APPWRITE_ALMEIRA_CONFIG.databaseId, 'media', queries);
|
setMediaItems(res.data);
|
||||||
|
}
|
||||||
if (more) {
|
setHasMore(res.data.length === 20);
|
||||||
setMediaItems(prev => [...prev, ...res.documents]);
|
|
||||||
} else {
|
} else {
|
||||||
setMediaItems(res.documents);
|
setHasMore(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
setHasMore(res.documents.length === 20);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching media:', error);
|
console.error('Error fetching media:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -92,13 +84,14 @@ export const MediaGridSelector = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If we have a value but haven't loaded its document, fetch just that one
|
// If we have a value but haven't loaded its document, fetch just that one
|
||||||
if (value && mode === 'field' && !mediaItems.some(m => m.$id === value)) {
|
if (value && mode === 'field' && !mediaItems.some(m => m.$id === value)) {
|
||||||
databases.getDocument(APPWRITE_ALMEIRA_CONFIG.databaseId, 'media', value)
|
getMediaServer(value)
|
||||||
.then(doc => {
|
.then(res => {
|
||||||
setMediaItems(prev => {
|
if (res.success && res.data) {
|
||||||
// Protect against race conditions duplicating the doc
|
setMediaItems(prev => {
|
||||||
if (prev.some(m => m.$id === value)) return prev;
|
if (prev.some(m => m.$id === value)) return prev;
|
||||||
return [...prev, doc];
|
return [...prev, res.data];
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}).catch(err => console.error("Error resolver media thumbnail:", err));
|
}).catch(err => console.error("Error resolver media thumbnail:", err));
|
||||||
}
|
}
|
||||||
}, [value, mode]);
|
}, [value, mode]);
|
||||||
@ -306,7 +299,10 @@ export const MediaGridSelector = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="imagen_destacada_id" value={value || ''} />
|
{/* Sólo incluir hidden input en modo campo, para evitar conflictos cuando se usa como modal puro */}
|
||||||
|
{mode === 'field' && label !== 'Logotipo Corporativo' && (
|
||||||
|
<input type="hidden" name="imagen_destacada_id" value={value || ''} />
|
||||||
|
)}
|
||||||
{renderModal()}
|
{renderModal()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
3
test_html.js
Normal file
3
test_html.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const DOMPurify = require('isomorphic-dompurify');
|
||||||
|
const txt = "Line 1\nLine 2\n\nLine 3\n<strong>Bold</strong> text";
|
||||||
|
console.log(DOMPurify.sanitize(txt));
|
||||||
Loading…
Reference in New Issue
Block a user