feat: HQ Hero — import existing /footage/main files as managed slides
Deploy to VPS / deploy (push) Has been cancelled

The hero CMS only listed HeroSlide rows from the database. Files dropped
directly into /public/footage/main were rendering on the live site (via
the home page's filesystem fallback) but invisible in the editor — so
the editor couldn't manage their focal point, alt text, ordering or
on/off toggle without re-uploading.

NEW SERVER ACTIONS (src/app/hq-command/dashboard/hero/actions.ts)
- listImportableFootage()
  Scans /public/footage/main, returns the list of files that aren't
  already referenced by a HeroSlide row. Each entry has filename,
  publicUrl, mediaType (image/video), file size and mtime.
- importFootageFiles(filenames[])
  For each selected filename: validates extension, checks existence,
  skips already-imported files, derives a sensible alt text (filename
  with extension stripped, dashes/underscores → spaces, leading
  numeric prefix removed), and creates a HeroSlide row at the next
  available order position. Returns {created, skipped} so the UI can
  show a precise toast.

UI (src/app/hq-command/dashboard/hero/page.tsx)
- New amber-tinted panel above the slide list, visible only when
  importable.length > 0. Shows every uncovered file as a thumbnail
  card with checkbox-style selection.
- 'Select all' / 'Clear' toggle, 'Import all' for one-click bulk
  import, 'Import N selected' once a subset is picked.
- After import: panel re-renders empty (since those files now have
  HeroSlide rows) and the new slides appear in the regular list,
  ready for focal-point and caption editing.

NO BACKEND-DATA CHANGES BEYOND ROW CREATION
- Filesystem untouched (no rename, no move). Files keep their original
  path under /public/footage/main, the new HeroSlide row simply points
  at /footage/main/<filename>.
- Public-facing pages render the same images either way (they read
  from HeroSlide first, footage scan second).
- After import: the home page now reads the slides from the DB and
  uses the focal-point + alt text data the editor sets.
This commit is contained in:
2026-05-05 20:05:29 -05:00
parent 778b35f15a
commit e177bca92f
2 changed files with 265 additions and 2 deletions
@@ -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<T>(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." };
}
}
+125 -2
View File
@@ -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<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Files that exist in /public/footage/main but aren't in HeroSlide yet.
const [importable, setImportable] = useState<FootageFile[]>([]);
const [importPicked, setImportPicked] = useState<Set<string>>(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() {
</div>
</div>
{/* 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 && (
<div className="mb-8 border border-amber-400/20 bg-gradient-to-br from-amber-400/[0.04] to-transparent rounded-2xl overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-amber-400/15">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-amber-400/15 text-amber-400 flex items-center justify-center">
<FolderInput size={16} />
</div>
<div>
<div className="text-sm font-medium text-white">
{importable.length} file{importable.length > 1 ? "s" : ""} in /public/footage/main not yet imported
</div>
<div className="text-xs text-[#86868B] leading-relaxed mt-0.5">
These are showing on the live site as fallback. Import them to manage focal point, captions and order from here.
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={pickAllImportable}
className="text-xs text-[#86868B] hover:text-white px-2 py-1.5"
>
{importPicked.size === importable.length ? "Clear" : "Select all"}
</button>
<button
onClick={() => handleImport(importable.map((f) => f.filename))}
disabled={importBusy}
className="bg-amber-400/15 text-amber-400 hover:bg-amber-400/25 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 inline-flex items-center gap-1.5"
>
{importBusy ? <Loader2 size={12} className="animate-spin" /> : <FolderInput size={12} />}
Import all
</button>
{importPicked.size > 0 && (
<button
onClick={() => handleImport(Array.from(importPicked))}
disabled={importBusy}
className="bg-amber-400 text-black hover:bg-amber-300 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 inline-flex items-center gap-1.5"
>
Import {importPicked.size} selected
</button>
)}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-2 p-4">
{importable.map((f) => {
const picked = importPicked.has(f.filename);
return (
<button
key={f.filename}
onClick={() => togglePick(f.filename)}
className={`relative group rounded-lg overflow-hidden border transition-all text-left ${
picked
? "border-amber-400 ring-2 ring-amber-400/30"
: "border-white/10 hover:border-white/20"
}`}
>
<div className="aspect-video bg-black">
{f.mediaType === "image" ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={f.publicUrl} alt={f.filename} className="w-full h-full object-cover" loading="lazy" />
) : (
<div className="w-full h-full flex items-center justify-center bg-blue-500/5">
<Video size={24} className="text-blue-400/60" />
</div>
)}
</div>
<div className="p-2">
<div className="text-[10px] text-white truncate font-mono">{f.filename}</div>
<div className="text-[9px] text-[#86868B] mt-0.5">{f.size}</div>
</div>
{picked && (
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded bg-amber-400 text-black flex items-center justify-center">
<Check size={12} />
</div>
)}
</button>
);
})}
</div>
</div>
)}
{/* Slides list */}
{loading ? (
<div className="flex items-center justify-center py-20 text-[#86868B]">