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"; "use server";
import fs from "fs";
import path from "path";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { revalidateContent } from "@/lib/revalidate"; import { revalidateContent } from "@/lib/revalidate";
import { translateContentForCMS } from "@/lib/aiTranslator"; 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() { export async function getHeroSlides() {
try { try {
const slides = await prisma.heroSlide.findMany({ const slides = await prisma.heroSlide.findMany({
@@ -143,3 +150,136 @@ function safeParse<T>(json: string | null | undefined, fallback: T): any {
return fallback; 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 { import {
ArrowLeft, Image as ImageIcon, Plus, Trash2, Loader2, GripVertical, ArrowLeft, Image as ImageIcon, Plus, Trash2, Loader2, GripVertical,
Eye, EyeOff, Crosshair, Sparkles, Upload, Check, X, ChevronDown, Eye, EyeOff, Crosshair, Sparkles, Upload, Check, X, ChevronDown,
FolderInput, Video,
} from "lucide-react"; } from "lucide-react";
import { import {
getHeroSlides, getHeroSlides,
@@ -14,6 +15,9 @@ import {
updateHeroSlide, updateHeroSlide,
deleteHeroSlide, deleteHeroSlide,
reorderHeroSlides, reorderHeroSlides,
listImportableFootage,
importFootageFiles,
type FootageFile,
} from "./actions"; } from "./actions";
interface SlideRow { interface SlideRow {
@@ -45,15 +49,48 @@ export default function HeroDashboard() {
const [draggedId, setDraggedId] = useState<string | null>(null); const [draggedId, setDraggedId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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 () => { const loadSlides = useCallback(async () => {
setLoading(true); setLoading(true);
const res = await getHeroSlides(); const [slidesRes, importableRes] = await Promise.all([
if (res.success && res.slides) setSlides(res.slides as SlideRow[]); 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); setLoading(false);
}, []); }, []);
useEffect(() => { loadSlides(); }, [loadSlides]); 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) => { const flashSaved = (id: string) => {
setSavedFlash(id); setSavedFlash(id);
setTimeout(() => setSavedFlash(null), 1500); setTimeout(() => setSavedFlash(null), 1500);
@@ -195,6 +232,92 @@ export default function HeroDashboard() {
</div> </div>
</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 */} {/* Slides list */}
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-20 text-[#86868B]"> <div className="flex items-center justify-center py-20 text-[#86868B]">