- Add SlidePanel, TaskDetailPanel, PostDetailPanel, TeamPanel, TeamMemberPanel - Add ProjectEditPanel, CollapsibleSection, DatePresetPicker, TaskCalendarView - Update App, AuthContext, i18n (ar/en), PostProduction, ProjectDetail, Projects - Update Settings, Tasks, Team pages - Update InteractiveTimeline, MemberCard, ProjectCard, TaskCard components - Update server API utilities - Remove tracked server/node_modules (now properly gitignored)
516 lines
22 KiB
JavaScript
516 lines
22 KiB
JavaScript
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||
import { format, differenceInDays, startOfDay, addDays, isBefore, isAfter } from 'date-fns'
|
||
import { Calendar, Rows3, Rows4 } from 'lucide-react'
|
||
|
||
const STATUS_COLORS = {
|
||
todo: 'bg-gray-500',
|
||
in_progress: 'bg-blue-500',
|
||
done: 'bg-emerald-500',
|
||
planning: 'bg-amber-500',
|
||
active: 'bg-blue-500',
|
||
paused: 'bg-orange-500',
|
||
completed: 'bg-emerald-500',
|
||
cancelled: 'bg-red-400',
|
||
draft: 'bg-gray-400',
|
||
in_review: 'bg-yellow-500',
|
||
approved: 'bg-indigo-500',
|
||
scheduled: 'bg-purple-500',
|
||
published: 'bg-emerald-500',
|
||
planned: 'bg-amber-400',
|
||
// tracks
|
||
organic_social: 'bg-green-500',
|
||
paid_social: 'bg-blue-500',
|
||
paid_search: 'bg-amber-500',
|
||
seo_content: 'bg-purple-500',
|
||
production: 'bg-red-500',
|
||
}
|
||
|
||
const PRIORITY_BORDER = {
|
||
urgent: 'ring-2 ring-red-400',
|
||
high: 'ring-2 ring-orange-300',
|
||
medium: '',
|
||
low: '',
|
||
}
|
||
|
||
const ZOOM_LEVELS = [
|
||
{ key: 'day', label: 'Day', pxPerDay: 48 },
|
||
{ key: 'week', label: 'Week', pxPerDay: 20 },
|
||
]
|
||
|
||
function getInitials(name) {
|
||
if (!name) return '?'
|
||
return name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)
|
||
}
|
||
|
||
export default function InteractiveTimeline({ items = [], mapItem, onDateChange, onItemClick, readOnly = false }) {
|
||
const containerRef = useRef(null)
|
||
const didDragRef = useRef(false)
|
||
const optimisticRef = useRef({}) // { [itemId]: { startDate, endDate } } — keeps bar in place until fresh data arrives
|
||
const [zoomIdx, setZoomIdx] = useState(0)
|
||
const [barMode, setBarMode] = useState('expanded') // 'compact' | 'expanded'
|
||
const [tooltip, setTooltip] = useState(null)
|
||
const [dragState, setDragState] = useState(null) // { itemId, mode: 'move'|'resize-left'|'resize-right', startX, origStart, origEnd }
|
||
const dragStateRef = useRef(null)
|
||
|
||
const pxPerDay = ZOOM_LEVELS[zoomIdx].pxPerDay
|
||
const today = useMemo(() => startOfDay(new Date()), [])
|
||
|
||
// Clear optimistic overrides when fresh data arrives
|
||
useEffect(() => {
|
||
optimisticRef.current = {}
|
||
}, [items])
|
||
|
||
// Map items
|
||
const mapped = useMemo(() => {
|
||
return items.map(raw => {
|
||
const m = mapItem(raw)
|
||
const opt = optimisticRef.current[m.id]
|
||
return {
|
||
...m,
|
||
_raw: raw,
|
||
startDate: opt?.startDate || (m.startDate ? startOfDay(new Date(m.startDate)) : null),
|
||
endDate: opt?.endDate || (m.endDate ? startOfDay(new Date(m.endDate)) : null),
|
||
}
|
||
})
|
||
}, [items, mapItem])
|
||
|
||
// Compute time range
|
||
const { earliest, latest, totalDays, days } = useMemo(() => {
|
||
let earliest = addDays(today, -7)
|
||
let latest = addDays(today, 28)
|
||
|
||
mapped.forEach(item => {
|
||
const s = item.startDate || today
|
||
const e = item.endDate || addDays(s, 3)
|
||
if (isBefore(s, earliest)) earliest = addDays(s, -3)
|
||
if (isAfter(e, latest)) latest = addDays(e, 7)
|
||
})
|
||
|
||
const totalDays = differenceInDays(latest, earliest) + 1
|
||
const days = []
|
||
for (let i = 0; i < totalDays; i++) {
|
||
days.push(addDays(earliest, i))
|
||
}
|
||
return { earliest, latest, totalDays, days }
|
||
}, [mapped, today])
|
||
|
||
// Auto-scroll to today on mount
|
||
useEffect(() => {
|
||
if (containerRef.current) {
|
||
const todayOffset = differenceInDays(today, earliest) * pxPerDay
|
||
containerRef.current.scrollLeft = Math.max(0, todayOffset - 200)
|
||
}
|
||
}, [earliest, pxPerDay, today])
|
||
|
||
// Drag handlers
|
||
const handleMouseDown = useCallback((e, item, mode) => {
|
||
if (readOnly || !onDateChange) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
didDragRef.current = false
|
||
const initial = {
|
||
itemId: item.id,
|
||
mode,
|
||
startX: e.clientX,
|
||
origStart: item.startDate || today,
|
||
origEnd: item.endDate || addDays(item.startDate || today, 3),
|
||
}
|
||
dragStateRef.current = initial
|
||
setDragState(initial)
|
||
}, [readOnly, onDateChange, today])
|
||
|
||
useEffect(() => {
|
||
if (!dragState) return
|
||
|
||
const handleMouseMove = (e) => {
|
||
const cur = dragStateRef.current
|
||
if (!cur) return
|
||
const dx = e.clientX - cur.startX
|
||
const dayDelta = Math.round(dx / pxPerDay)
|
||
if (dayDelta === 0) return
|
||
|
||
didDragRef.current = true
|
||
const newState = { ...cur }
|
||
|
||
if (cur.mode === 'move') {
|
||
newState.currentStart = addDays(cur.origStart, dayDelta)
|
||
newState.currentEnd = addDays(cur.origEnd, dayDelta)
|
||
} else if (cur.mode === 'resize-left') {
|
||
const newStart = addDays(cur.origStart, dayDelta)
|
||
if (isBefore(newStart, cur.origEnd)) {
|
||
newState.currentStart = newStart
|
||
newState.currentEnd = cur.origEnd
|
||
}
|
||
} else if (cur.mode === 'resize-right') {
|
||
const newEnd = addDays(cur.origEnd, dayDelta)
|
||
if (isAfter(newEnd, cur.origStart)) {
|
||
newState.currentStart = cur.origStart
|
||
newState.currentEnd = newEnd
|
||
}
|
||
}
|
||
dragStateRef.current = newState
|
||
setDragState(newState)
|
||
}
|
||
|
||
const handleMouseUp = () => {
|
||
const prev = dragStateRef.current
|
||
dragStateRef.current = null
|
||
setDragState(null)
|
||
if (prev && (prev.currentStart || prev.currentEnd) && onDateChange) {
|
||
const startDate = prev.currentStart || prev.origStart
|
||
const endDate = prev.currentEnd || prev.origEnd
|
||
// Keep bar in place visually until fresh data arrives
|
||
optimisticRef.current[prev.itemId] = { startDate, endDate }
|
||
onDateChange(prev.itemId, {
|
||
startDate: format(startDate, 'yyyy-MM-dd'),
|
||
endDate: format(endDate, 'yyyy-MM-dd'),
|
||
})
|
||
}
|
||
}
|
||
|
||
document.addEventListener('mousemove', handleMouseMove)
|
||
document.addEventListener('mouseup', handleMouseUp)
|
||
return () => {
|
||
document.removeEventListener('mousemove', handleMouseMove)
|
||
document.removeEventListener('mouseup', handleMouseUp)
|
||
}
|
||
}, [dragState?.itemId, pxPerDay, onDateChange])
|
||
|
||
const getBarPosition = useCallback((item) => {
|
||
let start, end
|
||
|
||
// If this item is being dragged, use the drag state
|
||
if (dragState && dragState.itemId === item.id && (dragState.currentStart || dragState.currentEnd)) {
|
||
start = dragState.currentStart || item.startDate || today
|
||
end = dragState.currentEnd || item.endDate || addDays(start, 3)
|
||
} else {
|
||
// Check optimistic position (keeps bar in place after drop, before API data refreshes)
|
||
const opt = optimisticRef.current[item.id]
|
||
start = opt?.startDate || item.startDate || today
|
||
end = opt?.endDate || item.endDate || addDays(start, 3)
|
||
}
|
||
|
||
// Ensure end is after start
|
||
if (!isAfter(end, start)) end = addDays(start, 1)
|
||
|
||
const left = differenceInDays(start, earliest) * pxPerDay
|
||
const width = Math.max(pxPerDay, (differenceInDays(end, start) + 1) * pxPerDay)
|
||
return { left, width }
|
||
}, [earliest, pxPerDay, today, dragState])
|
||
|
||
const scrollToToday = () => {
|
||
if (containerRef.current) {
|
||
const todayOffset = differenceInDays(today, earliest) * pxPerDay
|
||
containerRef.current.scrollTo({ left: Math.max(0, todayOffset - 200), behavior: 'smooth' })
|
||
}
|
||
}
|
||
|
||
const isExpanded = barMode === 'expanded'
|
||
const rowHeight = isExpanded ? 100 : 52
|
||
const barHeight = isExpanded ? 84 : 36
|
||
const headerHeight = 48
|
||
const labelWidth = isExpanded ? 280 : 220
|
||
const todayOffset = differenceInDays(today, earliest) * pxPerDay
|
||
|
||
if (items.length === 0) {
|
||
return (
|
||
<div className="bg-white rounded-xl border border-border py-16 text-center">
|
||
<Calendar className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||
<p className="text-text-secondary font-medium">No items to display</p>
|
||
<p className="text-sm text-text-tertiary mt-1">Add items with dates to see the timeline</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||
{/* Toolbar */}
|
||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
|
||
<div className="flex items-center gap-2">
|
||
{ZOOM_LEVELS.map((z, i) => (
|
||
<button
|
||
key={z.key}
|
||
onClick={() => setZoomIdx(i)}
|
||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||
zoomIdx === i
|
||
? 'bg-brand-primary text-white shadow-sm'
|
||
: 'text-text-tertiary hover:text-text-primary hover:bg-surface-tertiary'
|
||
}`}
|
||
>
|
||
{z.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setBarMode(m => m === 'compact' ? 'expanded' : 'compact')}
|
||
className="flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-text-secondary hover:text-text-primary hover:bg-surface-tertiary rounded-md transition-colors"
|
||
title={isExpanded ? 'Compact bars' : 'Expanded bars'}
|
||
>
|
||
{isExpanded ? <Rows4 className="w-3.5 h-3.5" /> : <Rows3 className="w-3.5 h-3.5" />}
|
||
{isExpanded ? 'Compact' : 'Expand'}
|
||
</button>
|
||
<button
|
||
onClick={scrollToToday}
|
||
className="flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-md transition-colors"
|
||
>
|
||
<Calendar className="w-3.5 h-3.5" />
|
||
Today
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Timeline */}
|
||
<div ref={containerRef} className="overflow-x-auto relative" style={{ cursor: dragState ? 'grabbing' : undefined }}>
|
||
<div style={{ minWidth: `${labelWidth + totalDays * pxPerDay}px` }}>
|
||
{/* Day header */}
|
||
<div className="flex sticky top-0 z-20 bg-white border-b border-border" style={{ height: headerHeight }}>
|
||
<div className="shrink-0 border-r border-border bg-surface-secondary flex items-center px-4 sticky left-0 z-30" style={{ width: labelWidth }}>
|
||
<span className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Item</span>
|
||
</div>
|
||
<div className="flex relative">
|
||
{days.map((day, i) => {
|
||
const isToday = differenceInDays(day, today) === 0
|
||
const isWeekend = day.getDay() === 0 || day.getDay() === 6
|
||
const isMonthStart = day.getDate() === 1
|
||
return (
|
||
<div
|
||
key={i}
|
||
style={{ width: pxPerDay, minWidth: pxPerDay }}
|
||
className={`flex flex-col items-center justify-center border-r border-border-light text-[10px] leading-tight ${
|
||
isToday ? 'bg-red-50 font-bold text-red-600' :
|
||
isWeekend ? 'bg-surface-tertiary/40 text-text-tertiary' :
|
||
'text-text-tertiary'
|
||
} ${isMonthStart ? 'border-l-2 border-l-text-tertiary/30' : ''}`}
|
||
>
|
||
{pxPerDay >= 30 && <div>{format(day, 'd')}</div>}
|
||
{pxPerDay >= 40 && <div className="text-[8px] uppercase">{format(day, 'EEE')}</div>}
|
||
{pxPerDay < 30 && day.getDate() % 7 === 1 && <div>{format(day, 'd')}</div>}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Rows */}
|
||
{mapped.map((item, idx) => {
|
||
const { left, width } = getBarPosition(item)
|
||
const statusColor = STATUS_COLORS[item.status] || STATUS_COLORS[item.type] || 'bg-gray-400'
|
||
const priorityRing = PRIORITY_BORDER[item.priority] || ''
|
||
const isDragging = dragState?.itemId === item.id
|
||
|
||
return (
|
||
<div
|
||
key={item.id}
|
||
className={`flex border-b border-border-light group/row hover:bg-surface-secondary/50 ${isDragging ? 'bg-blue-50/30' : ''}`}
|
||
style={{ height: rowHeight }}
|
||
>
|
||
{/* Label column */}
|
||
<div
|
||
className={`shrink-0 border-r border-border flex ${isExpanded ? 'flex-col justify-center gap-1' : 'items-center gap-2'} px-3 overflow-hidden sticky left-0 z-10 bg-white group-hover/row:bg-surface-secondary/50`}
|
||
style={{ width: labelWidth }}
|
||
>
|
||
{isExpanded ? (
|
||
<>
|
||
<div className="flex items-center gap-2">
|
||
{item.thumbnailUrl ? (
|
||
<div className="w-8 h-8 rounded overflow-hidden shrink-0">
|
||
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||
</div>
|
||
) : item.assigneeName ? (
|
||
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
|
||
{getInitials(item.assigneeName)}
|
||
</div>
|
||
) : null}
|
||
<span className="text-sm font-semibold text-text-primary truncate">{item.label}</span>
|
||
</div>
|
||
{item.description && (
|
||
<p className="text-[11px] text-text-tertiary line-clamp-2 leading-tight">{item.description}</p>
|
||
)}
|
||
{item.tags && item.tags.length > 0 && (
|
||
<div className="flex items-center gap-1 flex-wrap">
|
||
{item.tags.slice(0, 4).map((tag, i) => (
|
||
<span key={i} className="text-[9px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-secondary font-medium">{tag}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
{item.thumbnailUrl ? (
|
||
<div className="w-6 h-6 rounded overflow-hidden shrink-0">
|
||
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||
</div>
|
||
) : item.assigneeName ? (
|
||
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
|
||
{getInitials(item.assigneeName)}
|
||
</div>
|
||
) : null}
|
||
<span className="text-xs font-medium text-text-primary truncate">{item.label}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Bar area */}
|
||
<div className="relative flex-1" style={{ height: rowHeight }}>
|
||
{/* Today line */}
|
||
{todayOffset >= 0 && (
|
||
<div
|
||
className="absolute top-0 bottom-0 w-0.5 bg-red-400 z-10"
|
||
style={{ left: `${todayOffset + pxPerDay / 2}px` }}
|
||
>
|
||
{idx === 0 && (
|
||
<div className="absolute -top-0 left-1 text-[8px] font-bold text-red-500 bg-red-50 px-1 rounded whitespace-nowrap">
|
||
Today
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* The bar */}
|
||
<div
|
||
className={`absolute rounded-lg ${statusColor} ${priorityRing} shadow-sm transition-shadow hover:shadow-md select-none overflow-hidden group ${
|
||
!readOnly && onDateChange ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
|
||
} ${isDragging ? 'opacity-80 shadow-lg' : ''}`}
|
||
style={{
|
||
left: `${left}px`,
|
||
width: `${width}px`,
|
||
height: `${barHeight}px`,
|
||
top: isExpanded ? '8px' : '8px',
|
||
}}
|
||
onMouseDown={(e) => handleMouseDown(e, item, 'move')}
|
||
onClick={(e) => {
|
||
if (didDragRef.current) {
|
||
didDragRef.current = false
|
||
return
|
||
}
|
||
if (onItemClick) {
|
||
onItemClick(item._raw)
|
||
}
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!dragState) {
|
||
const rect = e.currentTarget.getBoundingClientRect()
|
||
setTooltip({
|
||
item,
|
||
x: rect.left + rect.width / 2,
|
||
y: rect.top - 8,
|
||
})
|
||
}
|
||
}}
|
||
onMouseLeave={() => setTooltip(null)}
|
||
>
|
||
{/* Left resize handle */}
|
||
{!readOnly && onDateChange && (
|
||
<div
|
||
className="absolute left-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
|
||
onMouseDown={(e) => handleMouseDown(e, item, 'resize-left')}
|
||
/>
|
||
)}
|
||
|
||
{/* Bar content */}
|
||
{isExpanded ? (
|
||
<div className="flex flex-col gap-0.5 px-3 py-1.5 flex-1 min-w-0 h-full">
|
||
<div className="flex items-center gap-1.5">
|
||
{item.assigneeName && width > 60 && (
|
||
<span className="text-[9px] font-bold text-white/80 bg-white/20 w-5 h-5 rounded-full flex items-center justify-center shrink-0">
|
||
{getInitials(item.assigneeName)}
|
||
</span>
|
||
)}
|
||
{width > 80 && (
|
||
<span className="text-xs font-semibold text-white truncate">
|
||
{item.label}
|
||
</span>
|
||
)}
|
||
{width > 120 && item.status && (
|
||
<span className="text-[9px] text-white/70 bg-white/15 px-1.5 py-0.5 rounded ml-auto shrink-0">
|
||
{item.status.replace(/_/g, ' ')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{width > 100 && item.description && (
|
||
<p className="text-[10px] text-white/60 line-clamp-2 leading-tight">
|
||
{item.description}
|
||
</p>
|
||
)}
|
||
{width > 80 && (
|
||
<div className="flex items-center gap-1.5 mt-auto">
|
||
{item.tags && item.tags.slice(0, 3).map((tag, i) => (
|
||
<span key={i} className="text-[8px] px-1 py-0.5 rounded bg-white/15 text-white/70 font-medium">{tag}</span>
|
||
))}
|
||
{width > 140 && item.startDate && item.endDate && (
|
||
<span className="text-[8px] text-white/50 ml-auto">
|
||
{format(item.startDate, 'MMM d')} – {format(item.endDate, 'MMM d')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-1.5 px-3 flex-1 min-w-0 h-full">
|
||
{item.assigneeName && width > 60 && (
|
||
<span className="text-[9px] font-bold text-white/80 bg-white/20 w-5 h-5 rounded-full flex items-center justify-center shrink-0">
|
||
{getInitials(item.assigneeName)}
|
||
</span>
|
||
)}
|
||
{width > 80 && (
|
||
<span className="text-xs font-medium text-white truncate">
|
||
{item.label}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Right resize handle */}
|
||
{!readOnly && onDateChange && (
|
||
<div
|
||
className="absolute right-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-white/30 z-10"
|
||
onMouseDown={(e) => handleMouseDown(e, item, 'resize-right')}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tooltip */}
|
||
{tooltip && !dragState && (
|
||
<div
|
||
className="fixed z-50 pointer-events-none"
|
||
style={{
|
||
left: tooltip.x,
|
||
top: tooltip.y,
|
||
transform: 'translate(-50%, -100%)',
|
||
}}
|
||
>
|
||
<div className="bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg max-w-xs">
|
||
<div className="font-semibold mb-1">{tooltip.item.label}</div>
|
||
<div className="text-gray-300 space-y-0.5">
|
||
{tooltip.item.startDate && (
|
||
<div>Start: {format(tooltip.item.startDate, 'MMM d, yyyy')}</div>
|
||
)}
|
||
{tooltip.item.endDate && (
|
||
<div>End: {format(tooltip.item.endDate, 'MMM d, yyyy')}</div>
|
||
)}
|
||
{tooltip.item.assigneeName && (
|
||
<div>Assignee: {tooltip.item.assigneeName}</div>
|
||
)}
|
||
{tooltip.item.status && (
|
||
<div>Status: {tooltip.item.status.replace(/_/g, ' ')}</div>
|
||
)}
|
||
</div>
|
||
{!readOnly && onDateChange && (
|
||
<div className="text-gray-400 mt-1 text-[10px] italic">
|
||
Drag to move · Drag edges to resize
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|