jettypod 4.4.79 → 4.4.81

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,16 @@ 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
+ // Calculate offset from pointer to card's top-left corner
121
+ const offsetX = pointerX - cardRect.left;
122
+ const offsetY = pointerY - cardRect.top;
123
+ setDragOffset({ x: offsetX, y: offsetY });
124
+ setDragPosition({ x: pointerX, y: pointerY });
125
+ setDraggedCardWidth(cardRect.width);
126
+ setDraggedItem(item);
127
+ }, []);
128
+
101
129
  const handleDrop = useCallback(async () => {
102
130
  if (!draggedItem) return;
103
131
 
@@ -137,6 +165,10 @@ export function DragProvider({ children }: { children: ReactNode }) {
137
165
  }
138
166
  }, [activeDropZone, activeEpicZone, draggedItem]);
139
167
 
168
+ // Calculate overlay position (pointer position minus offset = card top-left)
169
+ const overlayX = dragPosition.x - dragOffset.x;
170
+ const overlayY = dragPosition.y - dragOffset.y;
171
+
140
172
  return (
141
173
  <DragContext.Provider
142
174
  value={{
@@ -144,16 +176,40 @@ export function DragProvider({ children }: { children: ReactNode }) {
144
176
  draggedItem,
145
177
  activeDropZone,
146
178
  activeEpicZone,
179
+ dragPosition,
180
+ dragOffset,
181
+ draggedCardWidth,
147
182
  setDraggedItem,
148
183
  registerDropZone,
149
184
  unregisterDropZone,
150
185
  registerEpicDropZone,
151
186
  unregisterEpicDropZone,
152
187
  updatePointerPosition,
188
+ startDrag,
153
189
  handleDrop,
154
190
  }}
155
191
  >
156
192
  {children}
193
+ {/* Drag overlay - rendered via portal to escape overflow containers */}
194
+ {draggedItem && renderDragOverlay && typeof document !== 'undefined' &&
195
+ createPortal(
196
+ <div
197
+ style={{
198
+ position: 'fixed',
199
+ left: overlayX,
200
+ top: overlayY,
201
+ width: draggedCardWidth,
202
+ zIndex: 9999,
203
+ pointerEvents: 'none',
204
+ transform: 'scale(1.02)',
205
+ boxShadow: '0 10px 30px rgba(0, 0, 0, 0.2)',
206
+ }}
207
+ >
208
+ {renderDragOverlay(draggedItem)}
209
+ </div>,
210
+ document.body
211
+ )
212
+ }
157
213
  </DragContext.Provider>
158
214
  );
159
215
  }
@@ -12,16 +12,20 @@ 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) => {
23
24
  wasDraggingRef.current = true;
24
- setDraggedItem(item);
25
+ if (cardRef.current) {
26
+ const rect = cardRef.current.getBoundingClientRect();
27
+ startDrag(item, rect, info.point.x, info.point.y);
28
+ }
25
29
  };
26
30
 
27
31
  const handleDrag = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
@@ -44,14 +48,13 @@ export function DraggableCard({ item, children, disabled = false }: DraggableCar
44
48
 
45
49
  return (
46
50
  <motion.div
51
+ ref={cardRef}
47
52
  drag
48
53
  dragSnapToOrigin
49
54
  dragElastic={0.1}
50
55
  whileDrag={{
51
- scale: 1.02,
52
- boxShadow: '0 10px 30px rgba(0, 0, 0, 0.2)',
53
- zIndex: 50,
54
56
  cursor: 'grabbing',
57
+ opacity: 0,
55
58
  }}
56
59
  onDragStart={handleDragStart}
57
60
  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
+ }