feat: Replace basic texts with actual card layout generation using next/og for Word document export

This commit is contained in:
AI Bot 2026-08-23 15:41:23 -05:00
parent 3c169eab97
commit fbc7343730
4 changed files with 730 additions and 214 deletions

703
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -30,6 +30,7 @@
"axios": "^1.18.0", "axios": "^1.18.0",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"docx": "^9.7.1",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"isomorphic-dompurify": "^2.20.0", "isomorphic-dompurify": "^2.20.0",

View File

@ -0,0 +1,216 @@
import { NextResponse } from 'next/server';
import { ImageResponse } from 'next/og';
import { Document, Paragraph, TextRun, Packer, ImageRun, HeadingLevel, ExternalHyperlink, AlignmentType } from 'docx';
import { createAdminClient } from '@/lib/appwrite.server';
import { Query } from 'node-appwrite';
import { APPWRITE_ALMEIRA_CONFIG, endpoint, projectId } from '@/lib/appwrite';
import { collectionMap } from '@/lib/schema';
import { getAppwriteImageUrl } from '@/components/dashboard/MediaGridSelector';
export const runtime = 'nodejs';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const q = searchParams.get('q') || '';
const sort = searchParams.get('sort') || 'fecha_publicacion';
const dir = searchParams.get('dir') || 'desc';
const status = searchParams.get('status') || '';
const year = searchParams.get('year');
const month = searchParams.get('month');
const client = await createAdminClient();
const postsCollectionId = collectionMap['posts'];
const mediaCollectionId = collectionMap['media'];
if (!postsCollectionId) {
return NextResponse.json({ error: 'Configuración de colección incorrecta' }, { status: 500 });
}
const queries = [Query.limit(100), Query.offset(0)];
if (dir === 'asc') queries.push(Query.orderAsc(sort));
else queries.push(Query.orderDesc(sort));
if (q) queries.push(Query.contains('titulo', q));
if (status && status !== 'all') {
if (status === 'publicado') queries.push(Query.equal('estado', ['publicado', 'Publicado']));
else if (status === 'borrador') queries.push(Query.equal('estado', ['borrador', 'Borrador']));
else if (status === 'pendiente') queries.push(Query.equal('estado', ['pendiente', 'Pendiente']));
else if (status === 'archivado') queries.push(Query.equal('estado', ['archivado', 'Archivado']));
else queries.push(Query.equal('estado', status));
}
if (year || month) {
const filterYear = year ? Number(year) : new Date().getFullYear();
if (month) {
const startDate = new Date(Date.UTC(filterYear, Number(month) - 1, 1)).toISOString();
const endDate = new Date(Date.UTC(filterYear, Number(month), 1)).toISOString();
queries.push(Query.greaterThanEqual('fecha_publicacion', startDate));
queries.push(Query.lessThan('fecha_publicacion', endDate));
} else {
const startDate = new Date(Date.UTC(filterYear, 0, 1)).toISOString();
const endDate = new Date(Date.UTC(filterYear + 1, 0, 1)).toISOString();
queries.push(Query.greaterThanEqual('fecha_publicacion', startDate));
queries.push(Query.lessThan('fecha_publicacion', endDate));
}
}
const response = await client.databases.listDocuments(
APPWRITE_ALMEIRA_CONFIG.databaseId,
postsCollectionId,
queries
);
const posts = response.documents;
const mediaIds = Array.from(new Set(posts.map(p => typeof p.imagen_destacada_id === 'object' && p.imagen_destacada_id !== null ? p.imagen_destacada_id.$id : p.imagen_destacada_id).filter(id => typeof id === 'string' && id.length > 0) as string[]));
let mediaDocs: Record<string, any> = {};
if (mediaIds.length > 0 && mediaCollectionId) {
try {
const relRes = await client.databases.listDocuments(
APPWRITE_ALMEIRA_CONFIG.databaseId,
mediaCollectionId,
[Query.equal('$id', mediaIds), Query.limit(100)]
);
relRes.documents.forEach(doc => {
mediaDocs[doc.$id] = doc;
});
} catch (err) {
console.error('Error resolviendo las imágenes para el reporte', err);
}
}
const docChildren: any[] = [
new Paragraph({
text: `Reporte de Publicaciones - Almeira Web`,
heading: HeadingLevel.HEADING_1,
alignment: AlignmentType.CENTER,
spacing: { after: 400 }
})
];
for (const post of posts) {
const fechaFormat = post.fecha_publicacion ? new Date(post.fecha_publicacion).toLocaleDateString('es-CO') : 'Sin fecha';
const mediaId = typeof post.imagen_destacada_id === 'object' && post.imagen_destacada_id !== null ? post.imagen_destacada_id.$id : post.imagen_destacada_id;
let imageUrl = '';
if (mediaId && mediaDocs[mediaId]) {
const mediaInfo = mediaDocs[mediaId];
if (mediaInfo.tipo === 'imagen') {
imageUrl = getAppwriteImageUrl(mediaInfo.file_id, mediaInfo.bucket_id, 600, 400, false);
}
}
let imageAdded = false;
try {
const plainTextResumen = (post.resumen_redes || post.contenido || '').replace(/<[^>]*>?/gm, ' ').replace(/\s+/g, ' ').trim();
const safeResumen = plainTextResumen.length > 120 ? plainTextResumen.substring(0, 120) + '...' : plainTextResumen;
// USING @vercel/og syntax to create a React Node inline without JSX in a .ts file ?
// WAIT! This file is route.ts, JSX is not supported in .ts unless it's .tsx!
// Let's use react.createElement
const React = require('react');
const cardNode = React.createElement('div', {
style: {
display: 'flex', flexDirection: 'column', width: 600, height: 400,
backgroundColor: '#0f172a', color: 'white', borderRadius: 24,
overflow: 'hidden', border: '2px solid #1e293b', fontFamily: 'sans-serif'
}
},
imageUrl
? React.createElement('img', { src: imageUrl, style: { width: 600, height: 230, objectFit: 'cover' } })
: React.createElement('div', { style: { width: 600, height: 230, backgroundColor: '#1e293b', display: 'flex', alignItems: 'center', justifyContent: 'center' } },
React.createElement('span', { style: { color: '#475569', fontSize: 24 } }, 'Sin Imagen')
),
React.createElement('div', { style: { display: 'flex', flexDirection: 'column', padding: 24, gap: 8 } },
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
React.createElement('span', { style: { color: '#06b6d4', fontSize: 14, fontWeight: 'bold', textTransform: 'uppercase' } }, post.estado || 'Borrador'),
React.createElement('span', { style: { color: '#64748b', fontSize: 14 } }, fechaFormat)
),
React.createElement('h3', { style: { fontSize: 22, fontWeight: 'bold', margin: 0, lineHeight: 1.2, color: '#f8fafc' } }, post.titulo || 'Sin título'),
React.createElement('p', { style: { fontSize: 16, color: '#94a3b8', margin: 0, lineHeight: 1.4 } }, safeResumen)
)
);
const cardImage = new ImageResponse(cardNode, { width: 600, height: 400 });
const imgBuffer = await cardImage.arrayBuffer();
docChildren.push(
new Paragraph({
children: [
new ImageRun({
data: imgBuffer,
transformation: { width: 500, height: 333 },
type: 'png'
})
],
alignment: AlignmentType.CENTER,
spacing: { after: 200 }
})
);
imageAdded = true;
} catch (err) {
console.error('Error fetching image for Word Document', err);
}
if (!imageAdded) {
docChildren.push(
new Paragraph({
children: [
new TextRun({ text: "[No se pudo generar la tarjeta virtual]", italics: true })
],
alignment: AlignmentType.CENTER,
spacing: { after: 200 }
})
);
}
docChildren.push(
new Paragraph({
children: [
new TextRun({ text: "Enlace web: ", bold: true }),
new ExternalHyperlink({
children: [
new TextRun({ text: `https://almeira.com.co/${post.slug || post.$id}`, style: "Hyperlink" }),
],
link: `https://almeira.com.co/${post.slug || post.$id}`,
})
],
spacing: { after: 400 }
})
);
docChildren.push(
new Paragraph({ text: "______________________________________________________", alignment: AlignmentType.CENTER, spacing: { after: 200 } })
);
}
if (posts.length === 0) {
docChildren.push(new Paragraph({ text: "No se encontraron publicaciones con los filtros seleccionados." }));
}
const doc = new Document({
sections: [{
properties: {},
children: docChildren,
}]
});
const buffer = await Packer.toBuffer(doc);
const dataArray = new Uint8Array(buffer);
return new NextResponse(dataArray, {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'Content-Disposition': `attachment; filename=reporte_publicaciones_${new Date().toISOString().split('T')[0]}.docx`,
},
});
} catch (error) {
console.error('General error Word API:', error);
return NextResponse.json({ error: 'Error al generar el documento' }, { status: 500 });
}
}

