import React, { useState, useRef, useEffect } from 'react';
import { Plus, Trash2, ChevronDown, ChevronUp, Calendar, Download, Printer, ArrowLeftRight, FileJson, Upload, Indent, Outdent, Globe, AlignCenter, AlignLeft, AlignRight, Sun, Moon } from 'lucide-react';
// --- Translations ---
const TRANSLATIONS = {
en: {
save: "Save Template",
load: "Load Template",
pdf: "Export PDF",
image: "Export Image",
addStart: "Add Start",
addEnd: "Add End",
addStartTooltip: "Add Start Node",
addEndTooltip: "Add End Node",
titlePlaceholder: "Enter Title...",
subtitlePlaceholder: "Enter Subtitle...",
defaultTitle: "Hierarchical Timeline Builder", // Used for initial state only
promote: "Promote",
demote: "Demote",
switchSide: "Switch Side",
moveUp: "Move Up",
moveDown: "Move Down",
delete: "Delete",
datePlaceholder: "DATE",
titleNodePlaceholder: "Event Title...",
descNodePlaceholder: "Description...",
subTitleNodePlaceholder: "Sub-event Title...",
alignCenter: "Align Center",
alignSide: "Align Start",
subtitleDefault: "Hierarchical Timeline. Use Ctrl+B/I/U for formatting."
},
he: {
save: "שמור תבנית",
load: "טען תבנית",
pdf: "ייצוא PDF",
image: "ייצוא תמונה",
addStart: "הוסף התחלה",
addEnd: "הוסף סיום",
addStartTooltip: "הוסף התחלה",
addEndTooltip: "הוסף סיום",
titlePlaceholder: "הכנס כותרת...",
subtitlePlaceholder: "הכנס כותרת משנה...",
defaultTitle: "בונה רצף זמנים היררכי",
promote: "הפוך לראשי",
demote: "הפוך למשני",
switchSide: "החלף צד",
moveUp: "הזז למעלה",
moveDown: "הזז למטה",
delete: "מחק",
datePlaceholder: "תאריך",
titleNodePlaceholder: "כותרת האירוע...",
descNodePlaceholder: "תיאור...",
subTitleNodePlaceholder: "כותרת תת-אירוע...",
alignCenter: "יישור למרכז",
alignSide: "יישור להתחלה",
subtitleDefault: "ציר זמן היררכי. השתמשו ב-Ctrl+B/I/U לעיצוב."
}
};
// --- Rich Text Editor Component ---
const RichTextEditor = ({ value, onChange, className, placeholder, singleLine = false, align = 'start' }) => {
const editorRef = useRef(null);
useEffect(() => {
if (editorRef.current && editorRef.current.innerHTML !== value) {
if (document.activeElement !== editorRef.current) {
editorRef.current.innerHTML = value || '';
}
}
}, [value]);
const handleInput = (e) => {
onChange(e.currentTarget.innerHTML);
};
const handleKeyDown = (e) => {
if (singleLine && e.key === 'Enter') {
e.preventDefault();
return;
}
};
const alignClass = align === 'center' ? 'text-center' : 'text-start';
return (
);
};
// --- Extracted Components ---
const NodeControls = ({ node, index, isSub, totalNodes, onIndent, onOutdent, onToggleSide, onMove, onDelete, t, hoverClass }) => (
{isSub ? (
onOutdent(index)} className="p-1.5 text-slate-400 hover:text-orange-600 hover:bg-orange-50 dark:hover:bg-orange-900/30 rounded" title={t.promote}>
) : (
onIndent(index)} className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded" title={t.demote}>
)}
onToggleSide(node.id)} className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded" title={t.switchSide}>
onMove(index, 'up')} disabled={index === 0} className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-slate-50 dark:hover:bg-slate-700 rounded disabled:opacity-30" title={t.moveUp}>
onMove(index, 'down')} disabled={index === totalNodes - 1} className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-slate-50 dark:hover:bg-slate-700 rounded disabled:opacity-30" title={t.moveDown}>
onDelete(node.id)} className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 rounded ml-1" title={t.delete}>
);
const SubNodeRenderer = ({ node, index, totalNodes, onUpdate, onDelete, onToggleDate, onIndent, onOutdent, onToggleSide, onMove, t }) => {
return (
{/* Visual Hierarchy Line */}
{/* Controls - Only show when hovering this specific sub-node container */}
{/* Date Row */}
{node.date !== null && (
onUpdate(node.id, 'date', e.target.value)}
className="w-full font-semibold uppercase tracking-wider bg-transparent border-none focus:outline-none placeholder-blue-300 text-xs text-blue-600 dark:text-blue-400 dark:placeholder-blue-700"
placeholder={t.datePlaceholder}
/>
)}
{/* Title */}
onUpdate(node.id, 'title', val)}
singleLine={true}
className="w-full font-bold text-slate-800 dark:text-slate-100 text-lg border-b border-transparent hover:border-slate-200 dark:hover:border-slate-600 focus:border-blue-500 transition-colors py-1"
placeholder={t.subTitleNodePlaceholder}
/>
{/* Content */}
onUpdate(node.id, 'content', val)}
className="w-full text-slate-600 dark:text-slate-300 text-sm min-h-[20px]"
placeholder={t.descNodePlaceholder}
/>
{/* Mini Actions for Subnode */}
onToggleDate(node.id)} className={`p-1.5 rounded-full transition-colors ${node.date !== null ? 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/30' : 'text-slate-300 hover:text-blue-500 dark:text-slate-600 dark:hover:text-blue-400'}`}>
);
};
const TimelineApp = () => {
const fileInputRef = useRef(null);
const containerRef = useRef(null);
// --- Global State ---
const [uiLanguage, setUiLanguage] = useState('en');
const [darkMode, setDarkMode] = useState(false);
const t = TRANSLATIONS[uiLanguage];
// Title State - Defaults set directly
const [appTitle, setAppTitle] = useState("Hierarchical Timeline Builder");
const [appSubtitle, setAppSubtitle] = useState("Hierarchical Timeline. Use Ctrl+B/I/U for formatting.");
// Hover tracking for insert button
const [hoverY, setHoverY] = useState(null);
const [insertIndex, setInsertIndex] = useState(null);
// Update subtitle when language changes if it matches the default of the other language
useEffect(() => {
if (appSubtitle === TRANSLATIONS['en'].subtitleDefault || appSubtitle === TRANSLATIONS['he'].subtitleDefault) {
setAppSubtitle(t.subtitleDefault);
}
}, [uiLanguage, t.subtitleDefault, appSubtitle]);
useEffect(() => {
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [darkMode]);
// Alignment State (Consolidated)
const [headerAlign, setHeaderAlign] = useState('center');
// --- Nodes State ---
const [nodes, setNodes] = useState(() => {
try {
const saved = localStorage.getItem('timeline_nodes');
if (saved) {
let parsed = JSON.parse(saved);
return parsed.map((n, i) => ({
...n,
side: n.side || (i % 2 === 0 ? 'right' : 'left'),
type: 'event',
isSub: n.isSub || false
}));
}
} catch (e) {
console.error("Failed to load from local storage", e);
}
// Default initial state
return [
{
"id": "root-mech",
"type": "event",
"side": "center",
"title": "Mechanical Roots",
"date": "19th Century",
"content": "Before electricity, computer concepts were born as mechanical machines.",
"comments": [],
"isSub": false
},
{
"id": "init-2",
"type": "event",
"side": "right",
"title": "Charles Babbage & Analytical Engine",
"date": "1837",
"content": "Babbage designed a mechanical machine with all modern computer elements: CPU (Mill), Memory (Store).",
"comments": [],
"isSub": true
},
{
"id": "bexxiyo30",
"type": "event",
"side": "left",
"title": "Ada Lovelace",
"date": "1843",
"content": "Wrote the first algorithm intended for a machine, realizing it could process symbols, not just numbers.",
"comments": [],
"isSub": true
},
{
"id": "root-theory",
"type": "event",
"side": "center",
"title": "Theory & War",
"date": "1930s-40s",
"content": "WWII drove massive demand for calculation power for ballistics and cryptography.",
"comments": [],
"isSub": false
},
{
"id": "turing-univ",
"type": "event",
"side": "right",
"title": "Alan Turing & Universal Machine",
"date": "1936",
"content": "Laid theoretical foundations for CS with the 'Turing Machine' model.",
"comments": [],
"isSub": true
}
];
});
useEffect(() => {
localStorage.setItem('timeline_nodes', JSON.stringify(nodes));
}, [nodes]);
// Load external libraries
useEffect(() => {
const loadScript = (src, id) => {
if (!document.getElementById(id)) {
const script = document.createElement('script');
script.src = src;
script.id = id;
script.async = true;
document.body.appendChild(script);
}
};
loadScript("https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js", "html2canvas-script");
loadScript("https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js", "jspdf-script");
}, []);
const generateId = () => Math.random().toString(36).substr(2, 9);
// --- Grouping Logic for Rendering ---
const getRenderGroups = () => {
const groups = [];
let currentGroup = null;
nodes.forEach((node, index) => {
if (node.isSub && currentGroup) {
currentGroup.children.push({ ...node, originalIndex: index });
} else {
if (currentGroup) groups.push(currentGroup);
currentGroup = {
type: 'group',
main: { ...node, originalIndex: index },
children: []
};
}
});
if (currentGroup) groups.push(currentGroup);
return groups;
};
const renderGroups = getRenderGroups();
// --- Node Operations ---
const addNode = (index) => {
const prevNode = index > 0 ? nodes[index - 1] : null;
const prevSide = prevNode ? prevNode.side : 'left';
const newSide = prevSide === 'right' ? 'left' : 'right';
const newNode = {
id: generateId(),
type: 'event',
side: newSide,
title: 'New Event',
date: '',
content: '',
isSub: false
};
const newNodes = [...nodes];
newNodes.splice(index, 0, newNode);
setNodes(newNodes);
setHoverY(null); // Clear insertion marker
};
const deleteNode = (id) => {
if (nodes.length <= 1) return;
setNodes(nodes.filter(n => n.id !== id));
};
const moveNode = (index, direction) => {
if (direction === 'up' && index === 0) return;
if (direction === 'down' && index === nodes.length - 1) return;
const newNodes = [...nodes];
const targetIndex = direction === 'up' ? index - 1 : index + 1;
// Swap
const temp = newNodes[index];
newNodes[index] = newNodes[targetIndex];
newNodes[targetIndex] = temp;
setNodes(newNodes);
};
const updateNode = (id, field, value) => {
setNodes(nodes.map(n => n.id === id ? { ...n, [field]: value } : n));
};
const indentNode = (index) => {
const newNodes = [...nodes];
newNodes[index] = { ...newNodes[index], isSub: true };
setNodes(newNodes);
};
const outdentNode = (index) => {
const newNodes = [...nodes];
newNodes[index] = { ...newNodes[index], isSub: false };
setNodes(newNodes);
};
const toggleNodeSide = (id) => {
setNodes(nodes.map(n =>
n.id === id ? { ...n, side: n.side === 'right' ? 'left' : 'right' } : n
));
};
const toggleDate = (id) => {
setNodes(nodes.map(n => {
if (n.id === id) {
return { ...n, date: n.date === null ? '' : (n.date !== undefined && n.date !== null ? null : '') };
}
return n;
}));
};
// --- Hover Logic for Center Line ---
const handleMouseMove = (e) => {
if (!containerRef.current) return;
// Check if mouse is near center line
const containerRect = containerRef.current.getBoundingClientRect();
const centerX = containerRect.left + (containerRect.width / 2);
const mouseX = e.clientX;
const distance = Math.abs(mouseX - centerX);
if (distance > 50) { // Hover zone width
setHoverY(null);
return;
}
const mouseY = e.clientY;
// Find gaps between nodes
const groupElements = document.querySelectorAll('[data-group-index]');
let found = false;
Array.from(groupElements).forEach((el, idx) => {
if (found) return;
const rect = el.getBoundingClientRect();
// Check if mouse is above this node (start gap)
if (idx === 0 && mouseY < rect.top) {
// Covered by static button
}
// Check if mouse is below this node (gap to next)
else if (mouseY > rect.bottom) {
const nextEl = groupElements[idx + 1];
// If there's a next element, check if we are in the gap
if (nextEl) {
const nextRect = nextEl.getBoundingClientRect();
if (mouseY < nextRect.top) {
// In the gap!
setHoverY(mouseY - containerRect.top); // Precise position relative to container
const nodeIndex = parseInt(el.getAttribute('data-last-index'));
setInsertIndex(nodeIndex + 1);
found = true;
}
}
}
});
if (!found) setHoverY(null);
};
const handleMouseLeave = () => {
setHoverY(null);
};
// --- Export Handlers ---
const handleExportImage = async () => {
if (window.html2canvas) {
const element = document.getElementById('timeline-content');
const originalBg = element.style.backgroundColor;
const isDark = document.documentElement.classList.contains('dark');
element.style.backgroundColor = isDark ? '#1e293b' : '#f8fafc'; // slate-800 : slate-50
try {
const canvas = await window.html2canvas(element, {
backgroundColor: isDark ? '#1e293b' : '#f8fafc',
scale: 2,
useCORS: true,
ignoreElements: (element) => element.classList.contains('no-print')
});
const link = document.createElement('a');
link.download = 'timeline.png';
link.href = canvas.toDataURL();
link.click();
} finally {
element.style.backgroundColor = '';
}
} else { alert("Loading libraries..."); }
};
const handleExportPDF = async () => {
if (window.html2canvas && window.jspdf) {
const { jsPDF } = window.jspdf;
const element = document.getElementById('timeline-content');
const isDark = document.documentElement.classList.contains('dark');
const originalBg = element.style.backgroundColor;
// For PDF we usually want white background unless user really wants dark PDF
// Let's stick to WYSIWYG
element.style.backgroundColor = isDark ? '#1e293b' : '#ffffff';
try {
const canvas = await window.html2canvas(element, {
backgroundColor: isDark ? '#1e293b' : '#ffffff',
scale: 2,
useCORS: true,
ignoreElements: (element) => element.classList.contains('no-print')
});
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF('p', 'mm', 'a4');
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const imgProps = pdf.getImageProperties(imgData);
const imgHeight = (imgProps.height * pdfWidth) / imgProps.width;
if (imgHeight > pdfHeight) {
const customPdf = new jsPDF('p', 'mm', [pdfWidth, imgHeight + 20]);
customPdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, imgHeight);
customPdf.save('timeline.pdf');
} else {
pdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, imgHeight);
pdf.save('timeline.pdf');
}
} finally {
element.style.backgroundColor = originalBg;
}
} else { alert("PDF library loading..."); }
};
const handleSaveTemplate = () => {
// Create a clean copy of nodes to match the provided format exactly
const cleanNodes = nodes.map(node => ({
id: node.id,
type: node.type || 'event',
side: node.side,
title: node.title,
date: node.date,
content: node.content,
comments: node.comments || [],
isSub: node.isSub
}));
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(cleanNodes, null, 2));
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", "timeline_template.json");
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
};
const handleLoadTemplateClick = () => { fileInputRef.current.click(); };
const handleFileChange = (e) => {
const fileReader = new FileReader();
fileReader.readAsText(e.target.files[0], "UTF-8");
fileReader.onload = e => {
try {
const parsed = JSON.parse(e.target.result);
if (Array.isArray(parsed)) setNodes(parsed);
else alert("Invalid file format");
} catch (err) { alert("Error parsing file"); }
};
e.target.value = null;
};
const currentDir = uiLanguage === 'he' ? 'rtl' : 'ltr';
return (
{/* Language Switcher & Dark Mode */}
setUiLanguage(e.target.value)}
className="bg-transparent border-none outline-none text-sm text-slate-600 dark:text-slate-300 cursor-pointer py-1 pr-2"
>
English
עברית
setDarkMode(!darkMode)}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 rounded-md transition-colors"
>
{darkMode ? : }
{/* Header */}
{/* Timeline Container - Force LTR so nodes don't swap sides when language changes */}
{/* Central Line */}
{/* Static Add Start Button (No Text) */}
addNode(0)}
className="flex items-center justify-center w-10 h-10 bg-blue-50 dark:bg-slate-800 border border-blue-200 dark:border-blue-900/50 text-blue-600 dark:text-blue-400 rounded-full shadow-sm hover:shadow-md hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-all font-medium text-sm hover:scale-110"
title={t.addStartTooltip}
>
{/* Mouse Tracker Insert Button */}
{hoverY !== null && insertIndex !== null && (
addNode(insertIndex)}
className="w-8 h-8 rounded-full bg-blue-500 hover:bg-blue-600 text-white flex items-center justify-center shadow-lg transform hover:scale-110 transition-transform cursor-pointer border-2 border-white dark:border-slate-900"
title={t.addNode}
>
{/* Line decoration - makes it look like it's splitting the line */}
)}
{/* Render Groups Loop */}
{renderGroups.map((group, groupIndex) => {
const mainNode = group.main;
const children = group.children || [];
const index = mainNode.originalIndex;
// Calculate last index in this group to know where to insert after
const lastIndex = index + children.length;
const isRightSide = mainNode.side === 'right';
const layoutClass = isRightSide ? 'md:flex-row' : 'md:flex-row-reverse';
return (
{/* Dot */}
{/* Spacer */}
{/* Card Container */}
{/* Main Node Content - group/main-content SCOPED HOVER */}
{/* Controls - Only show on hovering THIS div */}
{mainNode.date !== null && (
updateNode(mainNode.id, 'date', e.target.value)} className="w-full font-semibold uppercase tracking-wider bg-transparent border-none focus:outline-none placeholder-blue-300 text-xs text-blue-600 dark:text-blue-400 dark:placeholder-blue-700" placeholder={t.datePlaceholder} />
)}
updateNode(mainNode.id, 'title', val)} singleLine={true} className="w-full font-bold text-slate-800 dark:text-slate-100 text-lg border-b border-transparent hover:border-slate-200 dark:hover:border-slate-600 focus:border-blue-500 transition-colors py-1" placeholder={t.titleNodePlaceholder} />
updateNode(mainNode.id, 'content', val)} className="w-full text-slate-600 dark:text-slate-300 text-sm min-h-[40px]" placeholder={t.descNodePlaceholder} />
{/* Main Actions */}
toggleDate(mainNode.id)} className={`p-1.5 rounded-full transition-colors ${mainNode.date !== null ? 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/30' : 'text-slate-400 hover:text-blue-500 dark:text-slate-500 dark:hover:text-blue-400'}`}>
{/* RENDER SUB-NODES (CONCATENATED) */}
{children.length > 0 && (
{children.map((child) => (
))}
)}
)})}
addNode(nodes.length)}
className="flex items-center justify-center w-10 h-10 bg-blue-50 dark:bg-slate-800 border border-blue-200 dark:border-blue-900/50 text-blue-600 dark:text-blue-400 rounded-full shadow-sm hover:shadow-md hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-all font-medium text-sm hover:scale-110"
title={t.addEndTooltip}
>
{/* Helper Styles for Buttons */}
);
};
export default TimelineApp;