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
+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>
);
}
+21 -11
View File
@@ -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.",
+88 -50
View File
@@ -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; // 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");
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]"
<div
id="technology"
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 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.03 }}
transition={{ duration: 1.5, ease: [0.25, 0.1, 0.25, 1] }}
initial={{ opacity: 0, scale: 1.05 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.03 }}
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":