mirror of
https://github.com/indentlabs/notebook.git
synced 2025-10-26 11:19:22 +00:00
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:
parent
f2bfcbc7d2
commit
3d6ce69488
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
296
app/assets/stylesheets/timeline_inspector.css
Normal file
296
app/assets/stylesheets/timeline_inspector.css
Normal 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;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
377
app/assets/stylesheets/timeline_sidebar.css
Normal file
377
app/assets/stylesheets/timeline_sidebar.css
Normal 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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user