Timeline

This prompt asks for a single HTML file creating an interactive timeline web app where students can add and view chronological events. It also includes a PDF download of the timeline and event details.

Code

Prompt

I am a teacher and want to create an interactive web app for my lessons. I want a cauldron simulator in which:

- The cauldron shows the current temperature.
- A 'liquid' in the cauldron changes visually (in height and color) based on the temperature.
- Two blocks are present: one for increasing (plus) and one for decreasing (minus) the temperature. You can drag these blocks to the cauldron to adjust the temperature.
- When dragging, animations (emojis) appear and a funny comment is shown depending on the temperature.
- There is also a reset button to reset the simulator to the starting values.

Design an interactive timeline in a single index.html file using React, CSS, JavaScript and HTML. The timeline should contain the following functionalities and properties:

1. **Enter events:**
- The timeline starts empty; students should be able to add their own historical events via an input form.
- The form contains the fields: date (numeric), title, description and optionally an image URL.
- After filling in, the event will automatically appear on the timeline in the correct chronological position.

2. **Chronological order:**
- The timeline is displayed horizontally with sufficient padding (left and right) so that the labels on the edges are clearly visible.
- The arrangement should be such that the youngest year (e.g. 1) is on the left and the oldest year (e.g. 1000) is on the right.

3. **Display of the timeline:**
- The timeline shows a visual marker and a label with the title and date for each event.
- When clicking on an event, a pop-up appears with extended information (title, date, description and if available the image).

4. **Download function:**
- Add a “Download PDF” button that generates a PDF of a screenshot of the timeline and a list with details of all events.

- The list of details (event details) is below the timeline, but is hidden in the web page (display: none) so that it is not visible on the website. It must be included in the PDF.
- During the generation of the PDF, the images must be temporarily hidden so that there is no unnecessary white space in the overview of events.

5. **Technical specifications:**
- Use html2canvas and jsPDF to generate the PDF.
- Make sure that the entire code (HTML, CSS, JavaScript/React) is in one index.html file.
- No zoom function is needed.

Create the code in such a way that it exactly meets the above specifications.

