jettypod 4.4.78 → 4.4.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
4
+ import { createPortal } from 'react-dom';
4
5
  import type { WorkItem } from '@/lib/db';
5
6
 
6
7
  type DropHandler = (itemId: number, newStatus: string) => Promise<void>;
@@ -26,12 +27,16 @@ interface DragContextType {
26
27
  draggedItem: WorkItem | null;
27
28
  activeDropZone: string | null;
28
29
  activeEpicZone: string | null;
30
+ dragPosition: { x: number; y: number };
31
+ dragOffset: { x: number; y: number };
32
+ draggedCardWidth: number;
29
33
  setDraggedItem: (item: WorkItem | null) => void;
30
34
  registerDropZone: (id: string, info: DropZoneInfo) => void;
31
35
  unregisterDropZone: (id: string) => void;
32
36
  registerEpicDropZone: (id: string, info: EpicDropZoneInfo) => void;
33
37
  unregisterEpicDropZone: (id: string) => void;
34
38
  updatePointerPosition: (x: number, y: number) => void;
39
+ startDrag: (item: WorkItem, cardRect: DOMRect, pointerX: number, pointerY: number) => void;
35
40
  handleDrop: () => Promise<void>;
36
41
  }
37
42
 
@@ -40,19 +45,31 @@ const DragContext = createContext<DragContextType>({
40
45
  draggedItem: null,
41
46
  activeDropZone: null,
42
47
  activeEpicZone: null,
48
+ dragPosition: { x: 0, y: 0 },
49
+ dragOffset: { x: 0, y: 0 },
50
+ draggedCardWidth: 0,
43
51
  setDraggedItem: () => {},
44
52
  registerDropZone: () => {},
45
53
  unregisterDropZone: () => {},
46
54
  registerEpicDropZone: () => {},
47
55
  unregisterEpicDropZone: () => {},
48
56
  updatePointerPosition: () => {},
57
+ startDrag: () => {},
49
58
  handleDrop: async () => {},
50
59
  });
51
60
 
