Fix tag disappearing issue during timeline filtering and optimize performance

- 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 <noreply@anthropic.com>
This commit is contained in:
Andrew Brown 2025-08-01 22:23:45 -07:00
parent f2bfcbc7d2
commit 3d6ce69488
11 changed files with 1887 additions and 228 deletions

View File

@ -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;
}
}
}

View File

@ -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;
}

View File

@ -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 {

View File

@ -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;
}

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -1,12 +1,12 @@
<% if timeline_event.timeline_event_entities.any? %>
<div class="border-t border-gray-100 pt-4" id="linked-content-<%= timeline_event.id %>">
<h4 class="text-sm font-medium text-gray-700 mb-4 flex items-center px-6">
<h4 class="text-sm font-medium text-gray-700 mb-4 flex items-center">
<i class="material-icons text-sm mr-2">link</i>
Linked Content
</h4>
<!-- Horizontal Scrolling Cards - Break out of parent padding -->
<div class="-mx-6 px-6 overflow-x-auto pb-2 linked-content-scroll linked-content-breakout">
<!-- Horizontal Scrolling Cards - Contained within parent -->
<div class="overflow-x-auto pb-2 linked-content-scroll linked-content-breakout">
<div class="flex space-x-4" style="width: max-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 %>
<!-- Gradient Overlay for Text Readability -->
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent"></div>
<div class="absolute inset-0 gradient-overlay"></div>
</div>
<!-- Name Badge -->
<div class="absolute top-2 left-2">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium type-badge bg-white/90 backdrop-blur-sm text-white">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium type-badge bg-white/90 backdrop-blur-sm text-gray-900">
<i class="material-icons text-xs mr-1 <%= klass.text_color %>"><%= klass.icon %></i>
<%= link_to entity.name.presence || "Untitled #{te_entity.entity_type}", entity %>
</span>

View File