View File

@ -215,6 +215,17 @@ export default async function EntityPage({
</button> </button>
</form> </form>
<div className="flex items-center gap-3">
{entity === 'posts' && (
<a
href={`/api/reports/posts?${buildQueryString(1)}`}
target="_blank"
className="group flex items-center gap-2 bg-gradient-to-r from-purple-600 to-pink-500 hover:from-purple-500 hover:to-pink-400 text-white text-sm font-bold px-6 py-3 rounded-full shadow-[0_0_20px_rgba(168,85,247,0.3)] hover:shadow-[0_0_30px_rgba(168,85,247,0.5)] transition-all hover:scale-105"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
Generar Informe
</a>
)}
<Link <Link
href={`/dashboard/${entity}/new`} href={`/dashboard/${entity}/new`}
className="group flex items-center gap-2 bg-gradient-to-r from-blue-600 to-cyan-500 hover:from-blue-500 hover:to-cyan-400 text-white text-sm font-bold px-6 py-3 rounded-full shadow-[0_0_20px_rgba(6,182,212,0.3)] hover:shadow-[0_0_30px_rgba(6,182,212,0.5)] transition-all hover:scale-105" className="group flex items-center gap-2 bg-gradient-to-r from-blue-600 to-cyan-500 hover:from-blue-500 hover:to-cyan-400 text-white text-sm font-bold px-6 py-3 rounded-full shadow-[0_0_20px_rgba(6,182,212,0.3)] hover:shadow-[0_0_30px_rgba(6,182,212,0.5)] transition-all hover:scale-105"
@ -222,6 +233,7 @@ export default async function EntityPage({
<span className="text-xl leading-none -mt-1">+</span> Nuevo <span className="text-xl leading-none -mt-1">+</span> Nuevo
</Link> </Link>
</div> </div>
</div>
{entity === 'posts' ? ( {entity === 'posts' ? (
<ErrorBoundary> <ErrorBoundary>