52
- export function DragProvider({ children }: { children: ReactNode }) {
61
+ interface DragProviderProps {
62
+ children: ReactNode;
63
+ renderDragOverlay?: (item: WorkItem) => ReactNode;
64
+ }
65
+
66
+ export function DragProvider({ children, renderDragOverlay }: DragProviderProps) {
53
67
  const [draggedItem, setDraggedItem] = useState<WorkItem | null>(null);
54
68
  const [activeDropZone, setActiveDropZone] = useState<string | null>(null);
55
69
  const [activeEpicZone, setActiveEpicZone] = useState<string | null>(null);
70
+ const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
71
+ const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
72
+ const [draggedCardWidth, setDraggedCardWidth] = useState(0);
56
73
  const dropZonesRef = useRef<Map<string, DropZoneInfo>>(new Map());
57
74
  const epicDropZonesRef = useRef<Map<string, EpicDropZoneInfo>>(new Map());
58
75
  const pointerPositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
@@ -75,6 +92,7 @@ export function DragProvider({ children }: { children: ReactNode }) {
75
92
 
76
93
  const updatePointerPosition = useCallback((x: number, y: number) => {
77
94
  pointerPositionRef.current = { x, y };
95
+ setDragPosition({ x, y });
78
96
  let foundZone: string | null = null;
79
97
  let foundEpicZone: string | null = null;
80
98
 
@@ -98,6 +116,18 @@ export function DragProvider({ children }: { children: ReactNode }) {
98
116
  setActiveEpicZone(foundEpicZone);
99
117
  }, []);
100
118
 
119
+ const startDrag = useCallback((item: WorkItem, cardRect: DOMRect, pointerX: number, pointerY: number) => {
120
+ console.log('[DragContext] startDrag called', { item: item.id, cardRect, pointerX, pointerY });
121
+ // Calculate offset from pointer to card's top-left corner
122
+ const offsetX = pointerX - cardRect.left;
123
+ const offsetY = pointerY - cardRect.top;
124
+ console.log('[DragContext] Setting drag state', { offsetX, offsetY, width: cardRect.width });
125
+ setDragOffset({ x: offsetX, y: offsetY });
126
+ setDragPosition({ x: pointerX, y: pointerY });
127
+ setDraggedCardWidth(cardRect.width);
128
+ setDraggedItem(item);
129
+ }, []);
130
+
101
131
  const handleDrop = useCallback(async () => {
102
132
  if (!draggedItem) return;
103
133
 
@@ -137,6 +167,20 @@ export function DragProvider({ children }: { children: ReactNode }) {
137
167
  }
138
168
  }, [activeDropZone, activeEpicZone, draggedItem]);
139
169
 
170
+ // Calculate overlay position (pointer position minus offset = card top-left)
171
+ const overlayX = dragPosition.x - dragOffset.x;
172
+ const overlayY = dragPosition.y - dragOffset.y;
173
+
174
+ // Debug: log portal render conditions
175
+ console.log('[DragContext] Portal render check', {
176
+ hasDraggedItem: !!draggedItem,
177
+ hasRenderOverlay: !!renderDragOverlay,
178
+ hasDocument: typeof document !== 'undefined',
179
+ overlayX,
180
+ overlayY,
181
+ draggedCardWidth,
182
+ });
183
+
140
184
  return (
141
185
  <DragContext.Provider
142
186
  value={{
@@ -144,16 +188,63 @@ export function DragProvider({ children }: { children: ReactNode }) {
144
188
  draggedItem,
145
189
  activeDropZone,
146
190
  activeEpicZone,
191
+ dragPosition,
192
+ dragOffset,
193
+ draggedCardWidth,
147
194
  setDraggedItem,
148
195
  registerDropZone,
149
196
  unregisterDropZone,
150
197
  registerEpicDropZone,
151
198
  unregisterEpicDropZone,
152
199
  updatePointerPosition,
200
+ startDrag,
153
201
  handleDrop,
154
202
  }}
155
203
  >
156
204
  {children}
205
+ {/* Drag overlay - rendered via portal to escape overflow containers */}
206
+ {draggedItem && renderDragOverlay && typeof document !== 'undefined' &&
207
+ createPortal(
208
+ <div
209
+ style={{
210
+ position: 'fixed',
211
+ left: overlayX,
212
+ top: overlayY,
213
+ width: draggedCardWidth,
214
+ zIndex: 9999,
215
+ pointerEvents: 'none',
216
+ transform: 'scale(1.02)',
217
+ boxShadow: '0 10px 30px rgba(0, 0, 0, 0.2)',
218
+ border: '4px solid red',
219
+ background: 'rgba(255, 0, 0, 0.1)',
220
+ }}
221
+ >
222
+ {renderDragOverlay(draggedItem)}
223
+ </div>,
224
+ document.body
225
+ )
226
+ }
227
+ {/* TEST: Always-visible portal to verify createPortal works */}
228
+ {typeof document !== 'undefined' &&
229
+ createPortal(
230
+ <div
231
+ style={{
232
+ position: 'fixed',
233
+ bottom: 20,
234
+ right: 20,
235
+ padding: '10px 20px',
236
+ background: 'green',
237
+ color: 'white',
238
+ fontWeight: 'bold',
239
+ zIndex: 9999,
240
+ borderRadius: 8,
241
+ }}
242
+ >
243
+ Portal Works! Dragging: {draggedItem ? `#${draggedItem.id}` : 'none'}
244
+ </div>,
245
+ document.body
246
+ )
247
+ }
157
248
  </DragContext.Provider>
158
249
  );
159
250
  }
@@ -12,16 +12,24 @@ interface DraggableCardProps {
12
12
  }
13
13
 
14
14
  export function DraggableCard({ item, children, disabled = false }: DraggableCardProps) {
15
- const { setDraggedItem, updatePointerPosition, handleDrop } = useDragContext();
15
+ const { startDrag, setDraggedItem, updatePointerPosition, handleDrop } = useDragContext();
16
16
  const wasDraggingRef = useRef(false);
17
+ const cardRef = useRef<HTMLDivElement>(null);
17
18
 
18
19
  if (disabled) {
19
20
  return <>{children}</>;
20
21
  }
21
22
 
22
- const handleDragStart = () => {
23
+ const handleDragStart = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
24
+ console.log('[DraggableCard] handleDragStart fired', { itemId: item.id, point: info.point });
23
25
  wasDraggingRef.current = true;
24
- setDraggedItem(item);
26
+ if (cardRef.current) {
27
+ const rect = cardRef.current.getBoundingClientRect();
28
+ console.log('[DraggableCard] Card rect:', rect);
29
+ startDrag(item, rect, info.point.x, info.point.y);
30
+ } else {
31
+ console.warn('[DraggableCard] cardRef.current is null!');
32
+ }
25
33
  };
26
34
 
27
35
  const handleDrag = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
@@ -44,14 +52,13 @@ export function DraggableCard({ item, children, disabled = false }: DraggableCar
44
52
 
45
53
  return (
46
54
  <motion.div
55
+ ref={cardRef}
47
56
  drag
48
57
  dragSnapToOrigin
49
58
  dragElastic={0.1}
50
59
  whileDrag={{
51
- scale: 1.02,
52
- boxShadow: '0 10px 30px rgba(0, 0, 0, 0.2)',
53
- zIndex: 50,
54
60
  cursor: 'grabbing',
61
+ opacity: 0,
55
62
  }}
56
63
  onDragStart={handleDragStart}
57
64
  onDrag={handleDrag}
@@ -4,6 +4,7 @@ import { useState, useCallback, useRef, useEffect } from 'react';
4
4
  import Link from 'next/link';
5
5
  import { useRouter } from 'next/navigation';
6
6
  import type { WorkItem, InFlightItem, KanbanGroup } from '@/lib/db';
7
+ import type { UndoAction } from '@/lib/undoStack';
7
8
  import { EditableTitle } from './EditableTitle';
8
9
  import { CardMenu } from './CardMenu';
9
10
  import { DragProvider, useDragContext } from './DragContext';
@@ -89,7 +90,7 @@ interface KanbanCardProps {
89
90
  showEpic?: boolean;
90
91
  isInFlight?: boolean;
91
92
  onTitleSave?: (id: number, newTitle: string) => Promise<void>;
92
- onStatusChange?: (id: number, newStatus: string) => Promise<void>;
93
+ onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
93
94
  }
94
95
 
95
96
  function KanbanCard({ item, epicTitle, showEpic = false, isInFlight = false, onTitleSave, onStatusChange }: KanbanCardProps) {
@@ -249,7 +250,7 @@ interface EpicGroupProps {
249
250
  isInFlight?: boolean;
250
251
  isDraggable?: boolean;
251
252
  onTitleSave?: (id: number, newTitle: string) => Promise<void>;
252
- onStatusChange?: (id: number, newStatus: string) => Promise<void>;
253
+ onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
253
254
  onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
254
255
  onOrderChange?: (id: number, newOrder: number) => Promise<void>;
255
256
  }
@@ -418,16 +419,53 @@ interface KanbanBoardProps {
418
419
  backlog: Map<string, KanbanGroup>;
419
420
  done: Map<string, KanbanGroup>;
420
421
  onTitleSave?: (id: number, newTitle: string) => Promise<void>;
421
- onStatusChange?: (id: number, newStatus: string) => Promise<void>;
422
+ onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
422
423
  onOrderChange?: (id: number, newOrder: number) => Promise<void>;
423
424
  onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
425
+ // Undo/redo support
426
+ onUndo?: () => Promise<UndoAction | null>;
427
+ onRedo?: () => Promise<UndoAction | null>;
428
+ canUndo?: boolean;
429
+ canRedo?: boolean;
424
430
  }
425
431
 
426
- export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChange, onOrderChange, onEpicAssign }: KanbanBoardProps) {
432
+ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChange, onOrderChange, onEpicAssign, onUndo, onRedo, canUndo, canRedo }: KanbanBoardProps) {
427
433
  const backlogCount = inFlight.length + Array.from(backlog.values()).reduce((sum, g) => sum + g.items.length, 0);
428
434
  const doneCount = Array.from(done.values()).reduce((sum, g) => sum + g.items.length, 0);
429
435
  const backlogContainerRef = useRef<HTMLDivElement>(null);
430
436
 
437
+ // Keyboard shortcuts for undo/redo (Cmd+Z / Cmd+Shift+Z)
438
+ useEffect(() => {
439
+ const handleKeyDown = async (e: KeyboardEvent) => {
440
+ // Only handle if Cmd (Mac) or Ctrl (Windows/Linux) is pressed
441
+ const isMod = e.metaKey || e.ctrlKey;
442
+ if (!isMod || e.key.toLowerCase() !== 'z') return;
443
+
444
+ // Don't intercept if user is typing in an input field
445
+ const target = e.target as HTMLElement;
446
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
447
+ return;
448
+ }
449
+
450
+ e.preventDefault();
451
+
452
+ if (e.shiftKey) {
453
+ // Cmd+Shift+Z = Redo
454
+ if (onRedo && canRedo) {
455
+ await onRedo();
456
+ }
457
+ } else {
458
+ // Cmd+Z = Undo
459
+ if (onUndo && canUndo) {
460
+ await onUndo();
461
+ }
462
+ }
463
+ };
464
+
465
+ document.addEventListener('keydown', handleKeyDown);
466
+ return () => document.removeEventListener('keydown', handleKeyDown);
467
+ }, [onUndo, onRedo, canUndo, canRedo]);
468
+
431
469
  // Build a set of epic IDs that have in-flight items
432
470
  const inFlightEpicIds = new Set<number>();
433
471
  for (const item of inFlight) {
@@ -490,8 +528,25 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
490
528
  await onOrderChange(itemId, newOrder);
491
529
  }, [getAllBacklogItems, onOrderChange]);
492
530
 
531
+ // Render function for the drag overlay
532
+ const renderDragOverlay = useCallback((item: WorkItem) => {
533
+ // Find epic title if this is an in-flight item
534
+ const inFlightItem = inFlight.find(i => i.id === item.id);
535
+ const epicTitle = inFlightItem?.epicTitle || null;
536
+ const isInFlightCard = inFlightItem !== undefined;
537
+
538
+ return (
539
+ <KanbanCard
540
+ item={item}
541
+ epicTitle={epicTitle}
542
+ showEpic={isInFlightCard}
543
+ isInFlight={isInFlightCard}
544
+ />
545
+ );
546
+ }, [inFlight]);
547
+
493
548
  return (
494
- <DragProvider>
549
+ <DragProvider renderDragOverlay={renderDragOverlay}>
495
550
  <div className="flex gap-4 overflow-x-auto h-full" data-testid="kanban-board">
496
551
  {/* Backlog Column */}
497
552
  <KanbanColumn title="Backlog" count={backlogCount}>
@@ -1,10 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { useState, useCallback } from 'react';
3
+ import { useState, useCallback, useMemo } from 'react';
4
4
  import { KanbanBoard } from './KanbanBoard';
5
5
  import { RecentDecisionsWidget } from './RecentDecisionsWidget';
6
+ import { ToastProvider, useToast } from './Toast';
6
7
  import { useWebSocket, type WebSocketMessage } from '../hooks/useWebSocket';
7
8
  import type { InFlightItem, KanbanGroup, Decision } from '@/lib/db';
9
+ import { UndoStack, type UndoAction } from '@/lib/undoStack';
8
10
 
9
11
  interface KanbanData {
10
12
  inFlight: InFlightItem[];
@@ -21,7 +23,37 @@ interface RealTimeKanbanWrapperProps {
21
23
  initialDecisions: Decision[];
22
24
  }
23
25
 
26
+ // Helper to find item by ID in the kanban data
27
+ function findItemById(data: KanbanData, id: number): { item: InFlightItem; status: string } | null {
28
+ // Check in-flight (status: in_progress)
29
+ const inFlightItem = data.inFlight.find(item => item.id === id);
30
+ if (inFlightItem) return { item: inFlightItem, status: 'in_progress' };
31
+
32
+ // Check backlog groups
33
+ for (const group of data.backlog.values()) {
34
+ const backlogItem = group.items.find(item => item.id === id);
35
+ if (backlogItem) return { item: backlogItem as InFlightItem, status: 'backlog' };
36
+ }
37
+
38
+ // Check done groups
39
+ for (const group of data.done.values()) {
40
+ const doneItem = group.items.find(item => item.id === id);
41
+ if (doneItem) return { item: doneItem as InFlightItem, status: 'done' };
42
+ }
43
+
44
+ return null;
45
+ }
46
+
24
47
  export function RealTimeKanbanWrapper({ initialData, initialDecisions }: RealTimeKanbanWrapperProps) {
48
+ return (
49
+ <ToastProvider>
50
+ <RealTimeKanbanContent initialData={initialData} initialDecisions={initialDecisions} />
51
+ </ToastProvider>
52
+ );
53
+ }
54
+
55
+ function RealTimeKanbanContent({ initialData, initialDecisions }: RealTimeKanbanWrapperProps) {
56
+ const { showToast } = useToast();
25
57
  const [data, setData] = useState<KanbanData>(() => ({
26
58
  inFlight: initialData.inFlight,
27
59
  backlog: new Map(initialData.backlog),
@@ -30,6 +62,10 @@ export function RealTimeKanbanWrapper({ initialData, initialDecisions }: RealTim
30
62
  const [decisions, setDecisions] = useState<Decision[]>(initialDecisions);
31
63
  const [statusError, setStatusError] = useState<string | null>(null);
32
64
 
65
+ // Undo/redo stack - created once per component instance
66
+ const [undoStack] = useState(() => new UndoStack());
67
+ const [undoRedoVersion, setUndoRedoVersion] = useState(0); // Force re-render on stack changes
68
+
33
69
  const refreshData = useCallback(async () => {
34
70
  const [kanbanResponse, decisionsResponse] = await Promise.all([
35
71
  fetch('/api/kanban'),
@@ -63,8 +99,14 @@ export function RealTimeKanbanWrapper({ initialData, initialDecisions }: RealTim
63
99
  await refreshData();
64
100
  }, [refreshData]);
65
101
 
66
- const handleStatusChange = useCallback(async (id: number, newStatus: string) => {
102
+ const handleStatusChange = useCallback(async (id: number, newStatus: string, skipUndo = false): Promise<{ success: boolean; notFound?: boolean }> => {
67
103
  setStatusError(null);
104
+
105
+ // Find the item to get its current status and title before the change
106
+ const found = findItemById(data, id);
107
+ const previousStatus = found?.status;
108
+ const itemTitle = found?.item.title ?? `Item #${id}`;
109
+
68
110
  try {
69
111
  const response = await fetch(`/api/work/${id}/status`, {
70
112
  method: 'PATCH',
@@ -74,17 +116,34 @@ export function RealTimeKanbanWrapper({ initialData, initialDecisions }: RealTim
74
116
  if (!response.ok) {
75
117
  if (response.status === 404) {
76
118
  setStatusError('Item no longer exists');
119
+ await refreshData();
120
+ return { success: false, notFound: true };
77
121
  } else {
78
122
  setStatusError('Failed to update status');
123
+ await refreshData();
124
+ return { success: false };
79
125
  }
80
- await refreshData();
81
- return;
82
126
  }
127
+
128
+ // Push to undo stack if this is a user-initiated change (not undo/redo)
129
+ if (!skipUndo && previousStatus && previousStatus !== newStatus) {
130
+ undoStack.push({
131
+ type: 'status_change',
132
+ itemId: id,
133
+ itemTitle,
134
+ before: previousStatus,
135
+ after: newStatus,
136
+ });
137
+ setUndoRedoVersion(v => v + 1); // Trigger re-render
138
+ }
139
+
83
140
  await refreshData();
141
+ return { success: true };
84
142
  } catch {
85
143
  setStatusError('Failed to update status');
144
+ return { success: false };
86
145
  }
87
- }, [refreshData]);
146
+ }, [refreshData, data, undoStack]);
88
147
 
89
148
  const handleOrderChange = useCallback(async (id: number, newOrder: number) => {
90
149
  try {
@@ -112,6 +171,82 @@ export function RealTimeKanbanWrapper({ initialData, initialDecisions }: RealTim
112
171
  }
113
172
  }, [refreshData]);
114
173
 
174
+ // Helper to format status for display
175
+ const formatStatus = (status: string): string => {
176
+ switch (status) {
177
+ case 'in_progress': return 'In Flight';
178
+ case 'backlog': return 'Backlog';
179
+ case 'done': return 'Done';
180
+ default: return status;
181
+ }
182
+ };
183
+
184
+ // Undo the most recent status change
185
+ const handleUndo = useCallback(async (): Promise<UndoAction | null> => {
186
+ const action = undoStack.undo();
187
+ if (!action) return null;
188
+
189
+ // Revert the status change (skipUndo=true to prevent adding to undo stack)
190
+ const result = await handleStatusChange(action.itemId, action.before, true);
191
+ setUndoRedoVersion(v => v + 1);
192
+
193
+ if (result.notFound) {
194
+ // Item was deleted - show error toast (statusError already set by handleStatusChange)
195
+ // The action is already removed from undo stack and won't be in redo stack
196
+ // because we didn't push it back - just leave it removed
197
+ showToast(`Item no longer exists: "${action.itemTitle}" was deleted`, 'error');
198
+ return null;
199
+ }
200
+
201
+ if (!result.success) {
202
+ // Other error - push action back to undo stack so user can retry
203
+ undoStack.push({
204
+ type: action.type,
205
+ itemId: action.itemId,
206
+ itemTitle: action.itemTitle,
207
+ before: action.after, // Swap because we want to retry undoing
208
+ after: action.before,
209
+ });
210
+ return null;
211
+ }
212
+
213
+ // Show success toast notification
214
+ showToast(`Undone: "${action.itemTitle}" moved back to ${formatStatus(action.before)}`);
215
+
216
+ return action;
217
+ }, [undoStack, handleStatusChange, showToast]);
218
+
219
+ // Redo the most recently undone status change
220
+ const handleRedo = useCallback(async (): Promise<UndoAction | null> => {
221
+ const action = undoStack.redo();
222
+ if (!action) return null;
223
+
224
+ // Re-apply the status change (skipUndo=true to prevent adding to undo stack)
225
+ const result = await handleStatusChange(action.itemId, action.after, true);
226
+ setUndoRedoVersion(v => v + 1);
227
+
228
+ if (result.notFound) {
229
+ // Item was deleted - show error toast
230
+ showToast(`Item no longer exists: "${action.itemTitle}" was deleted`, 'error');
231
+ return null;
232
+ }
233
+
234
+ if (!result.success) {
235
+ // Other error - push action back to redo stack so user can retry
236
+ // Note: This is a simplification; in production you might want more sophisticated retry logic
237
+ return null;
238
+ }
239
+
240
+ // Show success toast notification
241
+ showToast(`Redone: "${action.itemTitle}" moved to ${formatStatus(action.after)}`);
242
+
243
+ return action;
244
+ }, [undoStack, handleStatusChange, showToast]);
245
+
246
+ // Expose undo stack state
247
+ const canUndo = undoStack.canUndo();
248
+ const canRedo = undoStack.canRedo();
249
+
115
250
  const wsUrl = typeof window !== 'undefined'
116
251
  ? `ws://${window.location.hostname}:8080`
117
252
  : 'ws://localhost:8080';
@@ -171,6 +306,10 @@ export function RealTimeKanbanWrapper({ initialData, initialDecisions }: RealTim
171
306
  onStatusChange={handleStatusChange}
172
307
  onOrderChange={handleOrderChange}
173
308
  onEpicAssign={handleEpicAssign}
309
+ onUndo={handleUndo}
310
+ onRedo={handleRedo}
311
+ canUndo={canUndo}
312
+ canRedo={canRedo}
174
313
  />
175
314
  </div>
176
315
  <div className="w-60 flex-shrink-0">
@@ -0,0 +1,80 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
4
+ import { AnimatePresence, motion } from 'framer-motion';
5
+
6
+ interface Toast {
7
+ id: string;
8
+ message: string;
9
+ type?: 'info' | 'success' | 'error';
10
+ }
11
+
12
+ interface ToastContextValue {
13
+ showToast: (message: string, type?: Toast['type']) => void;
14
+ }
15
+
16
+ const ToastContext = createContext<ToastContextValue | null>(null);
17
+
18
+ export function useToast() {
19
+ const context = useContext(ToastContext);
20
+ if (!context) {
21
+ throw new Error('useToast must be used within a ToastProvider');
22
+ }
23
+ return context;
24
+ }
25
+
26
+ const TOAST_DURATION = 3000;
27
+
28
+ export function ToastProvider({ children }: { children: ReactNode }) {
29
+ const [toasts, setToasts] = useState<Toast[]>([]);
30
+
31
+ const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
32
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
33
+ setToasts(prev => [...prev, { id, message, type }]);
34
+
35
+ // Auto-dismiss after duration
36
+ setTimeout(() => {
37
+ setToasts(prev => prev.filter(t => t.id !== id));
38
+ }, TOAST_DURATION);
39
+ }, []);
40
+
41
+ const dismissToast = useCallback((id: string) => {
42
+ setToasts(prev => prev.filter(t => t.id !== id));
43
+ }, []);
44
+
45
+ return (
46
+ <ToastContext.Provider value={{ showToast }}>
47
+ {children}
48
+ <ToastContainer toasts={toasts} onDismiss={dismissToast} />
49
+ </ToastContext.Provider>
50
+ );
51
+ }
52
+
53
+ function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: string) => void }) {
54
+ return (
55
+ <div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2" data-testid="toast-container">
56
+ <AnimatePresence mode="popLayout">
57
+ {toasts.map(toast => (
58
+ <motion.div
59
+ key={toast.id}
60
+ initial={{ opacity: 0, y: 20, scale: 0.95 }}
61
+ animate={{ opacity: 1, y: 0, scale: 1 }}
62
+ exit={{ opacity: 0, y: -10, scale: 0.95 }}
63
+ transition={{ duration: 0.2 }}
64
+ className={`px-4 py-2 rounded-lg shadow-lg text-sm font-medium cursor-pointer ${
65
+ toast.type === 'success'
66
+ ? 'bg-green-600 text-white'
67
+ : toast.type === 'error'
68
+ ? 'bg-red-600 text-white'
69
+ : 'bg-zinc-800 text-white dark:bg-zinc-700'
70
+ }`}
71
+ onClick={() => onDismiss(toast.id)}
72
+ data-testid="toast"
73
+ >
74
+ {toast.message}
75
+ </motion.div>
76
+ ))}
77
+ </AnimatePresence>
78
+ </div>
79
+ );
80
+ }