diff --git a/app/assets/javascripts/timeline-editor.js b/app/assets/javascripts/timeline-editor.js index 3657be8c..7eec28e9 100644 --- a/app/assets/javascripts/timeline-editor.js +++ b/app/assets/javascripts/timeline-editor.js @@ -1,150 +1,1059 @@ -$(document).ready(function () { - function get_event_id_from_url(url) { - return url.split('/')[4]; +// Initialize timeline events sortable functionality +function initTimelineEventsSortable() { + // Check if jQuery UI is available + if (typeof $ === 'undefined' || !$.fn.sortable) { + console.error('jQuery UI Sortable not found - drag and drop disabled'); + return; } - $('.js-trigger-autosave-on-change').change(function () { - $(this).closest('.autosave-form').submit(); - console.log("Autosaving..."); - }); + const eventsContainer = $('.timeline-events-container'); + if (!eventsContainer.length) return; - $('.js-move-event-to-top').click(function () { - var event_container = $(this).closest('.timeline-event-container'); - var event_id = event_container.data('event-id'); + eventsContainer.sortable({ + items: '.timeline-event-container:not(.timeline-event-template)', + handle: '.timeline-event-drag-handle', + placeholder: 'timeline-event-placeholder', + cursor: 'grabbing', + opacity: 0.8, + tolerance: 'pointer', + distance: 10, // Prevent accidental drags + helper: 'clone', + start: function(event, ui) { + // Add visual feedback + ui.item.addClass('timeline-event-dragging'); + ui.placeholder.addClass('timeline-event-placeholder'); - $.get( - "/plan/move/timeline_events/" + event_id + "/top" - ).done(function () { - // Move in the UI - var event_id = get_event_id_from_url(this.url); - var event_container = $('.timeline-events-container').find('.timeline-event-container[data-event-id="' + event_id + '"]'); - var events_list = $('.timeline-events-container').find('.timeline-event-container'); + // Store original position for rollback if needed (count only event containers) + const allEvents = $('.timeline-events-container .timeline-event-container:not(.timeline-event-template)'); + ui.item.data('original-position', allEvents.index(ui.item)); + }, + update: function(event, ui) { + const eventId = ui.item.attr('data-event-id'); + const originalPosition = ui.item.data('original-position'); - event_container.insertBefore(events_list[0]); - }).fail(function() { - alert("Something went wrong and your change didn't save. Please try again."); - }); + // Count only the event containers before this one (not timeline header/rail) + const allEvents = $('.timeline-events-container .timeline-event-container:not(.timeline-event-template)'); + const newPosition = allEvents.index(ui.item); - return false; - }); - - $('.js-move-event-up').click(function () { - var event_container = $(this).closest('.timeline-event-container'); - var event_id = event_container.data('event-id'); - - $.get( - "/plan/move/timeline_events/" + event_id + "/up" - ).done(function () { - // Move in the UI - var event_id = get_event_id_from_url(this.url); - var event_container = $('.timeline-events-container').find('.timeline-event-container[data-event-id="' + event_id + '"]'); - - event_container.insertBefore(event_container.prev()); - }).fail(function() { - alert("Something went wrong and your change didn't save. Please try again."); - }); - - return false; - }); - - $('.js-move-event-down').click(function () { - var event_container = $(this).closest('.timeline-event-container'); - var event_id = event_container.data('event-id'); - - $.get( - "/plan/move/timeline_events/" + event_id + "/down" - ).done(function () { - // Move in the UI - var event_id = get_event_id_from_url(this.url); - var event_container = $('.timeline-events-container').find('.timeline-event-container[data-event-id="' + event_id + '"]'); - - event_container.insertAfter(event_container.next()); - }).fail(function() { - alert("Something went wrong and your change didn't save. Please try again."); - }); - - return false; - }); - - $('.js-move-event-to-bottom').click(function () { - var event_container = $(this).closest('.timeline-event-container'); - var event_id = event_container.data('event-id'); - - $.get( - "/plan/move/timeline_events/" + event_id + "/bottom" - ).done(function () { - // Move in the UI - var event_id = get_event_id_from_url(this.url); - var event_container = $('.timeline-events-container').find('.timeline-event-container[data-event-id="' + event_id + '"]'); - var events_list = $('.timeline-events-container').find('.timeline-event-container'); - - event_container.insertAfter(events_list[events_list.length - 1]); - }).fail(function() { - alert("Something went wrong and your change didn't save. Please try again."); - }); - - return false; - }); - - $('#js-create-timeline-event').click(function () { - var events_container = $('.timeline-events-container'); - var loading_indicator = $('.loading-indicator'); - - // Indiate we're LOADING! - loading_indicator.show(); - $('#js-create-timeline-event').attr('disabled', 'disabled'); - - // TODO hit the endpoint to create an event - $.post( - "/plan/timeline_events", - { - "timeline_event": { - "title": "Untitled Event", - "timeline_id": events_container.data('timeline-id') - } + if (!eventId) { + console.error('Event ID not found'); + return; } - ).done(function (data) { - var new_event_id = data["id"]; - var template = $('.timeline-event-template > .timeline-event-container'); - var cloned_template = template.clone(true).removeClass('timeline-event-template'); - var timeline_id = cloned_template.find('.timeline-event-container').first().data('timeline-id'); - // console.log('new event id = ' + new_event_id); - // console.log('timeline_id = ' + timeline_id); - // Update IDs to the newly-created event - cloned_template.data('event-id', new_event_id); - cloned_template.attr('data-event-id', new_event_id); + // Send the position directly - backend will convert to 1-based indexing + const targetPosition = newPosition; - // Update labels to jump to this event's fields - var title_field = cloned_template.find('.ref-title'); - title_field.find('input').attr('id', 'timeline-event-title-' + new_event_id); - title_field.find('label').attr('for', 'timeline-event-title-' + new_event_id); + // Show loading state + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl) { + Alpine.$data(alpineEl).autoSaveStatus = 'saving'; + } - var desc_field = cloned_template.find('.ref-description'); - desc_field.find('textarea').attr('id', 'timeline-event-description-' + new_event_id); - desc_field.find('label').attr('for', 'timeline-event-description-' + new_event_id); + // AJAX request to update position using new internal endpoint + $.ajax({ + url: '/internal/sort/timeline_events', + type: 'PATCH', + contentType: 'application/json', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content') + }, + data: JSON.stringify({ + content_id: eventId, + intended_position: targetPosition + }), + success: function(data) { + console.log('Timeline event position updated successfully:', data); - var notes_field = cloned_template.find('.ref-notes'); - notes_field.find('textarea').attr('id', 'timeline-event-notes-' + new_event_id); - notes_field.find('label').attr('for', 'timeline-event-notes-' + new_event_id); + // Update Alpine.js save status + if (alpineEl) { + Alpine.$data(alpineEl).autoSaveStatus = 'saved'; + setTimeout(() => { + if (Alpine.$data(alpineEl).autoSaveStatus === 'saved') { + Alpine.$data(alpineEl).autoSaveStatus = 'saved'; + } + }, 2000); + } - //cloned_template.find('input[name="timeline_event[timeline_id]"]').val(timeline_id); - cloned_template.find('.js-delete-timeline-event').attr('href', '/plan/timeline_events/' + new_event_id); - cloned_template.find('.autosave-form').attr('action', '/plan/timeline_events/' + new_event_id); + if (data.message) { + showTimelineSuccessMessage(data.message); + } + }, + error: function(xhr, status, error) { + console.error('Error updating timeline event position:', error); - cloned_template.appendTo(events_container); + // Update Alpine.js save status + if (alpineEl) { + Alpine.$data(alpineEl).autoSaveStatus = 'error'; + } - loading_indicator.hide(); - $('#js-create-timeline-event').removeAttr('disabled'); + // Revert to original position + const originalPosition = ui.item.data('original-position'); + if (typeof originalPosition !== 'undefined') { + revertEventPosition(ui.item, originalPosition); + } - }).fail(function () { - alert('Error 292'); + showTimelineErrorMessage('Failed to reorder events. Please try again.'); + } + }); + }, + stop: function(event, ui) { + // Remove visual feedback + ui.item.removeClass('timeline-event-dragging'); + } + }); - loading_indicator.hide(); - $('#js-create-timeline-event').removeAttr('disabled'); + // Add custom CSS for drag feedback and content drag & drop + if (!document.getElementById('timeline-drag-styles')) { + const style = document.createElement('style'); + style.id = 'timeline-drag-styles'; + style.textContent = ` + .timeline-event-placeholder { + height: 120px !important; + background: linear-gradient(45deg, #f3f4f6 25%, transparent 25%), + linear-gradient(-45deg, #f3f4f6 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #f3f4f6 75%), + linear-gradient(-45deg, transparent 75%, #f3f4f6 75%); + background-size: 20px 20px; + background-position: 0 0, 0 10px, 10px -10px, -10px 0px; + border: 2px dashed #10b981; + border-radius: 0.75rem; + margin: 0 0 2rem 0; + opacity: 0.7; + position: relative; + } + + .timeline-event-placeholder:before { + content: 'Drop event here'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: #10b981; + font-weight: 500; + font-size: 0.875rem; + } + + .timeline-event-dragging { + transform: rotate(2deg); + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + z-index: 1000; + } + + .ui-sortable-helper { + width: auto !important; + max-width: 600px; + } + + /* Content drag & drop styles */ + .draggable-content-item { + user-select: none; + } + + .draggable-content-item.dragging { + opacity: 0.5; + transform: scale(0.95); + } + + .event-drop-zone.drop-zone-active { + border-color: #10b981 !important; + border-width: 2px !important; + background-color: #f0fdf4 !important; + } + + .event-drop-zone.drop-zone-hover { + border-color: #059669 !important; + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1) !important; + transform: scale(1.02); + } + + .drop-indicator { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + z-index: 10; + background: rgba(16, 185, 129, 0.9); + color: white; + padding: 8px 16px; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + } + `; + document.head.appendChild(style); + } + + // Initialize content drag & drop functionality + initContentDragDrop(); +} + +// Initialize drag & drop for content linking +function initContentDragDrop() { + // Set up drag handlers for content items + document.addEventListener('dragstart', function(e) { + if (e.target.classList.contains('draggable-content-item')) { + const contentType = e.target.dataset.contentType; + const contentId = e.target.dataset.contentId; + const contentName = e.target.dataset.contentName; + + // Store data for drop handler + e.dataTransfer.setData('application/json', JSON.stringify({ + contentType: contentType, + contentId: contentId, + contentName: contentName + })); + + e.dataTransfer.effectAllowed = 'copy'; + + // Add dragging visual state + e.target.classList.add('dragging'); + + // Show all event drop zones + document.querySelectorAll('.event-drop-zone').forEach(zone => { + zone.classList.add('drop-zone-active'); + }); + } + }); + + document.addEventListener('dragend', function(e) { + if (e.target.classList.contains('draggable-content-item')) { + // Remove dragging visual state + e.target.classList.remove('dragging'); + + // Hide all event drop zones + document.querySelectorAll('.event-drop-zone').forEach(zone => { + zone.classList.remove('drop-zone-active', 'drop-zone-hover'); + // Remove any drop indicators + const indicator = zone.querySelector('.drop-indicator'); + if (indicator) indicator.remove(); + }); + } + }); + + // Set up drop handlers for timeline events + document.addEventListener('dragover', function(e) { + if (e.target.closest('.event-drop-zone')) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + + const dropZone = e.target.closest('.event-drop-zone'); + dropZone.classList.add('drop-zone-hover'); + + // Add drop indicator if not already present + if (!dropZone.querySelector('.drop-indicator')) { + const indicator = document.createElement('div'); + indicator.className = 'drop-indicator'; + indicator.textContent = 'Drop to link content'; + dropZone.style.position = 'relative'; + dropZone.appendChild(indicator); + } + } + }); + + document.addEventListener('dragleave', function(e) { + const dropZone = e.target.closest('.event-drop-zone'); + if (dropZone && !dropZone.contains(e.relatedTarget)) { + dropZone.classList.remove('drop-zone-hover'); + const indicator = dropZone.querySelector('.drop-indicator'); + if (indicator) indicator.remove(); + } + }); + + document.addEventListener('drop', function(e) { + const dropZone = e.target.closest('.event-drop-zone'); + if (dropZone) { + e.preventDefault(); + + try { + const dragData = JSON.parse(e.dataTransfer.getData('application/json')); + const eventId = dropZone.dataset.eventId; + + if (eventId && dragData.contentType && dragData.contentId) { + linkContentToEvent(eventId, dragData.contentType, dragData.contentId, dragData.contentName, dropZone); + } + } catch (error) { + console.error('Error parsing drag data:', error); + } + + // Clean up visual states + dropZone.classList.remove('drop-zone-hover', 'drop-zone-active'); + const indicator = dropZone.querySelector('.drop-indicator'); + if (indicator) indicator.remove(); + } + }); +} + +// Replace linked content section with server-rendered HTML +function replaceLinkedContentSection(eventId, html) { + const eventContainer = document.querySelector(`[data-event-id="${eventId}"]`); + if (!eventContainer) return; + + // Find the current linked content section or the location where it should be inserted + const existingSection = eventContainer.querySelector(`#linked-content-${eventId}`); + const cardBody = eventContainer.querySelector('.px-6.py-4.space-y-4'); + + if (existingSection) { + // Replace existing section + existingSection.outerHTML = html; + } else if (cardBody && html.trim()) { + // Insert new section at the end of the card body + cardBody.insertAdjacentHTML('beforeend', html); + } + + // Add entrance animation to the new section + const newSection = eventContainer.querySelector(`#linked-content-${eventId}`); + if (newSection) { + newSection.style.opacity = '0'; + newSection.style.transform = 'translateY(-10px)'; + setTimeout(() => { + newSection.style.transition = 'all 0.3s ease-out'; + newSection.style.opacity = '1'; + newSection.style.transform = 'translateY(0)'; + }, 10); + } +} + +// Link content to timeline event via drag & drop +function linkContentToEvent(eventId, contentType, contentId, contentName, dropZone) { + // Show loading indicator + const loadingIndicator = document.createElement('div'); + loadingIndicator.className = 'drop-indicator'; + loadingIndicator.innerHTML = '
Linking...'; + dropZone.style.position = 'relative'; + dropZone.appendChild(loadingIndicator); + + // Set Alpine.js auto-save status to saving + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl) { + Alpine.$data(alpineEl).autoSaveStatus = 'saving'; + } + + fetch(`/plan/timeline_events/${eventId}/link`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').getAttribute('content') + }, + body: JSON.stringify({ + entity_type: contentType, + entity_id: contentId + }) + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success') { + // Replace the linked content section with server-rendered HTML + replaceLinkedContentSection(eventId, data.html); + + // Update sidebar linked content if this event is selected + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl && Alpine.$data(alpineEl).selectedEventId == eventId) { + Alpine.$data(alpineEl).updateSidebarLinkedContent(eventId); + } + + // Show success feedback + loadingIndicator.innerHTML = 'check_circleLinked!'; + loadingIndicator.className = 'drop-indicator'; + + // Update Alpine.js save status + if (alpineEl) { + Alpine.$data(alpineEl).autoSaveStatus = 'saved'; + } + + setTimeout(() => { + loadingIndicator.remove(); + }, 2000); + + showTimelineSuccessMessage(`${contentName} linked to event successfully!`); + } else { + throw new Error(data.message || 'Failed to link content'); + } + }) + .catch(error => { + console.error('Error linking content:', error); + + // Show error feedback + loadingIndicator.innerHTML = 'errorFailed'; + loadingIndicator.className = 'drop-indicator'; + loadingIndicator.style.background = 'rgba(239, 68, 68, 0.9)'; + + // Update Alpine.js save status + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl) { + Alpine.$data(alpineEl).autoSaveStatus = 'error'; + } + + setTimeout(() => { + loadingIndicator.remove(); + }, 3000); + + showTimelineErrorMessage('Failed to link content. Please try again.'); + }); +} + +// Helper function to revert event position on error +function revertEventPosition(eventItem, originalPosition) { + const eventsContainer = eventItem.parent(); + const allEvents = eventsContainer.children('.timeline-event-container:not(.timeline-event-template)'); + + if (originalPosition === 0) { + // Insert before the first event (after header/rail) + allEvents.first().before(eventItem); + } else if (originalPosition >= allEvents.length - 1) { + // Insert after the last event + allEvents.last().after(eventItem); + } else { + // Insert before the event at the target position + allEvents.eq(originalPosition).before(eventItem); + } +} + +// Timeline-specific notification functions +function showTimelineSuccessMessage(message) { + showNotificationToast(message, 'success'); +} + +function showTimelineErrorMessage(message) { + showNotificationToast(message, 'error'); +} + +function showNotificationToast(message, type = 'info') { + const bgColor = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500'; + const icon = type === 'success' ? 'check_circle' : type === 'error' ? 'error' : 'info'; + + const toast = $(` ++ Start building your timeline by adding your first event. Track important moments, plot points, and key developments in chronological order. +
+ + `; + + // Add event listener to the new button + const newFirstEventBtn = emptyState.querySelector('#js-create-first-event'); + newFirstEventBtn.addEventListener('click', function() { + const timelineId = document.querySelector('.timeline-events-container').dataset.timelineId; + createTimelineEvent(timelineId); + emptyState.remove(); // Remove empty state when creating + }); + + eventsContainer.appendChild(emptyState); + } }); diff --git a/app/controllers/timeline_events_controller.rb b/app/controllers/timeline_events_controller.rb index afb521b7..d67ebb64 100644 --- a/app/controllers/timeline_events_controller.rb +++ b/app/controllers/timeline_events_controller.rb @@ -150,7 +150,7 @@ class TimelineEventsController < ApplicationController render json: { success: true, - message: "Timeline event moved to position #{intended_position + 1}", + message: "New position saved", # "Timeline event moved to position #{intended_position + 1}" timeline_event: { id: timeline_event.id, position: timeline_event.position, diff --git a/app/views/timelines/edit.html.erb b/app/views/timelines/edit.html.erb index 2f7ea6c5..8fe1f261 100644 --- a/app/views/timelines/edit.html.erb +++ b/app/views/timelines/edit.html.erb @@ -2977,1059 +2977,3 @@ function submitFormWithFetch(form) { } - \ No newline at end of file