feat: Replace basic texts with actual card layout generation using next/og for Word document export
This commit is contained in:
parent
3c169eab97
commit
fbc7343730
703
package-lock.json
generated
703
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -30,6 +30,7 @@
|
||||
"axios": "^1.18.0",
|
||||
"cheerio": "^1.2.0",
|
||||
"clsx": "^2.1.1",
|
||||
"docx": "^9.7.1",
|
||||
"dompurify": "^3.3.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"isomorphic-dompurify": "^2.20.0",
|
||||
|
||||
216
src/app/api/reports/posts/route.ts
Normal file
216
src/app/api/reports/posts/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -215,12 +215,24 @@ export default async function EntityPage({
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<span className="text-xl leading-none -mt-1">+</span> Nuevo
|
||||
</Link>
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<span className="text-xl leading-none -mt-1">+</span> Nuevo
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entity === 'posts' ? (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user