From 3d6ce69488f6f140d9cc5ff1d919f4e0fe232e10 Mon Sep 17 00:00:00 2001 From: Andrew Brown Date: Fri, 1 Aug 2025 22:23:45 -0700 Subject: [PATCH] Fix tag disappearing issue during timeline filtering and optimize performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Initialize eventTags object from server-rendered DOM data on page load - Add fallback in getEventTags() to scrape tags from DOM when cache is undefined - Optimize updateMainPanelTags with DOM query caching and content change detection - Remove redundant tag update calls during filtering to improve performance - Add proper cache invalidation when tags are added/removed - Only update highlight styling when actually needed during filter mode changes This resolves the issue where tags would disappear when filtering timeline events by ensuring the JavaScript tag data is always synchronized with the server-rendered content. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- app/assets/stylesheets/timeline_events.css | 404 +++++++--- app/assets/stylesheets/timeline_inspector.css | 296 ++++++++ .../stylesheets/timeline_linked_content.css | 168 +++-- app/assets/stylesheets/timeline_sidebar.css | 377 ++++++++++ app/controllers/documents_controller.rb | 2 - app/controllers/timeline_events_controller.rb | 77 +- app/controllers/timelines_controller.rb | 15 + app/models/timelines/timeline_event.rb | 69 +- .../_timeline_event_linked_content.html.erb | 10 +- app/views/timelines/edit.html.erb | 695 +++++++++++++++++- config/routes.rb | 2 + 11 files changed, 1887 insertions(+), 228 deletions(-) create mode 100644 app/assets/stylesheets/timeline_inspector.css create mode 100644 app/assets/stylesheets/timeline_sidebar.css diff --git a/app/assets/stylesheets/timeline_events.css b/app/assets/stylesheets/timeline_events.css index 056862c5..a027fed0 100644 --- a/app/assets/stylesheets/timeline_events.css +++ b/app/assets/stylesheets/timeline_events.css @@ -1,141 +1,343 @@ -/* Timeline Event Color Coding and Visual Hierarchy */ +/* ========================= + MODERN TIMELINE EDITOR + Paper-Style Card System + ========================= */ -/* Event Type Color Coding */ -.timeline-event-gray { border-left: 4px solid #6B7280; } -.timeline-event-blue { border-left: 4px solid #3B82F6; } -.timeline-event-green { border-left: 4px solid #10B981; } -.timeline-event-purple { border-left: 4px solid #8B5CF6; } -.timeline-event-amber { border-left: 4px solid #F59E0B; } -.timeline-event-red { border-left: 4px solid #EF4444; } -.timeline-event-indigo { border-left: 4px solid #6366F1; } -.timeline-event-pink { border-left: 4px solid #EC4899; } -.timeline-event-yellow { border-left: 4px solid #EAB308; } -.timeline-event-teal { border-left: 4px solid #14B8A6; } - -/* Event Type Background Tints */ -.timeline-event-gray .event-type-badge { @apply bg-gray-100 text-gray-800; } -.timeline-event-blue .event-type-badge { @apply bg-blue-100 text-blue-800; } -.timeline-event-green .event-type-badge { @apply bg-green-100 text-green-800; } -.timeline-event-purple .event-type-badge { @apply bg-purple-100 text-purple-800; } -.timeline-event-amber .event-type-badge { @apply bg-amber-100 text-amber-800; } -.timeline-event-red .event-type-badge { @apply bg-red-100 text-red-800; } -.timeline-event-indigo .event-type-badge { @apply bg-indigo-100 text-indigo-800; } -.timeline-event-pink .event-type-badge { @apply bg-pink-100 text-pink-800; } -.timeline-event-yellow .event-type-badge { @apply bg-yellow-100 text-yellow-800; } -.timeline-event-teal .event-type-badge { @apply bg-teal-100 text-teal-800; } - -/* Importance Level Visual Hierarchy */ -.timeline-event-major { - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - border-width: 2px; +/* Container Layout System */ +.timeline-events-container { + position: relative; + min-height: 200px; } -.timeline-event-major .event-title { - font-size: 1.25rem; - font-weight: 700; +/* Timeline Spine - Continuous Vertical Rail */ +.timeline-spine { + background: linear-gradient(to bottom, + transparent 0%, + #d1d5db 10%, + #9ca3af 50%, + #d1d5db 90%, + transparent 100% + ); + box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.1); } -.timeline-event-minor { - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); +/* Timeline Dots - Connected to Spine */ +.timeline-dot { + transition: all 0.2s ease-in-out; + position: relative; + z-index: 10; } -.timeline-event-minor .event-title { - font-size: 1.125rem; - font-weight: 600; +.timeline-dot:hover { + transform: scale(1.1); + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); } -.timeline-event-background { - opacity: 0.85; - border-style: dashed; +.timeline-dot::before { + content: ''; + position: absolute; + inset: -2px; + border-radius: 50%; + z-index: -1; } -.timeline-event-background .event-title { - font-size: 1rem; - font-weight: 500; - color: #6B7280; +/* Paper-Style Event Cards */ +.timeline-event-card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 16px; + box-shadow: + 0 1px 3px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: visible; } -/* Status Indicators */ -.status-badge-planned { @apply bg-yellow-100 text-yellow-800; } -.status-badge-happening { @apply bg-blue-100 text-blue-800; } -.status-badge-completed { @apply bg-green-100 text-green-800; } -.status-badge-cancelled { @apply bg-gray-100 text-gray-800; } +/* Selected Event State */ +.timeline-event-card.ring-2 { + border-color: #10b981; + box-shadow: + 0 0 0 2px rgba(16, 185, 129, 0.2), + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); + transform: translateY(-1px) scale(1.01); +} -/* Timeline Dot Colors */ -.timeline-dot-gray { background-color: #6B7280; } -.timeline-dot-blue { background-color: #3B82F6; } -.timeline-dot-green { background-color: #10B981; } -.timeline-dot-purple { background-color: #8B5CF6; } -.timeline-dot-amber { background-color: #F59E0B; } -.timeline-dot-red { background-color: #EF4444; } -.timeline-dot-indigo { background-color: #6366F1; } -.timeline-dot-pink { background-color: #EC4899; } -.timeline-dot-yellow { background-color: #EAB308; } -.timeline-dot-teal { background-color: #14B8A6; } +.timeline-event-card.ring-2::before { + opacity: 1; +} -/* Duration Events */ -.duration-event { +.timeline-event-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, + #10b981 0%, + #059669 50%, + #10b981 100% + ); + opacity: 0; + transition: opacity 0.3s ease; +} + +.timeline-event-card:hover { + transform: translateY(-2px) scale(1.02); + box-shadow: + 0 10px 25px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + border-color: #d1d5db; +} + +.timeline-event-card:hover::before { + opacity: 1; +} + +/* Card Header Styling */ +.timeline-event-card .px-6.py-4.border-b { + background: linear-gradient(135deg, #fefefe 0%, #f8fafc 100%); + border-bottom: 1px solid #f1f5f9; + border-radius: 16px 16px 0 0; position: relative; } -.duration-event::after { +.timeline-event-card .px-6.py-4.border-b::after { content: ''; position: absolute; - left: 32px; - top: 0; bottom: 0; - width: 2px; - background: linear-gradient(to bottom, currentColor 0%, transparent 100%); - opacity: 0.3; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, + transparent 0%, + #e2e8f0 50%, + transparent 100% + ); } -/* Filter Sidebar */ -.timeline-filters { - position: sticky; - top: 120px; - max-height: calc(100vh - 140px); - overflow-y: auto; +/* Event Title Input Styling */ +.timeline-event-card input[name*="[title]"] { + font-weight: 600; + color: #1f2937; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + padding: 0.25rem 0; + transition: all 0.2s ease; } -.filter-group { - margin-bottom: 1.5rem; +.timeline-event-card input[name*="[title]"]:hover { + border-bottom-color: #e5e7eb; } -.filter-checkbox { - display: flex; - align-items: center; +.timeline-event-card input[name*="[title]"]:focus { + border-bottom-color: #10b981; + box-shadow: 0 2px 4px rgba(16, 185, 129, 0.1); +} + +/* Time Label Input Styling */ +.timeline-event-card input[name*="[time_label]"] { + color: #6b7280; + font-size: 0.875rem; + background: transparent; + border: none; + border-bottom: 1px solid transparent; + padding: 0.125rem 0; + transition: all 0.2s ease; +} + +.timeline-event-card input[name*="[time_label]"]:hover { + border-bottom-color: #d1d5db; +} + +.timeline-event-card input[name*="[time_label]"]:focus { + border-bottom-color: #10b981; + color: #374151; +} + +/* Description Textarea Styling */ +.timeline-event-card textarea[name*="[description]"] { + background: #fefefe; + border: 1px solid #f3f4f6; + border-radius: 12px; + padding: 0.875rem 1rem; + transition: all 0.2s ease; + font-size: 0.9375rem; + line-height: 1.6; +} + +.timeline-event-card textarea[name*="[description]"]:hover { + background: #ffffff; + border-color: #e5e7eb; +} + +.timeline-event-card textarea[name*="[description]"]:focus { + background: #ffffff; + border-color: #10b981; + box-shadow: + 0 0 0 3px rgba(16, 185, 129, 0.1), + 0 2px 4px rgba(0, 0, 0, 0.05); +} + +/* Action Button Styling */ +.timeline-move-controls button { + background: transparent; + border: none; padding: 0.5rem; - border-radius: 0.5rem; - transition: background-color 0.2s; + border-radius: 8px; + color: #9ca3af; + transition: all 0.2s ease; + position: relative; } -.filter-checkbox:hover { - background-color: #F9FAFB; +.timeline-move-controls button:hover { + background: rgba(16, 185, 129, 0.1); + color: #10b981; + transform: scale(1.1); } -.filter-checkbox input[type="checkbox"] { - margin-right: 0.75rem; +.timeline-move-controls button:hover[onclick*="delete"] { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; } -.filter-color-dot { - width: 12px; - height: 12px; - border-radius: 50%; - margin-right: 0.5rem; +/* Action Dropdown Menu */ +.timeline-move-controls .absolute.right-0 { + background: white; + border: 1px solid #e5e7eb; + border-radius: 12px; + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + overflow: hidden; +} + +.timeline-move-controls .absolute.right-0 a { + padding: 0.75rem 1rem; + color: #374151; + transition: all 0.15s ease; + border-bottom: 1px solid #f9fafb; +} + +.timeline-move-controls .absolute.right-0 a:hover { + background: #f8fafc; + color: #10b981; +} + +.timeline-move-controls .absolute.right-0 a:last-child { + border-bottom: none; +} + +/* Empty State Styling */ +.text-center.py-16 { + animation: fadeInUp 0.6s ease-out; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.text-center.py-16 .mx-auto.h-24 { + background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); + transition: all 0.3s ease; +} + +.text-center.py-16:hover .mx-auto.h-24 { + transform: scale(1.05); + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +/* Form Focus States */ +.timeline-event-card input:focus, +.timeline-event-card textarea:focus { + outline: none; +} + +/* Loading Animation for New Events */ +.timeline-event-container.creating { + animation: slideInFromTop 0.4s ease-out; +} + +@keyframes slideInFromTop { + from { + opacity: 0; + transform: translateY(-20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Hover Group Effects */ +.timeline-event-container:hover .timeline-dot { + transform: scale(1.15); + box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4); +} + +/* Card Content Spacing */ +.timeline-event-card .space-y-4 > * + * { + margin-top: 1.5rem; +} + +/* Subtle Background Pattern */ +.timeline-event-card .px-6.py-4.space-y-4 { + background-image: + radial-gradient(circle at 1px 1px, rgba(16, 185, 129, 0.03) 1px, transparent 0); + background-size: 20px 20px; + background-position: 0 0; +} + +/* Paper Texture Effect */ +.timeline-event-card { + background-image: + linear-gradient(45deg, transparent 48%, rgba(255,255,255,0.8) 49%, rgba(255,255,255,0.8) 51%, transparent 52%), + linear-gradient(-45deg, transparent 48%, rgba(255,255,255,0.6) 49%, rgba(255,255,255,0.6) 51%, transparent 52%); + background-size: 3px 3px; +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .timeline-event-card { + margin-left: 0; + border-radius: 12px; + } + + .timeline-spine { + display: none; + } + + .timeline-dot { + position: relative; + left: auto; + top: auto; + margin-bottom: 1rem; + } } -/* Responsive Adjustments */ @media (max-width: 768px) { - .timeline-event-major .event-title { - font-size: 1.125rem; + .timeline-event-card { + border-radius: 8px; + margin-bottom: 1rem; } - .timeline-event-minor .event-title { - font-size: 1rem; + .timeline-event-card .px-6.py-4 { + padding: 1rem 1.25rem; } - - .timeline-filters { - position: static; - max-height: none; - } -} \ No newline at end of file +} + diff --git a/app/assets/stylesheets/timeline_inspector.css b/app/assets/stylesheets/timeline_inspector.css new file mode 100644 index 00000000..526dbc6b --- /dev/null +++ b/app/assets/stylesheets/timeline_inspector.css @@ -0,0 +1,296 @@ +/* ========================= + TIMELINE INSPECTOR PANEL + Modern Details & Linking UI + ========================= */ + +/* Inspector Panel Container */ +.timeline-inspector { + background: linear-gradient(135deg, #ffffff 0%, #fefefe 100%); + border-left: 1px solid #e5e7eb; + box-shadow: + inset 1px 0 0 0 rgba(255, 255, 255, 0.5), + -2px 0 8px -2px rgba(0, 0, 0, 0.05); + position: relative; + overflow: hidden; +} + +.timeline-inspector::before { + content: ''; + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 1px; + background: linear-gradient(180deg, + transparent 0%, + rgba(16, 185, 129, 0.1) 50%, + transparent 100% + ); +} + +/* Inspector Header */ +.timeline-inspector .px-6.py-4.border-b { + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); + border-bottom: 1px solid #e2e8f0; + position: relative; +} + +.timeline-inspector .px-6.py-4.border-b::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, + transparent 0%, + #cbd5e1 50%, + transparent 100% + ); +} + +/* Event Title in Inspector */ +.timeline-inspector h4 { + color: #1f2937; + font-weight: 600; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* Metadata Badges */ +.timeline-inspector .px-3.py-1 { + transition: all 0.2s ease; + backdrop-filter: blur(8px); + border: 1px solid transparent; +} + +.timeline-inspector .bg-blue-100 { + background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); + border-color: rgba(59, 130, 246, 0.2); +} + +.timeline-inspector .bg-yellow-100 { + background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); + border-color: rgba(245, 158, 11, 0.2); +} + +/* Description Panel */ +.timeline-inspector .bg-gray-50 { + background: linear-gradient(135deg, #f9fafb 0%, #f3f4f6 100%); + border: 1px solid #e5e7eb; + transition: all 0.2s ease; +} + +.timeline-inspector .bg-gray-50:hover { + background: linear-gradient(135deg, #ffffff 0%, #f9fafb 100%); + transform: translateY(-1px); + box-shadow: + 0 2px 4px 0 rgba(0, 0, 0, 0.05), + 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} + +/* Quick Action Buttons */ +.timeline-inspector button { + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; +} + +.timeline-inspector button::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, + transparent 0%, + rgba(255, 255, 255, 0.2) 50%, + transparent 100% + ); + transition: left 0.5s ease; +} + +.timeline-inspector button:hover::before { + left: 100%; +} + +/* Link Content Button */ +.timeline-inspector .bg-green-100 { + background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); + border: 1px solid #6ee7b7; + box-shadow: + 0 1px 2px 0 rgba(16, 185, 129, 0.1), + inset 0 1px 0 0 rgba(255, 255, 255, 0.1); +} + +.timeline-inspector .bg-green-100:hover { + background: linear-gradient(135deg, #a7f3d0 0%, #6ee7b7 100%); + transform: translateY(-1px); + box-shadow: + 0 2px 4px 0 rgba(16, 185, 129, 0.2), + 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} + +/* Delete Button */ +.timeline-inspector .bg-red-50 { + background: linear-gradient(135deg, #fef2f2 0%, #fecaca 100%); + border: 1px solid #fca5a5; + box-shadow: + 0 1px 2px 0 rgba(239, 68, 68, 0.1), + inset 0 1px 0 0 rgba(255, 255, 255, 0.1); +} + +.timeline-inspector .bg-red-50:hover { + background: linear-gradient(135deg, #fecaca 0%, #f87171 100%); + transform: translateY(-1px); + box-shadow: + 0 2px 4px 0 rgba(239, 68, 68, 0.2), + 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} + +/* Empty State Styling */ +.timeline-inspector .text-gray-500 { + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Linked Content Info Box */ +.timeline-inspector .bg-gray-50.rounded-lg { + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); + border: 1px solid #e2e8f0; + border-left: 3px solid #10b981; +} + +/* Section Dividers */ +.timeline-inspector .border-t, +.timeline-inspector .border-b { + border-color: #f1f5f9; +} + +/* Scrollbar Styling */ +.timeline-inspector .overflow-y-auto::-webkit-scrollbar { + width: 6px; +} + +.timeline-inspector .overflow-y-auto::-webkit-scrollbar-track { + background: transparent; +} + +.timeline-inspector .overflow-y-auto::-webkit-scrollbar-thumb { + background: rgba(156, 163, 175, 0.3); + border-radius: 3px; +} + +.timeline-inspector .overflow-y-auto::-webkit-scrollbar-thumb:hover { + background: rgba(156, 163, 175, 0.5); +} + +/* Mobile Responsive */ +@media (max-width: 1280px) { + .timeline-inspector { + position: fixed; + top: 0; + right: -100%; + width: 24rem; + height: 100vh; + z-index: 50; + transition: right 0.3s ease; + box-shadow: + -2px 0 10px 0 rgba(0, 0, 0, 0.1), + 0 0 0 1px rgba(0, 0, 0, 0.05); + } + + .timeline-inspector.open { + right: 0; + } + + /* Mobile backdrop */ + .timeline-inspector-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 40; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; + } + + .timeline-inspector-backdrop.open { + opacity: 1; + pointer-events: auto; + } +} + +/* Focus States */ +.timeline-inspector button:focus { + outline: 2px solid #10b981; + outline-offset: 2px; +} + +/* Loading States */ +.timeline-inspector button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none !important; +} + +.timeline-inspector button:disabled:hover { + transform: none !important; + box-shadow: none !important; +} + +/* Subtle pattern overlay */ +.timeline-inspector::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + radial-gradient(circle at 1px 1px, rgba(16, 185, 129, 0.02) 1px, transparent 0); + background-size: 20px 20px; + pointer-events: none; + z-index: 1; +} + +.timeline-inspector > * { + position: relative; + z-index: 2; +} + +/* Animation for content changes */ +.timeline-inspector [x-show] { + transition: all 0.2s ease-out; +} + +/* Enhanced section headers */ +.timeline-inspector h5 { + position: relative; + color: #1f2937; + font-weight: 600; +} + +.timeline-inspector h5::after { + content: ''; + position: absolute; + bottom: -0.25rem; + left: 0; + width: 1.5rem; + height: 2px; + background: linear-gradient(90deg, #10b981 0%, transparent 100%); + border-radius: 1px; +} \ No newline at end of file diff --git a/app/assets/stylesheets/timeline_linked_content.css b/app/assets/stylesheets/timeline_linked_content.css index c9530abf..b84e255c 100644 --- a/app/assets/stylesheets/timeline_linked_content.css +++ b/app/assets/stylesheets/timeline_linked_content.css @@ -1,4 +1,7 @@ -/* Timeline Event Linked Content Cards */ +/* ========================= + TIMELINE LINKED CONTENT + Paper-Style Card Integration + ========================= */ /* Horizontal Scrolling Container */ .linked-content-scroll { @@ -6,23 +9,12 @@ scrollbar-color: rgba(156, 163, 175, 0.3) transparent; } -/* Breakout container to escape parent padding constraints */ +/* Contained scrolling for linked content */ .linked-content-breakout { - /* Ensure full width utilization */ - width: 100vw; - max-width: calc(100vw - 2rem); /* Account for page margins */ - margin-left: calc(-50vw + 50%); - margin-right: calc(-50vw + 50%); -} - -@media (min-width: 1280px) { - .linked-content-breakout { - /* Constrain to container on larger screens */ - width: auto; - max-width: none; - margin-left: -1.5rem; - margin-right: -1.5rem; - } + /* Stay within parent container bounds */ + width: 100%; + margin-left: 0; + margin-right: 0; } .linked-content-scroll::-webkit-scrollbar { @@ -64,14 +56,45 @@ overflow: hidden; } -/* Compact Card Hover Effects */ +/* Linked Content Card Styling */ .linked-content-card { - transition: all 0.2s ease-in-out; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 12px; + background: white; + border: 1px solid #f3f4f6; + box-shadow: + 0 1px 3px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); + position: relative; + overflow: hidden; +} + +.linked-content-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, + #10b981 0%, + #059669 50%, + #10b981 100% + ); + opacity: 0; + transition: opacity 0.3s ease; } .linked-content-card:hover { - transform: translateY(-2px) scale(1.02); - box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15); + transform: translateY(-3px) scale(1.03); + box-shadow: + 0 10px 25px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + border-color: #e5e7eb; +} + +.linked-content-card:hover::before { + opacity: 1; } /* Image Placeholder Gradient */ @@ -79,65 +102,117 @@ background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%); } -/* Enhanced Badge Styles */ +/* Modern Badge Styles */ .type-badge { - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.95); + border: 1px solid rgba(229, 231, 235, 0.8); + box-shadow: + 0 1px 3px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); + backdrop-filter: blur(8px); + border-radius: 8px; + transition: all 0.2s ease; } -/* Remove Button Hover Effects */ +.linked-content-card:hover .type-badge { + background: rgba(255, 255, 255, 1); + transform: translateY(-1px); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +/* Modern Remove Button */ .remove-btn { transition: all 0.2s ease-in-out; - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.95); + border: 1px solid rgba(229, 231, 235, 0.8); + box-shadow: + 0 1px 3px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); + backdrop-filter: blur(8px); + border-radius: 8px; } .remove-btn:hover { - transform: scale(1.1); + transform: scale(1.1) translateY(-1px); + background: #fef2f2; + border-color: #fecaca; + color: #dc2626; + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); } -/* Name Card Overlay Styling */ +/* Clean Name Card Overlay */ .name-card-overlay { - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid rgba(255, 255, 255, 0.2); + background: white; + border: 1px solid #e5e7eb; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); } -/* Gradient Overlay Enhancement */ +/* Refined Gradient Overlay */ .gradient-overlay { background: linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, - rgba(0, 0, 0, 0.3) 40%, - transparent 70% + rgba(0, 0, 0, 0.4) 30%, + rgba(0, 0, 0, 0.1) 60%, + transparent 80% ); + border-radius: 0 0 12px 12px; } -/* Animation for new cards */ +/* Enhanced Animation for new cards */ @keyframes slideInFromRight { from { opacity: 0; - transform: translateX(20px); + transform: translateX(20px) scale(0.95); + } + 50% { + transform: translateX(-2px) scale(1.02); } to { opacity: 1; - transform: translateX(0); + transform: translateX(0) scale(1); } } .new-linked-card { - animation: slideInFromRight 0.3s ease-out; + animation: slideInFromRight 0.4s cubic-bezier(0.4, 0, 0.2, 1); } -/* Card Hover State Enhancements */ +/* Enhanced Card Hover States */ .linked-content-card:hover .name-card-overlay { background: rgba(255, 255, 255, 0.98); transform: translateY(-2px); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); } .linked-content-card:hover .type-badge { - transform: scale(1.05); + transform: scale(1.05) translateY(-1px); +} + +/* Paper texture for linked content */ +.linked-content-card::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + linear-gradient(45deg, transparent 48%, rgba(255,255,255,0.05) 49%, rgba(255,255,255,0.05) 51%, transparent 52%), + linear-gradient(-45deg, transparent 48%, rgba(255,255,255,0.03) 49%, rgba(255,255,255,0.03) 51%, transparent 52%); + background-size: 2px 2px; + pointer-events: none; + opacity: 0; + transition: opacity 0.3s ease; +} + +.linked-content-card:hover::after { + opacity: 1; } /* Text Selection Prevention */ @@ -172,10 +247,15 @@ } } -/* Focus states for accessibility */ +/* Enhanced Focus states for accessibility */ .linked-content-card:focus-within { outline: 2px solid #10B981; outline-offset: 2px; + transform: translateY(-2px); + box-shadow: + 0 0 0 4px rgba(16, 185, 129, 0.1), + 0 10px 25px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); } .linked-content-card a:focus { diff --git a/app/assets/stylesheets/timeline_sidebar.css b/app/assets/stylesheets/timeline_sidebar.css new file mode 100644 index 00000000..4df61a87 --- /dev/null +++ b/app/assets/stylesheets/timeline_sidebar.css @@ -0,0 +1,377 @@ +/* ========================= + TIMELINE SIDEBAR STYLING + Modern Tool Panel Design + ========================= */ + +/* Sidebar Container */ +.timeline-sidebar { + background: linear-gradient(135deg, #ffffff 0%, #fefefe 100%); + border-right: 1px solid #e5e7eb; + box-shadow: + inset -1px 0 0 0 rgba(255, 255, 255, 0.5), + 2px 0 8px -2px rgba(0, 0, 0, 0.05); + position: relative; + overflow: hidden; +} + +.timeline-sidebar::before { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 1px; + background: linear-gradient(180deg, + transparent 0%, + rgba(16, 185, 129, 0.1) 50%, + transparent 100% + ); +} + +/* Sidebar Header */ +.timeline-sidebar .px-6.py-4.border-b { + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); + border-bottom: 1px solid #e2e8f0; + position: relative; +} + +.timeline-sidebar .px-6.py-4.border-b::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, + transparent 0%, + #cbd5e1 50%, + transparent 100% + ); +} + +/* Section Styling */ +.timeline-sidebar .p-6.border-b { + border-bottom: 1px solid #f1f5f9; + position: relative; + transition: all 0.2s ease; +} + +.timeline-sidebar .p-6.border-b::after { + content: ''; + position: absolute; + bottom: 0; + left: 1.5rem; + right: 1.5rem; + height: 1px; + background: linear-gradient(90deg, + transparent 0%, + #e2e8f0 50%, + transparent 100% + ); +} + +.timeline-sidebar .p-6.border-b:hover { + background: rgba(248, 250, 252, 0.5); +} + +/* Search Input Styling */ +.timeline-sidebar input[type="text"] { + background: white; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 0.75rem 1rem 0.75rem 2.5rem; + font-size: 0.875rem; + transition: all 0.2s ease; + box-shadow: + 0 1px 2px 0 rgba(0, 0, 0, 0.05), + inset 0 1px 0 0 rgba(255, 255, 255, 0.05); +} + +.timeline-sidebar input[type="text"]:hover { + background: #fefefe; + border-color: #d1d5db; + box-shadow: + 0 1px 3px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); +} + +.timeline-sidebar input[type="text"]:focus { + background: white; + border-color: #10b981; + box-shadow: + 0 0 0 3px rgba(16, 185, 129, 0.1), + 0 1px 3px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); + outline: none; +} + +/* Search Icon Styling */ +.timeline-sidebar .absolute.inset-y-0.left-0 { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + color: #9ca3af; + transition: color 0.2s ease; +} + +.timeline-sidebar input[type="text"]:focus + .absolute.inset-y-0.left-0, +.timeline-sidebar input[type="text"]:hover + .absolute.inset-y-0.left-0 { + color: #10b981; +} + +/* Quick Action Buttons */ +.timeline-sidebar button { + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; +} + +.timeline-sidebar button::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, + transparent 0%, + rgba(255, 255, 255, 0.2) 50%, + transparent 100% + ); + transition: left 0.5s ease; +} + +.timeline-sidebar button:hover::before { + left: 100%; +} + +/* Primary Button (Add Event) */ +.timeline-sidebar .bg-green-600 { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + border: 1px solid #059669; + box-shadow: + 0 2px 4px 0 rgba(16, 185, 129, 0.2), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); +} + +.timeline-sidebar .bg-green-600:hover { + background: linear-gradient(135deg, #059669 0%, #047857 100%); + transform: translateY(-1px); + box-shadow: + 0 4px 8px 0 rgba(16, 185, 129, 0.3), + 0 2px 4px 0 rgba(0, 0, 0, 0.1); +} + +.timeline-sidebar .bg-green-600:active { + transform: translateY(0); + box-shadow: + 0 1px 2px 0 rgba(16, 185, 129, 0.2), + 0 1px 1px 0 rgba(0, 0, 0, 0.06); +} + +/* Secondary Button (Import Events) */ +.timeline-sidebar .bg-white.border { + background: white; + border: 1px solid #e5e7eb; + box-shadow: + 0 1px 2px 0 rgba(0, 0, 0, 0.05), + inset 0 1px 0 0 rgba(255, 255, 255, 0.1); +} + +.timeline-sidebar .bg-white.border:hover { + background: #f9fafb; + border-color: #d1d5db; + transform: translateY(-1px); + box-shadow: + 0 2px 4px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); +} + +.timeline-sidebar .bg-white.border:active { + transform: translateY(0); + background: #f3f4f6; +} + +/* Stats Section Styling */ +.timeline-sidebar .space-y-3 { + padding: 0.5rem 0; +} + +.timeline-sidebar .flex.justify-between.items-center { + padding: 0.5rem 0; + border-radius: 8px; + transition: all 0.15s ease; + position: relative; +} + +.timeline-sidebar .flex.justify-between.items-center:hover { + background: rgba(16, 185, 129, 0.05); + transform: translateX(4px); +} + +.timeline-sidebar .flex.justify-between.items-center::before { + content: ''; + position: absolute; + left: -0.5rem; + top: 0; + bottom: 0; + width: 3px; + background: #10b981; + border-radius: 0 2px 2px 0; + opacity: 0; + transition: opacity 0.15s ease; +} + +.timeline-sidebar .flex.justify-between.items-center:hover::before { + opacity: 1; +} + +/* Stats Text Styling */ +.timeline-sidebar .text-sm.text-gray-600 { + color: #6b7280; + font-weight: 500; + transition: color 0.15s ease; +} + +.timeline-sidebar .text-sm.font-medium.text-gray-900 { + color: #1f2937; + font-weight: 600; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + transition: all 0.15s ease; +} + +/* Section Headers */ +.timeline-sidebar h3, +.timeline-sidebar h4 { + color: #1f2937; + font-weight: 600; + position: relative; +} + +.timeline-sidebar h3::after, +.timeline-sidebar h4::after { + content: ''; + position: absolute; + bottom: -0.25rem; + left: 0; + width: 2rem; + height: 2px; + background: linear-gradient(90deg, #10b981 0%, transparent 100%); + border-radius: 1px; +} + +/* Scrollbar Styling */ +.timeline-sidebar .overflow-y-auto::-webkit-scrollbar { + width: 6px; +} + +.timeline-sidebar .overflow-y-auto::-webkit-scrollbar-track { + background: transparent; +} + +.timeline-sidebar .overflow-y-auto::-webkit-scrollbar-thumb { + background: rgba(156, 163, 175, 0.3); + border-radius: 3px; +} + +.timeline-sidebar .overflow-y-auto::-webkit-scrollbar-thumb:hover { + background: rgba(156, 163, 175, 0.5); +} + +/* Mobile Responsive */ +@media (max-width: 1024px) { + .timeline-sidebar { + position: fixed; + top: 0; + left: -100%; + width: 20rem; + height: 100vh; + z-index: 50; + transition: left 0.3s ease; + box-shadow: + 2px 0 10px 0 rgba(0, 0, 0, 0.1), + 0 0 0 1px rgba(0, 0, 0, 0.05); + } + + .timeline-sidebar.open { + left: 0; + } + + /* Mobile backdrop */ + .timeline-sidebar-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 40; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; + } + + .timeline-sidebar-backdrop.open { + opacity: 1; + pointer-events: auto; + } +} + +/* Focus and Accessibility */ +.timeline-sidebar button:focus { + outline: 2px solid #10b981; + outline-offset: 2px; +} + +.timeline-sidebar input:focus { + outline: none; +} + +/* Loading States */ +.timeline-sidebar button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none !important; +} + +.timeline-sidebar button:disabled:hover { + transform: none !important; + box-shadow: none !important; +} + +/* Animation for sidebar reveal */ +@keyframes slideInFromLeft { + from { + transform: translateX(-100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.timeline-sidebar.animate-in { + animation: slideInFromLeft 0.3s ease-out; +} + +/* Subtle pattern overlay */ +.timeline-sidebar::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + radial-gradient(circle at 1px 1px, rgba(16, 185, 129, 0.02) 1px, transparent 0); + background-size: 20px 20px; + pointer-events: none; + z-index: 1; +} + +.timeline-sidebar > * { + position: relative; + z-index: 2; +} \ No newline at end of file diff --git a/app/controllers/documents_controller.rb b/app/controllers/documents_controller.rb index d1385bd8..ff37a608 100644 --- a/app/controllers/documents_controller.rb +++ b/app/controllers/documents_controller.rb @@ -298,8 +298,6 @@ class DocumentsController < ApplicationController def determine_layout if action_name == 'edit' 'editor' - elsif ['index', 'show'].include?(action_name) - 'tailwind' elsif action_name == 'plaintext' 'plaintext' else diff --git a/app/controllers/timeline_events_controller.rb b/app/controllers/timeline_events_controller.rb index 99c5cc35..b9c5e6ca 100644 --- a/app/controllers/timeline_events_controller.rb +++ b/app/controllers/timeline_events_controller.rb @@ -2,7 +2,7 @@ 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 + :link_entity, :unlink_entity, :add_tag, :remove_tag ] # GET /timeline_events @@ -39,11 +39,17 @@ class TimelineEventsController < ApplicationController # PATCH/PUT /timeline_events/1 def update if @timeline_event.update(timeline_event_params) - redirect_to @timeline_event, notice: 'Timeline event was successfully updated.' + 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 - require 'pry' - binding.pry - render json: :failure + 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 @@ -122,6 +128,67 @@ class TimelineEventsController < ApplicationController @timeline_event.move_to_bottom if @timeline_event.can_be_modified_by?(current_user) 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. diff --git a/app/controllers/timelines_controller.rb b/app/controllers/timelines_controller.rb index 4ceaf72f..fadcb124 100644 --- a/app/controllers/timelines_controller.rb +++ b/app/controllers/timelines_controller.rb @@ -77,6 +77,9 @@ class TimelinesController < ApplicationController @suggested_page_tags = [] cache_linkable_content_for_each_content_type + # Collect all unique tags from timeline events for filtering + @timeline_event_tags = collect_timeline_event_tags + # Get content already linked to other events in this timeline @timeline_linked_content = {} @timeline_content_summary = {} @@ -192,4 +195,16 @@ class TimelinesController < ApplicationController def set_sidenav_expansion @sidenav_expansion = 'writing' end + + def collect_timeline_event_tags + # Get all unique tags from timeline events with their usage counts + tag_counts = @timeline.timeline_events + .joins(:page_tags) + .group('page_tags.tag') + .count + + # Return array of hashes with tag name and count for easy access in view + tag_counts.map { |tag, count| { name: tag, count: count } } + .sort_by { |tag_data| tag_data[:name].downcase } + end end diff --git a/app/models/timelines/timeline_event.rb b/app/models/timelines/timeline_event.rb index 18be3c9c..8babade3 100644 --- a/app/models/timelines/timeline_event.rb +++ b/app/models/timelines/timeline_event.rb @@ -4,59 +4,42 @@ class TimelineEvent < ApplicationRecord belongs_to :timeline, touch: true has_many :timeline_event_entities, dependent: :destroy + + include HasPageTags acts_as_list scope: [:timeline_id] - # Validations - validates :event_type, presence: true, inclusion: { in: %w[general plot_event character_development world_event historical battle political cultural mystery research] } - validates :importance_level, presence: true, inclusion: { in: %w[major minor background] } - validates :status, presence: true, inclusion: { in: %w[planned happening completed cancelled] } - - # Scopes - scope :by_type, ->(type) { where(event_type: type) } - scope :by_importance, ->(level) { where(importance_level: level) } - scope :by_status, ->(status) { where(status: status) } - scope :major_events, -> { where(importance_level: 'major') } - scope :completed_events, -> { where(status: 'completed') } - - # Event type definitions with display info + # Event type definitions with narrative focus and Material Icons EVENT_TYPES = { - 'general' => { name: 'General Event', color: 'gray', icon: 'event' }, - 'plot_event' => { name: 'Plot Event', color: 'blue', icon: 'timeline' }, - 'character_development' => { name: 'Character Development', color: 'green', icon: 'person' }, - 'world_event' => { name: 'World Event', color: 'purple', icon: 'public' }, - 'historical' => { name: 'Historical', color: 'amber', icon: 'history' }, - 'battle' => { name: 'Battle/Conflict', color: 'red', icon: 'sword' }, - 'political' => { name: 'Political', color: 'indigo', icon: 'gavel' }, - 'cultural' => { name: 'Cultural', color: 'pink', icon: 'celebration' }, - 'mystery' => { name: 'Mystery/Clue', color: 'yellow', icon: 'search' }, - 'research' => { name: 'Research Note', color: 'teal', icon: 'book' } + 'general' => { name: 'General', icon: 'radio_button_checked' }, + 'setup' => { name: 'Setup', icon: 'foundation' }, + 'exposition' => { name: 'Exposition', icon: 'info' }, + 'inciting_incident' => { name: 'Inciting Incident', icon: 'flash_on' }, + 'complication' => { name: 'Complication', icon: 'warning' }, + 'obstacle' => { name: 'Obstacle', icon: 'block' }, + 'conflict' => { name: 'Conflict', icon: 'gavel' }, + 'progress' => { name: 'Progress', icon: 'trending_up' }, + 'revelation' => { name: 'Revelation', icon: 'visibility' }, + 'transformation' => { name: 'Transformation', icon: 'autorenew' }, + 'climax' => { name: 'Climax', icon: 'whatshot' }, + 'resolution' => { name: 'Resolution', icon: 'check_circle' }, + 'aftermath' => { name: 'Aftermath', icon: 'restore' } }.freeze - IMPORTANCE_LEVELS = { - 'major' => { name: 'Major Event', weight: 3 }, - 'minor' => { name: 'Minor Event', weight: 2 }, - 'background' => { name: 'Background Detail', weight: 1 } - }.freeze - - STATUS_OPTIONS = { - 'planned' => { name: 'Planned', color: 'yellow' }, - 'happening' => { name: 'Happening', color: 'blue' }, - 'completed' => { name: 'Completed', color: 'green' }, - 'cancelled' => { name: 'Cancelled', color: 'gray' } - }.freeze + # Validation + validates :event_type, inclusion: { in: EVENT_TYPES.keys } # Helper methods def event_type_info EVENT_TYPES[event_type] || EVENT_TYPES['general'] end - def importance_info - IMPORTANCE_LEVELS[importance_level] || IMPORTANCE_LEVELS['minor'] + def event_type_icon + event_type_info[:icon] end - def status_info - STATUS_OPTIONS[status] || STATUS_OPTIONS['completed'] + def event_type_name + event_type_info[:name] end def has_duration? @@ -68,14 +51,6 @@ class TimelineEvent < ApplicationRecord "#{time_label} - #{end_time_label}" end - def color_class - "timeline-event-#{event_type_info[:color]}" - end - - def importance_weight - importance_info[:weight] - end - # todo move this to a real permissions authorizer def can_be_modified_by?(user) user == timeline.user diff --git a/app/views/shared/_timeline_event_linked_content.html.erb b/app/views/shared/_timeline_event_linked_content.html.erb index dfa7796b..652e17be 100644 --- a/app/views/shared/_timeline_event_linked_content.html.erb +++ b/app/views/shared/_timeline_event_linked_content.html.erb @@ -1,12 +1,12 @@ <% if timeline_event.timeline_event_entities.any? %>
-

+

link Linked Content

- -
+ +
<% timeline_event.timeline_event_entities.includes(:entity).order(:entity_type).each do |te_entity| %> <% next if te_entity.entity.nil? %> @@ -37,12 +37,12 @@ <% end %> -
+
- + <%= klass.icon %> <%= link_to entity.name.presence || "Untitled #{te_entity.entity_type}", entity %> diff --git a/app/views/timelines/edit.html.erb b/app/views/timelines/edit.html.erb index 2f6ec6a6..08b107ea 100644 --- a/app/views/timelines/edit.html.erb +++ b/app/views/timelines/edit.html.erb @@ -116,9 +116,80 @@ add Add Event + +
+ +
+ + +
+
+ + +
+

+ Show only events with selected tags + Show all events, highlight selected tags +

+
+ + + <% if @timeline_event_tags.any? %> +
+ <% @timeline_event_tags.each do |tag_data| %> + + <% end %> +
+ + +
+ +
+ <% else %> +
+ label_outline +

No tags found

+

Add tags to events to enable filtering

+
+ <% end %> +
+

Linked Content

@@ -237,7 +308,9 @@ data-position="<%= index %>"> -
+
+ <%= event.event_type_icon %> +
, title: '<%= j(event.title) %>', time_label: '<%= j(event.time_label) %>', + end_time_label: '<%= j(event.end_time_label) %>', description: '<%= j(event.description) %>', event_type: '<%= event.event_type || 'general' %>', - importance_level: '<%= event.importance_level || 'minor' %>', - status: '<%= event.status || 'completed' %>' + status: '<%= event.status || 'completed' %>', + tags: [<%= event.page_tags.map { |tag| "'#{j(tag.tag)}'" }.join(', ') %>] })" :class="{ 'ring-2 ring-green-500 ring-opacity-50': selectedEventId === <%= event.id %> }"> @@ -344,6 +418,20 @@ <% end %>
+ + <% if event.page_tags.any? %> +
+ <% event.page_tags.each do |tag| %> + + <%= tag.tag %> + + <% end %> +
+ <% else %> + + + <% end %> + <%= render 'shared/timeline_event_linked_content', timeline_event: event %>
@@ -415,20 +503,120 @@
- -
-
- -
-
-
- -
+ +
+ +
+ + + +
+ + <% TimelineEvent::EVENT_TYPES.each do |type_key, type_info| %> + + <% end %> +
+ +
+ + + +
+
+ +
+
+ + +
+ + + +
+ + +
+
Suggested Tags
+ <% %w[Character\ Development Character\ Introduction Worldbuilding Flashback Foreshadowing Action Discovery Plot\ Twist Turning\ Point Relationship Tonal\ Shift POV\ Shift].each do |suggested_tag| %> + + <% end %> + + +
+ +
+
+
+
+ + +
+ label_outline + No tags yet. Add tags to categorize this event. +
+
@@ -481,7 +669,9 @@ data-position="-1"> -
+
+ radio_button_checked +
@@ -583,6 +773,9 @@ x-on:input.debounce.500ms="submitFormRemotely($el.form)">
+ + +
@@ -1099,6 +1292,13 @@ function timelineEditor() { eventTypeFilters: [], importanceFilters: [], statusFilters: [], + eventTags: {}, // Store tags for each event: { eventId: [tag1, tag2, ...] } + loadingTags: new Set(), // Track which tags are currently being processed + + // Tag filtering state + showTagFilters: false, + selectedTagFilters: [], + tagFilterMode: 'filter', // 'filter' or 'highlight' // Reactive properties for real-time inspector panel updates liveTitle: '', @@ -1116,6 +1316,9 @@ function timelineEditor() { // Initialize method init() { + // Initialize eventTags from server-rendered DOM data + this.initializeEventTagsFromDOM(); + // Add fallback click handlers for server-rendered events in case Alpine.js @click isn't working setTimeout(() => { const serverEventCards = document.querySelectorAll('.timeline-event-card:not(.js-template-event-card)'); @@ -1144,7 +1347,8 @@ function timelineEditor() { description: descEl ? descEl.value : '', event_type: 'general', importance_level: 'minor', - status: 'completed' + status: 'completed', + tags: [] }; this.selectEvent(eventId, eventData); @@ -1154,6 +1358,29 @@ function timelineEditor() { }, 500); }, + // Initialize eventTags object from server-rendered DOM + initializeEventTagsFromDOM() { + const eventContainers = document.querySelectorAll('.timeline-event-container:not(.timeline-event-template)'); + + eventContainers.forEach(container => { + const eventId = container.getAttribute('data-event-id'); + if (!eventId) return; + + // Find the tags container for this event + const tagContainer = container.querySelector(`#event-tags-${eventId}`); + if (!tagContainer) { + this.eventTags[eventId] = []; + return; + } + + // Extract tag names from server-rendered spans + const tagSpans = tagContainer.querySelectorAll('span'); + const tags = Array.from(tagSpans).map(span => span.textContent.trim()); + + this.eventTags[eventId] = tags; + }); + }, + // Filter helper methods clearAllFilters() { this.eventTypeFilters = []; @@ -1178,6 +1405,21 @@ function timelineEditor() { return false; } + // Tag filter - only apply in filter mode, not highlight mode + if (this.tagFilterMode === 'filter' && this.selectedTagFilters.length > 0) { + const eventId = eventElement.getAttribute('data-event-id'); + const eventTags = this.getEventTags(eventId); + + // Check if event has any of the selected tags + const hasSelectedTag = this.selectedTagFilters.some(selectedTag => + eventTags.includes(selectedTag) + ); + + if (!hasSelectedTag) { + return false; + } + } + // Type filter if (this.eventTypeFilters.length > 0 && !this.eventTypeFilters.includes(eventData.type)) { return false; @@ -1226,6 +1468,7 @@ function timelineEditor() { events.forEach(eventElement => { const shouldShow = this.shouldShowEvent(eventElement); + const eventId = eventElement.getAttribute('data-event-id'); if (shouldShow) { eventElement.style.display = 'block'; @@ -1241,6 +1484,11 @@ function timelineEditor() { } }, 200); } + + // Update tag display for highlight mode (only when needed) + if (eventId && shouldShow && this.tagFilterMode === 'highlight' && this.selectedTagFilters.length > 0) { + this.updateMainPanelTags(eventId); + } }); // Update empty state @@ -1389,6 +1637,18 @@ function timelineEditor() { // Update sidebar linked content this.updateSidebarLinkedContent(eventId); + // Load tags for this event - use provided tags or ensure they're cached + if (eventData && eventData.tags) { + this.eventTags[eventId] = eventData.tags; + // Only update tag display if tags were actually provided + if (eventData.tags.length > 0) { + this.updateMainPanelTags(eventId); + } + } else { + // getEventTags() will handle caching from DOM if needed + this.getEventTags(eventId); + } + // Scroll inspector panel to top const inspectorContent = document.querySelector('.w-96 .overflow-y-auto'); if (inspectorContent) { @@ -1606,14 +1866,395 @@ function timelineEditor() { get selectedEventType() { if (!this.selectedEventId) return 'general'; - // For now, return from static data since event_type isn't in the form yet return this.selectedEventData?.event_type || 'general'; }, - get selectedEventImportance() { - if (!this.selectedEventId) return 'minor'; - // For now, return from static data since importance_level isn't in the form yet - return this.selectedEventData?.importance_level || 'minor'; + // Event type helper methods + getEventTypeIcon(eventId) { + if (!eventId) return 'radio_button_checked'; + const eventType = this.selectedEventData?.event_type || 'general'; + const eventTypeMap = { + 'general': 'radio_button_checked', + 'setup': 'foundation', + 'exposition': 'info', + 'inciting_incident': 'flash_on', + 'complication': 'warning', + 'obstacle': 'block', + 'conflict': 'gavel', + 'progress': 'trending_up', + 'revelation': 'visibility', + 'transformation': 'autorenew', + 'climax': 'whatshot', + 'resolution': 'check_circle', + 'aftermath': 'restore' + }; + return eventTypeMap[eventType] || 'radio_button_checked'; + }, + + getEventTypeName(eventId) { + if (!eventId) return 'General'; + const eventType = this.selectedEventData?.event_type || 'general'; + const eventTypeMap = { + 'general': 'General', + 'setup': 'Setup', + 'exposition': 'Exposition', + 'inciting_incident': 'Inciting Incident', + 'complication': 'Complication', + 'obstacle': 'Obstacle', + 'conflict': 'Conflict', + 'progress': 'Progress', + 'revelation': 'Revelation', + 'transformation': 'Transformation', + 'climax': 'Climax', + 'resolution': 'Resolution', + 'aftermath': 'Aftermath' + }; + return eventTypeMap[eventType] || 'General'; + }, + + // Update event type and refresh timeline dot + updateEventType(newType) { + if (!this.selectedEventId) return; + + // Update the selected event data + if (this.selectedEventData) { + this.selectedEventData.event_type = newType; + } + + // Find the event form and submit the update + const eventContainer = document.querySelector(`[data-event-id="${this.selectedEventId}"]`); + if (eventContainer) { + // Create a form to submit the event type update + const form = document.createElement('form'); + form.setAttribute('method', 'POST'); + form.setAttribute('action', `/plan/timeline_events/${this.selectedEventId}`); + form.classList.add('autosave-form'); + + // Add CSRF token + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + if (csrfToken) { + const csrfInput = document.createElement('input'); + csrfInput.type = 'hidden'; + csrfInput.name = 'authenticity_token'; + csrfInput.value = csrfToken; + form.appendChild(csrfInput); + } + + // Add method override + const methodInput = document.createElement('input'); + methodInput.type = 'hidden'; + methodInput.name = '_method'; + methodInput.value = 'patch'; + form.appendChild(methodInput); + + // Add event type + const eventTypeInput = document.createElement('input'); + eventTypeInput.type = 'hidden'; + eventTypeInput.name = 'timeline_event[event_type]'; + eventTypeInput.value = newType; + form.appendChild(eventTypeInput); + + // Submit via our existing auto-save mechanism + submitFormWithFetch(form); + + // Update the timeline dot icon immediately + const timelineDot = eventContainer.querySelector('.timeline-dot i'); + if (timelineDot) { + const iconMap = { + 'general': 'radio_button_checked', + 'setup': 'foundation', + 'exposition': 'info', + 'inciting_incident': 'flash_on', + 'complication': 'warning', + 'obstacle': 'block', + 'conflict': 'gavel', + 'progress': 'trending_up', + 'revelation': 'visibility', + 'transformation': 'autorenew', + 'climax': 'whatshot', + 'resolution': 'check_circle', + 'aftermath': 'restore' + }; + timelineDot.textContent = iconMap[newType] || 'radio_button_checked'; + } + } + }, + + // Tag management methods + getEventTags(eventId) { + if (!eventId) return []; + + // If we have cached tags, return them + if (this.eventTags[eventId]) { + return this.eventTags[eventId]; + } + + // Fallback: scrape tags from DOM if not in cache + const tagContainer = document.querySelector(`#event-tags-${eventId}`); + if (!tagContainer) { + this.eventTags[eventId] = []; + return []; + } + + const tagSpans = tagContainer.querySelectorAll('span'); + const tags = Array.from(tagSpans).map(span => span.textContent.trim()); + + // Cache the scraped tags + this.eventTags[eventId] = tags; + return tags; + }, + + loadEventTags(eventId) { + if (!eventId) return; + + // Find the event's existing tags from the server-rendered data + const eventContainer = document.querySelector(`[data-event-id="${eventId}"]`); + if (!eventContainer) { + this.eventTags[eventId] = []; + return; + } + + // For now, initialize empty array - in future could fetch from API + // The tags will be loaded when the page renders with existing data + this.eventTags[eventId] = []; + }, + + addEventTag(tagName) { + if (!this.selectedEventId || !tagName.trim()) return; + + const cleanTagName = tagName.trim(); + + // Check if tag already exists + const existingTags = this.getEventTags(this.selectedEventId); + if (existingTags.includes(cleanTagName)) { + return; // Tag already exists + } + + // Optimistically add tag to local state immediately + if (!this.eventTags[this.selectedEventId]) { + this.eventTags[this.selectedEventId] = []; + } + this.eventTags[this.selectedEventId].push(cleanTagName); + + // Clear tag container cache for this event + if (this._tagContainerCache) { + this._tagContainerCache.delete(this.selectedEventId); + } + + // Update the main panel tag display + this.updateMainPanelTags(this.selectedEventId); + + // Mark this tag as loading + this.loadingTags.add(`${this.selectedEventId}-${cleanTagName}`); + + // Set loading state + this.autoSaveStatus = 'saving'; + + // Make API call to add tag + fetch(`/plan/timeline_events/${this.selectedEventId}/tags`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'), + 'Accept': 'application/json' + }, + body: JSON.stringify({ + tag_name: cleanTagName + }) + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success') { + // Remove loading state for this tag + this.loadingTags.delete(`${this.selectedEventId}-${cleanTagName}`); + + // Tag was already added optimistically, just update save status + this.autoSaveStatus = 'saved'; + setTimeout(() => { + if (this.autoSaveStatus === 'saved') { + this.autoSaveStatus = 'saved'; // Keep it visible for a moment + } + }, 1000); + } else { + throw new Error(data.message || 'Failed to add tag'); + } + }) + .catch(error => { + console.error('Error adding tag:', error); + this.autoSaveStatus = 'error'; + + // Remove loading state for this tag + this.loadingTags.delete(`${this.selectedEventId}-${cleanTagName}`); + + // Remove the optimistically added tag since the request failed + if (this.eventTags[this.selectedEventId]) { + this.eventTags[this.selectedEventId] = this.eventTags[this.selectedEventId].filter(tag => tag !== cleanTagName); + } + + // Clear tag container cache for this event + if (this._tagContainerCache) { + this._tagContainerCache.delete(this.selectedEventId); + } + + // Update the main panel tag display + this.updateMainPanelTags(this.selectedEventId); + + // Show error for a few seconds, then revert + setTimeout(() => { + if (this.autoSaveStatus === 'error') { + this.autoSaveStatus = 'saved'; + } + }, 3000); + }); + }, + + removeEventTag(tagName) { + if (!this.selectedEventId || !tagName) return; + + // Store the original tags for rollback if needed + const originalTags = [...(this.eventTags[this.selectedEventId] || [])]; + + // Mark this tag as loading before removal + this.loadingTags.add(`${this.selectedEventId}-${tagName}`); + + // Optimistically remove tag from local state immediately + if (this.eventTags[this.selectedEventId]) { + this.eventTags[this.selectedEventId] = this.eventTags[this.selectedEventId].filter(tag => tag !== tagName); + } + + // Clear tag container cache for this event + if (this._tagContainerCache) { + this._tagContainerCache.delete(this.selectedEventId); + } + + // Update the main panel tag display + this.updateMainPanelTags(this.selectedEventId); + + // Set loading state + this.autoSaveStatus = 'saving'; + + // Make API call to remove tag + fetch(`/plan/timeline_events/${this.selectedEventId}/tags/${encodeURIComponent(tagName)}`, { + method: 'DELETE', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'), + 'Accept': 'application/json' + } + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success') { + // Remove loading state for this tag + this.loadingTags.delete(`${this.selectedEventId}-${tagName}`); + + // Tag was already removed optimistically, just update save status + this.autoSaveStatus = 'saved'; + setTimeout(() => { + if (this.autoSaveStatus === 'saved') { + this.autoSaveStatus = 'saved'; // Keep it visible for a moment + } + }, 1000); + } else { + throw new Error(data.message || 'Failed to remove tag'); + } + }) + .catch(error => { + console.error('Error removing tag:', error); + this.autoSaveStatus = 'error'; + + // Remove loading state for this tag + this.loadingTags.delete(`${this.selectedEventId}-${tagName}`); + + // Restore the original tags since the request failed + this.eventTags[this.selectedEventId] = originalTags; + + // Clear tag container cache for this event + if (this._tagContainerCache) { + this._tagContainerCache.delete(this.selectedEventId); + } + + // Update the main panel tag display + this.updateMainPanelTags(this.selectedEventId); + + // Show error for a few seconds, then revert + setTimeout(() => { + if (this.autoSaveStatus === 'error') { + this.autoSaveStatus = 'saved'; + } + }, 3000); + }); + }, + + // Helper method to check if a tag is currently loading + isTagLoading(eventId, tagName) { + return this.loadingTags.has(`${eventId}-${tagName}`); + }, + + // Update the main panel tag display for an event + updateMainPanelTags(eventId) { + // Cache DOM query + if (!this._tagContainerCache) { + this._tagContainerCache = new Map(); + } + + let tagContainer = this._tagContainerCache.get(eventId); + if (!tagContainer) { + tagContainer = document.querySelector(`#event-tags-${eventId}`); + if (!tagContainer) return; + this._tagContainerCache.set(eventId, tagContainer); + } + + const eventTags = this.getEventTags(eventId); + + if (eventTags.length === 0) { + // Batch DOM updates + if (!tagContainer.classList.contains('hidden')) { + tagContainer.classList.add('hidden'); + tagContainer.innerHTML = ''; + } + } else { + // Check if we need to update highlighting + const needsHighlightUpdate = this.tagFilterMode === 'highlight' && this.selectedTagFilters.length > 0; + const currentContent = tagContainer.innerHTML; + + const newContent = eventTags.map(tag => { + const isHighlighted = needsHighlightUpdate && this.selectedTagFilters.includes(tag); + return ` + + ${tag} + + `; + }).join(''); + + // Only update DOM if content actually changed + if (currentContent !== newContent) { + tagContainer.classList.remove('hidden'); + tagContainer.innerHTML = newContent; + } + } + }, + + // Clear all tag filters + clearTagFilters() { + this.selectedTagFilters = []; + this.applyEventFilters(); + + // Only update tag displays if we were in highlight mode + if (this.tagFilterMode === 'highlight') { + Object.keys(this.eventTags).forEach(eventId => { + this.updateMainPanelTags(eventId); + }); + } + }, + + // Handle tag filter mode change + changeTagFilterMode(newMode) { + this.tagFilterMode = newMode; + this.applyEventFilters(); } }; } @@ -1773,7 +2414,6 @@ document.addEventListener('DOMContentLoaded', function() { title: "Untitled Event", timeline_id: timelineId, event_type: "general", - importance_level: "minor", status: "completed" } }) @@ -1864,6 +2504,13 @@ document.addEventListener('DOMContentLoaded', function() { if (description) description.id = `timeline_event_description_${eventId}`; if (notes) notes.id = `timeline_event_notes_${eventId}`; + // Update the tag container ID + const tagContainer = newEvent.querySelector('.js-template-event-tags'); + if (tagContainer) { + tagContainer.id = `event-tags-${eventId}`; + tagContainer.classList.remove('js-template-event-tags'); + } + // Add click handler for event selection const eventCard = newEvent.querySelector('.js-template-event-card'); if (eventCard) { @@ -1881,8 +2528,8 @@ document.addEventListener('DOMContentLoaded', function() { end_time_label: endTimeLabel ? endTimeLabel.value : '', description: description ? description.value : '', event_type: 'general', - importance_level: 'minor', - status: 'completed' + status: 'completed', + tags: [] }; alpineData.selectEvent(eventId, eventData); } diff --git a/config/routes.rb b/config/routes.rb index 420b2df5..a2ff4ea4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -342,6 +342,8 @@ Rails.application.routes.draw do end post 'link', to: 'timeline_events#link_entity', on: :member post 'unlink/:entity_id', to: 'timeline_events#unlink_entity', on: :member, as: :unlink_entity + post 'tags', to: 'timeline_events#add_tag', on: :member, as: :add_tag + delete 'tags/:tag_name', to: 'timeline_events#remove_tag', on: :member, as: :remove_tag end # Content attributes