update on timeline on portfolio view + some corrections

This commit is contained in:
fahed
2026-02-10 13:20:49 +03:00
parent d15e54044e
commit 334727b232
37 changed files with 5119 additions and 1440 deletions

View File

@@ -0,0 +1,63 @@
import { useLanguage } from '../i18n/LanguageContext'
export default function EmptyState({
icon: Icon,
title,
description,
actionLabel,
onAction,
secondaryActionLabel,
onSecondaryAction,
compact = false
}) {
const { t } = useLanguage()
if (compact) {
return (
<div className="py-8 text-center">
{Icon && <Icon className="w-8 h-8 text-text-tertiary mx-auto mb-2" />}
<p className="text-sm text-text-secondary">{title}</p>
{description && <p className="text-xs text-text-tertiary mt-1">{description}</p>}
{actionLabel && (
<button
onClick={onAction}
className="mt-3 text-sm text-brand-primary hover:text-brand-primary-light font-medium"
>
{actionLabel}
</button>
)}
</div>
)
}
return (
<div className="py-16 text-center">
{Icon && (
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-surface-tertiary mb-4">
<Icon className="w-8 h-8 text-text-tertiary" />
</div>
)}
<h3 className="text-lg font-semibold text-text-primary mb-2">{title}</h3>
{description && <p className="text-sm text-text-secondary max-w-md mx-auto mb-6">{description}</p>}
<div className="flex items-center justify-center gap-3">
{actionLabel && (
<button
onClick={onAction}
className="px-5 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-all hover:-translate-y-0.5"
>
{actionLabel}
</button>
)}
{secondaryActionLabel && (
<button
onClick={onSecondaryAction}
className="px-5 py-2.5 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
>
{secondaryActionLabel}
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
import { AlertCircle, CheckCircle } from 'lucide-react'
export default function FormInput({
label,
type = 'text',
value,
onChange,
placeholder,
required = false,
error,
success,
helpText,
disabled = false,
className = '',
rows,
...props
}) {
const hasError = Boolean(error)
const hasSuccess = Boolean(success)
const isTextarea = type === 'textarea'
const inputClasses = `
w-full px-3 py-2 text-sm border rounded-lg
focus:outline-none focus:ring-2 transition-all
${hasError
? 'border-red-300 focus:border-red-500 focus:ring-red-500/20'
: hasSuccess
? 'border-emerald-300 focus:border-emerald-500 focus:ring-emerald-500/20'
: 'border-border focus:border-brand-primary focus:ring-brand-primary/20'
}
${disabled ? 'bg-surface-tertiary cursor-not-allowed opacity-60' : 'bg-white'}
${className}
`.trim()
const InputComponent = isTextarea ? 'textarea' : 'input'
return (
<div className="space-y-1">
{label && (
<label className="block text-sm font-medium text-text-primary">
{label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
)}
<div className="relative">
<InputComponent
type={isTextarea ? undefined : type}
value={value}
onChange={onChange}
placeholder={placeholder}
disabled={disabled}
className={inputClasses}
rows={rows}
{...props}
/>
{/* Validation icon */}
{(hasError || hasSuccess) && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
{hasError ? (
<AlertCircle className="w-4 h-4 text-red-500" />
) : (
<CheckCircle className="w-4 h-4 text-emerald-500" />
)}
</div>
)}
</div>
{/* Helper text or error message */}
{(error || success || helpText) && (
<p className={`text-xs ${hasError ? 'text-red-600' : hasSuccess ? 'text-emerald-600' : 'text-text-tertiary'}`}>
{error || success || helpText}
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,507 @@
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.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>
)}
<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.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>
)}
<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>
)
}

View File

@@ -19,7 +19,7 @@ export default function PostCard({ post, onClick, onMove, compact = false }) {
return (
<div
onClick={onClick}
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 hover:shadow-md cursor-pointer transition-all group overflow-hidden"
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden card-hover"
>
{post.thumbnail_url && (
<div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">

View File

@@ -0,0 +1,118 @@
// Reusable skeleton components for loading states
export function SkeletonCard() {
return (
<div className="bg-white rounded-xl border border-border p-5 animate-pulse">
<div className="h-4 bg-surface-tertiary rounded w-3/4 mb-3"></div>
<div className="h-3 bg-surface-tertiary rounded w-1/2 mb-2"></div>
<div className="h-3 bg-surface-tertiary rounded w-2/3"></div>
</div>
)
}
export function SkeletonStatCard() {
return (
<div className="bg-white rounded-xl border border-border p-5 animate-pulse">
<div className="flex items-start justify-between mb-4">
<div className="w-10 h-10 bg-surface-tertiary rounded-lg"></div>
<div className="h-3 bg-surface-tertiary rounded w-16"></div>
</div>
<div className="h-8 bg-surface-tertiary rounded w-20 mb-2"></div>
<div className="h-3 bg-surface-tertiary rounded w-24"></div>
</div>
)
}
export function SkeletonTable({ rows = 5, cols = 6 }) {
return (
<div className="bg-white rounded-xl border border-border overflow-hidden animate-pulse">
<div className="border-b border-border bg-surface-secondary p-4">
<div className="flex gap-4">
{[...Array(cols)].map((_, i) => (
<div key={i} className="h-3 bg-surface-tertiary rounded w-20"></div>
))}
</div>
</div>
<div className="divide-y divide-border-light">
{[...Array(rows)].map((_, i) => (
<div key={i} className="p-4">
<div className="flex gap-4">
{[...Array(cols)].map((_, j) => (
<div key={j} className="h-4 bg-surface-tertiary rounded flex-1"></div>
))}
</div>
</div>
))}
</div>
</div>
)
}
export function SkeletonKanbanBoard() {
return (
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
{[...Array(5)].map((_, colIdx) => (
<div key={colIdx} className="animate-pulse">
<div className="flex items-center gap-2 mb-3">
<div className="w-2.5 h-2.5 bg-surface-tertiary rounded-full"></div>
<div className="h-4 bg-surface-tertiary rounded w-24"></div>
<div className="h-5 bg-surface-tertiary rounded-full w-8"></div>
</div>
<div className="bg-surface-secondary rounded-xl p-2 space-y-2 min-h-[400px]">
{[...Array(3)].map((_, cardIdx) => (
<div key={cardIdx} className="bg-white rounded-lg border border-border p-3">
<div className="h-4 bg-surface-tertiary rounded w-full mb-2"></div>
<div className="h-3 bg-surface-tertiary rounded w-3/4 mb-3"></div>
<div className="flex gap-2">
<div className="h-5 bg-surface-tertiary rounded w-16"></div>
<div className="h-5 bg-surface-tertiary rounded w-20"></div>
</div>
</div>
))}
</div>
</div>
))}
</div>
)
}
export function SkeletonDashboard() {
return (
<div className="space-y-6">
{/* Header */}
<div className="animate-pulse">
<div className="h-8 w-64 bg-surface-tertiary rounded-lg mb-2"></div>
<div className="h-4 w-48 bg-surface-tertiary rounded"></div>
</div>
{/* Stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<SkeletonStatCard key={i} />
))}
</div>
{/* Content cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{[...Array(2)].map((_, i) => (
<div key={i} className="bg-white rounded-xl border border-border animate-pulse">
<div className="px-5 py-4 border-b border-border">
<div className="h-5 bg-surface-tertiary rounded w-32"></div>
</div>
<div className="divide-y divide-border-light">
{[...Array(5)].map((_, j) => (
<div key={j} className="px-5 py-3 flex gap-3">
<div className="flex-1 space-y-2">
<div className="h-4 bg-surface-tertiary rounded w-2/3"></div>
<div className="h-3 bg-surface-tertiary rounded w-1/2"></div>
</div>
<div className="h-6 bg-surface-tertiary rounded w-16"></div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -30,7 +30,7 @@ export default function TaskCard({ task, onMove, showProject = true }) {
const assignedName = task.assigned_name || task.assignedName
return (
<div className={`bg-white rounded-lg border border-border p-3 card-hover group ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}>
<div className={`bg-white rounded-lg border border-border p-3 card-hover group cursor-pointer ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}>
<div className="flex items-start gap-2.5">
{/* Priority dot */}
<div className={`w-2.5 h-2.5 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />

View File

@@ -0,0 +1,50 @@
import { useEffect } from 'react'
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react'
const TOAST_ICONS = {
success: CheckCircle,
error: AlertCircle,
info: Info,
warning: AlertTriangle,
}
const TOAST_COLORS = {
success: 'bg-emerald-50 border-emerald-200 text-emerald-800',
error: 'bg-red-50 border-red-200 text-red-800',
info: 'bg-blue-50 border-blue-200 text-blue-800',
warning: 'bg-amber-50 border-amber-200 text-amber-800',
}
const ICON_COLORS = {
success: 'text-emerald-500',
error: 'text-red-500',
info: 'text-blue-500',
warning: 'text-amber-500',
}
export default function Toast({ message, type = 'info', onClose, duration = 4000 }) {
const Icon = TOAST_ICONS[type]
const colorClass = TOAST_COLORS[type]
const iconColor = ICON_COLORS[type]
useEffect(() => {
if (duration > 0) {
const timer = setTimeout(onClose, duration)
return () => clearTimeout(timer)
}
}, [duration, onClose])
return (
<div className={`flex items-start gap-3 p-4 rounded-xl border shadow-lg ${colorClass} animate-slide-in min-w-[300px] max-w-md`}>
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${iconColor}`} />
<p className="flex-1 text-sm font-medium leading-snug">{message}</p>
<button
onClick={onClose}
className="p-0.5 hover:bg-black/5 rounded transition-colors shrink-0"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
)
}

View File

@@ -0,0 +1,52 @@
import { createContext, useContext, useState, useCallback } from 'react'
import Toast from './Toast'
const ToastContext = createContext()
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within ToastProvider')
}
return context
}
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([])
const addToast = useCallback((message, type = 'info', duration = 4000) => {
const id = Date.now() + Math.random()
setToasts(prev => [...prev, { id, message, type, duration }])
}, [])
const removeToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
const toast = {
success: (message, duration) => addToast(message, 'success', duration),
error: (message, duration) => addToast(message, 'error', duration),
info: (message, duration) => addToast(message, 'info', duration),
warning: (message, duration) => addToast(message, 'warning', duration),
}
return (
<ToastContext.Provider value={toast}>
{children}
{/* Toast container - fixed position */}
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
<div className="flex flex-col gap-2 pointer-events-auto">
{toasts.map(t => (
<Toast
key={t.id}
message={t.message}
type={t.type}
duration={t.duration}
onClose={() => removeToast(t.id)}
/>
))}
</div>
</div>
</ToastContext.Provider>
)
}