diff --git a/prisma/migrations/20260504120000_add_hero_slides_and_site_settings/migration.sql b/prisma/migrations/20260504120000_add_hero_slides_and_site_settings/migration.sql new file mode 100644 index 0000000..293d640 --- /dev/null +++ b/prisma/migrations/20260504120000_add_hero_slides_and_site_settings/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ae01db6..e4d7e38 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 (0–1 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()) diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index cbc17cc..01babca 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -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
- +
diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts index 21c9451..0646970 100644 --- a/src/app/api/assets/route.ts +++ b/src/app/api/assets/route.ts @@ -38,8 +38,15 @@ const SCOPE_ROOTS: Record = { 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 = { 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 }); diff --git a/src/app/hq-command/dashboard/hero/actions.ts b/src/app/hq-command/dashboard/hero/actions.ts new file mode 100644 index 0000000..1a23cb4 --- /dev/null +++ b/src/app/hq-command/dashboard/hero/actions.ts @@ -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 = {}; + 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 = { ...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) }; + } + } + } + + 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(json: string | null | undefined, fallback: T): any { + if (!json) return fallback; + try { + return JSON.parse(json); + } catch { + return fallback; + } +} diff --git a/src/app/hq-command/dashboard/hero/page.tsx b/src/app/hq-command/dashboard/hero/page.tsx new file mode 100644 index 0000000..380c40f --- /dev/null +++ b/src/app/hq-command/dashboard/hero/page.tsx @@ -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(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([]); + const [loading, setLoading] = useState(true); + const [savingId, setSavingId] = useState(null); + const [savedFlash, setSavedFlash] = useState(null); + const [expandedId, setExpandedId] = useState(null); + const [uploadHover, setUploadHover] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(""); + const [draggedId, setDraggedId] = useState(null); + const fileInputRef = useRef(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) => { + 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) => { + 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 ( +
+ {/* Header */} + + Back to Dashboard + + +
+
+
+ + Home Hero Carousel +
+

+ Hero Slides. +

+

+ Drag to reorder. Click an image to set its focal point. Auto-saves on every change. +

+
+
+ + {/* Drop zone */} +
{ 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()} + > + { handleFiles(e.target.files); e.target.value = ""; }} + /> + +
+ {isUploading ? uploadProgress : "Drop images or videos here"} +
+
+ PNG, JPG, WebP, MP4 — recommended 2560×1440 landscape, under 8MB +
+
+ + {/* Slides list */} + {loading ? ( +
+ Loading slides… +
+ ) : slides.length === 0 ? ( +
+ +

No hero slides yet.

+

The home page will fall back to /public/footage/main until you add some.

+
+ ) : ( +
+ {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 ( +
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 */} +
+ + + {/* Thumbnail with focal-point picker */} +
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" ? ( +
+ + {/* Expanded — caption overrides */} + {isExpanded && ( + { + setSavingId(slide.id); + await updateHeroSlide(slide.id, { ...vals, autoTranslate }); + setSavingId(null); + flashSaved(slide.id); + await loadSlides(); + }} + /> + )} +
+ ); + })} +
+ )} +
+ ); +} + +// ─── Caption editor ─────────────────────────────────────────────── +function CaptionEditor({ + initial, + onSave, +}: { + initial: { title: string; subtitle: string; description1: string; description2: string }; + onSave: (vals: typeof initial, autoTranslate: boolean) => Promise; +}) { + const [vals, setVals] = useState(initial); + const [autoTranslate, setAutoTranslate] = useState(false); + const [saving, setSaving] = useState(false); + + return ( +
+
+ Caption overrides (English — leave empty to use site defaults) +
+ +
+ 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" + /> + 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" + /> + 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" + /> + 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" + /> +
+ + + + +
+ ); +} diff --git a/src/app/hq-command/dashboard/page.tsx b/src/app/hq-command/dashboard/page.tsx index d758d07..64c25f7 100644 --- a/src/app/hq-command/dashboard/page.tsx +++ b/src/app/hq-command/dashboard/page.tsx @@ -3,19 +3,20 @@ export const dynamic = "force-dynamic"; import Link from "next/link"; -import { - Globe, - Layers, - Users, - ShieldCheck, - Activity, - History, - Newspaper, - BookOpen, - LogOut, +import { + Globe, + Layers, + Users, + ShieldCheck, + Activity, + History, + Newspaper, + BookOpen, + 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.", diff --git a/src/components/sections/HeroReel.tsx b/src/components/sections/HeroReel.tsx index ce22dab..5070a47 100644 --- a/src/components/sections/HeroReel.tsx +++ b/src/components/sections/HeroReel.tsx @@ -1,104 +1,142 @@ "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 { useTranslations } from "next-intl"; 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; // 0–1 + focalPointY: number; // 0–1 + 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(() => { + 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"); + 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 ( -
- - {images.length > 0 ? ( + {current ? ( - {`FLUX + {current.mediaType === "video" ? ( + ) : (
)} - {/* 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 */}
- {/* ── SUPERPOSICIÓN DE TEXTO MINIMALISTA Y BAJA ── */} - {/* 🔥 Usamos items-end y pb-24/pb-32 para tirarlo hacia la mitad inferior 🔥 */} + {/* Overlay text */}
- - {/* BLOQUE DE TÍTULOS */}
- {/* LEMA PRINCIPAL (Fuente Syncopate) */} -

- LET THE POWER FLUX +

+ {current?.title || "LET THE POWER FLUX"}

- - {/* FRASE SECUNDARIA (Fuente limpia y elegante) */} -

- INNOVATION NOT IMITATION + +

+ {current?.subtitle || "INNOVATION NOT IMITATION"}

- {/* ESPACIADOR INVISIBLE */} -
+
- {/* BLOQUE DE DESCRIPCIÓN TIPO "CONSOLA" */}

- {t("description1")} + {current?.description1 || t("description1")}

- {t("description2")} + {current?.description2 || t("description2")}

-
); -} \ No newline at end of file +} diff --git a/src/lib/revalidate.ts b/src/lib/revalidate.ts index b43e67c..5d793af 100644 --- a/src/lib/revalidate.ts +++ b/src/lib/revalidate.ts @@ -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":