diff --git a/src/app/hq-command/dashboard/hero/actions.ts b/src/app/hq-command/dashboard/hero/actions.ts index 1a23cb4..639f2b3 100644 --- a/src/app/hq-command/dashboard/hero/actions.ts +++ b/src/app/hq-command/dashboard/hero/actions.ts @@ -1,9 +1,16 @@ "use server"; +import fs from "fs"; +import path from "path"; import { prisma } from "@/lib/prisma"; import { revalidateContent } from "@/lib/revalidate"; import { translateContentForCMS } from "@/lib/aiTranslator"; +const FOOTAGE_DIR = path.join(process.cwd(), "public", "footage", "main"); +const FOOTAGE_PUBLIC_PREFIX = "/footage/main"; +const SUPPORTED_RE = /\.(png|jpe?g|webp|mp4|webm|mov)$/i; +const VIDEO_EXT_RE = /\.(mp4|webm|mov)$/i; + export async function getHeroSlides() { try { const slides = await prisma.heroSlide.findMany({ @@ -143,3 +150,136 @@ function safeParse(json: string | null | undefined, fallback: T): any { return fallback; } } + +// ─── Bridge between filesystem footage and DB-managed HeroSlides ───── +// The site historically rendered the hero from /public/footage/main without +// a database. Files dropped there are still visible on the live site (the +// home page falls back to a filesystem scan when HeroSlide is empty), but +// the editor can't manage them — no focal point, no per-slide caption, no +// order toggle. These two actions surface the existing files in the HQ UI +// so the editor can click "Import" and bring them under DB control. + +export interface FootageFile { + filename: string; + publicUrl: string; + mediaType: "image" | "video"; + size: string; + bytes: number; + modifiedAt: string; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** List footage/main files that don't yet have a corresponding HeroSlide. */ +export async function listImportableFootage() { + try { + if (!fs.existsSync(FOOTAGE_DIR)) { + return { success: true, available: [], importedCount: 0 }; + } + + const allFiles = fs + .readdirSync(FOOTAGE_DIR) + .filter((name) => SUPPORTED_RE.test(name)) + .filter((name) => !name.startsWith(".")); + + // Map of mediaUrl → exists in DB. We treat a slide as "already imported" + // if any HeroSlide row references the file path we'd assign here. + const existing = await prisma.heroSlide.findMany({ + select: { mediaUrl: true }, + }); + const importedSet = new Set(existing.map((s: any) => s.mediaUrl)); + + const available: FootageFile[] = allFiles + .filter((filename) => !importedSet.has(`${FOOTAGE_PUBLIC_PREFIX}/${filename}`)) + .map((filename): FootageFile => { + const fullPath = path.join(FOOTAGE_DIR, filename); + const stat = fs.statSync(fullPath); + const mediaType: "image" | "video" = VIDEO_EXT_RE.test(filename) ? "video" : "image"; + return { + filename, + publicUrl: `${FOOTAGE_PUBLIC_PREFIX}/${filename}`, + mediaType, + size: formatBytes(stat.size), + bytes: stat.size, + modifiedAt: stat.mtime.toISOString(), + }; + }) + .sort((a, b) => a.filename.localeCompare(b.filename)); + + return { + success: true, + available, + importedCount: existing.length, + }; + } catch (error: any) { + return { error: error.message || "Failed to scan footage folder." }; + } +} + +/** Create HeroSlide rows for the given filenames, appended after current order. */ +export async function importFootageFiles(filenames: string[]) { + try { + if (!Array.isArray(filenames) || filenames.length === 0) { + return { error: "No files selected." }; + } + + const last = await prisma.heroSlide.findFirst({ + orderBy: { order: "desc" }, + select: { order: true }, + }); + let nextOrder = last ? last.order + 1 : 0; + + const created: string[] = []; + const skipped: { filename: string; reason: string }[] = []; + + for (const raw of filenames) { + // Sanity: only accept filenames (no path traversal), supported ext. + const filename = path.basename(raw); + if (!SUPPORTED_RE.test(filename)) { + skipped.push({ filename, reason: "Unsupported extension" }); + continue; + } + const fullPath = path.join(FOOTAGE_DIR, filename); + if (!fs.existsSync(fullPath)) { + skipped.push({ filename, reason: "File not found on disk" }); + continue; + } + + const mediaUrl = `${FOOTAGE_PUBLIC_PREFIX}/${filename}`; + const already = await prisma.heroSlide.findFirst({ where: { mediaUrl } }); + if (already) { + skipped.push({ filename, reason: "Already imported" }); + continue; + } + + // Default alt = filename without extension, dashes/underscores → spaces. + const altText = filename + .replace(/\.[^.]+$/, "") + .replace(/[-_]+/g, " ") + .replace(/^\d+\s+/, "") // strip leading "01 " ordering prefix + .trim(); + + await prisma.heroSlide.create({ + data: { + mediaUrl, + mediaType: VIDEO_EXT_RE.test(filename) ? "video" : "image", + altText: altText || filename, + order: nextOrder, + isActive: true, + }, + }); + created.push(filename); + nextOrder++; + } + + revalidateContent({ scope: "hero" }); + revalidateContent({ scope: "footage" }); + return { success: true, created, skipped }; + } catch (error: any) { + return { error: error.message || "Import failed." }; + } +} diff --git a/src/app/hq-command/dashboard/hero/page.tsx b/src/app/hq-command/dashboard/hero/page.tsx index 5acc382..5dfcce7 100644 --- a/src/app/hq-command/dashboard/hero/page.tsx +++ b/src/app/hq-command/dashboard/hero/page.tsx @@ -7,6 +7,7 @@ import Link from "next/link"; import { ArrowLeft, Image as ImageIcon, Plus, Trash2, Loader2, GripVertical, Eye, EyeOff, Crosshair, Sparkles, Upload, Check, X, ChevronDown, + FolderInput, Video, } from "lucide-react"; import { getHeroSlides, @@ -14,6 +15,9 @@ import { updateHeroSlide, deleteHeroSlide, reorderHeroSlides, + listImportableFootage, + importFootageFiles, + type FootageFile, } from "./actions"; interface SlideRow { @@ -45,15 +49,48 @@ export default function HeroDashboard() { const [draggedId, setDraggedId] = useState(null); const fileInputRef = useRef(null); + // Files that exist in /public/footage/main but aren't in HeroSlide yet. + const [importable, setImportable] = useState([]); + const [importPicked, setImportPicked] = useState>(new Set()); + const [importBusy, setImportBusy] = useState(false); + const loadSlides = useCallback(async () => { setLoading(true); - const res = await getHeroSlides(); - if (res.success && res.slides) setSlides(res.slides as SlideRow[]); + const [slidesRes, importableRes] = await Promise.all([ + getHeroSlides(), + listImportableFootage(), + ]); + if (slidesRes.success && slidesRes.slides) setSlides(slidesRes.slides as SlideRow[]); + if (importableRes.success && importableRes.available) { + setImportable(importableRes.available); + setImportPicked(new Set()); + } setLoading(false); }, []); useEffect(() => { loadSlides(); }, [loadSlides]); + // ─── Import existing footage ──────────────────────────────────── + const togglePick = (filename: string) => { + setImportPicked((prev) => { + const next = new Set(prev); + if (next.has(filename)) next.delete(filename); + else next.add(filename); + return next; + }); + }; + const pickAllImportable = () => { + if (importPicked.size === importable.length) setImportPicked(new Set()); + else setImportPicked(new Set(importable.map((f) => f.filename))); + }; + const handleImport = async (filenames: string[]) => { + if (filenames.length === 0) return; + setImportBusy(true); + await importFootageFiles(filenames); + setImportBusy(false); + await loadSlides(); + }; + const flashSaved = (id: string) => { setSavedFlash(id); setTimeout(() => setSavedFlash(null), 1500); @@ -195,6 +232,92 @@ export default function HeroDashboard() { + {/* Import existing footage panel — only shown when there are files + in /public/footage/main that aren't tracked as HeroSlide rows yet. + Lets editors bring their existing assets under DB management + without re-uploading. */} + {importable.length > 0 && ( +
+
+
+
+ +
+
+
+ {importable.length} file{importable.length > 1 ? "s" : ""} in /public/footage/main not yet imported +
+
+ These are showing on the live site as fallback. Import them to manage focal point, captions and order from here. +
+
+
+
+ + + {importPicked.size > 0 && ( + + )} +
+
+ +
+ {importable.map((f) => { + const picked = importPicked.has(f.filename); + return ( + + ); + })} +
+
+ )} + {/* Slides list */} {loading ? (