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
@@ -0,0 +1,35 @@
-- ─────────────────────────────────────────────────────────────────────────
-- ADDITIVE MIGRATION — only adds new tables, never modifies or drops.
-- Existing data (AdminUser, ClientUser w/ 2FA, GlobalNode, etc.) untouched.
-- ─────────────────────────────────────────────────────────────────────────
-- HeroSlide: carousel slides shown on the home hero section.
-- Replaces filesystem-scan of /public/footage/main with CMS control.
CREATE TABLE IF NOT EXISTS "HeroSlide" (
"id" TEXT NOT NULL,
"mediaUrl" TEXT NOT NULL,
"mediaType" TEXT NOT NULL DEFAULT 'image',
"altText" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"focalPointX" DOUBLE PRECISION NOT NULL DEFAULT 0.5,
"focalPointY" DOUBLE PRECISION NOT NULL DEFAULT 0.5,
"translationsJson" TEXT DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HeroSlide_pkey" PRIMARY KEY ("id")
);
-- SiteSetting: key-value config for favicon, logo, footer, OG image, etc.
CREATE TABLE IF NOT EXISTS "SiteSetting" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"valueJson" TEXT NOT NULL DEFAULT '{}',
"translationsJson" TEXT DEFAULT '{}',
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SiteSetting_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "SiteSetting_key_key" ON "SiteSetting"("key");
+47 -1
View File
@@ -248,7 +248,53 @@ model PageContent {
}
// ------------------------------------------------------
// 11. CLIENT PORTAL (Usuarios B2B Aprobados) 🔥 NUEVO
// 11. HERO REEL (Carrusel principal del Home)
// ------------------------------------------------------
// Manages the rotating images/videos shown in the home hero section.
// Replaces the previous filesystem-scan approach (fs.readdirSync of
// /public/footage/main) with full CMS control: ordering, on/off toggle,
// focal-point per slide for proper responsive cropping on mobile/tablet,
// and per-slide alt text for SEO.
model HeroSlide {
id String @id @default(cuid())
mediaUrl String // Public path, e.g. "/footage/main/01_tifas.png"
mediaType String @default("image") // "image" | "video"
altText String? // For accessibility + SEO; falls back to title if null
order Int @default(0)
isActive Boolean @default(true)
// Focal point for object-position on mobile/tablet crops (01 range).
// Lets the editor pick "what should stay visible when the image is cropped".
focalPointX Float @default(0.5)
focalPointY Float @default(0.5)
// Optional per-slide caption that overrides the global Hero text.
// Stored as JSON keyed by locale: {"en":{"title":"...","subtitle":"..."}}
translationsJson String? @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ------------------------------------------------------
// 12. SITE SETTINGS (Favicon, Footer, Branding global)
// ------------------------------------------------------
// Single-row pattern (key-value) for global site config that doesn't
// fit any other model: favicon, logos, footer, OG image, social links.
model SiteSetting {
id String @id @default(cuid())
key String @unique // e.g. "favicon", "footer", "logo", "og_image", "hero_text"
valueJson String @default("{}") // Flexible JSON payload per key
// 🌍 Translation engine (used for things like footer link labels)
translationsJson String? @default("{}")
updatedAt DateTime @updatedAt
}
// ------------------------------------------------------
// 13. CLIENT PORTAL (Usuarios B2B Aprobados)
// ------------------------------------------------------
model ClientUser {
id String @id @default(cuid())
+50 -11
View File
@@ -23,18 +23,57 @@ export const revalidate = 60;
export default async function Home({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
// --- 1. LECTURA DE IMÁGENES ---
let footageImages: string[] = [];
// --- 1. SLIDES DEL HERO (CMS-managed via HeroSlide model, fallback to filesystem) ---
let heroSlides: Array<{
mediaUrl: string;
mediaType: string;
altText: string | null;
focalPointX: number;
focalPointY: number;
translationsJson: string | null;
}> = [];
try {
const footageDir = path.join(process.cwd(), "public", "footage", "main");
if (fs.existsSync(footageDir)) {
const files = fs.readdirSync(footageDir);
footageImages = files
.filter(file => /\.(png|jpe?g|webp)$/i.test(file))
.map(file => `/footage/main/${file}`);
}
const dbSlides = await prisma.heroSlide.findMany({
where: { isActive: true },
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
select: {
mediaUrl: true,
mediaType: true,
altText: true,
focalPointX: true,
focalPointY: true,
translationsJson: true,
},
});
heroSlides = dbSlides.map((s: any) => getLocalizedData(s, locale));
} catch (error) {
console.error("Error reading footage directory:", error);
console.error("Error fetching hero slides from DB:", error);
}
// Fallback: scan /public/footage/main when CMS has no active slides yet.
// Lets the site keep working immediately after the migration runs but
// before the editor populates HeroSlide rows.
if (heroSlides.length === 0) {
try {
const footageDir = path.join(process.cwd(), "public", "footage", "main");
if (fs.existsSync(footageDir)) {
const files = fs.readdirSync(footageDir);
heroSlides = files
.filter((file) => /\.(png|jpe?g|webp)$/i.test(file))
.sort()
.map((file) => ({
mediaUrl: `/footage/main/${file}`,
mediaType: "image",
altText: null,
focalPointX: 0.5,
focalPointY: 0.5,
translationsJson: null,
}));
}
} catch (error) {
console.error("Error reading footage directory:", error);
}
}
// --- 2. NODOS DEL GLOBO ---
@@ -93,7 +132,7 @@ export default async function Home({ params }: { params: Promise<{ locale: strin
<main className="relative min-h-screen flex flex-col items-center w-full">
<BreathingField />
<div className="w-full overflow-hidden flex flex-col items-center justify-center">
<HeroReel images={footageImages} />
<HeroReel slides={heroSlides} />
</div>
<WhatWeDo />
<div className="w-full overflow-hidden flex flex-col items-center">
+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 });
@@ -0,0 +1,145 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidateContent } from "@/lib/revalidate";
import { translateContentForCMS } from "@/lib/aiTranslator";
export async function getHeroSlides() {
try {
const slides = await prisma.heroSlide.findMany({
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
});
return { success: true, slides };
} catch (error: any) {
return { error: error.message };
}
}
export async function createHeroSlide(input: {
mediaUrl: string;
mediaType?: string;
altText?: string;
order?: number;
}) {
try {
const last = await prisma.heroSlide.findFirst({
orderBy: { order: "desc" },
select: { order: true },
});
const nextOrder = input.order ?? (last ? last.order + 1 : 0);
const slide = await prisma.heroSlide.create({
data: {
mediaUrl: input.mediaUrl,
mediaType: input.mediaType || "image",
altText: input.altText || null,
order: nextOrder,
},
});
revalidateContent({ scope: "hero" });
return { success: true, slide };
} catch (error: any) {
return { error: error.message };
}
}
export async function updateHeroSlide(
id: string,
patch: {
mediaUrl?: string;
mediaType?: string;
altText?: string | null;
isActive?: boolean;
focalPointX?: number;
focalPointY?: number;
order?: number;
title?: string;
subtitle?: string;
description1?: string;
description2?: string;
autoTranslate?: boolean;
}
) {
try {
const data: any = {};
if (patch.mediaUrl !== undefined) data.mediaUrl = patch.mediaUrl;
if (patch.mediaType !== undefined) data.mediaType = patch.mediaType;
if (patch.altText !== undefined) data.altText = patch.altText;
if (patch.isActive !== undefined) data.isActive = patch.isActive;
if (patch.focalPointX !== undefined) data.focalPointX = patch.focalPointX;
if (patch.focalPointY !== undefined) data.focalPointY = patch.focalPointY;
if (patch.order !== undefined) data.order = patch.order;
// Per-slide caption overrides + AI translation
if (
patch.title !== undefined ||
patch.subtitle !== undefined ||
patch.description1 !== undefined ||
patch.description2 !== undefined
) {
const existing = await prisma.heroSlide.findUnique({ where: { id } });
const baseTranslations = existing?.translationsJson
? safeParse(existing.translationsJson, {})
: {};
const englishOverrides: Record<string, string> = {};
if (patch.title !== undefined) englishOverrides.title = patch.title;
if (patch.subtitle !== undefined) englishOverrides.subtitle = patch.subtitle;
if (patch.description1 !== undefined) englishOverrides.description1 = patch.description1;
if (patch.description2 !== undefined) englishOverrides.description2 = patch.description2;
const merged: Record<string, any> = { ...baseTranslations, en: { ...baseTranslations.en, ...englishOverrides } };
if (patch.autoTranslate) {
const aiResult = await translateContentForCMS(englishOverrides);
if (aiResult) {
for (const [locale, fields] of Object.entries(aiResult)) {
merged[locale] = { ...merged[locale], ...(fields as Record<string, string>) };
}
}
}
data.translationsJson = JSON.stringify(merged);
}
const slide = await prisma.heroSlide.update({ where: { id }, data });
revalidateContent({ scope: "hero" });
return { success: true, slide };
} catch (error: any) {
return { error: error.message };
}
}
export async function deleteHeroSlide(id: string) {
try {
await prisma.heroSlide.delete({ where: { id } });
revalidateContent({ scope: "hero" });
return { success: true };
} catch (error: any) {
return { error: error.message };
}
}
export async function reorderHeroSlides(orderedIds: string[]) {
try {
await prisma.$transaction(
orderedIds.map((id, idx) =>
prisma.heroSlide.update({ where: { id }, data: { order: idx } })
)
);
revalidateContent({ scope: "hero" });
return { success: true };
} catch (error: any) {
return { error: error.message };
}
}
function safeParse<T>(json: string | null | undefined, fallback: T): any {
if (!json) return fallback;
try {
return JSON.parse(json);
} catch {
return fallback;
}
}
+423
View File
@@ -0,0 +1,423 @@
"use client";
export const dynamic = "force-dynamic";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import {
ArrowLeft, Image as ImageIcon, Plus, Trash2, Loader2, GripVertical,
Eye, EyeOff, Crosshair, Sparkles, Upload, Check, X, ChevronDown,
} from "lucide-react";
import {
getHeroSlides,
createHeroSlide,
updateHeroSlide,
deleteHeroSlide,
reorderHeroSlides,
} from "./actions";
interface SlideRow {
id: string;
mediaUrl: string;
mediaType: string;
altText: string | null;
isActive: boolean;
focalPointX: number;
focalPointY: number;
order: number;
translationsJson: string | null;
}
function safeParseJson<T>(json: string | null | undefined, fallback: T): any {
if (!json) return fallback;
try { return JSON.parse(json); } catch { return fallback; }
}
export default function HeroDashboard() {
const [slides, setSlides] = useState<SlideRow[]>([]);
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<string | null>(null);
const [savedFlash, setSavedFlash] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [uploadHover, setUploadHover] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const [draggedId, setDraggedId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadSlides = useCallback(async () => {
setLoading(true);
const res = await getHeroSlides();
if (res.success && res.slides) setSlides(res.slides as SlideRow[]);
setLoading(false);
}, []);
useEffect(() => { loadSlides(); }, [loadSlides]);
const flashSaved = (id: string) => {
setSavedFlash(id);
setTimeout(() => setSavedFlash(null), 1500);
};
// ─── Upload ─────────────────────────────────────────────────────
const uploadFile = async (file: File) => {
setIsUploading(true);
setUploadProgress(`Uploading ${file.name}...`);
try {
const fd = new FormData();
fd.append("scope", "footage");
fd.append("file", file);
const res = await fetch("/api/assets", { method: "POST", body: fd });
const data = await res.json();
if (data.success) {
await createHeroSlide({
mediaUrl: data.file.publicUrl,
mediaType: data.file.mediaType === "video" ? "video" : "image",
altText: file.name.replace(/\.[^.]+$/, "").replace(/[-_]/g, " "),
});
setUploadProgress(`${data.file.name}`);
setTimeout(() => setUploadProgress(""), 1800);
await loadSlides();
} else {
setUploadProgress(`${data.error}`);
setTimeout(() => setUploadProgress(""), 4000);
}
} catch (err: any) {
setUploadProgress(`${err.message}`);
setTimeout(() => setUploadProgress(""), 4000);
}
setIsUploading(false);
};
const handleFiles = (files: FileList | null) => {
if (!files) return;
Array.from(files).forEach(uploadFile);
};
// ─── Updates with auto-save ─────────────────────────────────────
const patchSlide = async (id: string, patch: Partial<SlideRow>) => {
setSlides((prev) => prev.map((s) => (s.id === id ? { ...s, ...patch } : s)));
setSavingId(id);
const res = await updateHeroSlide(id, patch as any);
setSavingId(null);
if (res.success) flashSaved(id);
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this slide? The image file stays on disk and can be re-added.")) return;
await deleteHeroSlide(id);
await loadSlides();
};
// ─── Drag and drop reorder ──────────────────────────────────────
const onDragStart = (id: string) => setDraggedId(id);
const onDragOver = (e: React.DragEvent) => e.preventDefault();
const onDrop = async (targetId: string) => {
if (!draggedId || draggedId === targetId) return;
const ids = slides.map((s) => s.id);
const fromIdx = ids.indexOf(draggedId);
const toIdx = ids.indexOf(targetId);
if (fromIdx < 0 || toIdx < 0) return;
const reordered = [...ids];
reordered.splice(fromIdx, 1);
reordered.splice(toIdx, 0, draggedId);
setSlides((prev) => reordered.map((id, i) => ({ ...prev.find((s) => s.id === id)!, order: i })));
setDraggedId(null);
await reorderHeroSlides(reordered);
};
// ─── Focal point picker ─────────────────────────────────────────
const onFocalClick = (id: string, e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
const clamp = (v: number) => Math.max(0, Math.min(1, v));
patchSlide(id, { focalPointX: clamp(x), focalPointY: clamp(y) });
};
return (
<div className="min-h-screen p-6 md:p-12 max-w-6xl mx-auto">
{/* Header */}
<Link
href="/hq-command/dashboard"
className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-white mb-6 transition-colors"
>
<ArrowLeft size={14} /> Back to Dashboard
</Link>
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 mb-8">
<div>
<div className="flex items-center gap-2 text-[#00F0FF] mb-2">
<ImageIcon size={16} />
<span className="text-[10px] uppercase tracking-widest font-bold">Home Hero Carousel</span>
</div>
<h1 className="text-3xl md:text-4xl font-light text-white">
Hero <span className="font-medium">Slides.</span>
</h1>
<p className="text-[#86868B] mt-2 text-sm">
Drag to reorder. Click an image to set its focal point. Auto-saves on every change.
</p>
</div>
</div>
{/* Drop zone */}
<div
onDragEnter={(e) => { e.preventDefault(); setUploadHover(true); }}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => setUploadHover(false)}
onDrop={(e) => {
e.preventDefault();
setUploadHover(false);
handleFiles(e.dataTransfer.files);
}}
className={`mb-8 border-2 border-dashed rounded-3xl p-10 text-center transition-all cursor-pointer ${
uploadHover
? "border-[#00F0FF] bg-[#00F0FF]/5"
: "border-white/10 bg-white/[0.02] hover:bg-white/[0.04]"
}`}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*"
multiple
className="hidden"
onChange={(e) => { handleFiles(e.target.files); e.target.value = ""; }}
/>
<Upload size={32} className="mx-auto text-[#00F0FF] mb-3 opacity-60" />
<div className="text-white font-medium mb-1">
{isUploading ? uploadProgress : "Drop images or videos here"}
</div>
<div className="text-xs text-[#86868B]">
PNG, JPG, WebP, MP4 recommended 2560×1440 landscape, under 8MB
</div>
</div>
{/* Slides list */}
{loading ? (
<div className="flex items-center justify-center py-20 text-[#86868B]">
<Loader2 className="animate-spin mr-2" size={16} /> Loading slides
</div>
) : slides.length === 0 ? (
<div className="text-center py-20 text-[#86868B] bg-white/[0.02] rounded-3xl border border-white/5">
<ImageIcon size={32} className="mx-auto mb-3 opacity-40" />
<p>No hero slides yet.</p>
<p className="text-xs mt-1">The home page will fall back to /public/footage/main until you add some.</p>
</div>
) : (
<div className="space-y-3">
{slides.map((slide) => {
const isExpanded = expandedId === slide.id;
const isSaving = savingId === slide.id;
const justSaved = savedFlash === slide.id;
const en = safeParseJson(slide.translationsJson, {})?.en || {};
return (
<div
key={slide.id}
draggable
onDragStart={() => onDragStart(slide.id)}
onDragOver={onDragOver}
onDrop={() => onDrop(slide.id)}
className={`bg-[#111] border rounded-2xl overflow-hidden transition-all ${
draggedId === slide.id ? "opacity-50" : ""
} ${slide.isActive ? "border-white/10" : "border-white/5 opacity-60"}`}
>
{/* Row */}
<div className="flex items-center gap-3 p-3">
<button className="cursor-grab text-[#86868B] hover:text-white p-1" title="Drag to reorder">
<GripVertical size={16} />
</button>
{/* Thumbnail with focal-point picker */}
<div
onClick={(e) => onFocalClick(slide.id, e)}
className="relative w-32 h-20 rounded-lg overflow-hidden bg-black flex-shrink-0 cursor-crosshair group"
title="Click to set focal point"
>
{slide.mediaType === "video" ? (
<video
src={slide.mediaUrl}
muted
className="absolute inset-0 w-full h-full object-cover"
style={{
objectPosition: `${slide.focalPointX * 100}% ${slide.focalPointY * 100}%`,
}}
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={slide.mediaUrl}
alt={slide.altText || ""}
className="absolute inset-0 w-full h-full object-cover"
style={{
objectPosition: `${slide.focalPointX * 100}% ${slide.focalPointY * 100}%`,
}}
/>
)}
{/* Focal point indicator */}
<div
className="absolute w-4 h-4 -ml-2 -mt-2 border-2 border-white rounded-full pointer-events-none shadow-lg"
style={{
left: `${slide.focalPointX * 100}%`,
top: `${slide.focalPointY * 100}%`,
boxShadow: "0 0 0 2px rgba(0,0,0,0.5)",
}}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
<Crosshair size={20} className="text-white" />
</div>
</div>
{/* Alt text + URL */}
<div className="flex-1 min-w-0">
<input
type="text"
value={slide.altText || ""}
onChange={(e) => patchSlide(slide.id, { altText: e.target.value })}
placeholder="Alt text (for SEO + accessibility)"
className="w-full bg-transparent border-0 outline-none text-white text-sm placeholder:text-[#86868B] focus:bg-white/[0.04] rounded px-2 py-1 -mx-2 -my-1"
/>
<div className="text-[10px] text-[#86868B] truncate font-mono mt-1">
{slide.mediaUrl}
</div>
</div>
{/* Status indicators */}
<div className="flex items-center gap-1 text-xs">
{isSaving && <Loader2 size={12} className="animate-spin text-[#00F0FF]" />}
{justSaved && (
<span className="text-emerald-400 flex items-center gap-1">
<Check size={12} /> Saved
</span>
)}
</div>
{/* Actions */}
<button
onClick={() => patchSlide(slide.id, { isActive: !slide.isActive })}
className={`p-2 rounded-lg transition-colors ${
slide.isActive
? "text-emerald-400 hover:bg-emerald-500/10"
: "text-[#86868B] hover:bg-white/5"
}`}
title={slide.isActive ? "Hide from carousel" : "Show in carousel"}
>
{slide.isActive ? <Eye size={16} /> : <EyeOff size={16} />}
</button>
<button
onClick={() => setExpandedId(isExpanded ? null : slide.id)}
className="p-2 rounded-lg text-[#86868B] hover:bg-white/5 hover:text-white"
title="Caption overrides"
>
<Sparkles size={16} className={isExpanded ? "text-[#00F0FF]" : ""} />
</button>
<button
onClick={() => handleDelete(slide.id)}
className="p-2 rounded-lg text-rose-400 hover:bg-rose-500/10"
title="Delete slide"
>
<Trash2 size={16} />
</button>
</div>
{/* Expanded — caption overrides */}
{isExpanded && (
<CaptionEditor
initial={{
title: en.title || "",
subtitle: en.subtitle || "",
description1: en.description1 || "",
description2: en.description2 || "",
}}
onSave={async (vals, autoTranslate) => {
setSavingId(slide.id);
await updateHeroSlide(slide.id, { ...vals, autoTranslate });
setSavingId(null);
flashSaved(slide.id);
await loadSlides();
}}
/>
)}
</div>
);
})}
</div>
)}
</div>
);
}
// ─── Caption editor ───────────────────────────────────────────────
function CaptionEditor({
initial,
onSave,
}: {
initial: { title: string; subtitle: string; description1: string; description2: string };
onSave: (vals: typeof initial, autoTranslate: boolean) => Promise<void>;
}) {
const [vals, setVals] = useState(initial);
const [autoTranslate, setAutoTranslate] = useState(false);
const [saving, setSaving] = useState(false);
return (
<div className="border-t border-white/5 bg-white/[0.02] p-5 space-y-3">
<div className="text-[10px] uppercase tracking-widest text-[#86868B] font-bold mb-3">
Caption overrides (English leave empty to use site defaults)
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<input
value={vals.title}
onChange={(e) => setVals({ ...vals, title: e.target.value })}
placeholder="Title (e.g. LET THE POWER FLUX)"
className="bg-black/40 border border-white/10 text-white text-sm rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40"
/>
<input
value={vals.subtitle}
onChange={(e) => setVals({ ...vals, subtitle: e.target.value })}
placeholder="Subtitle (e.g. INNOVATION NOT IMITATION)"
className="bg-black/40 border border-white/10 text-white text-sm rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40"
/>
<input
value={vals.description1}
onChange={(e) => setVals({ ...vals, description1: e.target.value })}
placeholder="Description line 1"
className="bg-black/40 border border-white/10 text-white text-sm rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40 md:col-span-2"
/>
<input
value={vals.description2}
onChange={(e) => setVals({ ...vals, description2: e.target.value })}
placeholder="Description line 2"
className="bg-black/40 border border-white/10 text-white text-sm rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40 md:col-span-2"
/>
</div>
<label className="flex items-center gap-2 text-xs text-[#86868B] cursor-pointer">
<input
type="checkbox"
checked={autoTranslate}
onChange={(e) => setAutoTranslate(e.target.checked)}
className="accent-[#00F0FF]"
/>
Auto-translate to IT, VEC, ES, DE with AI
</label>
<button
onClick={async () => {
setSaving(true);
await onSave(vals, autoTranslate);
setSaving(false);
}}
disabled={saving}
className="px-4 py-2 bg-[#00F0FF] text-black text-sm font-medium rounded-lg hover:bg-[#00F0FF]/80 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
Save captions
</button>
</div>
);
}
+11 -1
View File
@@ -15,7 +15,8 @@ import {
LogOut,
Radar,
Wrench,
Server
Server,
Image as ImageIcon,
} from "lucide-react";
import { prisma } from "@/lib/prisma";
import { logoutAdmin } from "@/app/hq-command/login/actions";
@@ -27,6 +28,15 @@ export default async function DashboardPage() {
const appsCount = await prisma.application.count({ where: { isActive: true } });
const modules = [
{
title: "Hero Carousel",
description: "Manage the rotating images and videos on the home page hero section.",
icon: ImageIcon,
href: "/hq-command/dashboard/hero",
color: "text-[#FF6B9D]",
bg: "bg-[#FF6B9D]/10",
border: "hover:border-[#FF6B9D]/50"
},
{
title: "Global Network",
description: "Manage physical installations, nodes, and events on the 3D Holographic Map.",
+78 -40
View File
@@ -1,38 +1,65 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import Image from "next/image";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslations } from "next-intl";
// 🔥 IMPORTAMOS LA FUENTE FUTURISTA/EXTENDIDA SÓLO PARA EL HERO 🔥
import { Syncopate } from "next/font/google";
const syncopate = Syncopate({ weight: ['400', '700'], subsets: ['latin'] });
interface HeroReelProps {
images: string[];
const syncopate = Syncopate({ weight: ["400", "700"], subsets: ["latin"] });
export interface HeroSlideData {
mediaUrl: string;
mediaType: string; // "image" | "video"
altText: string | null;
focalPointX: number; // 01
focalPointY: number; // 01
translationsJson?: string | null;
// Optional per-slide overrides (already merged via getLocalizedData server-side)
title?: string;
subtitle?: string;
description1?: string;
description2?: string;
}
export default function HeroReel({ images }: HeroReelProps) {
interface HeroReelProps {
slides: HeroSlideData[];
}
// ── Backwards-compat wrapper: the old API used `images: string[]` ─────────────
// (Server pages should pass `slides` going forward; legacy callers still work.)
export default function HeroReel(props: HeroReelProps | { images: string[] }) {
const slides = useMemo<HeroSlideData[]>(() => {
if ("slides" in props) return props.slides;
return (props.images || []).map((src) => ({
mediaUrl: src,
mediaType: "image",
altText: null,
focalPointX: 0.5,
focalPointY: 0.5,
}));
}, [props]);
const [currentIndex, setCurrentIndex] = useState(0);
const t = useTranslations("HeroReel");
useEffect(() => {
if (!images || images.length <= 1) return;
if (slides.length <= 1) return;
const timer = setInterval(() => {
setCurrentIndex((prevIndex) => (prevIndex + 1) % images.length);
setCurrentIndex((prev) => (prev + 1) % slides.length);
}, 3600);
return () => clearInterval(timer);
}, [images]);
}, [slides.length]);
const current = slides[currentIndex];
return (
<div
id="technology"
className="relative w-screen h-[100vh] mt-0 mb-0 overflow-hidden z-0 bg-[#050505]"
className="relative w-full max-w-[100vw] h-[100svh] mt-0 mb-0 overflow-hidden z-0 bg-[#050505]"
>
<AnimatePresence mode="popLayout">
{images.length > 0 ? (
{current ? (
<motion.div
key={currentIndex}
initial={{ opacity: 0, scale: 1.05 }}
@@ -41,62 +68,73 @@ export default function HeroReel({ images }: HeroReelProps) {
transition={{ duration: 1.5, ease: [0.25, 0.1, 0.25, 1] }}
className="absolute inset-0 w-full h-full"
>
<Image
src={images[currentIndex]}
alt={`FLUX Vision ${currentIndex}`}
fill
quality={100}
sizes="100vw"
className="object-cover"
priority={currentIndex === 0}
/>
{current.mediaType === "video" ? (
<video
key={current.mediaUrl}
src={current.mediaUrl}
autoPlay
muted
playsInline
loop
className="absolute inset-0 w-full h-full object-cover"
style={{
objectPosition: `${current.focalPointX * 100}% ${current.focalPointY * 100}%`,
}}
/>
) : (
<Image
src={current.mediaUrl}
alt={current.altText || `FLUX Vision ${currentIndex + 1}`}
fill
quality={90}
sizes="100vw"
priority={currentIndex === 0}
className="object-cover"
style={{
objectPosition: `${current.focalPointX * 100}% ${current.focalPointY * 100}%`,
}}
/>
)}
</motion.div>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-[#111] to-[#0A0A0C]" />
)}
</AnimatePresence>
{/* Gradientes sutiles en los bordes para garantizar que el texto siempre sea legible sin importar la foto */}
{/* Subtle edge gradients to keep text legible regardless of photo content */}
<div className="absolute inset-0 bg-gradient-to-r from-black/50 via-black/10 to-transparent pointer-events-none z-10" />
<div className="absolute bottom-0 left-0 right-0 h-1/2 bg-gradient-to-t from-black/80 via-black/20 to-transparent pointer-events-none z-10" />
{/* ── SUPERPOSICIÓN DE TEXTO MINIMALISTA Y BAJA ── */}
{/* 🔥 Usamos items-end y pb-24/pb-32 para tirarlo hacia la mitad inferior 🔥 */}
{/* Overlay text */}
<div className="absolute inset-0 z-20 flex items-end justify-start px-6 md:px-12 lg:px-24 pb-24 md:pb-32 pointer-events-none">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4, duration: 1, ease: "easeOut" }}
// Estructura en columna, alineado a la izquierda
className="w-full max-w-5xl flex flex-col items-start gap-4 md:gap-6"
>
{/* BLOQUE DE TÍTULOS */}
<div className="flex flex-col gap-1 md:gap-3">
{/* LEMA PRINCIPAL (Fuente Syncopate) */}
<h1 className={`${syncopate.className} text-4xl md:text-5xl lg:text-[5.5rem] font-bold text-white uppercase tracking-widest leading-[1.1] drop-shadow-2xl`}>
LET THE POWER FLUX
<h1
className={`${syncopate.className} text-3xl sm:text-4xl md:text-5xl lg:text-[5.5rem] font-bold text-white uppercase tracking-widest leading-[1.1] drop-shadow-2xl`}
>
{current?.title || "LET THE POWER FLUX"}
</h1>
{/* FRASE SECUNDARIA (Fuente limpia y elegante) */}
<h2 className="text-lg md:text-2xl lg:text-[2rem] font-light text-white uppercase tracking-[0.2em] drop-shadow-lg">
INNOVATION NOT IMITATION
<h2 className="text-base sm:text-lg md:text-2xl lg:text-[2rem] font-light text-white uppercase tracking-[0.2em] drop-shadow-lg">
{current?.subtitle || "INNOVATION NOT IMITATION"}
</h2>
</div>
{/* ESPACIADOR INVISIBLE */}
<div className="h-2 md:h-4"></div>
<div className="h-2 md:h-4" />
{/* BLOQUE DE DESCRIPCIÓN TIPO "CONSOLA" */}
<div className="flex flex-col gap-2 md:gap-3">
<p className="font-mono text-[10px] md:text-xs lg:text-sm text-white/90 uppercase tracking-widest drop-shadow-md">
{t("description1")}
{current?.description1 || t("description1")}
</p>
<p className="font-mono text-[10px] md:text-xs lg:text-sm text-white/90 uppercase tracking-widest drop-shadow-md">
{t("description2")}
{current?.description2 || t("description2")}
</p>
</div>
</motion.div>
</div>
</div>
+8
View File
@@ -17,8 +17,10 @@ export type RevalidateScope =
| "heritage"
| "operations-inbox"
| "footage"
| "branding"
| "hero"
| "timeline"
| "settings"
| "all";
export interface RevalidateOptions {
@@ -61,6 +63,12 @@ export function revalidateContent({ scope, slug }: RevalidateOptions) {
case "footage":
safeRevalidate(`/${locale}`);
break;
case "branding":
case "settings":
// Brand assets and global settings affect every page (header/footer/favicon).
safeRevalidate("/", "layout");
safeRevalidate(`/${locale}`);
break;
case "operations-inbox":
break;
case "all":