notebook/app/controllers/timeline_events_controller.rb
Andrew Brown 44c60b1d94 Add drag & drop reordering for timeline events
- Add drag handles to timeline event cards with hover animations
- Implement jQuery UI Sortable for intuitive drag & drop reordering
- Create internal /sort/timeline_events API endpoint using acts_as_list
- Fix jQuery UI off-by-one position reporting with universal -1 adjustment
- Add comprehensive drag feedback with placeholders and visual states
- Preserve existing dropdown move options for accessibility
- Include mobile-responsive touch-friendly drag areas

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-02 00:37:51 -07:00

240 lines
7.4 KiB
Ruby

class TimelineEventsController < ApplicationController
before_action :set_timeline_event, only: [
:show, :edit, :update, :destroy,
:move_up, :move_to_top, :move_down, :move_to_bottom,
:link_entity, :unlink_entity, :add_tag, :remove_tag
]
# GET /timeline_events
def index
@timeline_events = TimelineEvent.all
end
# GET /timeline_events/1
def show
end
# GET /timeline_events/new
def new
@timeline_event = TimelineEvent.new
end
# GET /timeline_events/1/edit
def edit
end
# POST /timeline_events
def create
raise "No Access: (signed in: #{user_signed_in?})" unless user_signed_in? && current_user.timelines.pluck(:id).include?(timeline_event_params.fetch('timeline_id').to_i)
@timeline_event = TimelineEvent.new(timeline_event_params)
if @timeline_event.save
render json: { status: 'success', id: @timeline_event.reload.id }
else
raise "Failed to create TimelineEvent"
end
end
# PATCH/PUT /timeline_events/1
def update
if @timeline_event.update(timeline_event_params)
respond_to do |format|
format.html { redirect_to @timeline_event, notice: 'Timeline event was successfully updated.' }
format.json { render json: { status: 'success', message: 'Timeline event updated successfully' } }
format.js { render json: { status: 'success', message: 'Timeline event updated successfully' } }
end
else
respond_to do |format|
format.html { render :edit }
format.json { render json: { status: 'error', message: 'Failed to update timeline event', errors: @timeline_event.errors.full_messages }, status: :unprocessable_entity }
format.js { render json: { status: 'error', message: 'Failed to update timeline event', errors: @timeline_event.errors.full_messages }, status: :unprocessable_entity }
end
end
end
# DELETE /timeline_events/1
def destroy
timeline = @timeline_event.timeline
@timeline_event.destroy
redirect_to edit_timeline_path(timeline), notice: 'Timeline event was successfully removed.'
end
def link_entity
return render json: { error: 'Not authorized' }, status: :forbidden unless @timeline_event.can_be_modified_by?(current_user)
entity = @timeline_event.timeline_event_entities.find_or_create_by(timeline_event_entity_params)
if entity.persisted?
# Reload the timeline event to get the latest linked content
@timeline_event.reload
render json: {
status: 'success',
message: 'Content linked successfully',
html: render_to_string(
partial: 'shared/timeline_event_linked_content',
locals: { timeline_event: @timeline_event }
)
}
else
render json: {
status: 'error',
message: 'Failed to link content',
errors: entity.errors.full_messages
}, status: :unprocessable_entity
end
end
def unlink_entity
return render json: { error: 'Not authorized' }, status: :forbidden unless @timeline_event.can_be_modified_by?(current_user)
entity = @timeline_event.timeline_event_entities.find_by(id: params[:entity_id].to_i)
if entity&.destroy
# Reload the timeline event to get the latest linked content
@timeline_event.reload
render json: {
status: 'success',
message: 'Content unlinked successfully',
html: render_to_string(
partial: 'shared/timeline_event_linked_content',
locals: { timeline_event: @timeline_event }
)
}
else
render json: {
status: 'error',
message: 'Failed to unlink content'
}, status: :unprocessable_entity
end
end
# Move functions
def move_up
@timeline_event.move_higher if @timeline_event.can_be_modified_by?(current_user)
end
def move_down
@timeline_event.move_lower if @timeline_event.can_be_modified_by?(current_user)
end
def move_to_top
@timeline_event.move_to_top if @timeline_event.can_be_modified_by?(current_user)
end
def move_to_bottom
@timeline_event.move_to_bottom if @timeline_event.can_be_modified_by?(current_user)
end
# Drag and drop sorting endpoint (internal API)
def sort
content_id = params[:content_id]
intended_position = params[:intended_position].to_i
timeline_event = TimelineEvent.find_by(id: content_id)
unless timeline_event
render json: { error: "Timeline event not found" }, status: :not_found
return
end
unless timeline_event.can_be_modified_by?(current_user)
render json: { error: "You don't have permission to reorder that timeline event" }, status: :forbidden
return
end
# Use acts_as_list to move to the intended position
timeline_event.insert_at(intended_position + 1) # acts_as_list is 1-indexed
render json: {
success: true,
message: "Timeline event moved to position #{intended_position + 1}",
timeline_event: {
id: timeline_event.id,
position: timeline_event.position,
title: timeline_event.title
}
}, status: :ok
end
def add_tag
return render json: { error: 'Not authorized' }, status: :forbidden unless @timeline_event.can_be_modified_by?(current_user)
tag_name = params[:tag_name]&.strip
return render json: { error: 'Tag name is required' }, status: :bad_request if tag_name.blank?
# Check if tag already exists for this event
existing_tag = @timeline_event.page_tags.find_by(tag: tag_name)
if existing_tag
return render json: {
status: 'error',
message: 'Tag already exists for this event'
}, status: :unprocessable_entity
end
# Create the tag
tag = @timeline_event.page_tags.create(
tag: tag_name,
slug: PageTagService.slug_for(tag_name),
user: current_user
)
if tag.persisted?
render json: {
status: 'success',
message: 'Tag added successfully',
tag: {
id: tag.id,
name: tag.tag
}
}
else
render json: {
status: 'error',
message: 'Failed to add tag',
errors: tag.errors.full_messages
}, status: :unprocessable_entity
end
end
def remove_tag
return render json: { error: 'Not authorized' }, status: :forbidden unless @timeline_event.can_be_modified_by?(current_user)
tag_name = params[:tag_name]&.strip
return render json: { error: 'Tag name is required' }, status: :bad_request if tag_name.blank?
tag = @timeline_event.page_tags.find_by(tag: tag_name)
if tag&.destroy
render json: {
status: 'success',
message: 'Tag removed successfully'
}
else
render json: {
status: 'error',
message: 'Tag not found or failed to remove'
}, status: :unprocessable_entity
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_timeline_event
@timeline_event = TimelineEvent.find(params[:id])
end
# Only allow a trusted parameter "white list" through.
def timeline_event_params
params.require(:timeline_event).permit(:time_label, :title, :description, :notes, :timeline_id,
:event_type, :importance_level, :end_time_label, :status, :private_notes)
end
def timeline_event_entity_params
params.permit(:entity_type, :entity_id)
end
end