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

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

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

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

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

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

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

DEPLOY NOTES
- After git pull on VPS, run the migration ONCE:
    docker compose exec app npx prisma migrate deploy
  Then:
    docker compose up -d --build app
  Existing data (AdminUser w/ 2FA, ClientUser, GlobalNode, Application,
  TimelineEvent, NewsArticle, HeritageSection, SparePart, OperationsSignal,
  NotificationRoute, PageContent) is NOT touched. Migration only creates
  two new tables.
This commit is contained in:
2026-05-04 09:34:49 -05:00
parent 6e46808c27
commit b9201a437c
9 changed files with 855 additions and 84 deletions
@@ -0,0 +1,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.",