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