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:
+38
-11
@@ -38,8 +38,15 @@ const SCOPE_ROOTS: Record<string, string> = {
|
||||
news: path.join(process.cwd(), "public", "news"),
|
||||
// 🔥 NUEVO: Scope para el Component Matrix
|
||||
parts: path.join(process.cwd(), "public", "parts"),
|
||||
// 🔥 NUEVO: Hero carousel media (flat folder, slug ignored)
|
||||
footage: path.join(process.cwd(), "public", "footage", "main"),
|
||||
// 🔥 NUEVO: Site-wide brand assets (favicon, logo, OG image)
|
||||
branding: path.join(process.cwd(), "public", "branding"),
|
||||
};
|
||||
|
||||
// Scopes that ignore the `slug` parameter and write directly under their root.
|
||||
const FLAT_SCOPES = new Set(["footage", "branding"]);
|
||||
|
||||
const MEDIA_TYPES: Record<string, string[]> = {
|
||||
image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"],
|
||||
video: [".mp4", ".webm", ".mov"],
|
||||
@@ -73,7 +80,18 @@ function sanitizePath(input: string): string {
|
||||
|
||||
function buildSafePath(scope: string, slug: string, subPath?: string): string | null {
|
||||
const root = SCOPE_ROOTS[scope];
|
||||
if (!root || !slug) return null;
|
||||
if (!root) return null;
|
||||
|
||||
// Flat scopes (footage, branding) ignore slug and operate directly on root.
|
||||
if (FLAT_SCOPES.has(scope)) {
|
||||
if (!subPath || subPath === "" || subPath === "/") return root;
|
||||
const cleaned = sanitizePath(subPath);
|
||||
const fullPath = path.join(root, cleaned);
|
||||
if (!path.resolve(fullPath).startsWith(path.resolve(root))) return null;
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
if (!slug) return null;
|
||||
const appDir = path.join(root, slug);
|
||||
if (!subPath || subPath === "" || subPath === "/") return appDir;
|
||||
const cleaned = sanitizePath(subPath);
|
||||
@@ -82,6 +100,12 @@ function buildSafePath(scope: string, slug: string, subPath?: string): string |
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
function buildPublicUrl(scope: string, slug: string, rel: string): string {
|
||||
if (scope === "footage") return `/footage/main/${rel}`;
|
||||
if (scope === "branding") return `/branding/${rel}`;
|
||||
return `/${scope}/${slug}/${rel}`;
|
||||
}
|
||||
|
||||
function buildBreadcrumbs(subPath: string) {
|
||||
const parts = subPath ? subPath.split("/").filter(Boolean) : [];
|
||||
const crumbs = [{ name: "Root", path: "" }];
|
||||
@@ -98,11 +122,11 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const scope = searchParams.get("scope") || "applications";
|
||||
const slug = searchParams.get("slug");
|
||||
const slug = searchParams.get("slug") || "";
|
||||
const subPath = searchParams.get("path") || "";
|
||||
|
||||
if (!slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
|
||||
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: `Invalid scope "${scope}"` }, { status: 400 });
|
||||
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
|
||||
|
||||
const dirPath = buildSafePath(scope, slug, subPath);
|
||||
if (!dirPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
||||
@@ -137,7 +161,7 @@ export async function GET(request: NextRequest) {
|
||||
mediaType: getFileType(entry.name),
|
||||
extension: path.extname(entry.name).toLowerCase(),
|
||||
path: rel,
|
||||
publicUrl: `/${scope}/${slug}/${rel}`,
|
||||
publicUrl: buildPublicUrl(scope, slug, rel),
|
||||
size: getFileSize(stats.size),
|
||||
sizeBytes: stats.size,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
@@ -166,12 +190,13 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const scope = (formData.get("scope") as string) || "applications";
|
||||
const slug = formData.get("slug") as string;
|
||||
const slug = (formData.get("slug") as string) || "";
|
||||
const subPath = formData.get("path") as string || "";
|
||||
const file = formData.get("file") as File;
|
||||
|
||||
if (!slug || !file) return NextResponse.json({ error: "Missing slug or file" }, { status: 400 });
|
||||
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
|
||||
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
|
||||
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
|
||||
|
||||
const ext = path.extname(file.name).toLowerCase();
|
||||
if (!ALL_EXTENSIONS.includes(ext)) {
|
||||
@@ -201,7 +226,7 @@ export async function POST(request: NextRequest) {
|
||||
success: true,
|
||||
file: {
|
||||
name: safeName,
|
||||
publicUrl: `/${scope}/${slug}/${rel}`,
|
||||
publicUrl: buildPublicUrl(scope, slug, rel),
|
||||
path: rel,
|
||||
mediaType: getFileType(safeName),
|
||||
size: getFileSize(file.size),
|
||||
@@ -218,10 +243,11 @@ export async function POST(request: NextRequest) {
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { scope = "applications", slug, folderName, parentPath = "" } = body;
|
||||
const { scope = "applications", slug = "", folderName, parentPath = "" } = body;
|
||||
|
||||
if (!slug || !folderName) return NextResponse.json({ error: "Missing slug or folderName" }, { status: 400 });
|
||||
if (!folderName) return NextResponse.json({ error: "Missing folderName" }, { status: 400 });
|
||||
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
|
||||
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
|
||||
|
||||
const safe = folderName.toLowerCase().trim().replace(/\s+/g, "-").replace(/[^a-z0-9_-]/g, "");
|
||||
if (!safe) return NextResponse.json({ error: "Invalid folder name" }, { status: 400 });
|
||||
@@ -249,10 +275,11 @@ export async function PUT(request: NextRequest) {
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { scope = "applications", slug, filePath } = body;
|
||||
const { scope = "applications", slug = "", filePath } = body;
|
||||
|
||||
if (!slug || !filePath) return NextResponse.json({ error: "Missing params" }, { status: 400 });
|
||||
if (!filePath) return NextResponse.json({ error: "Missing filePath" }, { status: 400 });
|
||||
if (!SCOPE_ROOTS[scope]) return NextResponse.json({ error: "Invalid scope" }, { status: 400 });
|
||||
if (!FLAT_SCOPES.has(scope) && !slug) return NextResponse.json({ error: "Missing slug" }, { status: 400 });
|
||||
|
||||
const targetPath = buildSafePath(scope, slug, filePath);
|
||||
if (!targetPath) return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
||||
|
||||
Reference in New Issue
Block a user