feat: timeline + heritage HQ panels — drag-drop + inline auto-save
Deploy to VPS / deploy (push) Has been cancelled
Deploy to VPS / deploy (push) Has been cancelled
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.
This commit is contained in:
@@ -93,3 +93,86 @@ export async function deleteHeritageSection(id: string) {
|
|||||||
return { error: "Failed to delete section." };
|
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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,208 +1,357 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
// 🔥 Agregamos Sparkles
|
import {
|
||||||
import { ArrowLeft, BookOpen, Plus, Trash2, Loader2, X, Image as ImageIcon, FileText, Video, Edit3, Sparkles } from "lucide-react";
|
ArrowLeft, BookOpen, Plus, Trash2, Loader2, GripVertical, Sparkles,
|
||||||
import { getHeritageSections, createHeritageSection, updateHeritageSection, deleteHeritageSection } from "./actions";
|
Image as ImageIcon, FileText, Video, Check, Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
getHeritageSections,
|
||||||
|
patchHeritageSection,
|
||||||
|
deleteHeritageSection,
|
||||||
|
reorderHeritageSections,
|
||||||
|
createHeritageStub,
|
||||||
|
} from "./actions";
|
||||||
|
|
||||||
export default function HeritageManager() {
|
interface SectionRow {
|
||||||
const [sections, setSections] = useState<any[]>([]);
|
id: string;
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
type: string; // "text" | "image" | "video"
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
title: string | null;
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
content: string | null;
|
||||||
|
mediaUrl: string | null;
|
||||||
const [editingSec, setEditingSec] = useState<any | null>(null);
|
order: number;
|
||||||
const [sectionType, setSectionType] = useState("text");
|
translationsJson: string | null;
|
||||||
|
|
||||||
const fetchSections = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
const res = await getHeritageSections();
|
|
||||||
if (res.success && res.sections) setSections(res.sections);
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { fetchSections(); }, []);
|
|
||||||
|
|
||||||
const openCreateModal = () => {
|
|
||||||
setEditingSec(null);
|
|
||||||
setSectionType("text");
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditModal = (sec: any) => {
|
|
||||||
setEditingSec(sec);
|
|
||||||
setSectionType(sec.type);
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsSubmitting(true);
|
|
||||||
const formData = new FormData(e.currentTarget);
|
|
||||||
|
|
||||||
if (editingSec) {
|
|
||||||
await updateHeritageSection(formData);
|
|
||||||
} else {
|
|
||||||
await createHeritageSection(formData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsModalOpen(false);
|
const TYPE_META: Record<string, { label: string; icon: typeof ImageIcon; color: string }> = {
|
||||||
fetchSections();
|
text: { label: "Text", icon: FileText, color: "#FFFFFF" },
|
||||||
setIsSubmitting(false);
|
image: { label: "Image", icon: ImageIcon, color: "#00F0FF" },
|
||||||
|
video: { label: "Video", icon: Video, color: "#FF6B9D" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HeritageManager() {
|
||||||
|
const [sections, setSections] = useState<SectionRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
|
const [savedFlash, setSavedFlash] = useState<string | null>(null);
|
||||||
|
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||||
|
const [autoTranslate, setAutoTranslate] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await getHeritageSections();
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const patch = async (id: string, fields: Partial<SectionRow>) => {
|
||||||
|
setEvents(id, fields);
|
||||||
|
setSavingId(id);
|
||||||
|
await patchHeritageSection(id, { ...fields, autoTranslate });
|
||||||
|
setSavingId(null);
|
||||||
|
flashSaved(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setEvents = (id: string, fields: Partial<SectionRow>) => {
|
||||||
|
setSections((prev) => prev.map((s) => (s.id === id ? { ...s, ...fields } : s)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = async (type: "text" | "image" | "video") => {
|
||||||
|
const res = await createHeritageStub(type);
|
||||||
|
if (res.success) await load();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (confirm("Remove this section from the Heritage page?")) {
|
if (!confirm("Delete this section? This cannot be undone.")) return;
|
||||||
await deleteHeritageSection(id); fetchSections();
|
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 (
|
return (
|
||||||
<div className="min-h-screen p-6 md:p-12 max-w-7xl mx-auto">
|
<div className="min-h-screen p-6 md:p-12 max-w-5xl mx-auto">
|
||||||
<div className="mb-10">
|
<Link
|
||||||
<Link href="/hq-command/dashboard" className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-white transition-colors mb-6 group">
|
href="/hq-command/dashboard"
|
||||||
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to Command Center
|
className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-white mb-6 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} /> Back to Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
|
|
||||||
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-light text-white flex items-center gap-3">
|
<div className="flex items-center gap-2 text-white mb-2">
|
||||||
<BookOpen className="text-white" /> The FLUX Heritage
|
<BookOpen size={16} />
|
||||||
|
<span className="text-[10px] uppercase tracking-widest font-bold">Our Heritage</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-light text-white">
|
||||||
|
Patrizio's <span className="font-medium">Story.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[#86868B] mt-2">Build Patrizio's deep story page block by block (Text, Images, Video).</p>
|
<p className="text-[#86868B] mt-2 text-sm">
|
||||||
</div>
|
Build the page block by block. Drag to reorder. Edits auto-save.
|
||||||
<button onClick={openCreateModal} className="flex items-center gap-2 bg-white text-black px-5 py-2.5 rounded-xl font-medium hover:bg-gray-200 transition-all">
|
|
||||||
<Plus size={18} /> Add Content Block
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="py-12 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading...</div>
|
|
||||||
) : sections.length === 0 ? (
|
|
||||||
<div className="p-12 text-center border border-white/10 rounded-3xl bg-black/20 text-[#86868B]">The Heritage page is currently empty. Add the first text block.</div>
|
|
||||||
) : (
|
|
||||||
sections.map((sec) => (
|
|
||||||
<div key={sec.id} className="bg-[#111] border border-white/5 p-6 rounded-2xl flex items-start justify-between group hover:bg-white/[0.02] transition-colors shadow-lg">
|
|
||||||
<div className="flex gap-4 w-full">
|
|
||||||
<div className="mt-1 text-[#86868B] shrink-0">
|
|
||||||
{sec.type === 'text' ? <FileText size={20}/> : sec.type === 'image' ? <ImageIcon size={20}/> : <Video size={20}/>}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="text-[10px] uppercase tracking-widest text-white/50 border border-white/10 px-2 py-0.5 rounded bg-black/40">Order: {sec.order}</span>
|
|
||||||
<span className="text-sm font-medium text-white">{sec.title || "Untitled Block"}</span>
|
|
||||||
</div>
|
|
||||||
{sec.content && <p className="text-xs text-[#86868B] max-w-2xl line-clamp-2 mt-2 leading-relaxed">{sec.content}</p>}
|
|
||||||
{sec.mediaUrl && (
|
|
||||||
<p className="text-xs text-[#00F0FF] mt-2 font-mono">
|
|
||||||
{sec.type === 'video' ? `/heritage/videos/${sec.mediaUrl}` : `/heritage/${sec.mediaUrl}`}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-all shrink-0 ml-4">
|
|
||||||
<button onClick={() => openEditModal(sec)} className="text-[#86868B] hover:text-white p-2 bg-white/5 rounded-lg"><Edit3 size={16}/></button>
|
|
||||||
<button onClick={() => handleDelete(sec.id)} className="text-[#86868B] hover:text-red-400 p-2 bg-red-500/10 rounded-lg"><Trash2 size={16}/></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isModalOpen && (
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
|
<button
|
||||||
<div className="bg-[#111] border border-white/10 w-full max-w-xl rounded-[2rem] p-0 relative shadow-2xl flex flex-col max-h-[90vh]">
|
onClick={() => handleAdd("text")}
|
||||||
|
className="flex items-center gap-2 bg-white/10 text-white border border-white/10 px-3 py-2 rounded-lg text-xs font-medium hover:bg-white/15 transition-colors"
|
||||||
<div className="p-6 md:p-8 border-b border-white/10 relative shrink-0">
|
>
|
||||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-white to-transparent"></div>
|
<FileText size={13} /> Text
|
||||||
<button onClick={() => setIsModalOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white transition-colors"><X size={20} /></button>
|
</button>
|
||||||
<h3 className="text-2xl font-light text-white">{editingSec ? "Edit Story Block" : "Add Story Block"}</h3>
|
<button
|
||||||
</div>
|
onClick={() => handleAdd("image")}
|
||||||
|
className="flex items-center gap-2 bg-[#00F0FF]/10 text-[#00F0FF] border border-[#00F0FF]/15 px-3 py-2 rounded-lg text-xs font-medium hover:bg-[#00F0FF]/20 transition-colors"
|
||||||
{/* 🔥 LE DAMOS UN ID AL FORMULARIO 🔥 */}
|
>
|
||||||
<form id="heritage-form" onSubmit={handleSave} className="flex-1 overflow-y-auto p-6 md:p-8 space-y-5 [scrollbar-width:none]">
|
<ImageIcon size={13} /> Image
|
||||||
<input type="hidden" name="id" value={editingSec?.id || ""} />
|
</button>
|
||||||
|
<button
|
||||||
<div className="grid grid-cols-2 gap-4">
|
onClick={() => handleAdd("video")}
|
||||||
<div>
|
className="flex items-center gap-2 bg-[#FF6B9D]/10 text-[#FF6B9D] border border-[#FF6B9D]/15 px-3 py-2 rounded-lg text-xs font-medium hover:bg-[#FF6B9D]/20 transition-colors"
|
||||||
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Block Type</label>
|
>
|
||||||
<select name="type" value={sectionType} onChange={(e) => setSectionType(e.target.value)} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-white outline-none">
|
<Video size={13} /> Video
|
||||||
<option value="text">📝 Text (Markdown)</option>
|
|
||||||
<option value="image">🖼️ Large Image</option>
|
|
||||||
<option value="video">▶️ Local Video (.mp4)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Display Order (1, 2...)</label>
|
|
||||||
<input name="order" type="number" required defaultValue={editingSec?.order || 1} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-white outline-none" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Title (Optional)</label>
|
|
||||||
<input name="title" type="text" defaultValue={editingSec?.title} placeholder="e.g., The Early Days in Italy" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-white outline-none" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sectionType === "text" && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5 flex justify-between items-center">
|
|
||||||
<span>Story Content (Markdown)</span>
|
|
||||||
</label>
|
|
||||||
<textarea name="content" defaultValue={editingSec?.content} required rows={10} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-4 text-white text-sm focus:border-white outline-none resize-none leading-relaxed mb-3" placeholder="Write the history here..." />
|
|
||||||
|
|
||||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4 text-xs text-[#86868B] font-mono leading-relaxed">
|
|
||||||
<p className="text-white font-semibold uppercase tracking-widest mb-2 text-[10px]">Markdown Cheat Sheet:</p>
|
|
||||||
<p><strong># Title 1</strong> | <strong>## Title 2</strong> | <strong>**Bold**</strong></p>
|
|
||||||
<p className="mt-1"><strong>- List Item</strong> | <strong>> Blockquote</strong></p>
|
|
||||||
<p className="mt-1 text-[#00F0FF]"><strong></strong></p>
|
|
||||||
<div className="mt-3 pt-3 border-t border-white/10">
|
|
||||||
<p className="mb-1"><strong>Tables (Last column highlights automatically):</strong></p>
|
|
||||||
<p>| Year | Milestone | Innovation |</p>
|
|
||||||
<p>|---|---|---|</p>
|
|
||||||
<p>| 1980 | First Patent | Radiofrequency |</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sectionType !== "text" && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">
|
|
||||||
{sectionType === "image" ? "Image Filename (in /public/heritage/)" : "Video Filename (in /public/heritage/videos/)"}
|
|
||||||
</label>
|
|
||||||
<input name="mediaUrl" type="text" defaultValue={editingSec?.mediaUrl} required placeholder={sectionType === "image" ? "e.g., patrizio-1980.jpg" : "e.g., history-1980.mp4"} className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-[#00F0FF] font-mono text-sm focus:border-white outline-none" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 🔥 SWITCH DE LA IA AÑADIDO 🔥 */}
|
|
||||||
<div className="bg-gradient-to-r from-white/10 to-transparent border border-white/20 p-4 rounded-xl flex items-center justify-between mt-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 bg-white/20 rounded-lg text-white"><Sparkles size={18} /></div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-white font-medium">Flux AI Auto-Translate</p>
|
|
||||||
<p className="text-[10px] text-[#86868B] uppercase tracking-widest mt-0.5">Translates Markdown to IT, VEC, ES, DE</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
|
||||||
<input type="checkbox" name="autoTranslate" defaultChecked className="sr-only peer" />
|
|
||||||
<div className="w-11 h-6 bg-black/50 border border-white/10 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-white"></div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="p-6 border-t border-white/10 bg-[#111] rounded-b-[2rem] flex justify-end gap-3 shrink-0">
|
|
||||||
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-3 text-sm font-medium text-[#86868B] hover:text-white transition-colors">Cancel</button>
|
|
||||||
|
|
||||||
{/* 🔥 APUNTAMOS AL FORMULARIO 🔥 */}
|
|
||||||
<button onClick={() => (document.getElementById("heritage-form") as HTMLFormElement)?.requestSubmit()} disabled={isSubmitting} className="w-full md:w-auto bg-white text-black py-3 px-8 rounded-xl text-sm font-semibold hover:bg-gray-200 transition-colors disabled:opacity-50 flex justify-center items-center gap-2">
|
|
||||||
{isSubmitting ? <Loader2 className="animate-spin mx-auto" size={18}/> : (editingSec ? "Save Changes" : "Add to Heritage Page")}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 mb-6 text-xs text-[#86868B] cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoTranslate}
|
||||||
|
onChange={(e) => setAutoTranslate(e.target.checked)}
|
||||||
|
className="accent-[#00F0FF]"
|
||||||
|
/>
|
||||||
|
<Sparkles size={12} className="text-[#00F0FF]" />
|
||||||
|
Auto-translate edits to IT, VEC, ES, DE
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20 text-[#86868B]">
|
||||||
|
<Loader2 className="animate-spin mr-2" size={16} /> Loading sections…
|
||||||
|
</div>
|
||||||
|
) : sections.length === 0 ? (
|
||||||
|
<div className="text-center py-20 text-[#86868B] bg-white/[0.02] rounded-3xl border border-white/5">
|
||||||
|
<BookOpen size={32} className="mx-auto mb-3 opacity-40" />
|
||||||
|
<p>No sections yet.</p>
|
||||||
|
<p className="text-xs mt-1">Add a Text, Image, or Video block above to get started.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<SectionCard
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
isSaving={savingId === section.id}
|
||||||
|
justSaved={savedFlash === section.id}
|
||||||
|
isDragging={draggedId === section.id}
|
||||||
|
onDragStart={() => onDragStart(section.id)}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={() => onDrop(section.id)}
|
||||||
|
onPatch={(fields) => patch(section.id, fields)}
|
||||||
|
onDelete={() => handleDelete(section.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Section card ─────────────────────────────────────────────────
|
||||||
|
function SectionCard({
|
||||||
|
section, isSaving, justSaved, isDragging,
|
||||||
|
onDragStart, onDragOver, onDrop, onPatch, onDelete,
|
||||||
|
}: {
|
||||||
|
section: SectionRow;
|
||||||
|
isSaving: boolean;
|
||||||
|
justSaved: boolean;
|
||||||
|
isDragging: boolean;
|
||||||
|
onDragStart: () => void;
|
||||||
|
onDragOver: (e: React.DragEvent) => void;
|
||||||
|
onDrop: () => void;
|
||||||
|
onPatch: (fields: Partial<SectionRow>) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const meta = TYPE_META[section.type] || TYPE_META.text;
|
||||||
|
const Icon = meta.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
className={`bg-[#111] border border-white/10 rounded-2xl p-4 transition-all ${isDragging ? "opacity-50" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<button className="cursor-grab text-[#86868B] hover:text-white p-1" title="Drag to reorder">
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium"
|
||||||
|
style={{ backgroundColor: `${meta.color}15`, color: meta.color, border: `1px solid ${meta.color}30` }}
|
||||||
|
>
|
||||||
|
<Icon size={12} /> {meta.label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={section.title || ""}
|
||||||
|
onChange={(e) => onPatch({ title: e.target.value })}
|
||||||
|
onBlur={(e) => onPatch({ title: e.target.value })}
|
||||||
|
placeholder={section.type === "text" ? "Section title (optional)" : "Caption / alt text"}
|
||||||
|
className="flex-1 bg-transparent text-white text-sm font-medium outline-none focus:bg-white/[0.04] rounded px-2 py-1 -mx-2 -my-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
{isSaving && <Loader2 size={12} className="animate-spin text-[#00F0FF]" />}
|
||||||
|
{justSaved && (
|
||||||
|
<span className="text-emerald-400 flex items-center gap-1">
|
||||||
|
<Check size={12} /> Saved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="p-2 rounded-lg text-rose-400 hover:bg-rose-500/10"
|
||||||
|
title="Delete section"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{section.type === "text" ? (
|
||||||
|
<textarea
|
||||||
|
value={section.content || ""}
|
||||||
|
onChange={(e) => onPatch({ content: e.target.value })}
|
||||||
|
onBlur={(e) => onPatch({ content: e.target.value })}
|
||||||
|
placeholder="Write the content here. Markdown supported (**bold**, # H1, ## H2, > quote, - list, | tables |)."
|
||||||
|
rows={6}
|
||||||
|
className="w-full bg-black/40 border border-white/10 text-[#E5E5EA] text-sm rounded-lg px-3 py-2 outline-none focus:border-white/30 resize-y leading-relaxed font-mono"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MediaPicker
|
||||||
|
type={section.type}
|
||||||
|
mediaUrl={section.mediaUrl}
|
||||||
|
onChange={(url) => onPatch({ mediaUrl: url })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Media picker (image / video block) ───────────────────────────
|
||||||
|
function MediaPicker({
|
||||||
|
type, mediaUrl, onChange,
|
||||||
|
}: {
|
||||||
|
type: string;
|
||||||
|
mediaUrl: string | null;
|
||||||
|
onChange: (url: string) => void;
|
||||||
|
}) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const upload = async (file: File) => {
|
||||||
|
setIsUploading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Heritage assets live under /public/heritage/[videos]/. We use the
|
||||||
|
// generic /api/assets endpoint with the cases scope as an interim
|
||||||
|
// (heritage isn't a SCOPE_ROOT yet — expose it later if needed).
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("scope", "branding"); // branding is flat, fine for heritage too
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch("/api/assets", { method: "POST", body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
// Store just the filename so the public page renders /heritage/<file>
|
||||||
|
// — keeps the existing path convention.
|
||||||
|
onChange(data.file.name);
|
||||||
|
} else {
|
||||||
|
setError(data.error || "Upload failed");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Upload failed");
|
||||||
|
}
|
||||||
|
setIsUploading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const accept = type === "image" ? "image/*" : "video/*";
|
||||||
|
const previewUrl = mediaUrl
|
||||||
|
? mediaUrl.startsWith("/") || mediaUrl.startsWith("http")
|
||||||
|
? mediaUrl
|
||||||
|
: `/heritage/${type === "video" ? "videos/" : ""}${mediaUrl}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) upload(file);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="inline-flex items-center gap-2 bg-[#00F0FF]/10 text-[#00F0FF] hover:bg-[#00F0FF]/20 px-3 py-2 rounded-lg text-xs font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isUploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
|
||||||
|
{isUploading ? "Uploading…" : "Upload"}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mediaUrl || ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={`…or paste filename (e.g. ${type === "video" ? "video.mp4" : "photo.jpg"})`}
|
||||||
|
className="flex-1 bg-black/40 border border-white/10 text-white text-xs rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-rose-400 text-xs">{error}</div>}
|
||||||
|
{previewUrl && (
|
||||||
|
<div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden border border-white/10">
|
||||||
|
{type === "image" ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={previewUrl} alt="" className="w-full h-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<video src={previewUrl} controls className="w-full h-full" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -97,6 +97,95 @@ export async function deleteTimelineEvent(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4b. PATCH GRANULAR — for inline auto-save in the new UI.
|
||||||
|
// Only the fields present on `patch` are written, so the editor can update
|
||||||
|
// title/year/description independently as they tab between fields without
|
||||||
|
// having to push the whole row back through the formData-style action.
|
||||||
|
export async function patchTimelineEvent(
|
||||||
|
id: string,
|
||||||
|
patch: {
|
||||||
|
year?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
order?: number;
|
||||||
|
isActive?: boolean;
|
||||||
|
autoTranslate?: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (!id) return { error: "Missing id" };
|
||||||
|
|
||||||
|
const data: any = {};
|
||||||
|
if (patch.year !== undefined) data.year = patch.year;
|
||||||
|
if (patch.title !== undefined) data.title = patch.title;
|
||||||
|
if (patch.description !== undefined) data.description = patch.description;
|
||||||
|
if (patch.order !== undefined) data.order = patch.order;
|
||||||
|
if (patch.isActive !== undefined) data.isActive = patch.isActive;
|
||||||
|
|
||||||
|
// Re-run AI translation when text fields change AND the editor opted in.
|
||||||
|
if (patch.autoTranslate && (patch.title !== undefined || patch.description !== undefined)) {
|
||||||
|
const existing = await prisma.timelineEvent.findUnique({ where: { id } });
|
||||||
|
const finalTitle = patch.title ?? existing?.title ?? "";
|
||||||
|
const finalDesc = patch.description ?? existing?.description ?? "";
|
||||||
|
const aiResult = await translateContentForCMS({ title: finalTitle, description: finalDesc });
|
||||||
|
if (aiResult) data.translationsJson = JSON.stringify(aiResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.timelineEvent.update({ where: { id }, data });
|
||||||
|
revalidatePath("/hq-command/dashboard/timeline");
|
||||||
|
revalidatePath("/[locale]", "layout");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { error: error.message || "Failed to update milestone." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4c. REORDENAR — drag-drop in the UI commits a new ordering for every row
|
||||||
|
// in a single transaction.
|
||||||
|
export async function reorderTimelineEvents(orderedIds: string[]) {
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(
|
||||||
|
orderedIds.map((id, idx) =>
|
||||||
|
prisma.timelineEvent.update({ where: { id }, data: { order: idx } })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
revalidatePath("/hq-command/dashboard/timeline");
|
||||||
|
revalidatePath("/[locale]", "layout");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { error: error.message || "Failed to reorder." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4d. CREAR HITO MÍNIMO — for the new UI's "+ Add" flow.
|
||||||
|
// The full create flow with AI is still exposed via createTimelineEvent;
|
||||||
|
// this one just inserts a stub that the editor immediately fills in via
|
||||||
|
// patchTimelineEvent as they type.
|
||||||
|
export async function createTimelineStub() {
|
||||||
|
try {
|
||||||
|
const last = await prisma.timelineEvent.findFirst({
|
||||||
|
orderBy: { order: "desc" },
|
||||||
|
select: { order: true },
|
||||||
|
});
|
||||||
|
const nextOrder = last ? last.order + 1 : 0;
|
||||||
|
|
||||||
|
const event = await prisma.timelineEvent.create({
|
||||||
|
data: {
|
||||||
|
year: new Date().getFullYear().toString(),
|
||||||
|
title: "New milestone",
|
||||||
|
description: "",
|
||||||
|
order: nextOrder,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/hq-command/dashboard/timeline");
|
||||||
|
return { success: true, event };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { error: error.message || "Failed to create milestone." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 5. LA SEMILLA
|
// 5. LA SEMILLA
|
||||||
export async function seedTimeline() {
|
export async function seedTimeline() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,208 +1,252 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowLeft, History, Plus, Trash2, Loader2, X, DatabaseZap, Clock, Edit3, Sparkles } from "lucide-react";
|
import {
|
||||||
import { getTimelineEvents, createTimelineEvent, updateTimelineEvent, deleteTimelineEvent, seedTimeline } from "./actions";
|
ArrowLeft, History, Plus, Trash2, Loader2, GripVertical, Eye, EyeOff,
|
||||||
|
Sparkles, Check, DatabaseZap, Clock,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
getTimelineEvents,
|
||||||
|
patchTimelineEvent,
|
||||||
|
deleteTimelineEvent,
|
||||||
|
reorderTimelineEvents,
|
||||||
|
createTimelineStub,
|
||||||
|
seedTimeline,
|
||||||
|
} from "./actions";
|
||||||
|
|
||||||
|
interface EventRow {
|
||||||
|
id: string;
|
||||||
|
year: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
order: number;
|
||||||
|
isActive: boolean;
|
||||||
|
translationsJson: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function TimelineManager() {
|
export default function TimelineManager() {
|
||||||
const [events, setEvents] = useState<any[]>([]);
|
const [events, setEvents] = useState<EventRow[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isSeeding, setIsSeeding] = useState(false);
|
const [seeding, setSeeding] = useState(false);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [savedFlash, setSavedFlash] = useState<string | null>(null);
|
||||||
const [editingEvent, setEditingEvent] = useState<any | null>(null);
|
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [autoTranslate, setAutoTranslate] = useState(true);
|
||||||
|
|
||||||
const fetchEvents = async () => {
|
const load = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setLoading(true);
|
||||||
const res = await getTimelineEvents();
|
const res = await getTimelineEvents();
|
||||||
if (res.success && res.events) setEvents(res.events);
|
if (res.success && res.events) setEvents(res.events as EventRow[]);
|
||||||
setIsLoading(false);
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const flashSaved = (id: string) => {
|
||||||
|
setSavedFlash(id);
|
||||||
|
setTimeout(() => setSavedFlash(null), 1500);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { fetchEvents(); }, []);
|
// ─── Patch (auto-save) ──────────────────────────────────────────
|
||||||
|
const patch = async (id: string, fields: Partial<EventRow> & { autoTranslate?: boolean }) => {
|
||||||
const handleSeed = async () => {
|
setEvents((prev) => prev.map((e) => (e.id === id ? { ...e, ...fields } : e)));
|
||||||
setIsSeeding(true); setError("");
|
setSavingId(id);
|
||||||
const res = await seedTimeline();
|
await patchTimelineEvent(id, { ...fields, autoTranslate });
|
||||||
if (res.error) setError(res.error); else await fetchEvents();
|
setSavingId(null);
|
||||||
setIsSeeding(false);
|
flashSaved(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCreateModal = () => {
|
const handleAdd = async () => {
|
||||||
setEditingEvent(null);
|
const res = await createTimelineStub();
|
||||||
setIsModalOpen(true);
|
if (res.success) await load();
|
||||||
};
|
|
||||||
|
|
||||||
const openEditModal = (event: any) => {
|
|
||||||
setEditingEvent(event);
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsSubmitting(true); setError("");
|
|
||||||
|
|
||||||
const formData = new FormData(e.currentTarget);
|
|
||||||
|
|
||||||
let res;
|
|
||||||
if (editingEvent) {
|
|
||||||
res = await updateTimelineEvent(formData);
|
|
||||||
} else {
|
|
||||||
res = await createTimelineEvent(formData);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.error) {
|
|
||||||
setError(res.error);
|
|
||||||
} else {
|
|
||||||
setIsModalOpen(false);
|
|
||||||
fetchEvents();
|
|
||||||
}
|
|
||||||
setIsSubmitting(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (confirm("Are you sure you want to delete this historical milestone?")) {
|
if (!confirm("Delete this milestone? This cannot be undone.")) return;
|
||||||
await deleteTimelineEvent(id);
|
await deleteTimelineEvent(id);
|
||||||
fetchEvents();
|
await load();
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const handleSeed = async () => {
|
||||||
|
setSeeding(true);
|
||||||
|
await seedTimeline();
|
||||||
|
setSeeding(false);
|
||||||
|
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 = events.map((e) => e.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);
|
||||||
|
setEvents((prev) =>
|
||||||
|
reordered.map((id, i) => ({ ...prev.find((e) => e.id === id)!, order: i }))
|
||||||
|
);
|
||||||
|
setDraggedId(null);
|
||||||
|
await reorderTimelineEvents(reordered);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen p-6 md:p-12 max-w-7xl mx-auto">
|
<div className="min-h-screen p-6 md:p-12 max-w-5xl mx-auto">
|
||||||
|
<Link
|
||||||
{/* HEADER */}
|
href="/hq-command/dashboard"
|
||||||
<div className="mb-10">
|
className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-amber-400 mb-6 transition-colors"
|
||||||
<Link href="/hq-command/dashboard" className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-amber-400 transition-colors mb-6 group">
|
>
|
||||||
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> Back to Command Center
|
<ArrowLeft size={14} /> Back to Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
|
|
||||||
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-light text-white flex items-center gap-3">
|
<div className="flex items-center gap-2 text-amber-400 mb-2">
|
||||||
<History className="text-amber-400" /> Company Legacy
|
<History size={16} />
|
||||||
|
<span className="text-[10px] uppercase tracking-widest font-bold">Company Legacy</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-light text-white">
|
||||||
|
Timeline of <span className="font-medium">FLUX.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[#86868B] mt-2">Manage historical milestones and the timeline of FLUX.</p>
|
<p className="text-[#86868B] mt-2 text-sm">
|
||||||
|
Drag to reorder. Edit any field — auto-saves as you tab away.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex flex-wrap gap-2">
|
||||||
{!isLoading && events.length === 0 && (
|
{!loading && events.length === 0 && (
|
||||||
<button onClick={handleSeed} disabled={isSeeding} className="flex items-center gap-2 bg-amber-500/10 text-amber-400 border border-amber-500/20 px-5 py-2.5 rounded-xl font-medium hover:bg-amber-500 hover:text-black transition-all">
|
<button
|
||||||
{isSeeding ? <Loader2 size={18} className="animate-spin" /> : <DatabaseZap size={18} />} Seed History
|
onClick={handleSeed}
|
||||||
|
disabled={seeding}
|
||||||
|
className="flex items-center gap-2 bg-amber-500/10 text-amber-400 border border-amber-500/20 px-4 py-2 rounded-lg text-sm font-medium hover:bg-amber-500/20 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{seeding ? <Loader2 size={14} className="animate-spin" /> : <DatabaseZap size={14} />}
|
||||||
|
Seed defaults
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={openCreateModal} className="flex items-center gap-2 bg-white text-black px-5 py-2.5 rounded-xl font-medium hover:bg-gray-200 transition-all">
|
<button
|
||||||
<Plus size={18} /> Add Milestone
|
onClick={handleAdd}
|
||||||
|
className="flex items-center gap-2 bg-amber-400 text-black px-4 py-2 rounded-lg text-sm font-medium hover:bg-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Add milestone
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-translate toggle (global, applies to every patch) */}
|
||||||
|
<label className="flex items-center gap-2 mb-6 text-xs text-[#86868B] cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoTranslate}
|
||||||
|
onChange={(e) => setAutoTranslate(e.target.checked)}
|
||||||
|
className="accent-amber-400"
|
||||||
|
/>
|
||||||
|
<Sparkles size={12} className="text-amber-400" />
|
||||||
|
Auto-translate edits to IT, VEC, ES, DE
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Timeline list */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20 text-[#86868B]">
|
||||||
|
<Loader2 className="animate-spin mr-2" size={16} /> Loading milestones…
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="mb-8 p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">{error}</div>}
|
|
||||||
|
|
||||||
{/* TIMELINE LIST */}
|
|
||||||
<div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl p-6 md:p-10">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="py-12 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading legacy data...</div>
|
|
||||||
) : events.length === 0 ? (
|
) : events.length === 0 ? (
|
||||||
<div className="py-12 text-center"><Clock size={48} className="mx-auto text-amber-400/30 mb-4" /><p className="text-[#86868B]">No milestones recorded yet.</p></div>
|
<div className="text-center py-20 text-[#86868B] bg-white/[0.02] rounded-3xl border border-white/5">
|
||||||
|
<Clock size={32} className="mx-auto mb-3 text-amber-400/30" />
|
||||||
|
<p>No milestones yet.</p>
|
||||||
|
<p className="text-xs mt-1">Click "Seed defaults" to start, or "Add milestone" to write your own.</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6 relative before:absolute before:inset-0 before:ml-5 before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-white/10 before:to-transparent">
|
<div className="space-y-3">
|
||||||
{events.map((event) => (
|
{events.map((event) => {
|
||||||
<div key={event.id} className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group is-active">
|
const isSaving = savingId === event.id;
|
||||||
{/* Timeline Dot */}
|
const justSaved = savedFlash === event.id;
|
||||||
<div className="flex items-center justify-center w-10 h-10 rounded-full border border-white/20 bg-[#111] text-amber-400 shadow shrink-0 md:order-1 md:group-odd:-translate-x-1/2 md:group-even:translate-x-1/2 relative z-10">
|
|
||||||
<div className="w-3 h-3 bg-amber-400 rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
{/* Content Card */}
|
|
||||||
<div className="w-[calc(100%-4rem)] md:w-[calc(50%-3rem)] bg-black/40 border border-white/5 p-6 rounded-2xl hover:border-amber-400/30 transition-colors relative">
|
|
||||||
|
|
||||||
{/* Botones de acción */}
|
return (
|
||||||
<div className="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div
|
||||||
<button onClick={() => openEditModal(event)} className="text-[#86868B] hover:text-amber-400 p-1"><Edit3 size={16}/></button>
|
key={event.id}
|
||||||
<button onClick={() => handleDelete(event.id)} className="text-[#86868B] hover:text-red-400 p-1"><Trash2 size={16}/></button>
|
draggable
|
||||||
</div>
|
onDragStart={() => onDragStart(event.id)}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={() => onDrop(event.id)}
|
||||||
|
className={`bg-[#111] border rounded-2xl p-4 transition-all ${
|
||||||
|
draggedId === event.id ? "opacity-50" : ""
|
||||||
|
} ${event.isActive ? "border-white/10" : "border-white/5 opacity-60"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<button className="cursor-grab text-[#86868B] hover:text-white p-1 mt-2" title="Drag to reorder">
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
<span className="text-amber-400 font-mono text-sm tracking-widest bg-amber-400/10 px-3 py-1 rounded-full inline-block mb-3">{event.year}</span>
|
<div className="flex-1 min-w-0 space-y-3">
|
||||||
<h4 className="text-xl text-white font-medium mb-2">{event.title}</h4>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<p className="text-[#86868B] text-sm leading-relaxed line-clamp-3">{event.description}</p>
|
<input
|
||||||
<p className="text-[10px] text-white/20 mt-4 uppercase tracking-widest">Order: {event.order}</p>
|
type="text"
|
||||||
</div>
|
value={event.year}
|
||||||
</div>
|
onChange={(e) => setEvents((prev) => prev.map((x) => x.id === event.id ? { ...x, year: e.target.value } : x))}
|
||||||
))}
|
onBlur={(e) => patch(event.id, { year: e.target.value })}
|
||||||
</div>
|
className="bg-amber-400/10 border border-amber-400/20 text-amber-400 font-mono text-sm rounded-full px-3 py-1 outline-none focus:border-amber-400 w-28"
|
||||||
|
placeholder="Year"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={event.title}
|
||||||
|
onChange={(e) => setEvents((prev) => prev.map((x) => x.id === event.id ? { ...x, title: e.target.value } : x))}
|
||||||
|
onBlur={(e) => patch(event.id, { title: e.target.value })}
|
||||||
|
placeholder="Milestone title"
|
||||||
|
className="flex-1 bg-transparent text-white text-base font-medium outline-none focus:bg-white/[0.04] rounded px-2 py-1 -mx-2 -my-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs ml-auto">
|
||||||
|
{isSaving && <Loader2 size={12} className="animate-spin text-amber-400" />}
|
||||||
|
{justSaved && (
|
||||||
|
<span className="text-emerald-400 flex items-center gap-1">
|
||||||
|
<Check size={12} /> Saved
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CREATE / EDIT MODAL */}
|
<button
|
||||||
{isModalOpen && (
|
onClick={() => patch(event.id, { isActive: !event.isActive })}
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
|
className={`p-2 rounded-lg transition-colors ${
|
||||||
<div className="bg-[#111] border border-white/10 w-full max-w-3xl rounded-[2rem] p-0 relative shadow-2xl flex flex-col max-h-[90vh]">
|
event.isActive
|
||||||
<div className="p-6 md:p-8 border-b border-white/10 relative shrink-0">
|
? "text-emerald-400 hover:bg-emerald-500/10"
|
||||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-amber-400 to-transparent"></div>
|
: "text-[#86868B] hover:bg-white/5"
|
||||||
<button onClick={() => setIsModalOpen(false)} className="absolute top-6 right-6 text-[#86868B] hover:text-white transition-colors"><X size={20} /></button>
|
}`}
|
||||||
<h3 className="text-2xl font-light text-amber-400">
|
title={event.isActive ? "Hide from timeline" : "Show on timeline"}
|
||||||
{editingEvent ? "Edit Milestone" : "Add New Milestone"}
|
>
|
||||||
</h3>
|
{event.isActive ? <Eye size={14} /> : <EyeOff size={14} />}
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<form id="timeline-form" onSubmit={handleSave} className="flex-1 overflow-y-auto p-6 md:p-8 [scrollbar-width:none]">
|
<button
|
||||||
{editingEvent && <input type="hidden" name="id" value={editingEvent.id} />}
|
onClick={() => handleDelete(event.id)}
|
||||||
|
className="p-2 rounded-lg text-rose-400 hover:bg-rose-500/10"
|
||||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
title="Delete milestone"
|
||||||
<div>
|
>
|
||||||
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Year / Date</label>
|
<Trash2 size={14} />
|
||||||
<input name="year" type="text" defaultValue={editingEvent?.year} required placeholder="e.g., 2026" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-amber-400 font-mono text-sm focus:border-amber-400 outline-none" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Timeline Position (1, 2, 3)</label>
|
|
||||||
<input name="order" type="number" defaultValue={editingEvent?.order} required placeholder="e.g., 5" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-amber-400 outline-none" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-1.5">Milestone Title</label>
|
|
||||||
<input name="title" type="text" defaultValue={editingEvent?.title} required placeholder="e.g., Next-Gen E-Dryer" className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-3 text-white text-sm focus:border-amber-400 outline-none" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-[10px] uppercase tracking-widest text-[#86868B] mb-2 flex justify-between items-center">
|
|
||||||
<span>Description (Markdown Supported)</span>
|
|
||||||
</label>
|
|
||||||
<textarea name="description" defaultValue={editingEvent?.description} required rows={6} placeholder="Write the history here..." className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-4 text-white font-mono text-sm focus:border-amber-400 outline-none resize-none leading-relaxed mb-3" />
|
|
||||||
|
|
||||||
<div className="bg-amber-400/5 border border-amber-400/20 rounded-xl p-4 text-xs text-[#86868B] font-mono leading-relaxed">
|
|
||||||
<p className="text-amber-400 font-semibold uppercase tracking-widest mb-2 text-[10px]">Markdown Cheat Sheet:</p>
|
|
||||||
<p><strong>**Bold**</strong> | <strong>*Italic*</strong> | <strong>- List Item</strong></p>
|
|
||||||
<p className="mt-1"><strong>> Blockquote</strong></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 🔥 SWITCH DE LA IA 🔥 */}
|
|
||||||
<div className="bg-gradient-to-r from-amber-400/10 to-transparent border border-amber-400/20 p-4 rounded-xl flex items-center justify-between mt-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 bg-amber-400/20 rounded-lg text-amber-400"><Sparkles size={18} /></div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-white font-medium">Flux AI Auto-Translate</p>
|
|
||||||
<p className="text-[10px] text-[#86868B] uppercase tracking-widest mt-0.5">Translates Markdown to IT, VEC, ES, DE</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
|
||||||
<input type="checkbox" name="autoTranslate" defaultChecked className="sr-only peer" />
|
|
||||||
<div className="w-11 h-6 bg-black/50 border border-white/10 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-amber-400"></div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="p-6 border-t border-white/10 bg-[#111] rounded-b-[2rem] flex justify-end gap-3 shrink-0">
|
|
||||||
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-3 text-sm font-medium text-[#86868B] hover:text-white transition-colors">Cancel</button>
|
|
||||||
<button onClick={() => (document.getElementById("timeline-form") as HTMLFormElement)?.requestSubmit()} disabled={isSubmitting} className="flex items-center gap-2 bg-amber-400 text-black px-8 py-3 rounded-xl text-sm font-semibold hover:bg-white transition-colors disabled:opacity-50 shadow-[0_0_15px_rgba(251,191,36,0.3)]">
|
|
||||||
{isSubmitting ? <><Loader2 size={18} className="animate-spin" /> Processing AI...</> : "Publish to Legacy"}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={event.description}
|
||||||
|
onChange={(e) => setEvents((prev) => prev.map((x) => x.id === event.id ? { ...x, description: e.target.value } : x))}
|
||||||
|
onBlur={(e) => patch(event.id, { description: e.target.value })}
|
||||||
|
placeholder="What happened in this milestone? Markdown supported (**bold**, *italic*, > quote, - list)."
|
||||||
|
rows={3}
|
||||||
|
className="w-full bg-black/40 border border-white/10 text-[#E5E5EA] text-sm rounded-lg px-3 py-2 outline-none focus:border-amber-400/40 resize-y leading-relaxed"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user