feat: hero carousel CMS + responsive mobile/iPad fix + flat-scope assets

Replaces the filesystem-scan hero (fs.readdirSync of /public/footage/main)
with a fully CMS-driven HeroSlide model. Editors can now drag-drop reorder,
toggle slides on/off, set focal points for proper mobile cropping, and
auto-translate per-slide captions.

NEW SCHEMA (additive — does not touch existing tables)
- HeroSlide: mediaUrl, mediaType, altText, order, isActive, focalPointX,
  focalPointY, translationsJson, timestamps
- SiteSetting: key-value JSON store for site-wide config (favicon, logo,
  footer, OG image) — wired up in next commit
- Migration 20260504120000_add_hero_slides_and_site_settings/migration.sql
  uses CREATE TABLE IF NOT EXISTS, additive only

HERO REEL REFACTOR (Bug #4 — responsive mobile/iPad)
- Switches from `images: string[]` to `slides: HeroSlideData[]` while
  keeping a backwards-compat path so legacy callers still work
- w-screen → w-full max-w-[100vw] (no horizontal scroll on iOS)
- h-[100vh] → h-[100svh] so iOS Safari URL bar doesn't push content
- Reduces title font sizes on small viewports (text-3xl → text-4xl
  → text-5xl → text-[5.5rem]) so the headline stays inside the canvas
- objectPosition driven by focal-point fields per slide
- Native <video> support for video slides

HQ COMMAND — /hq-command/dashboard/hero
- Drag-drop reorder, click-to-set-focal-point, inline alt-text editing
- Auto-save with "Saving…" / "Saved ✓" indicators
- Per-slide caption overrides (title, subtitle, descriptions)
- Optional one-click AI translation to IT, VEC, ES, DE
- Drop-zone uploader → /api/assets (scope=footage, flat folder)

API — /api/assets
- New flat scopes: "footage" (writes to /public/footage/main) and
  "branding" (writes to /public/branding) — slug-less for site-wide assets
- New buildPublicUrl helper centralises the URL convention
- Revalidate helper expanded with branding + settings scopes

HOME PAGE
- Reads hero slides from DB first; falls back to filesystem scan when
  HeroSlide table is empty (so production keeps working immediately
  after migration runs but before the editor populates rows)

DEPLOY NOTES
- After git pull on VPS, run the migration ONCE:
    docker compose exec app npx prisma migrate deploy
  Then:
    docker compose up -d --build app
  Existing data (AdminUser w/ 2FA, ClientUser, GlobalNode, Application,
  TimelineEvent, NewsArticle, HeritageSection, SparePart, OperationsSignal,
  NotificationRoute, PageContent) is NOT touched. Migration only creates
  two new tables.
This commit is contained in:
2026-05-04 09:34:49 -05:00
parent 6e46808c27
commit b9201a437c
9 changed files with 855 additions and 84 deletions
+38 -11
View File
@@ -38,8 +38,15 @@ const SCOPE_ROOTS: Record<string, string> = {
news: path.join(process.cwd(), "public", "news"),
// 🔥 NUEVO: Scope para el Component Matrix
parts: path.join(process.cwd(), "public", "parts"),
// 🔥 NUEVO: Hero carousel media (flat folder, slug ignored)
footage: path.join(process.cwd(), "public", "footage", "main"),
// 🔥 NUEVO: Site-wide brand assets (favicon, logo, OG image)
branding: path.join(process.cwd(), "public", "branding"),
};
// Scopes that ignore the `slug` parameter and write directly under their root.
const FLAT_SCOPES = new Set(["footage", "branding"]);
const MEDIA_TYPES: Record<string, string[]> = {
image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"],
video: [".mp4", ".webm", ".mov"],
@@ -73,7 +80,18 @@ function sanitizePath(input: string): string {
function buildSafePath(scope: string, slug: string, subPath?: string): string | null {
const root = SCOPE_ROOTS[scope];
if (!root || !slug) return null;
if (!root) return null;
// Flat scopes (footage, branding) ignore slug and operate directly on root.
if (FLAT_SCOPES.has(scope)) {
if (!subPath || subPath === "" || subPath === "/") return root;
const cleaned = sanitizePath(subPath);
const fullPath = path.join(root, cleaned);
if (!path.resolve(fullPath).startsWith(path.resolve(root))) return null;
return fullPath;
}
if (!slug) return null;
const appDir = path.join(root, slug);
if (!subPath || subPath === "" || subPath === "/") return appDir;
const cleaned = sanitizePath(subPath);
@@ -82,6 +100,12 @@ function buildSafePath(scope: string, slug: string, subPath?: string): string |
return fullPath;
}
function buildPublicUrl(scope: string, slug: string, rel: string): string {
if (scope === "footage") return `/footage/main/${rel}`;
if (scope === "branding") return `/branding/${rel}`;
return `/${scope}/${slug}/${rel}`;
}
function buildBreadcrumbs(subPath: string) {
const parts = subPath ? subPath.split("/").filter(Boolean) : [];
const crumbs = [{ name: "Root", path: "" }];
@@ -98,11 +122,11 @@ export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const scope = searchParams.get("scope") || "applications";
const slug = searchParams.get("slug");
const slug = searchParams.get("slug") || "";
const subPath = searchParams.get("path") || "";
if (!slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: `Invalid scope "${scope}"` }, { status: 400 });
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
const dirPath = buildSafePath(scope, slug, subPath);
if (!dirPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
@@ -137,7 +161,7 @@ export async function GET(request: NextRequest) {
mediaType: getFileType(entry.name),
extension: path.extname(entry.name).toLowerCase(),
path: rel,
publicUrl: `/${scope}/${slug}/${rel}`,
publicUrl: buildPublicUrl(scope, slug, rel),
size: getFileSize(stats.size),
sizeBytes: stats.size,
modifiedAt: stats.mtime.toISOString(),
@@ -166,12 +190,13 @@ export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const scope = (formData.get("scope") as string) || "applications";
const slug = formData.get("slug") as string;
const slug = (formData.get("slug") as string) || "";
const subPath = formData.get("path") as string || "";
const file = formData.get("file") as File;
if (!slug || !file) return NextResponse.json({ error: "Missing slug or file" }, { status: 400 });
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
const ext = path.extname(file.name).toLowerCase();
if (!ALL_EXTENSIONS.includes(ext)) {
@@ -201,7 +226,7 @@ export async function POST(request: NextRequest) {
success: true,
file: {
name: safeName,
publicUrl: `/${scope}/${slug}/${rel}`,
publicUrl: buildPublicUrl(scope, slug, rel),
path: rel,
mediaType: getFileType(safeName),
size: getFileSize(file.size),
@@ -218,10 +243,11 @@ export async function POST(request: NextRequest) {
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { scope = "applications", slug, folderName, parentPath = "" } = body;
const { scope = "applications", slug = "", folderName, parentPath = "" } = body;
if (!slug || !folderName) return NextResponse.json({ error: "Missing slug or folderName" }, { status: 400 });
if (!folderName) return NextResponse.json({ error: "Missing folderName" }, { status: 400 });
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
const safe = folderName.toLowerCase().trim().replace(/\s+/g, "-").replace(/[^a-z0-9_-]/g, "");
if (!safe) return NextResponse.json({ error: "Invalid folder name" }, { status: 400 });
@@ -249,10 +275,11 @@ export async function PUT(request: NextRequest) {
export async function DELETE(request: NextRequest) {
try {
const body = await request.json();
const { scope = "applications", slug, filePath } = body;
const { scope = "applications", slug = "", filePath } = body;
if (!slug || !filePath) return NextResponse.json({ error: "Missing params" }, { status: 400 });
if (!filePath) return NextResponse.json({ error: "Missing filePath" }, { status: 400 });
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
const targetPath = buildSafePath(scope, slug, filePath);
if (!targetPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 });