diff --git a/.gitignore b/.gitignore
index a31ee31..10fc9f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,3 +50,7 @@ public/news/
public/parts/
public/operations-inbox/
public/footage/
+
+# Local Claude Code / MCP config — agent-specific, not project
+.mcp.json
+.claude/
diff --git a/next.config.ts b/next.config.ts
index 10d5b70..03bf14d 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -4,14 +4,20 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
const nextConfig = {
output: "standalone" as const,
- images: {
+ images: {
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,
serverExternalPackages: ['nodemailer'],
experimental: {
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,
},
},
};
diff --git a/nginx/conf.d/flux.conf b/nginx/conf.d/flux.conf
index 7a352ab..83bf6c7 100644
--- a/nginx/conf.d/flux.conf
+++ b/nginx/conf.d/flux.conf
@@ -36,6 +36,7 @@ server {
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
+ # Next.js bundles use content hashing — safe to cache forever
location /_next/static/ {
proxy_pass http://nextjs;
expires 365d;
@@ -43,6 +44,17 @@ server {
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 {
limit_req zone=login burst=10 nodelay;
proxy_pass http://nextjs;
@@ -104,46 +116,50 @@ server {
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/ {
alias /srv/cases/;
- expires 30d;
- add_header Cache-Control "public";
+ add_header Cache-Control "public, max-age=300, must-revalidate" always;
access_log off;
}
location /applications/ {
alias /srv/applications/;
- expires 30d;
- add_header Cache-Control "public";
+ add_header Cache-Control "public, max-age=300, must-revalidate" always;
access_log off;
}
location /news/ {
alias /srv/news/;
- expires 30d;
- add_header Cache-Control "public";
+ add_header Cache-Control "public, max-age=300, must-revalidate" always;
access_log off;
}
location /parts/ {
alias /srv/parts/;
- expires 30d;
- add_header Cache-Control "public";
+ add_header Cache-Control "public, max-age=300, must-revalidate" always;
access_log off;
}
location /operations-inbox/ {
alias /srv/operations-inbox/;
- expires 7d;
+ add_header Cache-Control "private, max-age=60, must-revalidate" always;
access_log off;
}
location /footage/ {
alias /srv/footage/;
- expires 30d;
- add_header Cache-Control "public";
+ add_header Cache-Control "public, max-age=300, must-revalidate" always;
access_log off;
}
diff --git a/src/app/[locale]/applications/[slug]/page.tsx b/src/app/[locale]/applications/[slug]/page.tsx
index 9b80035..c3cbf36 100644
--- a/src/app/[locale]/applications/[slug]/page.tsx
+++ b/src/app/[locale]/applications/[slug]/page.tsx
@@ -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 fs from "fs";
@@ -46,8 +47,6 @@ export async function generateStaticParams() {
}
}
-export const revalidate = 60;
-
// 🔥 AHORA RECIBIMOS EL LOCALE DESDE LA URL
export default async function ApplicationPage({ params }: { params: Promise<{ slug: string, locale: string }> }) {
const resolvedParams = await params;
diff --git a/src/app/[locale]/heritage/page.tsx b/src/app/[locale]/heritage/page.tsx
index 6d35fd1..b79b262 100644
--- a/src/app/[locale]/heritage/page.tsx
+++ b/src/app/[locale]/heritage/page.tsx
@@ -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 Image from "next/image";
@@ -28,21 +29,21 @@ const renderMarkdown = (text: string) => {
if (inTable) {
elements.push(
-
+
-
+
{tableHeaders.map((th, i) => (
- |
+ |
{parseInline(th)}
|
))}
-
+
{tableRows.map((row, rIdx) => (
-
+
{row.map((cell, cIdx) => (
- |
+ |
{parseInline(cell)}
|
))}
@@ -62,11 +63,11 @@ const renderMarkdown = (text: string) => {
if (listItems.length > 0) {
elements.push(
isOrderedList ? (
-
+
{listItems}
) : (
-
+
)
@@ -81,11 +82,11 @@ const renderMarkdown = (text: string) => {
let parts = str.split(boldRegex);
return parts.map((part, i) => {
- if (i % 2 === 1) return {part};
-
+ if (i % 2 === 1) return {part};
+
let subParts = part.split(italicRegex);
return subParts.map((subPart, j) => {
- if (j % 2 === 1) return {subPart};
+ if (j % 2 === 1) return {subPart};
return subPart;
});
});
@@ -119,7 +120,7 @@ const renderMarkdown = (text: string) => {
if (imgMatch) {
pushList(); pushTable();
elements.push(
-
+
);
@@ -127,19 +128,19 @@ const renderMarkdown = (text: string) => {
}
const h3Match = trimmed.match(/^###\s*(.*)/);
- if (h3Match) { pushList(); pushTable(); elements.push(
{parseInline(h3Match[1])}
); return; }
+ if (h3Match) { pushList(); pushTable(); elements.push(
{parseInline(h3Match[1])}
); return; }
const h2Match = trimmed.match(/^##\s*(.*)/);
- if (h2Match) { pushList(); pushTable(); elements.push(
{parseInline(h2Match[1])}
); return; }
+ if (h2Match) { pushList(); pushTable(); elements.push(
{parseInline(h2Match[1])}
); return; }
const h1Match = trimmed.match(/^#\s*(.*)/);
- if (h1Match) { pushList(); pushTable(); elements.push(
{parseInline(h1Match[1])}
); return; }
+ if (h1Match) { pushList(); pushTable(); elements.push(
{parseInline(h1Match[1])}
); return; }
const quoteMatch = trimmed.match(/^>\s*(.*)/);
if (quoteMatch) {
pushList(); pushTable();
elements.push(
-
+
{parseInline(quoteMatch[1])}
);
@@ -162,7 +163,7 @@ const renderMarkdown = (text: string) => {
pushList();
elements.push(
-
+
{parseInline(trimmed)}
);
@@ -195,12 +196,12 @@ export default async function HeritagePage({ params }: { params: Promise<{ local
const sections = rawSections.map(sec => getLocalizedData(sec, locale));
return (
-
+
{/* NAVEGACIÓN FLOTANTE */}
-
+
{t("backToOverview")}
@@ -209,10 +210,10 @@ export default async function HeritagePage({ params }: { params: Promise<{ local
-
+
{t("subtitle")}
-
+
{t("title1")}
{t("title2")}
@@ -225,27 +226,24 @@ export default async function HeritagePage({ params }: { params: Promise<{ local
) : (
sections.map((sec) => (
-
+
{/* El título ya viene traducido */}
- {sec.title &&
{sec.title}
}
-
- {/* 🔥 BLOQUE DE TEXTO CON SÚPER MARKDOWN 🔥 */}
+ {sec.title &&
{sec.title}
}
+
{sec.type === 'text' && (
{renderMarkdown(sec.content || "")}
)}
- {/* 🔥 BLOQUE DE IMAGEN GIGANTE 🔥 */}
{sec.type === 'image' && sec.mediaUrl && (
-
+
)}
- {/* 🔥 BLOQUE DE VIDEO NATIVO (MP4 LOCAL) — AUTOPLAY ON SCROLL 🔥 */}
{sec.type === 'video' && sec.mediaUrl && (
-
+
}) {
const resolvedParams = await params;
diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx
index d7bd18a..cbc17cc 100644
--- a/src/app/[locale]/page.tsx
+++ b/src/app/[locale]/page.tsx
@@ -15,7 +15,9 @@ import ApplicationsDeep from "@/components/sections/ApplicationsDeep";
import HeroReel from "@/components/sections/HeroReel";
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
export default async function Home({ params }: { params: Promise<{ locale: string }> }) {
diff --git a/src/app/[locale]/parts/page.tsx b/src/app/[locale]/parts/page.tsx
index f7c5cc3..ce2f3fa 100644
--- a/src/app/[locale]/parts/page.tsx
+++ b/src/app/[locale]/parts/page.tsx
@@ -1,14 +1,13 @@
-export const dynamic = "force-dynamic";
-
import { prisma } from "@/lib/prisma";
import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations } from "next-intl/server";
import { Suspense } from "react";
-import ComponentGrid from "./_components/ComponentGrid";
+import ComponentGrid from "./_components/ComponentGrid";
import { Metadata } from "next";
import { getClientSession } from "@/app/actions/clientAuth";
-export const revalidate = 0;
+// B2B portal — auth-gated, never cached.
+export const revalidate = 0;
export const metadata: Metadata = {
title: "Component Matrix | FLUX",
diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts
index 4e8fc3e..21c9451 100644
--- a/src/app/api/assets/route.ts
+++ b/src/app/api/assets/route.ts
@@ -30,6 +30,7 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
+import { revalidateContent, type RevalidateScope } from "@/lib/revalidate";
const SCOPE_ROOTS: Record = {
applications: path.join(process.cwd(), "public", "applications"),
@@ -193,6 +194,9 @@ export async function POST(request: NextRequest) {
const rel = subPath ? `${subPath}/${safeName}` : safeName;
+ // 🔥 Invalida caché para que la imagen aparezca sin recompilar
+ revalidateContent({ scope: scope as RevalidateScope, slug });
+
return NextResponse.json({
success: true,
file: {
@@ -229,6 +233,8 @@ export async function PUT(request: NextRequest) {
fs.mkdirSync(targetPath, { recursive: true });
+ revalidateContent({ scope: scope as RevalidateScope, slug });
+
return NextResponse.json({
success: true,
folder: { name: safe, path: parentPath ? `${parentPath}/${safe}` : safe }
@@ -256,6 +262,8 @@ export async function DELETE(request: NextRequest) {
fs.unlinkSync(targetPath);
+ revalidateContent({ scope: scope as RevalidateScope, slug });
+
return NextResponse.json({ success: true, deleted: filePath });
} catch (error) {
console.error("Asset DELETE error:", error);
diff --git a/src/app/api/public-upload/route.ts b/src/app/api/public-upload/route.ts
index bc70dbd..f9e4214 100644
--- a/src/app/api/public-upload/route.ts
+++ b/src/app/api/public-upload/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
+import { revalidateContent } from "@/lib/revalidate";
// 1. REGLAS DE SEGURIDAD ESTRICTAS
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
const publicUrl = `/operations-inbox/${folderName}/${safeFileName}`;
- return NextResponse.json({
- success: true,
- url: publicUrl,
+ // Invalida caché del operations-inbox / dashboard
+ revalidateContent({ scope: "operations-inbox", slug: folderName });
+
+ return NextResponse.json({
+ success: true,
+ url: publicUrl,
fileName: safeFileName,
type: ext === '.mp4' || ext === '.mov' ? 'video' : 'image'
});
diff --git a/src/lib/revalidate.ts b/src/lib/revalidate.ts
new file mode 100644
index 0000000..b43e67c
--- /dev/null
+++ b/src/lib/revalidate.ts
@@ -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 = {
+ 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 });
+}