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,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>
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user