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:
@@ -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
@@ -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 {
|
model ClientUser {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|||||||
@@ -23,19 +23,58 @@ export const revalidate = 60;
|
|||||||
export default async function Home({ params }: { params: Promise<{ locale: string }> }) {
|
export default async function Home({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
|
||||||
// --- 1. LECTURA DE IMÁGENES ---
|
// --- 1. SLIDES DEL HERO (CMS-managed via HeroSlide model, fallback to filesystem) ---
|
||||||
let footageImages: string[] = [];
|
let heroSlides: Array<{
|
||||||
|
mediaUrl: string;
|
||||||
|
mediaType: string;
|
||||||
|
altText: string | null;
|
||||||
|
focalPointX: number;
|
||||||
|
focalPointY: number;
|
||||||
|
translationsJson: string | null;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 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 {
|
try {
|
||||||
const footageDir = path.join(process.cwd(), "public", "footage", "main");
|
const footageDir = path.join(process.cwd(), "public", "footage", "main");
|
||||||
if (fs.existsSync(footageDir)) {
|
if (fs.existsSync(footageDir)) {
|
||||||
const files = fs.readdirSync(footageDir);
|
const files = fs.readdirSync(footageDir);
|
||||||
footageImages = files
|
heroSlides = files
|
||||||
.filter(file => /\.(png|jpe?g|webp)$/i.test(file))
|
.filter((file) => /\.(png|jpe?g|webp)$/i.test(file))
|
||||||
.map(file => `/footage/main/${file}`);
|
.sort()
|
||||||
|
.map((file) => ({
|
||||||
|
mediaUrl: `/footage/main/${file}`,
|
||||||
|
mediaType: "image",
|
||||||
|
altText: null,
|
||||||
|
focalPointX: 0.5,
|
||||||
|
focalPointY: 0.5,
|
||||||
|
translationsJson: null,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error reading footage directory:", error);
|
console.error("Error reading footage directory:", error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- 2. NODOS DEL GLOBO ---
|
// --- 2. NODOS DEL GLOBO ---
|
||||||
let mapNodes: any[] = [];
|
let mapNodes: any[] = [];
|
||||||
@@ -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">
|
<main className="relative min-h-screen flex flex-col items-center w-full">
|
||||||
<BreathingField />
|
<BreathingField />
|
||||||
<div className="w-full overflow-hidden flex flex-col items-center justify-center">
|
<div className="w-full overflow-hidden flex flex-col items-center justify-center">
|
||||||
<HeroReel images={footageImages} />
|
<HeroReel slides={heroSlides} />
|
||||||
</div>
|
</div>
|
||||||
<WhatWeDo />
|
<WhatWeDo />
|
||||||
<div className="w-full overflow-hidden flex flex-col items-center">
|
<div className="w-full overflow-hidden flex flex-col items-center">
|
||||||
|
|||||||
+38
-11
@@ -38,8 +38,15 @@ const SCOPE_ROOTS: Record<string, string> = {
|
|||||||
news: path.join(process.cwd(), "public", "news"),
|
news: path.join(process.cwd(), "public", "news"),
|
||||||
// 🔥 NUEVO: Scope para el Component Matrix
|
// 🔥 NUEVO: Scope para el Component Matrix
|
||||||
parts: path.join(process.cwd(), "public", "parts"),
|
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[]> = {
|
const MEDIA_TYPES: Record<string, string[]> = {
|
||||||
image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"],
|
image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"],
|
||||||
video: [".mp4", ".webm", ".mov"],
|
video: [".mp4", ".webm", ".mov"],
|
||||||
@@ -73,7 +80,18 @@ function sanitizePath(input: string): string {
|
|||||||
|
|
||||||
function buildSafePath(scope: string, slug: string, subPath?: string): string | null {
|
function buildSafePath(scope: string, slug: string, subPath?: string): string | null {
|
||||||
const root = SCOPE_ROOTS[scope];
|
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);
|
const appDir = path.join(root, slug);
|
||||||
if (!subPath || subPath === "" || subPath === "/") return appDir;
|
if (!subPath || subPath === "" || subPath === "/") return appDir;
|
||||||
const cleaned = sanitizePath(subPath);
|
const cleaned = sanitizePath(subPath);
|
||||||
@@ -82,6 +100,12 @@ function buildSafePath(scope: string, slug: string, subPath?: string): string |
|
|||||||
return fullPath;
|
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) {
|
function buildBreadcrumbs(subPath: string) {
|
||||||
const parts = subPath ? subPath.split("/").filter(Boolean) : [];
|
const parts = subPath ? subPath.split("/").filter(Boolean) : [];
|
||||||
const crumbs = [{ name: "Root", path: "" }];
|
const crumbs = [{ name: "Root", path: "" }];
|
||||||
@@ -98,11 +122,11 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const scope = searchParams.get("scope") || "applications";
|
const scope = searchParams.get("scope") || "applications";
|
||||||
const slug = searchParams.get("slug");
|
const slug = searchParams.get("slug") || "";
|
||||||
const subPath = searchParams.get("path") || "";
|
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 (!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);
|
const dirPath = buildSafePath(scope, slug, subPath);
|
||||||
if (!dirPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
if (!dirPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
||||||
@@ -137,7 +161,7 @@ export async function GET(request: NextRequest) {
|
|||||||
mediaType: getFileType(entry.name),
|
mediaType: getFileType(entry.name),
|
||||||
extension: path.extname(entry.name).toLowerCase(),
|
extension: path.extname(entry.name).toLowerCase(),
|
||||||
path: rel,
|
path: rel,
|
||||||
publicUrl: `/${scope}/${slug}/${rel}`,
|
publicUrl: buildPublicUrl(scope, slug, rel),
|
||||||
size: getFileSize(stats.size),
|
size: getFileSize(stats.size),
|
||||||
sizeBytes: stats.size,
|
sizeBytes: stats.size,
|
||||||
modifiedAt: stats.mtime.toISOString(),
|
modifiedAt: stats.mtime.toISOString(),
|
||||||
@@ -166,12 +190,13 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const scope = (formData.get("scope") as string) || "applications";
|
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 subPath = formData.get("path") as string || "";
|
||||||
const file = formData.get("file") as File;
|
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 (!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();
|
const ext = path.extname(file.name).toLowerCase();
|
||||||
if (!ALL_EXTENSIONS.includes(ext)) {
|
if (!ALL_EXTENSIONS.includes(ext)) {
|
||||||
@@ -201,7 +226,7 @@ export async function POST(request: NextRequest) {
|
|||||||
success: true,
|
success: true,
|
||||||
file: {
|
file: {
|
||||||
name: safeName,
|
name: safeName,
|
||||||
publicUrl: `/${scope}/${slug}/${rel}`,
|
publicUrl: buildPublicUrl(scope, slug, rel),
|
||||||
path: rel,
|
path: rel,
|
||||||
mediaType: getFileType(safeName),
|
mediaType: getFileType(safeName),
|
||||||
size: getFileSize(file.size),
|
size: getFileSize(file.size),
|
||||||
@@ -218,10 +243,11 @@ export async function POST(request: NextRequest) {
|
|||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
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 (!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, "");
|
const safe = folderName.toLowerCase().trim().replace(/\s+/g, "-").replace(/[^a-z0-9_-]/g, "");
|
||||||
if (!safe) return NextResponse.json({ error: "Invalid folder name" }, { status: 400 });
|
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) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
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 (!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);
|
const targetPath = buildSafePath(scope, slug, filePath);
|
||||||
if (!targetPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Radar,
|
Radar,
|
||||||
Wrench,
|
Wrench,
|
||||||
Server
|
Server,
|
||||||
|
Image as ImageIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logoutAdmin } from "@/app/hq-command/login/actions";
|
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 appsCount = await prisma.application.count({ where: { isActive: true } });
|
||||||
|
|
||||||
const modules = [
|
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",
|
title: "Global Network",
|
||||||
description: "Manage physical installations, nodes, and events on the 3D Holographic Map.",
|
description: "Manage physical installations, nodes, and events on the 3D Holographic Map.",
|
||||||
|
|||||||
@@ -1,38 +1,65 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
// 🔥 IMPORTAMOS LA FUENTE FUTURISTA/EXTENDIDA SÓLO PARA EL HERO 🔥
|
|
||||||
import { Syncopate } from "next/font/google";
|
import { Syncopate } from "next/font/google";
|
||||||
const syncopate = Syncopate({ weight: ['400', '700'], subsets: ['latin'] });
|
|
||||||
|
|
||||||
interface HeroReelProps {
|
const syncopate = Syncopate({ weight: ["400", "700"], subsets: ["latin"] });
|
||||||
images: string[];
|
|
||||||
|
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<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 [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const t = useTranslations("HeroReel");
|
const t = useTranslations("HeroReel");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!images || images.length <= 1) return;
|
if (slides.length <= 1) return;
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setCurrentIndex((prevIndex) => (prevIndex + 1) % images.length);
|
setCurrentIndex((prev) => (prev + 1) % slides.length);
|
||||||
}, 3600);
|
}, 3600);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [images]);
|
}, [slides.length]);
|
||||||
|
|
||||||
|
const current = slides[currentIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id="technology"
|
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">
|
<AnimatePresence mode="popLayout">
|
||||||
{images.length > 0 ? (
|
{current ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={currentIndex}
|
key={currentIndex}
|
||||||
initial={{ opacity: 0, scale: 1.05 }}
|
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] }}
|
transition={{ duration: 1.5, ease: [0.25, 0.1, 0.25, 1] }}
|
||||||
className="absolute inset-0 w-full h-full"
|
className="absolute inset-0 w-full h-full"
|
||||||
>
|
>
|
||||||
<Image
|
{current.mediaType === "video" ? (
|
||||||
src={images[currentIndex]}
|
<video
|
||||||
alt={`FLUX Vision ${currentIndex}`}
|
key={current.mediaUrl}
|
||||||
fill
|
src={current.mediaUrl}
|
||||||
quality={100}
|
autoPlay
|
||||||
sizes="100vw"
|
muted
|
||||||
className="object-cover"
|
playsInline
|
||||||
priority={currentIndex === 0}
|
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>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-[#111] to-[#0A0A0C]" />
|
<div className="absolute inset-0 bg-gradient-to-br from-[#111] to-[#0A0A0C]" />
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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 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" />
|
<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 ── */}
|
{/* Overlay text */}
|
||||||
{/* 🔥 Usamos items-end y pb-24/pb-32 para tirarlo hacia la mitad inferior 🔥 */}
|
|
||||||
<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">
|
<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
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.4, duration: 1, ease: "easeOut" }}
|
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"
|
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">
|
<div className="flex flex-col gap-1 md:gap-3">
|
||||||
{/* LEMA PRINCIPAL (Fuente Syncopate) */}
|
<h1
|
||||||
<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`}>
|
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`}
|
||||||
LET THE POWER FLUX
|
>
|
||||||
|
{current?.title || "LET THE POWER FLUX"}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* FRASE SECUNDARIA (Fuente limpia y elegante) */}
|
<h2 className="text-base sm:text-lg md:text-2xl lg:text-[2rem] font-light text-white uppercase tracking-[0.2em] drop-shadow-lg">
|
||||||
<h2 className="text-lg md:text-2xl lg:text-[2rem] font-light text-white uppercase tracking-[0.2em] drop-shadow-lg">
|
{current?.subtitle || "INNOVATION NOT IMITATION"}
|
||||||
INNOVATION NOT IMITATION
|
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ESPACIADOR INVISIBLE */}
|
<div className="h-2 md:h-4" />
|
||||||
<div className="h-2 md:h-4"></div>
|
|
||||||
|
|
||||||
{/* BLOQUE DE DESCRIPCIÓN TIPO "CONSOLA" */}
|
|
||||||
<div className="flex flex-col gap-2 md:gap-3">
|
<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">
|
<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>
|
||||||
<p className="font-mono text-[10px] md:text-xs lg:text-sm text-white/90 uppercase tracking-widest drop-shadow-md">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ export type RevalidateScope =
|
|||||||
| "heritage"
|
| "heritage"
|
||||||
| "operations-inbox"
|
| "operations-inbox"
|
||||||
| "footage"
|
| "footage"
|
||||||
|
| "branding"
|
||||||
| "hero"
|
| "hero"
|
||||||
| "timeline"
|
| "timeline"
|
||||||
|
| "settings"
|
||||||
| "all";
|
| "all";
|
||||||
|
|
||||||
export interface RevalidateOptions {
|
export interface RevalidateOptions {
|
||||||
@@ -61,6 +63,12 @@ export function revalidateContent({ scope, slug }: RevalidateOptions) {
|
|||||||
case "footage":
|
case "footage":
|
||||||
safeRevalidate(`/${locale}`);
|
safeRevalidate(`/${locale}`);
|
||||||
break;
|
break;
|
||||||
|
case "branding":
|
||||||
|
case "settings":
|
||||||
|
// Brand assets and global settings affect every page (header/footer/favicon).
|
||||||
|
safeRevalidate("/", "layout");
|
||||||
|
safeRevalidate(`/${locale}`);
|
||||||
|
break;
|
||||||
case "operations-inbox":
|
case "operations-inbox":
|
||||||
break;
|
break;
|
||||||
case "all":
|
case "all":
|
||||||
|
|||||||
Reference in New Issue
Block a user