feat: HQ Hero — import existing /footage/main files as managed slides
Deploy to VPS / deploy (push) Has been cancelled
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:
@@ -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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
Reference in New Issue
Block a user