Example Output

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Interactive Timeline - Add and Download Events</title>
    <style>
      * {
        box-sizing: border-box;
      }
      body {
        margin: 0;
        font-family: Arial, sans-serif;
        background: #f0f0f0;
        padding: 20px;
      }
      .container {
        max-width: 1200px;
        margin: 0 auto;
      }
      h2,
      h3 {
        text-align: center;
      }
      /* Timeline styling */
      .timeline-container {
        position: relative;
        overflow-x: auto;
        white-space: nowrap;
        background: #fff;
        border: 1px solid #ccc;
        border-radius: 8px;
        padding: 20px 50px; /* extra left/right padding so labels are fully visible */
        margin: 20px 0;
        min-height: 120px;
      }
      .timeline {
        position: relative;
        height: 100px;
        border-top: 2px solid #333;
      }
      .event {
        position: absolute;
        top: -10px;
        cursor: pointer;
        transition: transform 0.2s;
      }
      .event:hover {
        transform: scale(1.2);
      }
      .event-marker {
        width: 20px;
        height: 20px;
        background: #0074d9;
        border: 2px solid #fff;
        border-radius: 50%;
      }
      .event-label {
        position: absolute;
        top: 25px;
        left: -40px;
        width: 100px;
        text-align: center;
        font-size: 12px;
        pointer-events: none;
      }
      .no-events {
        text-align: center;
        color: #777;
        padding-top: 40px;
      }
      /* Timeline details: hidden on screen, but temporarily visible for PDF download */
      #timeline-details {
        display: none;
      }
      .timeline-details .event-detail {
        background: #fff;
        border: 1px solid #ccc;
        border-radius: 8px;
        padding: 10px;
        margin-bottom: 10px;
      }
      .timeline-details .event-detail img {
        max-width: 100%;
        border-radius: 4px;
        margin-top: 10px;
      }
      /* Add Event Form */
      .add-event-form {
        background: #fff;
        padding: 20px;
        border-radius: 8px;
        border: 1px solid #ccc;
        margin-bottom: 20px;
      }
      .add-event-form h3 {
        margin-top: 0;
      }
      .add-event-form label {
        display: block;
        margin: 10px 0 5px;
      }
      .add-event-form input[type='text'],
      .add-event-form input[type='number'],
      .add-event-form textarea {
        width: 100%;
        padding: 8px;
        border: 1px solid #ccc;
        border-radius: 4px;
      }
      .add-event-form textarea {
        resize: vertical;
        min-height: 60px;
      }
      .add-event-form .error {
        color: red;
        font-size: 12px;
      }
      .add-event-form button {
        margin-top: 10px;
        padding: 10px 20px;
        cursor: pointer;
      }
      /* Download Button */
      .download-button {
        display: block;
        margin: 20px auto;
        padding: 10px 20px;
        font-size: 16px;
        cursor: pointer;
      }
      /* Popup styles */
      .popup-overlay {
        position: fixed;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        background: rgba(0, 0, 0, 0.6);
        display: flex;
        justify-content: center;
        align-items: center;
        z-index: 1000;
      }
      .popup {
        background: #fff;
        border-radius: 8px;
        padding: 20px;
        width: 90%;
        max-width: 500px;
        position: relative;
        animation: fadeIn 0.3s ease;
      }
      .popup img {
        width: 100%;
        border-radius: 4px;
        margin-bottom: 10px;
      }
      .popup h3 {
        margin-top: 0;
      }
      .popup .close-btn {
        position: absolute;
        top: 10px;
        right: 10px;
        background: transparent;
        border: none;
        font-size: 20px;
        cursor: pointer;
      }
      @keyframes fadeIn {
        from {
          opacity: 0;
          transform: scale(0.8);
        }
        to {
          opacity: 1;
          transform: scale(1);
        }
      }
    </style>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
  </head>
  <body>
    <div id="root"></div>
    <script
      crossorigin
      src="https://unpkg.com/react@18/umd/react.development.js"
    ></script>
    <script
      crossorigin
      src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"
    ></script>
    <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>

    <script type="text/babel">
      const { useState } = React;

      /**************** Popup Component ****************/
      function Popup({ event, onClose }) {
        return (
          <div className="popup-overlay" onClick={onClose}>
            <div className="popup" onClick={(e) => e.stopPropagation()}>
              <button className="close-btn" onClick={onClose}>
                ×
              </button>
              {event.image && <img src={event.image} alt={event.title} />}
              <h3>{event.title}</h3>
              <p>
                <strong>Date:</strong> {event.date}
              </p>
              <p>{event.description}</p>
            </div>
          </div>
        );
      }

      /**************** Timeline Component ****************/
      function Timeline({ events, onEventClick }) {
        if (events.length === 0) {
          return (
            <div className="timeline-container">
              <div className="no-events">No events added.</div>
            </div>
          );
        }

        // Determine the minimum and maximum date.
        // Currently: the youngest year (lowest value, e.g., 1) should be on the left and the oldest (highest value, e.g., 1000) on the right.
        const dates = events.map((e) => Number(e.date));
        const minDate = Math.min(...dates);
        const maxDate = Math.max(...dates);
        const range = maxDate - minDate || 1;

        return (
          <div className="timeline-container">
            <div className="timeline">
              {events.map((event) => {
                // If event.date is equal to minDate, the position is 0% (left) and if equal to maxDate, 100% (right).
                const posPercent = ((event.date - minDate) / range) * 100;
                return (
                  <div
                    key={event.id}
                    className="event"
                    style={{ left: `${posPercent}%` }}
                    onClick={(e) => {
                      e.stopPropagation();
                      onEventClick(event);
                    }}
                  >
                    <div className="event-marker"></div>
                    <div className="event-label">
                      <div>{event.title}</div>
                      <div style={{ fontSize: '10px', color: '#666' }}>
                        {event.date}
                      </div>
                    </div>
                  </div>
                );
              })}
            </div>
          </div>
        );
      }

      /**************** Timeline Details Component ****************/
      function TimelineDetails({ events }) {
        if (events.length === 0) {
          return null;
        }
        // Sort events in ascending order: youngest (lowest year) first, oldest (highest year) last.
        const sortedEvents = [...events].sort((a, b) => a.date - b.date);
        return (
          <div id="timeline-details" className="timeline-details">
            {sortedEvents.map((event) => (
              <div key={event.id} className="event-detail">
                <h4>
                  {event.title} ({event.date})
                </h4>
                <p>{event.description}</p>
                {event.image && <img src={event.image} alt={event.title} />}
              </div>
            ))}
          </div>
        );
      }

      /**************** Add Event Form Component ****************/
      function AddEventForm({ onAddEvent }) {
        const [date, setDate] = useState('');
        const [title, setTitle] = useState('');
        const [description, setDescription] = useState('');
        const [image, setImage] = useState('');
        const [errors, setErrors] = useState({});

        const validate = () => {
          const errs = {};
          if (!date) errs.date = 'Date is required.';
          else if (isNaN(Number(date)))
            errs.date = 'Date must be a number.';
          if (!title.trim()) errs.title = 'Title is required.';
          if (!description.trim())
            errs.description = 'Description is required.';
          return errs;
        };

        const handleSubmit = (e) => {
          e.preventDefault();
          const errs = validate();
          if (Object.keys(errs).length > 0) {
            setErrors(errs);
            return;
          }
          const newEvent = {
            id: Date.now(),
            date: Number(date),
            title: title.trim(),
            description: description.trim(),
            image: image.trim() || null,
          };
          onAddEvent(newEvent);
          setErrors({});
          setDate('');
          setTitle('');
          setDescription('');
          setImage('');
        };

        return (
          <form className="add-event-form" onSubmit={handleSubmit}>
            <h3>Add a new event</h3>
            <label>Date (e.g., 1945):</label>
            <input
              type="number"
              value={date}
              onChange={(e) => setDate(e.target.value)}
              placeholder="Year"
            />
            {errors.date && <div className="error">{errors.date}</div>}

            <label>Title:</label>
            <input
              type="text"
              value={title}
              onChange={(e) => setTitle(e.target.value)}
              placeholder="Title of the event"
            />
            {errors.title && <div className="error">{errors.title}</div>}

            <label>Description:</label>
            <textarea
              value={description}
              onChange={(e) => setDescription(e.target.value)}
              placeholder="Describe the event"
            />
            {errors.description && (
              <div className="error">{errors.description}</div>
            )}

            <label>Image URL (optional):</label>
            <input
              type="text"
              value={image}
              onChange={(e) => setImage(e.target.value)}
              placeholder="http://..."
            />

            <button type="submit">Add event</button>
          </form>
        );
      }

      /**************** Main App Component ****************/
      function App() {
        // Start with an empty list of events.
        const [events, setEvents] = useState([]);
        const [selectedEvent, setSelectedEvent] = useState(null);

        // Add a new event and sort in ascending order (youngest first).
        const handleAddEvent = (newEvent) => {
          const updated = [...events, newEvent].sort((a, b) => a.date - b.date);
          setEvents(updated);
        };

        const handleEventClick = (event) => {
          setSelectedEvent(event);
        };

        // Download function: Takes a screenshot of the 'downloadable-area' (timeline and hidden details) and downloads it as PDF.
        // All images are temporarily hidden so that no unnecessary white space is created.
        const downloadPDF = () => {
          const area = document.getElementById('downloadable-area');
          // Temporarily hide all <img> elements within the downloadable area
          const images = area.querySelectorAll('img');
          const originalDisplays = [];
          images.forEach((img, index) => {
            originalDisplays[index] = img.style.display;
            img.style.display = 'none';
          });

          // Make the timeline details temporarily visible for the screenshot
          const details = document.getElementById('timeline-details');
          const originalDetailsDisplay = details.style.display;
          details.style.display = 'block';

          html2canvas(area, { scale: 2 }).then((canvas) => {
            const imgData = canvas.toDataURL('image/png');
            const pdf = new jspdf.jsPDF('p', 'pt', 'a4');
            const pdfWidth = pdf.internal.pageSize.getWidth();
            const pdfHeight = (canvas.height * pdfWidth) / canvas.width;
            pdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, pdfHeight);
            pdf.save('timeline.pdf');

            // Restore the original display of images and details
            images.forEach((img, index) => {
              img.style.display = originalDisplays[index];
            });
            details.style.display = originalDetailsDisplay;
          });
        };

        return (
          <div className="container">
            <h2>Interactive Timeline</h2>
            {/* Downloadable content: timeline and details (hidden on screen, but visible in PDF) */}
            <div id="downloadable-area">
              <Timeline events={events} onEventClick={handleEventClick} />
              <TimelineDetails events={events} />
            </div>
            <AddEventForm onAddEvent={handleAddEvent} />
            <button className="download-button" onClick={downloadPDF}>
              Download PDF
            </button>
            {selectedEvent && (
              <Popup
                event={selectedEvent}
                onClose={() => setSelectedEvent(null)}
              />
            )}
          </div>
        );
      }

      ReactDOM.createRoot(document.getElementById('root')).render(<App />);
    </script>
  </body>
</html>