fix: instant CMS uploads + heritage dark/light + ISR caching

Eliminates the need to run "docker compose build" after uploading
images via HQ Command. Heritage page now respects light/dark mode.

CACHE INVALIDATION
- New helper src/lib/revalidate.ts called from /api/assets and
  /api/public-upload after every upload, delete, folder create
- Pages switch from force-dynamic to ISR with revalidate=60
  (regenerated on demand whenever content changes, plus 60s safety)
- Nginx now sends "max-age=300, must-revalidate" instead of "expires 30d"
  on /cases/, /applications/, /news/, /parts/, /footage/, /operations-inbox/
  so browsers revalidate via If-Modified-Since (304s on unchanged files)
- Next.js Image Optimizer aligned with same TTL via minimumCacheTTL=300
  and adds /_next/image location block in Nginx for correct headers

HERITAGE DARK/LIGHT FIX (Bug #8)
- Replaces hardcoded #0A0A0C / #00F0FF / text-white with proper
  light + dark variants throughout markdown renderer (tables, lists,
  headings, blockquotes, paragraphs, images)
- Hero section, navigation pill, and CMS-driven sections now switch
  with the global theme toggle

SECURITY HARDENING
- Server actions bodySizeLimit reduced from 500MB to 50MB
  (large uploads still go through /api/assets which uses Nginx 500MB cap)

DEPLOY NOTES
- Run on VPS:
    git pull
    docker compose up -d --build app
    docker compose exec nginx nginx -s reload
- No DB schema changes in this commit. Existing 2FA users / data untouched.
This commit is contained in:
2026-05-04 09:27:46 -05:00
parent 226b721721
commit 6e46808c27
12 changed files with 198 additions and 60 deletions
+4
View File
@@ -50,3 +50,7 @@ public/news/
public/parts/ public/parts/
public/operations-inbox/ public/operations-inbox/
public/footage/ public/footage/
# Local Claude Code / MCP config — agent-specific, not project
.mcp.json
.claude/
+8 -2
View File
@@ -4,14 +4,20 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
const nextConfig = { const nextConfig = {
output: "standalone" as const, output: "standalone" as const,
images: { images: {
qualities: [75, 90, 100], qualities: [75, 90, 100],
// Image Optimizer cache TTL — keeps optimized variants for 5 min,
// matching Nginx max-age. Picks up replaced source files quickly.
minimumCacheTTL: 300,
formats: ['image/avif', 'image/webp'] as ('image/avif' | 'image/webp')[],
}, },
reactStrictMode: true, reactStrictMode: true,
serverExternalPackages: ['nodemailer'], serverExternalPackages: ['nodemailer'],
experimental: { experimental: {
serverActions: { serverActions: {
bodySizeLimit: '500mb' as const, // 50MB cap — large enough for hero images and CMS uploads,
// small enough to limit DoS surface. Use /api/assets for big files.
bodySizeLimit: '50mb' as const,
}, },
}, },
}; };
+28 -12
View File
@@ -36,6 +36,7 @@ server {
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Next.js bundles use content hashing — safe to cache forever
location /_next/static/ { location /_next/static/ {
proxy_pass http://nextjs; proxy_pass http://nextjs;
expires 365d; expires 365d;
@@ -43,6 +44,17 @@ server {
access_log off; access_log off;
} }
# Next.js image optimizer — short cache, browser revalidates
location /_next/image {
proxy_pass http://nextjs;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
add_header Cache-Control "public, max-age=300, must-revalidate" always;
}
location /hq-command/login { location /hq-command/login {
limit_req zone=login burst=10 nodelay; limit_req zone=login burst=10 nodelay;
proxy_pass http://nextjs; proxy_pass http://nextjs;
@@ -104,46 +116,50 @@ server {
access_log off; access_log off;
} }
# Serve uploaded assets directly from disk (bypass Next.js) # ─────────────────────────────────────────────────────────────────
# User-uploaded assets — served directly from disk (bypass Next.js)
#
# Cache strategy: short max-age + must-revalidate.
# Browser caches for 5 minutes, then asks Nginx "did this change?"
# via If-Modified-Since. Nginx auto-replies 304 (Not Modified) if the
# file's mtime is unchanged, or serves the new file if it changed.
# This means new CMS uploads appear within ~5 min without rebuild
# AND saved bandwidth on unchanged files.
# ─────────────────────────────────────────────────────────────────
location /cases/ { location /cases/ {
alias /srv/cases/; alias /srv/cases/;
expires 30d; add_header Cache-Control "public, max-age=300, must-revalidate" always;
add_header Cache-Control "public";
access_log off; access_log off;
} }
location /applications/ { location /applications/ {
alias /srv/applications/; alias /srv/applications/;
expires 30d; add_header Cache-Control "public, max-age=300, must-revalidate" always;
add_header Cache-Control "public";
access_log off; access_log off;
} }
location /news/ { location /news/ {
alias /srv/news/; alias /srv/news/;
expires 30d; add_header Cache-Control "public, max-age=300, must-revalidate" always;
add_header Cache-Control "public";
access_log off; access_log off;
} }
location /parts/ { location /parts/ {
alias /srv/parts/; alias /srv/parts/;
expires 30d; add_header Cache-Control "public, max-age=300, must-revalidate" always;
add_header Cache-Control "public";
access_log off; access_log off;
} }
location /operations-inbox/ { location /operations-inbox/ {
alias /srv/operations-inbox/; alias /srv/operations-inbox/;
expires 7d; add_header Cache-Control "private, max-age=60, must-revalidate" always;
access_log off; access_log off;
} }
location /footage/ { location /footage/ {
alias /srv/footage/; alias /srv/footage/;
expires 30d; add_header Cache-Control "public, max-age=300, must-revalidate" always;
add_header Cache-Control "public";
access_log off; access_log off;
} }
@@ -1,4 +1,5 @@
export const dynamic = "force-dynamic"; // ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
export const revalidate = 60;
import Link from "next/link"; import Link from "next/link";
import fs from "fs"; import fs from "fs";
@@ -46,8 +47,6 @@ export async function generateStaticParams() {
} }
} }
export const revalidate = 60;
// 🔥 AHORA RECIBIMOS EL LOCALE DESDE LA URL // 🔥 AHORA RECIBIMOS EL LOCALE DESDE LA URL
export default async function ApplicationPage({ params }: { params: Promise<{ slug: string, locale: string }> }) { export default async function ApplicationPage({ params }: { params: Promise<{ slug: string, locale: string }> }) {
const resolvedParams = await params; const resolvedParams = await params;
+28 -30
View File
@@ -1,4 +1,5 @@
export const dynamic = "force-dynamic"; // ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
export const revalidate = 60;
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
@@ -28,21 +29,21 @@ const renderMarkdown = (text: string) => {
if (inTable) { if (inTable) {
elements.push( elements.push(
<div key={`table-${elements.length}`} className="my-12 w-full overflow-x-auto pb-4 [scrollbar-width:none]"> <div key={`table-${elements.length}`} className="my-12 w-full overflow-x-auto pb-4 [scrollbar-width:none]">
<table className="w-full text-left border-collapse min-w-[600px] shadow-2xl rounded-2xl overflow-hidden border border-white/5"> <table className="w-full text-left border-collapse min-w-[600px] shadow-2xl rounded-2xl overflow-hidden border border-black/5 dark:border-white/5">
<thead> <thead>
<tr className="bg-[#111]"> <tr className="bg-[#F5F5F7] dark:bg-[#111]">
{tableHeaders.map((th, i) => ( {tableHeaders.map((th, i) => (
<th key={i} className={`p-5 border-b border-white/10 text-xs uppercase tracking-widest font-semibold ${i === tableHeaders.length - 1 ? 'text-[#00F0FF] bg-[#00F0FF]/5' : 'text-white'}`}> <th key={i} className={`p-5 border-b border-black/10 dark:border-white/10 text-xs uppercase tracking-widest font-semibold ${i === tableHeaders.length - 1 ? 'text-[#0066CC] dark:text-[#00F0FF] bg-[#0066CC]/5 dark:bg-[#00F0FF]/5' : 'text-[#1D1D1F] dark:text-white'}`}>
{parseInline(th)} {parseInline(th)}
</th> </th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody className="bg-black/40 backdrop-blur-md"> <tbody className="bg-white/40 dark:bg-black/40 backdrop-blur-md">
{tableRows.map((row, rIdx) => ( {tableRows.map((row, rIdx) => (
<tr key={rIdx} className="hover:bg-white/[0.02] transition-colors group"> <tr key={rIdx} className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors group">
{row.map((cell, cIdx) => ( {row.map((cell, cIdx) => (
<td key={cIdx} className={`p-5 border-b border-white/5 text-sm ${cIdx === 0 ? 'text-[#A1A1A6] font-medium' : cIdx === row.length - 1 ? 'text-white font-semibold bg-[#00F0FF]/5 group-hover:bg-[#00F0FF]/10 transition-colors' : 'text-white/80'}`}> <td key={cIdx} className={`p-5 border-b border-black/5 dark:border-white/5 text-sm ${cIdx === 0 ? 'text-[#6E6E73] dark:text-[#A1A1A6] font-medium' : cIdx === row.length - 1 ? 'text-[#1D1D1F] dark:text-white font-semibold bg-[#0066CC]/5 dark:bg-[#00F0FF]/5 group-hover:bg-[#0066CC]/10 dark:group-hover:bg-[#00F0FF]/10 transition-colors' : 'text-[#1D1D1F]/80 dark:text-white/80'}`}>
{parseInline(cell)} {parseInline(cell)}
</td> </td>
))} ))}
@@ -62,11 +63,11 @@ const renderMarkdown = (text: string) => {
if (listItems.length > 0) { if (listItems.length > 0) {
elements.push( elements.push(
isOrderedList ? ( isOrderedList ? (
<ol key={`ol-${elements.length}`} className="list-decimal ml-6 mb-8 text-[#A1A1A6] space-y-2 text-lg font-light marker:text-[#00F0FF]"> <ol key={`ol-${elements.length}`} className="list-decimal ml-6 mb-8 text-[#6E6E73] dark:text-[#A1A1A6] space-y-2 text-lg font-light marker:text-[#0066CC] dark:marker:text-[#00F0FF]">
{listItems} {listItems}
</ol> </ol>
) : ( ) : (
<ul key={`ul-${elements.length}`} className="list-disc ml-6 mb-8 text-[#A1A1A6] space-y-2 text-lg font-light marker:text-[#00F0FF]"> <ul key={`ul-${elements.length}`} className="list-disc ml-6 mb-8 text-[#6E6E73] dark:text-[#A1A1A6] space-y-2 text-lg font-light marker:text-[#0066CC] dark:marker:text-[#00F0FF]">
{listItems} {listItems}
</ul> </ul>
) )
@@ -81,11 +82,11 @@ const renderMarkdown = (text: string) => {
let parts = str.split(boldRegex); let parts = str.split(boldRegex);
return parts.map((part, i) => { return parts.map((part, i) => {
if (i % 2 === 1) return <strong key={i} className="font-semibold text-white">{part}</strong>; if (i % 2 === 1) return <strong key={i} className="font-semibold text-[#1D1D1F] dark:text-white">{part}</strong>;
let subParts = part.split(italicRegex); let subParts = part.split(italicRegex);
return subParts.map((subPart, j) => { return subParts.map((subPart, j) => {
if (j % 2 === 1) return <em key={`${i}-${j}`} className="italic text-white/90">{subPart}</em>; if (j % 2 === 1) return <em key={`${i}-${j}`} className="italic text-[#1D1D1F]/90 dark:text-white/90">{subPart}</em>;
return subPart; return subPart;
}); });
}); });
@@ -119,7 +120,7 @@ const renderMarkdown = (text: string) => {
if (imgMatch) { if (imgMatch) {
pushList(); pushTable(); pushList(); pushTable();
elements.push( elements.push(
<div key={`img-${idx}`} className="relative w-full my-12 rounded-[2rem] overflow-hidden border border-white/10 shadow-2xl bg-[#111]"> <div key={`img-${idx}`} className="relative w-full my-12 rounded-[2rem] overflow-hidden border border-black/10 dark:border-white/10 shadow-2xl bg-[#F5F5F7] dark:bg-[#111]">
<img src={imgMatch[2]} alt={imgMatch[1]} className="w-full h-auto object-cover hover:scale-105 transition-transform duration-1000 grayscale hover:grayscale-0" loading="lazy" /> <img src={imgMatch[2]} alt={imgMatch[1]} className="w-full h-auto object-cover hover:scale-105 transition-transform duration-1000 grayscale hover:grayscale-0" loading="lazy" />
</div> </div>
); );
@@ -127,19 +128,19 @@ const renderMarkdown = (text: string) => {
} }
const h3Match = trimmed.match(/^###\s*(.*)/); const h3Match = trimmed.match(/^###\s*(.*)/);
if (h3Match) { pushList(); pushTable(); elements.push(<h3 key={idx} className="text-2xl mt-8 mb-4 font-medium text-[#00F0FF]">{parseInline(h3Match[1])}</h3>); return; } if (h3Match) { pushList(); pushTable(); elements.push(<h3 key={idx} className="text-2xl mt-8 mb-4 font-medium text-[#0066CC] dark:text-[#00F0FF]">{parseInline(h3Match[1])}</h3>); return; }
const h2Match = trimmed.match(/^##\s*(.*)/); const h2Match = trimmed.match(/^##\s*(.*)/);
if (h2Match) { pushList(); pushTable(); elements.push(<h2 key={idx} className="text-3xl mt-10 mb-5 font-light text-white tracking-tight">{parseInline(h2Match[1])}</h2>); return; } if (h2Match) { pushList(); pushTable(); elements.push(<h2 key={idx} className="text-3xl mt-10 mb-5 font-light text-[#1D1D1F] dark:text-white tracking-tight">{parseInline(h2Match[1])}</h2>); return; }
const h1Match = trimmed.match(/^#\s*(.*)/); const h1Match = trimmed.match(/^#\s*(.*)/);
if (h1Match) { pushList(); pushTable(); elements.push(<h1 key={idx} className="text-4xl md:text-5xl mt-12 mb-6 font-light text-white tracking-tight">{parseInline(h1Match[1])}</h1>); return; } if (h1Match) { pushList(); pushTable(); elements.push(<h1 key={idx} className="text-4xl md:text-5xl mt-12 mb-6 font-light text-[#1D1D1F] dark:text-white tracking-tight">{parseInline(h1Match[1])}</h1>); return; }
const quoteMatch = trimmed.match(/^>\s*(.*)/); const quoteMatch = trimmed.match(/^>\s*(.*)/);
if (quoteMatch) { if (quoteMatch) {
pushList(); pushTable(); pushList(); pushTable();
elements.push( elements.push(
<blockquote key={idx} className="border-l-4 border-[#00F0FF] pl-5 py-2 my-8 text-xl font-light italic text-[#A1A1A6] bg-[#00F0FF]/5 rounded-r-xl"> <blockquote key={idx} className="border-l-4 border-[#0066CC] dark:border-[#00F0FF] pl-5 py-2 my-8 text-xl font-light italic text-[#6E6E73] dark:text-[#A1A1A6] bg-[#0066CC]/5 dark:bg-[#00F0FF]/5 rounded-r-xl">
{parseInline(quoteMatch[1])} {parseInline(quoteMatch[1])}
</blockquote> </blockquote>
); );
@@ -162,7 +163,7 @@ const renderMarkdown = (text: string) => {
pushList(); pushList();
elements.push( elements.push(
<p key={idx} className="text-[#A1A1A6] font-light leading-relaxed mb-6 text-lg"> <p key={idx} className="text-[#6E6E73] dark:text-[#A1A1A6] font-light leading-relaxed mb-6 text-lg">
{parseInline(trimmed)} {parseInline(trimmed)}
</p> </p>
); );
@@ -195,12 +196,12 @@ export default async function HeritagePage({ params }: { params: Promise<{ local
const sections = rawSections.map(sec => getLocalizedData(sec, locale)); const sections = rawSections.map(sec => getLocalizedData(sec, locale));
return ( return (
<main className="relative min-h-screen pb-24 bg-[#0A0A0C]"> <main className="relative min-h-screen pb-24 bg-[#F5F5F7] dark:bg-[#0A0A0C]">
<BreathingField /> <BreathingField />
{/* NAVEGACIÓN FLOTANTE */} {/* NAVEGACIÓN FLOTANTE */}
<div className="fixed top-24 left-6 z-50"> <div className="fixed top-24 left-6 z-50">
<Link href={`/${locale}/#our-story`} className="inline-flex items-center gap-2 text-sm font-medium text-[#86868B] hover:text-white transition-colors py-2 px-4 bg-white/5 backdrop-blur-md rounded-full border border-white/10"> <Link href={`/${locale}/#our-story`} className="inline-flex items-center gap-2 text-sm font-medium text-[#6E6E73] dark:text-[#86868B] hover:text-[#1D1D1F] dark:hover:text-white transition-colors py-2 px-4 bg-white/60 dark:bg-white/5 backdrop-blur-md rounded-full border border-black/10 dark:border-white/10">
<ArrowLeft size={16} /> {t("backToOverview")} <ArrowLeft size={16} /> {t("backToOverview")}
</Link> </Link>
</div> </div>
@@ -209,10 +210,10 @@ export default async function HeritagePage({ params }: { params: Promise<{ local
<section className="relative w-full h-[70vh] flex items-center justify-center overflow-hidden"> <section className="relative w-full h-[70vh] flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(0,102,204,0.15)_0%,transparent_70%)]" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(0,102,204,0.15)_0%,transparent_70%)]" />
<div className="relative z-10 max-w-4xl mx-auto px-6 text-center"> <div className="relative z-10 max-w-4xl mx-auto px-6 text-center">
<span className="text-[#00F0FF] uppercase tracking-widest text-sm font-semibold mb-4 block"> <span className="text-[#0066CC] dark:text-[#00F0FF] uppercase tracking-widest text-sm font-semibold mb-4 block">
{t("subtitle")} {t("subtitle")}
</span> </span>
<h1 className="text-5xl md:text-7xl font-light text-white tracking-tight leading-tight"> <h1 className="text-5xl md:text-7xl font-light text-[#1D1D1F] dark:text-white tracking-tight leading-tight">
{t("title1")} <br/> <span className="text-[#86868B]">{t("title2")}</span> {t("title1")} <br/> <span className="text-[#86868B]">{t("title2")}</span>
</h1> </h1>
</div> </div>
@@ -225,27 +226,24 @@ export default async function HeritagePage({ params }: { params: Promise<{ local
) : ( ) : (
sections.map((sec) => ( sections.map((sec) => (
<div key={sec.id} className="animate-in fade-in slide-in-from-bottom-8 duration-1000"> <div key={sec.id} className="animate-in fade-in slide-in-from-bottom-8 duration-1000">
{/* El título ya viene traducido */} {/* El título ya viene traducido */}
{sec.title && <h2 className="text-3xl font-medium text-white mb-6">{sec.title}</h2>} {sec.title && <h2 className="text-3xl font-medium text-[#1D1D1F] dark:text-white mb-6">{sec.title}</h2>}
{/* 🔥 BLOQUE DE TEXTO CON SÚPER MARKDOWN 🔥 */}
{sec.type === 'text' && ( {sec.type === 'text' && (
<div className="max-w-none"> <div className="max-w-none">
{renderMarkdown(sec.content || "")} {renderMarkdown(sec.content || "")}
</div> </div>
)} )}
{/* 🔥 BLOQUE DE IMAGEN GIGANTE 🔥 */}
{sec.type === 'image' && sec.mediaUrl && ( {sec.type === 'image' && sec.mediaUrl && (
<div className="relative w-full h-80 md:h-[500px] rounded-3xl overflow-hidden border border-white/10 shadow-2xl"> <div className="relative w-full h-80 md:h-[500px] rounded-3xl overflow-hidden border border-black/10 dark:border-white/10 shadow-2xl">
<Image src={`/heritage/${sec.mediaUrl}`} alt={sec.title || "Heritage Image"} fill className="object-cover grayscale hover:grayscale-0 transition-all duration-1000" /> <Image src={`/heritage/${sec.mediaUrl}`} alt={sec.title || "Heritage Image"} fill className="object-cover grayscale hover:grayscale-0 transition-all duration-1000" />
</div> </div>
)} )}
{/* 🔥 BLOQUE DE VIDEO NATIVO (MP4 LOCAL) — AUTOPLAY ON SCROLL 🔥 */}
{sec.type === 'video' && sec.mediaUrl && ( {sec.type === 'video' && sec.mediaUrl && (
<div className="relative w-full aspect-video rounded-3xl overflow-hidden border border-white/10 shadow-2xl bg-black"> <div className="relative w-full aspect-video rounded-3xl overflow-hidden border border-black/10 dark:border-white/10 shadow-2xl bg-black">
<AutoPlayVideo <AutoPlayVideo
src={`/heritage/videos/${sec.mediaUrl}`} src={`/heritage/videos/${sec.mediaUrl}`}
className="absolute inset-0 w-full h-full object-cover" className="absolute inset-0 w-full h-full object-cover"
+2 -1
View File
@@ -1,4 +1,5 @@
export const dynamic = "force-dynamic"; // ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
export const revalidate = 60;
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
+3 -4
View File
@@ -1,5 +1,3 @@
export const dynamic = "force-dynamic";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -7,9 +5,10 @@ import { Newspaper, ArrowRight, Calendar } from "lucide-react";
import BreathingField from "@/components/visuals/BreathingField"; import BreathingField from "@/components/visuals/BreathingField";
import { getLocalizedData } from "@/lib/i18nHelper"; import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations } from "next-intl/server"; // 🔥 IMPORTAMOS LA VERSIÓN DE SERVIDOR import { getTranslations } from "next-intl/server";
export const revalidate = 60; // ISR: revalidates every 60s + on-demand via revalidatePath after CMS uploads.
export const revalidate = 60;
export default async function NewsHub({ params }: { params: Promise<{ locale: string }> }) { export default async function NewsHub({ params }: { params: Promise<{ locale: string }> }) {
const resolvedParams = await params; const resolvedParams = await params;
+3 -1
View File
@@ -15,7 +15,9 @@ import ApplicationsDeep from "@/components/sections/ApplicationsDeep";
import HeroReel from "@/components/sections/HeroReel"; import HeroReel from "@/components/sections/HeroReel";
import WhatWeDo from "@/components/sections/WhatWeDo"; import WhatWeDo from "@/components/sections/WhatWeDo";
export const dynamic = "force-dynamic"; // ISR: page is statically generated, but revalidates on demand via
// revalidatePath() after CMS uploads, plus a 60s safety window.
export const revalidate = 60;
// ✅ Next.js 16: params es Promise y DEBE ser awaiteado // ✅ Next.js 16: params es Promise y DEBE ser awaiteado
export default async function Home({ params }: { params: Promise<{ locale: string }> }) { export default async function Home({ params }: { params: Promise<{ locale: string }> }) {
+3 -4
View File
@@ -1,14 +1,13 @@
export const dynamic = "force-dynamic";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getLocalizedData } from "@/lib/i18nHelper"; import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { Suspense } from "react"; import { Suspense } from "react";
import ComponentGrid from "./_components/ComponentGrid"; import ComponentGrid from "./_components/ComponentGrid";
import { Metadata } from "next"; import { Metadata } from "next";
import { getClientSession } from "@/app/actions/clientAuth"; import { getClientSession } from "@/app/actions/clientAuth";
export const revalidate = 0; // B2B portal — auth-gated, never cached.
export const revalidate = 0;
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Component Matrix | FLUX", title: "Component Matrix | FLUX",
+8
View File
@@ -30,6 +30,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { revalidateContent, type RevalidateScope } from "@/lib/revalidate";
const SCOPE_ROOTS: Record<string, string> = { const SCOPE_ROOTS: Record<string, string> = {
applications: path.join(process.cwd(), "public", "applications"), applications: path.join(process.cwd(), "public", "applications"),
@@ -193,6 +194,9 @@ export async function POST(request: NextRequest) {
const rel = subPath ? `${subPath}/${safeName}` : safeName; const rel = subPath ? `${subPath}/${safeName}` : safeName;
// 🔥 Invalida caché para que la imagen aparezca sin recompilar
revalidateContent({ scope: scope as RevalidateScope, slug });
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
file: { file: {
@@ -229,6 +233,8 @@ export async function PUT(request: NextRequest) {
fs.mkdirSync(targetPath, { recursive: true }); fs.mkdirSync(targetPath, { recursive: true });
revalidateContent({ scope: scope as RevalidateScope, slug });
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
folder: { name: safe, path: parentPath ? `${parentPath}/${safe}` : safe } folder: { name: safe, path: parentPath ? `${parentPath}/${safe}` : safe }
@@ -256,6 +262,8 @@ export async function DELETE(request: NextRequest) {
fs.unlinkSync(targetPath); fs.unlinkSync(targetPath);
revalidateContent({ scope: scope as RevalidateScope, slug });
return NextResponse.json({ success: true, deleted: filePath }); return NextResponse.json({ success: true, deleted: filePath });
} catch (error) { } catch (error) {
console.error("Asset DELETE error:", error); console.error("Asset DELETE error:", error);
+7 -3
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { revalidateContent } from "@/lib/revalidate";
// 1. REGLAS DE SEGURIDAD ESTRICTAS // 1. REGLAS DE SEGURIDAD ESTRICTAS
const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.mp4', '.mov']; const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.mp4', '.mov'];
@@ -56,9 +57,12 @@ export async function POST(request: NextRequest) {
// 6. DEVOLVER LA URL PÚBLICA PARA GUARDARLA EN POSTGRES // 6. DEVOLVER LA URL PÚBLICA PARA GUARDARLA EN POSTGRES
const publicUrl = `/operations-inbox/${folderName}/${safeFileName}`; const publicUrl = `/operations-inbox/${folderName}/${safeFileName}`;
return NextResponse.json({ // Invalida caché del operations-inbox / dashboard
success: true, revalidateContent({ scope: "operations-inbox", slug: folderName });
url: publicUrl,
return NextResponse.json({
success: true,
url: publicUrl,
fileName: safeFileName, fileName: safeFileName,
type: ext === '.mp4' || ext === '.mov' ? 'video' : 'image' type: ext === '.mp4' || ext === '.mov' ? 'video' : 'image'
}); });
+102
View File
@@ -0,0 +1,102 @@
// src/lib/revalidate.ts
// ─────────────────────────────────────────────────────────────────────────────
// Cache invalidation helper.
// Called after every CMS mutation (upload, edit, delete) so newly uploaded
// images/text appear without rebuilding Docker.
// ─────────────────────────────────────────────────────────────────────────────
import { revalidatePath } from "next/cache";
const LOCALES = ["en", "it", "vec", "es", "de"] as const;
export type RevalidateScope =
| "applications"
| "cases"
| "news"
| "parts"
| "heritage"
| "operations-inbox"
| "footage"
| "hero"
| "timeline"
| "all";
export interface RevalidateOptions {
scope: RevalidateScope;
slug?: string;
}
function safeRevalidate(path: string, type: "page" | "layout" = "page") {
try {
revalidatePath(path, type);
} catch (err) {
console.warn(`[revalidate] Failed to revalidate path "${path}":`, err);
}
}
export function revalidateContent({ scope, slug }: RevalidateOptions) {
safeRevalidate("/", "layout");
for (const locale of LOCALES) {
safeRevalidate(`/${locale}`);
switch (scope) {
case "applications":
safeRevalidate(`/${locale}/applications`);
if (slug) safeRevalidate(`/${locale}/applications/${slug}`);
break;
case "news":
safeRevalidate(`/${locale}/news`);
if (slug) safeRevalidate(`/${locale}/news/${slug}`);
break;
case "parts":
safeRevalidate(`/${locale}/parts`);
break;
case "heritage":
safeRevalidate(`/${locale}/heritage`);
break;
case "cases":
case "timeline":
case "hero":
case "footage":
safeRevalidate(`/${locale}`);
break;
case "operations-inbox":
break;
case "all":
safeRevalidate(`/${locale}/applications`);
safeRevalidate(`/${locale}/news`);
safeRevalidate(`/${locale}/parts`);
safeRevalidate(`/${locale}/heritage`);
if (slug) {
safeRevalidate(`/${locale}/applications/${slug}`);
safeRevalidate(`/${locale}/news/${slug}`);
}
break;
}
}
}
const SCOPE_FROM_PATH: Record<string, RevalidateScope> = {
applications: "applications",
cases: "cases",
news: "news",
parts: "parts",
heritage: "heritage",
"operations-inbox": "operations-inbox",
footage: "footage",
};
export function revalidateFromPublicPath(publicUrl: string) {
const segments = publicUrl.replace(/^\/+/, "").split("/");
const top = segments[0];
const slug = segments[1];
const scope = SCOPE_FROM_PATH[top];
if (!scope) {
safeRevalidate("/", "layout");
return;
}
revalidateContent({ scope, slug });
}