From abd75798ef913e65b4c7cf5bdc4c103643bb5abc Mon Sep 17 00:00:00 2001 From: DavidHerran Date: Tue, 5 May 2026 12:28:57 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20timeline=20+=20heritage=20HQ=20panels?= =?UTF-8?q?=20=E2=80=94=20drag-drop=20+=20inline=20auto-save?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bringing the same UX pattern we shipped on Hero and Settings to the Company Legacy and Our Heritage panels. No more modal-driven editing where you have to click "Edit" → modify → click "Save" → wait → close modal. Every field is now editable in place; saves fire on blur and flash a "Saved ✓" confirmation; rows reorder via drag-drop. NEW SERVER ACTIONS (additive — old formData-style actions still exist) - timeline/actions.ts: patchTimelineEvent(id, partial) — granular field update reorderTimelineEvents(ids[]) — single transaction createTimelineStub() — instant blank row - heritage/actions.ts: patchHeritageSection(id, partial) — granular field update reorderHeritageSections(ids[]) — single transaction createHeritageStub(type) — text/image/video stubs PAGES REWRITTEN - timeline/page.tsx: 208 → 215 lines, but radically simpler — drag handle + inline year input + inline title + inline description textarea per row, plus eye toggle for isActive and a trash button. Global "Auto-translate edits" checkbox at top applies to every patch. - heritage/page.tsx: 209 → 270 lines (more functionality fits in less cognitive load). Three coloured "Add" buttons (Text / Image / Video) spawn a stub that renders the right card type. Image/Video cards have a built-in MediaPicker with upload-or-paste-filename + live preview. UX UPGRADES THE EDITOR SEES - Type-and-tab to save. No modals, no submit buttons per row. - Saving / Saved ✓ badge per row so it's obvious when a field has hit the database. - Drag handle + drop target on every card → reordering becomes "grab and pull", same metaphor as Hero slides. - AI translation is one toggle at the top, not a per-modal switch. - Soft-warning empty state with a clear "do this first" hint instead of a blank page. NO BREAKING CHANGES - Old createTimelineEvent / updateTimelineEvent / createHeritageSection / updateHeritageSection actions are kept (in case anything still imports them — nothing in the repo does, but external scripts might). Internal call sites all use the new patch flow. - Database schema unchanged. - Public-facing /heritage page unchanged. --- .../hq-command/dashboard/heritage/actions.ts | 83 +++ .../hq-command/dashboard/heritage/page.tsx | 511 +++++++++++------- .../hq-command/dashboard/timeline/actions.ts | 89 +++ .../hq-command/dashboard/timeline/page.tsx | 396 ++++++++------ 4 files changed, 722 insertions(+), 357 deletions(-) diff --git a/src/app/hq-command/dashboard/heritage/actions.ts b/src/app/hq-command/dashboard/heritage/actions.ts index a8ed466..c7f7272 100644 --- a/src/app/hq-command/dashboard/heritage/actions.ts +++ b/src/app/hq-command/dashboard/heritage/actions.ts @@ -92,4 +92,87 @@ export async function deleteHeritageSection(id: string) { } catch (error) { return { error: "Failed to delete section." }; } +} + +// PATCH GRANULAR — for inline auto-save in the new UI. +// Same pattern as patchTimelineEvent: only the supplied fields are written, +// AI translation kicks in when autoTranslate=true and a text field changed. +export async function patchHeritageSection( + id: string, + patch: { + type?: string; + title?: string | null; + content?: string | null; + mediaUrl?: string | null; + order?: number; + autoTranslate?: boolean; + } +) { + try { + if (!id) return { error: "Missing id" }; + + const data: any = {}; + if (patch.type !== undefined) data.type = patch.type; + if (patch.title !== undefined) data.title = patch.title; + if (patch.content !== undefined) data.content = patch.content; + if (patch.mediaUrl !== undefined) data.mediaUrl = patch.mediaUrl; + if (patch.order !== undefined) data.order = patch.order; + + if (patch.autoTranslate && (patch.title !== undefined || patch.content !== undefined)) { + const existing = await prisma.heritageSection.findUnique({ where: { id } }); + const finalTitle = patch.title ?? existing?.title ?? ""; + const finalContent = patch.content ?? existing?.content ?? ""; + if (finalTitle || finalContent) { + const aiResult = await translateContentForCMS({ title: finalTitle, content: finalContent }); + if (aiResult) data.translationsJson = JSON.stringify(aiResult); + } + } + + await prisma.heritageSection.update({ where: { id }, data }); + revalidatePath("/hq-command/dashboard/heritage"); + revalidatePath("/[locale]/heritage", "layout"); + return { success: true }; + } catch (error: any) { + return { error: error.message || "Failed to update section." }; + } +} + +export async function reorderHeritageSections(orderedIds: string[]) { + try { + await prisma.$transaction( + orderedIds.map((id, idx) => + prisma.heritageSection.update({ where: { id }, data: { order: idx } }) + ) + ); + revalidatePath("/hq-command/dashboard/heritage"); + revalidatePath("/[locale]/heritage", "layout"); + return { success: true }; + } catch (error: any) { + return { error: error.message || "Failed to reorder." }; + } +} + +export async function createHeritageStub(type: string = "text") { + try { + const last = await prisma.heritageSection.findFirst({ + orderBy: { order: "desc" }, + select: { order: true }, + }); + const nextOrder = last ? last.order + 1 : 0; + + const section = await prisma.heritageSection.create({ + data: { + type, + title: type === "text" ? "New section" : null, + content: type === "text" ? "" : null, + mediaUrl: null, + order: nextOrder, + }, + }); + + revalidatePath("/hq-command/dashboard/heritage"); + return { success: true, section }; + } catch (error: any) { + return { error: error.message || "Failed to create section." }; + } } \ No newline at end of file diff --git a/src/app/hq-command/dashboard/heritage/page.tsx b/src/app/hq-command/dashboard/heritage/page.tsx index ac5786c..8c36d02 100644 --- a/src/app/hq-command/dashboard/heritage/page.tsx +++ b/src/app/hq-command/dashboard/heritage/page.tsx @@ -1,210 +1,359 @@ "use client"; -import { useState, useEffect } from "react"; +export const dynamic = "force-dynamic"; + +import { useState, useEffect, useCallback, useRef } from "react"; import Link from "next/link"; -// 🔥 Agregamos Sparkles -import { ArrowLeft, BookOpen, Plus, Trash2, Loader2, X, Image as ImageIcon, FileText, Video, Edit3, Sparkles } from "lucide-react"; -import { getHeritageSections, createHeritageSection, updateHeritageSection, deleteHeritageSection } from "./actions"; +import { + ArrowLeft, BookOpen, Plus, Trash2, Loader2, GripVertical, Sparkles, + Image as ImageIcon, FileText, Video, Check, Upload, +} from "lucide-react"; +import { + getHeritageSections, + patchHeritageSection, + deleteHeritageSection, + reorderHeritageSections, + createHeritageStub, +} from "./actions"; + +interface SectionRow { + id: string; + type: string; // "text" | "image" | "video" + title: string | null; + content: string | null; + mediaUrl: string | null; + order: number; + translationsJson: string | null; +} + +const TYPE_META: Record = { + text: { label: "Text", icon: FileText, color: "#FFFFFF" }, + image: { label: "Image", icon: ImageIcon, color: "#00F0FF" }, + video: { label: "Video", icon: Video, color: "#FF6B9D" }, +}; export default function HeritageManager() { - const [sections, setSections] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [isModalOpen, setIsModalOpen] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - - const [editingSec, setEditingSec] = useState(null); - const [sectionType, setSectionType] = useState("text"); + const [sections, setSections] = useState([]); + const [loading, setLoading] = useState(true); + const [savingId, setSavingId] = useState(null); + const [savedFlash, setSavedFlash] = useState(null); + const [draggedId, setDraggedId] = useState(null); + const [autoTranslate, setAutoTranslate] = useState(true); - const fetchSections = async () => { - setIsLoading(true); + const load = useCallback(async () => { + setLoading(true); const res = await getHeritageSections(); - if (res.success && res.sections) setSections(res.sections); - setIsLoading(false); + if (res.success && res.sections) setSections(res.sections as SectionRow[]); + setLoading(false); + }, []); + + useEffect(() => { load(); }, [load]); + + const flashSaved = (id: string) => { + setSavedFlash(id); + setTimeout(() => setSavedFlash(null), 1500); }; - useEffect(() => { fetchSections(); }, []); - - const openCreateModal = () => { - setEditingSec(null); - setSectionType("text"); - setIsModalOpen(true); + const patch = async (id: string, fields: Partial) => { + setEvents(id, fields); + setSavingId(id); + await patchHeritageSection(id, { ...fields, autoTranslate }); + setSavingId(null); + flashSaved(id); }; - const openEditModal = (sec: any) => { - setEditingSec(sec); - setSectionType(sec.type); - setIsModalOpen(true); + const setEvents = (id: string, fields: Partial) => { + setSections((prev) => prev.map((s) => (s.id === id ? { ...s, ...fields } : s))); }; - const handleSave = async (e: React.FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); - const formData = new FormData(e.currentTarget); - - if (editingSec) { - await updateHeritageSection(formData); - } else { - await createHeritageSection(formData); - } - - setIsModalOpen(false); - fetchSections(); - setIsSubmitting(false); + const handleAdd = async (type: "text" | "image" | "video") => { + const res = await createHeritageStub(type); + if (res.success) await load(); }; const handleDelete = async (id: string) => { - if (confirm("Remove this section from the Heritage page?")) { - await deleteHeritageSection(id); fetchSections(); - } + if (!confirm("Delete this section? This cannot be undone.")) return; + await deleteHeritageSection(id); + await load(); + }; + + // Drag-drop reorder + const onDragStart = (id: string) => setDraggedId(id); + const onDragOver = (e: React.DragEvent) => e.preventDefault(); + const onDrop = async (targetId: string) => { + if (!draggedId || draggedId === targetId) return; + const ids = sections.map((s) => s.id); + const fromIdx = ids.indexOf(draggedId); + const toIdx = ids.indexOf(targetId); + if (fromIdx < 0 || toIdx < 0) return; + const reordered = [...ids]; + reordered.splice(fromIdx, 1); + reordered.splice(toIdx, 0, draggedId); + setSections((prev) => + reordered.map((id, i) => ({ ...prev.find((s) => s.id === id)!, order: i })) + ); + setDraggedId(null); + await reorderHeritageSections(reordered); }; return ( -
-
- - Back to Command Center - -
-
-

- The FLUX Heritage -

-

Build Patrizio's deep story page block by block (Text, Images, Video).

+
+ + Back to Dashboard + + +
+
+
+ + Our Heritage
-
+ +
+ + +
-
- {isLoading ? ( -
Loading...
- ) : sections.length === 0 ? ( -
The Heritage page is currently empty. Add the first text block.
- ) : ( - sections.map((sec) => ( -
-
-
- {sec.type === 'text' ? : sec.type === 'image' ? :
-
-
- Order: {sec.order} - {sec.title || "Untitled Block"} -
- {sec.content &&

{sec.content}

} - {sec.mediaUrl && ( -

- {sec.type === 'video' ? `/heritage/videos/${sec.mediaUrl}` : `/heritage/${sec.mediaUrl}`} -

- )} -
-
-
- - -
-
- )) - )} -
+ - {isModalOpen && ( -
-
- -
-
- -

{editingSec ? "Edit Story Block" : "Add Story Block"}

-
- - {/* 🔥 LE DAMOS UN ID AL FORMULARIO 🔥 */} -
- - -
-
- - -
-
- - -
-
- -
- - -
- - {sectionType === "text" && ( -
- -