fix: redireccion de publicaciones, formulario completo y utilidades de importacion
This commit is contained in:
parent
baa2ace85a
commit
ed8e115c56
Binary file not shown.
367
fix-scraped-images.js
Normal file
367
fix-scraped-images.js
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const cheerio = require('cheerio');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { Client, Databases, Storage, ID, Query } = require('node-appwrite');
|
||||||
|
require('dotenv').config({ path: '.env.local' });
|
||||||
|
|
||||||
|
let InputFile;
|
||||||
|
try { InputFile = require('node-appwrite/file').InputFile; } catch (e) { InputFile = null; }
|
||||||
|
|
||||||
|
const client = new Client()
|
||||||
|
.setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT)
|
||||||
|
.setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID)
|
||||||
|
.setKey(process.env.APPWRITE_API_KEY);
|
||||||
|
|
||||||
|
const databases = new Databases(client);
|
||||||
|
const storage = new Storage(client);
|
||||||
|
const DB_ID = process.env.APPWRITE_DATABASE_ID;
|
||||||
|
const BUCKET_ID = process.env.NEXT_PUBLIC_APPWRITE_MEDIA_BUCKET_ID;
|
||||||
|
|
||||||
|
const scrapedNews = [
|
||||||
|
{
|
||||||
|
title: "Gobernación del Cauca lideró mesa técnica para fortalecer la respuesta ante la actividad del volcán Puracé",
|
||||||
|
dateStr: "jueves, 20 de agosto de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-lideró-mesa-técnica-para-fortalecer-la-respuesta-ante-la-actividad-del-volcán-Puracé.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "¡Gracias Cauca! Ayudas humanitarias recolectadas superan las 80 toneladas",
|
||||||
|
dateStr: "miércoles, 19 de agosto de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/-¡Gracias-Cauca!-Ayudas-humanitarias-recolectadas-superan-las-80-toneladas.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gobernación del Cauca fortalece la conectividad vial en la vereda El Túnel, Cajibío",
|
||||||
|
dateStr: "martes, 18 de agosto de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-fortalece-la-conectividad-vial-en-la-vereda-El-Túnel,-Cajibío.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gobierno Departamental mantiene seguimiento a afectaciones ocasionadas por el sismo del 10 de agosto",
|
||||||
|
dateStr: "martes, 18 de agosto de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Gobierno-Departamental-mantiene-seguimiento-a-afectaciones-ocasionadas-por-el-sismo-del-10-de-agosto-.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Más de 40 toneladas de ayudas serán entregadas en las regiones afectadas del país",
|
||||||
|
dateStr: "viernes, 14 de agosto de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Más-de-40-toneladas-de-ayudas-serán-entregadas-en-las-regiones-afectadas-del-país.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gobierno departamental hace llamado vehemente al Gobierno Nacional frente a la situación de orden público que atenta contra...",
|
||||||
|
dateStr: "jueves, 13 de agosto de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Gobierno-departamental-hace-llamado-vehemente-al-Gobierno-Nacional-frente-a-la-situación-de-orden-público-que-atenta-contra.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gobernación del Cauca sigue consolidando afectaciones tras el sismo del 10 de agosto",
|
||||||
|
dateStr: "jueves, 13 de agosto de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-sigue-consolidando-afectaciones-tras-el-sismo-del-10-de-agosto.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gobernación del Cauca sustentó proyecto para reducir hasta el 80 % de intereses y sanciones en impuestos departamentales",
|
||||||
|
dateStr: "jueves, 13 de agosto de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-sustentó-proyecto-para-reducir-hasta-el-80--de-intereses-y-sanciones-en-impuestos-departamentales.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gobernación del Cauca activa Plan de Evaluación Integral tras sismo de 7.4 registrado en el país",
|
||||||
|
dateStr: "lunes, 10 de agosto de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-activa-Plan-de-Evaluación-Integral-tras-sismo-de-7-4-registrado-en-el-país.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gobernación del Cauca está en permanente monitoreo y atención a municipios afectados tras el sismo de 7.4",
|
||||||
|
dateStr: "lunes, 10 de agosto de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-está-en-permanente-monitoreo-y-atención-a-municipios-afectados-tras-el-sismo-de-7-4.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gobernación del Cauca desplegó acciones simultáneas frente a la alerta volcánica del Puracé, la protección de la vía Panamericana...",
|
||||||
|
dateStr: "domingo, 9 de agosto de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-desplegó-acciones-simultáneas-frente-a-la-alerta-volcánica-del-Puracé,-la-protección-de-la-vía-Paname.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gobernación del Cauca entregó ayudas humanitarias ante la emergencia generada por la actividad del volcán Puracé",
|
||||||
|
dateStr: "miércoles, 5 de agosto de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-entregó-ayudas-humanitarias-ante-la-emergencia-generada-por-la-actividad-del-volcán-Puracé.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gobernación del Cauca intensifica acciones para proteger la salud de las comunidades tras la alerta naranja del volcán Puracé",
|
||||||
|
dateStr: "sábado, 1 de agosto de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-intensifica-acciones-para-proteger-la-salud-de-las-comunidades-tras-la-alerta-naranja-del-volcán-Pura.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gobernación del Cauca unifica sus trámites tributarios en el portal web institucional en cumplimiento de la Ley de Transparencia",
|
||||||
|
dateStr: "martes, 28 de julio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-unifica-sus-trámites-tributarios-en-el-portal-web-institucional-en-cumplimiento-de-la-Ley-de-Transpar.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Invitación rueda de prensa sobre el estado actual de la Industria Licorera del Cauca",
|
||||||
|
dateStr: "martes, 28 de julio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Invitación-rueda-de-prensa-sobre-el-estado-actual-de-la-Industria-Licorera-del-Cauca.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gobierno Departamental fortalece la defensa de los Derechos Humanos con la Mesa Territorial de Garantías",
|
||||||
|
dateStr: "lunes, 27 de julio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/-Gobierno-Departamental-fortalece-la-defensa-de-los-Derechos-Humanos-con-la-Mesa-Territorial-de-Garantías--En-el-marco-del-.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "La Afrocolombianidad se vive y se fortalece en el Cauca con La Fuerza del Pueblo",
|
||||||
|
dateStr: "viernes, 24 de julio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/La-Afrocolombianidad-se-vive-y-se-fortalece-en-el-Cauca-con-La-Fuerza-del-Pueblo.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gobernación del Cauca hace un llamado urgente a entidades competentes para garantizar atención en salud a más de 400.000 afiliados de Asmet Salud",
|
||||||
|
dateStr: "jueves, 23 de julio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-hace-un-llamado-urgente-a-entidades-competentes-para-garantizar-atención-en-salud-a-más-de-400-000-af.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Golpe al contrabando en el Norte del Cauca: decomisadas 586 unidades de licor",
|
||||||
|
dateStr: "martes, 21 de julio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/-Golpe-al-contrabando-en-el-Norte-del-Cauca-decomisadas-586-unidades-de-licor.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Con Escuela y Café, la Gobernación del Cauca fortalece la formación de los jóvenes del departamento",
|
||||||
|
dateStr: "martes, 21 de julio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Con-Escuela-y-Café,-la-Gobernación-del-Cauca-fortalece-la-formación-de-los-jóvenes-del-departamento.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Vías para la Vida: el sector rural de Popayán, una prioridad para el Gobierno Departamental",
|
||||||
|
dateStr: "sábado, 11 de julio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Vías-para-la-Vida-el-sector-rural-de-Popayán,-una-prioridad-para-el-Gobierno-Departamental.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "La paz se construye con acciones en los territorios: Gobernación del Cauca entrega ayudas integrales en El Carmelo, Cajibío",
|
||||||
|
dateStr: "miércoles, 8 de julio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/La-paz-se-construye-con-acciones-en-los-territorios-Gobernación-del-Cauca-entrega-ayudas-integrales-en-El-Carmelo,-Cajibío.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "El Cauca se destaca por su gestión administrativa a nivel nacional",
|
||||||
|
dateStr: "lunes, 6 de julio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/El-Cauca-se-destaca-por-su-gestión-administrative-a-nivel-nacional.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gobernación del Cauca alerta sobre enlace fraudulento para el pago del impuesto vehicular",
|
||||||
|
dateStr: "lunes, 6 de julio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-alerta-sobre-enlace-fraudulento-para-el-pago-del-impuesto-vehicular.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Cauca se consolida como referente mundial del café con la VI Feria \"Cauca Sabe a Café 2026\"",
|
||||||
|
dateStr: "viernes, 26 de junio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/-Cauca-se-consolida-como-referente-mundial-del-café-con-la-VI-Feria-Cauca-Sabe-a-Café-2026.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Este 26 de junio Indeportes Cauca rinde cuentas a la ciudadanía",
|
||||||
|
dateStr: "martes, 23 de junio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/-Este-26-de-junio-Indeportes-Cauca-rinde-cuentas-a-la-ciudadanía.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Participación histórica fortalece la democracia en el Cauca",
|
||||||
|
dateStr: "domingo, 21 de junio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/-Participación-histórica-fortalece-la-democracia-en-el-Cauca.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Con normalidad y garantías, se realiza la segunda vuelta presidencial en los 42 municipios del Cauca",
|
||||||
|
dateStr: "domingo, 21 de junio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Con-normalidad-y-garantías,-se-realiza-la-segunda-vuelta-presidencial-en-los-42-municipios-del-Cauca.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Cauca fortalece garantías para un proceso electoral seguro, transparente y participativo en la segunda vuelta presidencial",
|
||||||
|
dateStr: "miércoles, 10 de junio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Cauca-fortalece-garantías-para-un-proceso-electoral-seguro,-transparente-y-participativo-en-la-segunda-vuelta-presidencial.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "La Industria Licorera del Cauca se vistió de oro en París",
|
||||||
|
dateStr: "martes, 9 de junio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/La-Industria-Licorera-del-Cauca-se-vistió-de-oro-en-París.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gobernación del Cauca fortalece acciones de vigilancia sanitaria en establecimientos de estética",
|
||||||
|
dateStr: "sábado, 6 de junio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-fortalece-acciones-de-vigilancia-sanitaria-en-establecimientos-de-estética.aspx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gobernación del Cauca fortalece el bienestar animal, la movilidad rural and la productividad campesina en Timbío",
|
||||||
|
dateStr: "viernes, 5 de junio de 2026",
|
||||||
|
link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-fortalece-el-bienestar-animal,-la-movilidad-rural-y-la-productividad-campesina-en-Timbío.aspx"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function slugify(text) {
|
||||||
|
return text
|
||||||
|
.toString()
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9 -]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
const collections = await databases.listCollections(DB_ID);
|
||||||
|
const map = {};
|
||||||
|
collections.collections.forEach(c => { map[c.name.toLowerCase()] = c.$id; });
|
||||||
|
const postsCollectionId = map['posts'] || 'posts';
|
||||||
|
const mediaCollectionId = map['media'] || 'media';
|
||||||
|
|
||||||
|
console.log(`Starting self-healing scan on ${scrapedNews.length} articles...`);
|
||||||
|
|
||||||
|
for (let i = 0; i < scrapedNews.length; i++) {
|
||||||
|
const item = scrapedNews[i];
|
||||||
|
const slug = `gobernacion-web-${slugify(item.title)}`;
|
||||||
|
console.log(`\nChecking [${i+1}/${scrapedNews.length}]: "${item.title}"`);
|
||||||
|
|
||||||
|
const postsRes = await databases.listDocuments(DB_ID, postsCollectionId, [
|
||||||
|
Query.equal('slug', slug),
|
||||||
|
Query.limit(1)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (postsRes.documents.length === 0) {
|
||||||
|
console.log(`-> Post not found in database. Skipping.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = postsRes.documents[0];
|
||||||
|
let needsHealing = false;
|
||||||
|
let oldMediaId = post.imagen_destacada_id;
|
||||||
|
let oldFileId = null;
|
||||||
|
|
||||||
|
if (!oldMediaId) {
|
||||||
|
console.log("-> Post has no featured image ID. Needs healing!");
|
||||||
|
needsHealing = true;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const mediaDoc = await databases.getDocument(DB_ID, mediaCollectionId, oldMediaId);
|
||||||
|
oldFileId = mediaDoc.file_id;
|
||||||
|
const file = await storage.getFile(BUCKET_ID, oldFileId);
|
||||||
|
if (file.mimeType.includes('html') || file.mimeType.includes('text')) {
|
||||||
|
console.log(`-> Found corrupted HTML file payload (ID: ${oldFileId}, Mime: ${file.mimeType}). Needs healing!`);
|
||||||
|
needsHealing = true;
|
||||||
|
} else {
|
||||||
|
console.log(`-> Image is valid (Mime: ${file.mimeType}).`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`-> Missing file or media document in Appwrite: ${e.message}. Needs healing!`);
|
||||||
|
needsHealing = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needsHealing) continue;
|
||||||
|
|
||||||
|
const url = 'https://cauca.gov.co' + item.link;
|
||||||
|
console.log(`-> Healing: Fetching webpage to scrape image... ${url}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.get(encodeURI(decodeURI(url)), {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36'
|
||||||
|
},
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
const $ = cheerio.load(res.data);
|
||||||
|
let image = null;
|
||||||
|
|
||||||
|
// 1. Check body content first
|
||||||
|
$('.ms-rtestate-field img').each((idx, el) => {
|
||||||
|
const src = $(el).attr('src');
|
||||||
|
if (src && !src.includes('logo') && !src.includes('icon')) {
|
||||||
|
image = src.startsWith('http') ? src : 'https://cauca.gov.co' + src;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Try old path
|
||||||
|
if (!image) {
|
||||||
|
$('img').each((idx, el) => {
|
||||||
|
const src = $(el).attr('src');
|
||||||
|
if (src && src.includes('/Prensa/PublishingImages/')) {
|
||||||
|
image = src.startsWith('http') ? src : 'https://cauca.gov.co' + src;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try general image fallback
|
||||||
|
if (!image) {
|
||||||
|
$('img').each((idx, el) => {
|
||||||
|
const src = $(el).attr('src');
|
||||||
|
if (src && !src.includes('logo') && !src.includes('icon') && !src.includes('menu') && src.length > 10) {
|
||||||
|
image = src.startsWith('http') ? src : 'https://cauca.gov.co' + src;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
console.log("-> Could not find any valid image on this article page.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`-> Found image URL: ${image}`);
|
||||||
|
console.log("-> Downloading binary image with correct User-Agent...");
|
||||||
|
|
||||||
|
const imgRes = await axios.get(encodeURI(decodeURI(image)), {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = Buffer.from(imgRes.data, 'binary');
|
||||||
|
|
||||||
|
// Verify that the response is actually binary image and not HTML
|
||||||
|
const isHtml = buffer.toString('utf-8', 0, 50).trim().toLowerCase().startsWith('<!doctype') || buffer.toString('utf-8', 0, 20).trim().toLowerCase().startsWith('<html');
|
||||||
|
if (isHtml) {
|
||||||
|
console.error("-> Error: Image request returned HTML instead of binary image data!");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlParts = image.split('/');
|
||||||
|
const filename = urlParts[urlParts.length - 1] || 'image.jpg';
|
||||||
|
const filePath = `/tmp/heal_${ID.unique()}_${filename}`;
|
||||||
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
|
||||||
|
let fileParam;
|
||||||
|
if (InputFile && InputFile.fromPath) fileParam = InputFile.fromPath(filePath, filename);
|
||||||
|
else fileParam = fs.createReadStream(filePath);
|
||||||
|
|
||||||
|
console.log("-> Uploading new binary image file to Appwrite...");
|
||||||
|
const newFile = await storage.createFile(BUCKET_ID, ID.unique(), fileParam);
|
||||||
|
|
||||||
|
console.log("-> Creating new media document in database...");
|
||||||
|
const newMediaDoc = await databases.createDocument(DB_ID, mediaCollectionId, ID.unique(), {
|
||||||
|
file_id: newFile.$id,
|
||||||
|
bucket_id: BUCKET_ID,
|
||||||
|
titulo: item.title,
|
||||||
|
tipo: 'imagen'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`-> Updating post document "${post.titulo}" to link new image...`);
|
||||||
|
await databases.updateDocument(DB_ID, postsCollectionId, post.$id, {
|
||||||
|
imagen_destacada_id: newMediaDoc.$id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup temp file
|
||||||
|
try { fs.unlinkSync(filePath); } catch (e) {}
|
||||||
|
|
||||||
|
// Delete old corrupted file and media document if they existed
|
||||||
|
if (oldFileId) {
|
||||||
|
console.log(`-> Cleaning up old corrupted file ID ${oldFileId} from storage...`);
|
||||||
|
try { await storage.deleteFile(BUCKET_ID, oldFileId); } catch (e) { console.log(` (Failed to delete file: ${e.message})`); }
|
||||||
|
}
|
||||||
|
if (oldMediaId) {
|
||||||
|
console.log(`-> Cleaning up old corrupted media document ID ${oldMediaId} from database...`);
|
||||||
|
try { await databases.deleteDocument(DB_ID, mediaCollectionId, oldMediaId); } catch (e) { console.log(` (Failed to delete media document: ${e.message})`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[HEALED SUCCESS] Post "${post.titulo}" now has its correct featured image!`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`-> Error healing post:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nSelf-healing scan complete!");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Fatal Error:", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run();
|
||||||
243
import-images.js
Normal file
243
import-images.js
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { Client, Databases, Storage, ID, Query } = require('node-appwrite');
|
||||||
|
const stringSimilarity = require('string-similarity');
|
||||||
|
const sharp = require('sharp');
|
||||||
|
require('dotenv').config({ path: '.env.local' });
|
||||||
|
|
||||||
|
let InputFile;
|
||||||
|
try { InputFile = require('node-appwrite/file').InputFile; } catch (e) { InputFile = null; }
|
||||||
|
|
||||||
|
const client = new Client()
|
||||||
|
.setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT)
|
||||||
|
.setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID)
|
||||||
|
.setKey(process.env.APPWRITE_API_KEY);
|
||||||
|
|
||||||
|
const databases = new Databases(client);
|
||||||
|
const storage = new Storage(client);
|
||||||
|
const DB_ID = process.env.APPWRITE_DATABASE_ID;
|
||||||
|
const BUCKET_ID = process.env.NEXT_PUBLIC_APPWRITE_MEDIA_BUCKET_ID;
|
||||||
|
|
||||||
|
const DIR_PENDIENTES = '/home/carlos/Documentos/Almeira Web/imagenes pendientes';
|
||||||
|
const DIR_PROCESADOS = path.join(DIR_PENDIENTES, 'Procesados');
|
||||||
|
|
||||||
|
if (!fs.existsSync(DIR_PROCESADOS)) {
|
||||||
|
fs.mkdirSync(DIR_PROCESADOS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanFilenameTitle(filename) {
|
||||||
|
const ext = path.extname(filename);
|
||||||
|
let base = path.basename(filename, ext);
|
||||||
|
return base.replace(/\(1\)/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
console.log('=== INICIANDO PROCESAMIENTO DE IMÁGENES PENDIENTES ===');
|
||||||
|
console.log(`Endpoint: ${process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT}`);
|
||||||
|
console.log(`Proyecto: ${process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID}`);
|
||||||
|
console.log(`Base de datos: ${DB_ID}`);
|
||||||
|
console.log(`Bucket de medios: ${BUCKET_ID}\n`);
|
||||||
|
|
||||||
|
// 1. Obtener mapeo dinámico de colecciones
|
||||||
|
const collections = await databases.listCollections(DB_ID);
|
||||||
|
const colMap = {};
|
||||||
|
collections.collections.forEach(c => { colMap[c.name.toLowerCase()] = c.$id; });
|
||||||
|
const postsCollectionId = colMap['posts'] || 'posts';
|
||||||
|
const mediaCollectionId = colMap['media'] || 'media';
|
||||||
|
|
||||||
|
// 2. Leer archivos en "imagenes pendientes"
|
||||||
|
const files = fs.readdirSync(DIR_PENDIENTES).filter(file => {
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.jfif'].includes(ext);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.log('No se encontraron imágenes pendientes en el directorio.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`Se encontraron ${files.length} imágenes para procesar.`);
|
||||||
|
|
||||||
|
// 3. Cargar todos los posts de la base de datos
|
||||||
|
console.log('Cargando publicaciones de la base de datos...');
|
||||||
|
const posts = [];
|
||||||
|
let cursor = null;
|
||||||
|
while (true) {
|
||||||
|
const queries = [Query.limit(100)];
|
||||||
|
if (cursor) queries.push(Query.cursorAfter(cursor));
|
||||||
|
const res = await databases.listDocuments(DB_ID, postsCollectionId, queries);
|
||||||
|
posts.push(...res.documents);
|
||||||
|
if (res.documents.length < 100) break;
|
||||||
|
cursor = res.documents[res.documents.length - 1].$id;
|
||||||
|
}
|
||||||
|
console.log(`Se cargaron ${posts.length} publicaciones de la base de datos.`);
|
||||||
|
|
||||||
|
const postTitles = posts.map(p => p.titulo || '');
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
const filePath = path.join(DIR_PENDIENTES, file);
|
||||||
|
const cleanTitle = cleanFilenameTitle(file);
|
||||||
|
const normCleanTitle = normalizeText(cleanTitle);
|
||||||
|
|
||||||
|
console.log(`\n[${i+1}/${files.length}] Procesando archivo: "${file}"`);
|
||||||
|
|
||||||
|
// Buscar coincidencia en posts
|
||||||
|
let bestPost = null;
|
||||||
|
let bestScore = 0;
|
||||||
|
|
||||||
|
// Primero buscar coincidencia exacta normalizada
|
||||||
|
for (const post of posts) {
|
||||||
|
const normPostTitle = normalizeText(post.titulo);
|
||||||
|
if (normPostTitle === normCleanTitle) {
|
||||||
|
bestPost = post;
|
||||||
|
bestScore = 1.0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si no hay exacta, buscar por similitud de cadena
|
||||||
|
if (bestScore < 1.0 && postTitles.length > 0) {
|
||||||
|
const matches = stringSimilarity.findBestMatch(cleanTitle, postTitles);
|
||||||
|
if (matches.bestMatch.rating >= 0.85) { // Umbral de confianza alto (>= 0.85)
|
||||||
|
bestScore = matches.bestMatch.rating;
|
||||||
|
bestPost = posts[matches.bestMatchIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedTitle = bestPost ? bestPost.titulo : cleanTitle;
|
||||||
|
if (bestPost) {
|
||||||
|
console.log(` -> Publicación coincidente encontrada: "${bestPost.titulo}" (Similitud: ${bestScore.toFixed(2)})`);
|
||||||
|
} else {
|
||||||
|
console.log(` -> No se encontró publicación coincidente en la base de datos. Se subirá solo a la biblioteca de medios.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redimensionar imagen a 640x360 usando sharp
|
||||||
|
let uploadFilename = file;
|
||||||
|
let fileExt = path.extname(file).toLowerCase();
|
||||||
|
if (fileExt === '.jfif') {
|
||||||
|
uploadFilename = path.basename(file, fileExt) + '.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempResizedPath = path.join('/tmp', `resized_${ID.unique()}_${path.basename(uploadFilename)}`);
|
||||||
|
console.log(` -> Redimensionando imagen a 640x360...`);
|
||||||
|
try {
|
||||||
|
await sharp(filePath)
|
||||||
|
.resize(640, 360, {
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'center'
|
||||||
|
})
|
||||||
|
.toFile(tempResizedPath);
|
||||||
|
console.log(` -> Imagen redimensionada guardada en ${tempResizedPath}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` ❌ Error redimensionando la imagen: ${err.message}`);
|
||||||
|
continue; // Saltar este archivo si falla el redimensionado
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subir a Appwrite Storage
|
||||||
|
let mediaDocId = null;
|
||||||
|
try {
|
||||||
|
console.log(` -> Subiendo imagen redimensionada a Appwrite Storage...`);
|
||||||
|
let fileParam;
|
||||||
|
if (InputFile && InputFile.fromPath) {
|
||||||
|
fileParam = InputFile.fromPath(tempResizedPath, uploadFilename);
|
||||||
|
} else {
|
||||||
|
fileParam = fs.createReadStream(tempResizedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadedFile = await storage.createFile(BUCKET_ID, ID.unique(), fileParam);
|
||||||
|
console.log(` -> Archivo subido exitosamente con ID: ${uploadedFile.$id}`);
|
||||||
|
|
||||||
|
// Crear documento en la colección de media
|
||||||
|
console.log(` -> Creando registro en la biblioteca de medios...`);
|
||||||
|
const mediaDoc = await databases.createDocument(DB_ID, mediaCollectionId, ID.unique(), {
|
||||||
|
file_id: uploadedFile.$id,
|
||||||
|
bucket_id: BUCKET_ID,
|
||||||
|
titulo: matchedTitle,
|
||||||
|
tipo: 'imagen',
|
||||||
|
alt_text: matchedTitle
|
||||||
|
});
|
||||||
|
mediaDocId = mediaDoc.$id;
|
||||||
|
console.log(` -> Registro de biblioteca de medios creado con ID: ${mediaDocId}`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` ❌ Error subiendo a Appwrite / creando registro de media: ${err.message}`);
|
||||||
|
// Eliminar archivo temporal
|
||||||
|
try { fs.unlinkSync(tempResizedPath); } catch (_) {}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminar archivo temporal
|
||||||
|
try { fs.unlinkSync(tempResizedPath); } catch (_) {}
|
||||||
|
|
||||||
|
// Asociar imagen destacada si corresponde
|
||||||
|
if (bestPost) {
|
||||||
|
let needsUpdate = false;
|
||||||
|
let oldMediaId = bestPost.imagen_destacada_id;
|
||||||
|
|
||||||
|
if (!oldMediaId) {
|
||||||
|
console.log(` -> La publicación no tiene imagen destacada. Se procederá a asociarla.`);
|
||||||
|
needsUpdate = true;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
console.log(` -> Verificando imagen destacada actual (${oldMediaId})...`);
|
||||||
|
const currentMediaDoc = await databases.getDocument(DB_ID, mediaCollectionId, oldMediaId);
|
||||||
|
const fileInfo = await storage.getFile(BUCKET_ID, currentMediaDoc.file_id);
|
||||||
|
if (fileInfo.mimeType.includes('html') || fileInfo.mimeType.includes('text')) {
|
||||||
|
console.log(` -> Imagen actual corrupta (HTML/Text). Se procederá a sanarla/reemplazarla.`);
|
||||||
|
needsUpdate = true;
|
||||||
|
} else {
|
||||||
|
console.log(` -> La publicación ya cuenta con una imagen destacada válida (${fileInfo.mimeType}). No se actualizará.`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` -> Referencia de imagen actual rota o inexistente en Appwrite. Se procederá a asociarla.`);
|
||||||
|
needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsUpdate) {
|
||||||
|
try {
|
||||||
|
console.log(` -> Actualizando publicación para establecer la nueva imagen destacada...`);
|
||||||
|
await databases.updateDocument(DB_ID, postsCollectionId, bestPost.$id, {
|
||||||
|
imagen_destacada_id: mediaDocId
|
||||||
|
});
|
||||||
|
console.log(` ✓ Publicación "${bestPost.titulo}" vinculada exitosamente.`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` ❌ Error vinculando la imagen a la publicación: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mover archivo original a Procesados
|
||||||
|
try {
|
||||||
|
let destPath = path.join(DIR_PROCESADOS, file);
|
||||||
|
if (fs.existsSync(destPath)) {
|
||||||
|
const ext = path.extname(file);
|
||||||
|
const base = path.basename(file, ext);
|
||||||
|
destPath = path.join(DIR_PROCESADOS, `${base}_${Date.now()}${ext}`);
|
||||||
|
}
|
||||||
|
fs.renameSync(filePath, destPath);
|
||||||
|
console.log(` -> Archivo original movido a Procesados.`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` ❌ Error moviendo archivo original a Procesados: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== PROCESAMIENTO COMPLETADO EXITOSAMENTE ===');
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fatal durante la ejecución:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@ -387,7 +387,13 @@ async function run() {
|
|||||||
if (image) {
|
if (image) {
|
||||||
try {
|
try {
|
||||||
console.log(`-> Downloading image: ${image}`);
|
console.log(`-> Downloading image: ${image}`);
|
||||||
const imgRes = await axios.get(encodeURI(image), { responseType: 'arraybuffer', timeout: 10000 });
|
const imgRes = await axios.get(encodeURI(image), {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36'
|
||||||
|
}
|
||||||
|
});
|
||||||
const buffer = Buffer.from(imgRes.data, 'binary');
|
const buffer = Buffer.from(imgRes.data, 'binary');
|
||||||
|
|
||||||
// Extract filename from URL
|
// Extract filename from URL
|
||||||
|
|||||||
@ -1,169 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @file PublicationDrawer.tsx
|
|
||||||
* @description Slide-in SideDrawer for inline post editing.
|
|
||||||
* Opens when a user clicks a PostCard (gated by RBAC).
|
|
||||||
* Renders entirely in a Portal, focus-trapped, Escape-closeable.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { X, Edit2, Loader2 } from 'lucide-react';
|
|
||||||
import type { Post, AuthUser, PublicationLayoutProps } from '@/types/publications';
|
|
||||||
|
|
||||||
export interface PublicationDrawerProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
post: Post | null;
|
|
||||||
user: AuthUser;
|
|
||||||
relationData: Record<string, Array<{ $id: string; [key: string]: unknown }>>;
|
|
||||||
submitAction: (id: string, formData: FormData) => Promise<{ success: boolean; error?: string; data?: unknown } | void>;
|
|
||||||
appwriteConfig: {
|
|
||||||
endpoint: string;
|
|
||||||
projectId: string;
|
|
||||||
databaseId: string;
|
|
||||||
};
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PublicationDrawer: React.FC<PublicationDrawerProps> = ({
|
|
||||||
isOpen,
|
|
||||||
post,
|
|
||||||
user,
|
|
||||||
relationData,
|
|
||||||
submitAction,
|
|
||||||
appwriteConfig,
|
|
||||||
onClose,
|
|
||||||
onSuccess,
|
|
||||||
}) => {
|
|
||||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
// Focus management
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
const timer = setTimeout(() => closeButtonRef.current?.focus(), 150);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
// Keyboard handler
|
|
||||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') onClose();
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
};
|
|
||||||
}, [isOpen, handleKeyDown]);
|
|
||||||
|
|
||||||
const wrappedSubmit = useCallback(async (formData: FormData) => {
|
|
||||||
if (!post?.$id) return { success: false, error: 'No post ID found' };
|
|
||||||
const result = await submitAction(post.$id, formData);
|
|
||||||
if (!result || result.success) {
|
|
||||||
onSuccess?.();
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [submitAction, onSuccess, post]);
|
|
||||||
|
|
||||||
if (typeof document === 'undefined') return null;
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div
|
|
||||||
className={`fixed inset-0 z-[1000] flex justify-end transition-visibility duration-300 ${isOpen ? 'visible' : 'invisible pointer-events-none'}`}
|
|
||||||
aria-modal="true"
|
|
||||||
role="dialog"
|
|
||||||
aria-label={post ? `Editando: ${post.titulo}` : 'Editor de publicación'}
|
|
||||||
>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 bg-slate-950/75 backdrop-blur-sm transition-opacity duration-300 ${isOpen ? 'opacity-100' : 'opacity-0'}`}
|
|
||||||
onClick={onClose}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Drawer Panel */}
|
|
||||||
<div
|
|
||||||
className={`relative z-10 w-full max-w-3xl h-full bg-[#060f1e] border-l border-slate-800 shadow-2xl shadow-cyan-500/5
|
|
||||||
overflow-y-auto flex flex-col
|
|
||||||
transition-transform duration-300 ease-out
|
|
||||||
${isOpen ? 'translate-x-0' : 'translate-x-full'}`}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 bg-[#060f1e]/95 backdrop-blur-sm border-b border-slate-800">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 bg-cyan-500/10 rounded-lg text-cyan-400">
|
|
||||||
<Edit2 size={16} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-black text-white uppercase tracking-widest">Editar Publicación</h2>
|
|
||||||
{post && (
|
|
||||||
<p className="text-[10px] text-slate-500 font-medium mt-0.5 truncate max-w-[320px]">
|
|
||||||
{post.titulo}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
ref={closeButtonRef}
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-2 rounded-xl border border-slate-800 text-slate-400 hover:text-white hover:border-slate-600 transition-all"
|
|
||||||
aria-label="Cerrar panel de edición"
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="flex-1 p-6">
|
|
||||||
{post && isOpen ? (
|
|
||||||
<LazyPublicationLayout
|
|
||||||
initialData={post}
|
|
||||||
user={user}
|
|
||||||
relationData={relationData}
|
|
||||||
submitAction={wrappedSubmit}
|
|
||||||
appwriteConfig={appwriteConfig}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-32 gap-3 text-slate-600 text-sm">
|
|
||||||
<Loader2 size={16} className="animate-spin" />
|
|
||||||
Cargando editor…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Lazy-loads PublicationLayout to code-split it from the list page */
|
|
||||||
function LazyPublicationLayout(props: PublicationLayoutProps) {
|
|
||||||
const [Layout, setLayout] = useState<React.ComponentType<PublicationLayoutProps> | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
import('./PublicationLayout').then((mod) => {
|
|
||||||
setLayout(() => mod.default as React.ComponentType<PublicationLayoutProps>);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!Layout) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center gap-3 h-32 text-slate-500">
|
|
||||||
<Loader2 size={18} className="animate-spin" />
|
|
||||||
Cargando formulario…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Layout {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PublicationDrawer;
|
|
||||||
@ -178,7 +178,10 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
setOptimisticState('error', result.error || 'Error al guardar.');
|
setOptimisticState('error', result.error || 'Error al guardar.');
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: any) {
|
||||||
|
if (err && (err.message === 'NEXT_REDIRECT' || err.digest?.startsWith('NEXT_REDIRECT'))) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
setOptimisticState('error', err instanceof Error ? err.message : 'Error desconocido.');
|
setOptimisticState('error', err instanceof Error ? err.message : 'Error desconocido.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -69,10 +69,6 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
|
|||||||
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
const [sortBy, setSortBy] = useState<SortKey>('fecha_publicacion');
|
const [sortBy, setSortBy] = useState<SortKey>('fecha_publicacion');
|
||||||
const [drawerPost, setDrawerPost] = useState<Post | null>(null);
|
|
||||||
|
|
||||||
// Lazy-load Drawer to avoid shipping it unless needed
|
|
||||||
const [DrawerComponent, setDrawerComponent] = useState<React.ComponentType<import('./PublicationDrawer').PublicationDrawerProps> | null>(null);
|
|
||||||
|
|
||||||
const canEdit = user?.$id === 'admin-emergency' || user?.labels?.includes('admin') || user?.labels?.includes('editor');
|
const canEdit = user?.$id === 'admin-emergency' || user?.labels?.includes('admin') || user?.labels?.includes('editor');
|
||||||
const userRole = canEdit ? 'admin' : 'viewer';
|
const userRole = canEdit ? 'admin' : 'viewer';
|
||||||
@ -109,18 +105,9 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
|
|||||||
|
|
||||||
// ── HANDLERS ────────────────────────────────
|
// ── HANDLERS ────────────────────────────────
|
||||||
|
|
||||||
const handleEdit = useCallback(async (post: Post) => {
|
const handleEdit = useCallback((post: Post) => {
|
||||||
// Lazy-load the drawer component
|
router.push(`/dashboard/${entity}/${post.$id}`);
|
||||||
if (!DrawerComponent) {
|
}, [entity, router]);
|
||||||
const mod = await import('./PublicationDrawer');
|
|
||||||
setDrawerComponent(() => mod.PublicationDrawer);
|
|
||||||
}
|
|
||||||
setDrawerPost(post);
|
|
||||||
}, [DrawerComponent]);
|
|
||||||
|
|
||||||
const handleCloseDrawer = useCallback(() => {
|
|
||||||
setDrawerPost(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDelete = useCallback((postId: string) => {
|
const handleDelete = useCallback((postId: string) => {
|
||||||
if (!confirm('¿Eliminar esta publicación permanentemente?')) return;
|
if (!confirm('¿Eliminar esta publicación permanentemente?')) return;
|
||||||
@ -143,11 +130,7 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
|
|||||||
});
|
});
|
||||||
}, [toast]);
|
}, [toast]);
|
||||||
|
|
||||||
const handleEditSuccess = useCallback(() => {
|
|
||||||
setDrawerPost(null);
|
|
||||||
toast.success('Publicación actualizada correctamente.');
|
|
||||||
router.refresh();
|
|
||||||
}, [toast, router]);
|
|
||||||
|
|
||||||
// ── RENDER ──────────────────────────────────
|
// ── RENDER ──────────────────────────────────
|
||||||
|
|
||||||
@ -277,19 +260,7 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* SideDrawer — lazy loaded */}
|
|
||||||
{DrawerComponent && (
|
|
||||||
<DrawerComponent
|
|
||||||
isOpen={!!drawerPost}
|
|
||||||
post={drawerPost}
|
|
||||||
user={user}
|
|
||||||
relationData={relationData}
|
|
||||||
submitAction={submitEditAction}
|
|
||||||
appwriteConfig={appwriteConfig}
|
|
||||||
onClose={handleCloseDrawer}
|
|
||||||
onSuccess={handleEditSuccess}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user