@ -116,9 +116,80 @@
<i class="material-icons text-lg mr-2">add</i>
Add Event
</button>
<button @click="showTagFilters = !showTagFilters"
class="w-full inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 shadow-sm transition-colors"
:class="{ 'bg-gray-100 text-gray-900': showTagFilters }">
<i class="material-icons text-lg mr-2">filter_list</i>
Filter Events
</button>
</div>
</div>
<!-- Tag Filters Panel -->
<div x-show="showTagFilters"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
class="px-6 pb-6 border-b border-gray-100">
<!-- Filter Mode Toggle -->
<div class="mb-4">
<div class="flex rounded-lg bg-gray-100 p-1">
<button @click="changeTagFilterMode('filter')"
:class="tagFilterMode === 'filter' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'"
class="flex-1 px-3 py-1 text-sm font-medium rounded-md transition-colors">
Filter
</button>
<button @click="changeTagFilterMode('highlight')"
:class="tagFilterMode === 'highlight' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'"
class="flex-1 px-3 py-1 text-sm font-medium rounded-md transition-colors">
Highlight
</button>
</div>
<p class="text-xs text-gray-500 mt-1">
<span x-show="tagFilterMode === 'filter'">Show only events with selected tags</span>
<span x-show="tagFilterMode === 'highlight'">Show all events, highlight selected tags</span>
</p>
</div>
<!-- Tag Selection List -->
<% if @timeline_event_tags.any? %>
<div class="space-y-2 max-h-48 overflow-y-auto">
<% @timeline_event_tags.each do |tag_data| %>
<label class="flex items-center space-x-2 text-sm cursor-pointer hover:bg-gray-50 rounded px-2 py-1 transition-colors">
<input type="checkbox"
x-model="selectedTagFilters"
value="<%= tag_data[:name] %>"
@change="applyEventFilters()"
class="rounded border-gray-300 text-green-600 focus:ring-green-500 focus:ring-offset-0">
<span class="flex-1 text-gray-700"><%= tag_data[:name] %></span>
<span class="text-xs text-gray-500 bg-gray-200 px-2 py-0.5 rounded-full"><%= tag_data[:count] %></span>
</label>
<% end %>
</div>
<!-- Clear Filters -->
<div class="mt-3 pt-3 border-t border-gray-200">
<button @click="clearTagFilters()"
x-show="selectedTagFilters.length > 0"
class="text-sm text-gray-600 hover:text-gray-900 transition-colors">
<i class="material-icons text-sm mr-1">clear</i>
Clear all filters
</button>
</div>
<% else %>
<div class="text-center py-4 text-gray-500">
<i class="material-icons text-2xl mb-2 text-gray-300">label_outline</i>
<p class="text-sm">No tags found</p>
<p class="text-xs text-gray-400">Add tags to events to enable filtering</p>
</div>
<% end %>
</div>
<!-- Linked Content Summary -->
<div class="p-6">
<h4 class="text-sm font-medium text-gray-900 mb-3">Linked Content</h4>
@ -237,7 +308,9 @@
data-position="<%= index %>">
<!-- Timeline Connection -->
<div class="absolute left-6 top-6 w-6 h-6 bg-white border-4 border-green-500 rounded-full z-10 timeline-dot shadow-sm"></div>
<div class="absolute left-6 top-6 w-6 h-6 <%= Timeline.color %> rounded-full z-10 timeline-dot shadow-sm flex items-center justify-center">
<i class="material-icons text-white text-xs"><%= event.event_type_icon %></i>
</div>
<!-- Event Card -->
<div class="ml-16 bg-white rounded-xl shadow-sm border border-gray-200 hover:shadow-md transition-all duration-200 timeline-event-card group cursor-pointer"
@ -245,10 +318,11 @@
id: <%= event.id %>,
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 %> }">
<!-- Card Header -->
@ -344,6 +418,20 @@
<% end %>
</div>
<!-- Tags Display -->
<% if event.page_tags.any? %>
<div class="flex flex-wrap gap-1" id="event-tags-<%= event.id %>">
<% event.page_tags.each do |tag| %>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 border border-gray-200">
<%= tag.tag %>
</span>
<% end %>
</div>
<% else %>
<!-- Empty container for tags to be populated by JavaScript -->
<div class="flex flex-wrap gap-1 hidden" id="event-tags-<%= event.id %>"></div>
<% end %>
<!-- Linked Content -->
<%= render 'shared/timeline_event_linked_content', timeline_event: event %>
</div>
@ -415,20 +503,120 @@
</div>
<!-- Event Metadata -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Type</label>
<div class="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium capitalize"
x-text="selectedEventType"></div>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Importance</label>
<div class="px-3 py-1 bg-yellow-100 text-yellow-800 rounded-full text-xs font-medium capitalize"
x-text="selectedEventImportance"></div>
<!-- Event Type Selector -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-2">Event Type</label>
<div class="relative" x-data="{ showEventTypeDropdown: false }">
<button @click="showEventTypeDropdown = !showEventTypeDropdown"
class="w-full flex items-center justify-between px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors text-sm"
type="button">
<div class="flex items-center">
<i class="material-icons text-sm mr-2 text-green-600" x-text="getEventTypeIcon(selectedEventId)"></i>
<span class="capitalize" x-text="getEventTypeName(selectedEventId)"></span>
</div>
<i class="material-icons text-sm text-gray-400 transition-transform duration-200"
:class="showEventTypeDropdown ? 'rotate-180' : ''">expand_more</i>
</button>
<!-- Event Type Dropdown -->
<div x-show="showEventTypeDropdown"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.away="showEventTypeDropdown = false"
class="absolute z-20 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-64 overflow-y-auto">
<% TimelineEvent::EVENT_TYPES.each do |type_key, type_info| %>
<button type="button"
@click="updateEventType('<%= type_key %>'); showEventTypeDropdown = false"
class="w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center transition-colors border-b border-gray-100 last:border-b-0">
<i class="material-icons text-sm mr-3 text-green-600"><%= type_info[:icon] %></i>
<span class="text-sm"><%= type_info[:name] %></span>
</button>
<% end %>
</div>
</div>
</div>
<!-- Event Tags -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-2">Tags</label>
<!-- Existing Tags Display -->
<div x-show="selectedEventId && getEventTags(selectedEventId).length > 0" class="mb-3">
<div class="flex flex-wrap gap-2">
<template x-for="tag in getEventTags(selectedEventId)" :key="tag">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium transition-all duration-200"
:class="isTagLoading(selectedEventId, tag) ? 'bg-gray-100 text-gray-500' : 'bg-blue-100 text-blue-800'">
<!-- Loading spinner for tags being processed -->
<div x-show="isTagLoading(selectedEventId, tag)" class="animate-spin rounded-full h-3 w-3 border-b border-gray-400 mr-1"></div>
<span x-text="tag"></span>
<button @click="removeEventTag(tag)"
type="button"
:disabled="isTagLoading(selectedEventId, tag)"
class="ml-1 p-0.5 rounded-full transition-colors"
:class="isTagLoading(selectedEventId, tag) ? 'text-gray-400 cursor-not-allowed' : 'hover:bg-blue-200'">
<i class="material-icons text-xs">close</i>
</button>
</span>
</template>
</div>
</div>
<!-- Tag Input with Suggestions -->
<div class="relative" x-data="{ showTagSuggestions: false, tagInput: '' }">
<input type="text"
x-model="tagInput"
@input="showTagSuggestions = tagInput.length > 0"
@focus="showTagSuggestions = tagInput.length > 0"
@keydown.enter.prevent="addEventTag(tagInput); tagInput = ''; showTagSuggestions = false"
@keydown.escape="tagInput = ''; showTagSuggestions = false"
placeholder="Add tag..."
class="w-full px-3 py-2 text-xs border border-gray-200 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500">
<!-- Tag Suggestions Dropdown -->
<div x-show="showTagSuggestions"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
@click.away="showTagSuggestions = false"
class="absolute z-20 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-32 overflow-y-auto">
<!-- Default Suggested Tags -->
<div class="py-1">
<div class="px-3 py-1 text-xs font-medium text-gray-500 bg-gray-50 border-b border-gray-100">Suggested Tags</div>
<% %w[Character\ Development Character\ Introduction Worldbuilding Flashback Foreshadowing Action Discovery Plot\ Twist Turning\ Point Relationship Tonal\ Shift POV\ Shift].each do |suggested_tag| %>
<button type="button"
x-show="'<%= suggested_tag %>'.toLowerCase().includes(tagInput.toLowerCase()) || tagInput === ''"
@click="addEventTag('<%= suggested_tag %>'); tagInput = ''; showTagSuggestions = false"
class="w-full text-left px-3 py-2 text-xs hover:bg-gray-50 transition-colors">
<%= suggested_tag %>
</button>
<% end %>
<!-- Custom Tag Option -->
<div x-show="tagInput && tagInput.length > 1" class="border-t border-gray-100">
<button type="button"
@click="addEventTag(tagInput); tagInput = ''; showTagSuggestions = false"
class="w-full text-left px-3 py-2 text-xs hover:bg-gray-50 transition-colors text-green-600 font-medium">
<i class="material-icons text-xs mr-1">add</i>
<span>Add "</span><span x-text="tagInput"></span><span>"</span>
</button>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div x-show="selectedEventId && getEventTags(selectedEventId).length === 0"
class="text-xs text-gray-500 bg-gray-50 rounded-lg p-3 mt-2">
<i class="material-icons text-xs mr-1">label_outline</i>
No tags yet. Add tags to categorize this event.
</div>
</div>
<!-- Linked Content Section -->
<div>
@ -481,7 +669,9 @@
data-position="-1">
<!-- Timeline Connection -->
<div class="absolute left-6 top-6 w-6 h-6 bg-white border-4 border-green-500 rounded-full z-10 timeline-dot shadow-sm"></div>
<div class="absolute left-6 top-6 w-6 h-6 <%= Timeline.color %> rounded-full z-10 timeline-dot shadow-sm flex items-center justify-center">
<i class="material-icons text-white text-xs">radio_button_checked</i>
</div>
<!-- Event Card -->
<div class="ml-16 bg-white rounded-xl shadow-sm border border-gray-200 hover:shadow-md transition-all duration-200 timeline-event-card group cursor-pointer js-template-event-card">
@ -583,6 +773,9 @@
x-on:input.debounce.500ms="submitFormRemotely($el.form)"></textarea>
</form>
</div>
<!-- Tags Display (Empty for template, will be populated by JavaScript) -->
<div class="flex flex-wrap gap-1 hidden js-template-event-tags"></div>
</div>
</div>
</div>
@ -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 `
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium transition-colors ${
isHighlighted
? 'bg-yellow-200 text-yellow-800 border border-yellow-300'
: 'bg-gray-100 text-gray-600 border border-gray-200'
}">
${tag}
</span>
`;
}).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);
}

View File

@ -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