@tpitre/story-ui 1.0.0
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.
- package/.env.sample +17 -0
- package/LICENSE +21 -0
- package/README.md +531 -0
- package/dist/cli/index.js +250 -0
- package/dist/cli/setup.js +289 -0
- package/dist/index.js +12 -0
- package/dist/mcp-server/index.js +64 -0
- package/dist/mcp-server/routes/claude.js +30 -0
- package/dist/mcp-server/routes/components.js +26 -0
- package/dist/mcp-server/routes/generateStory.js +289 -0
- package/dist/mcp-server/routes/memoryStories.js +141 -0
- package/dist/mcp-server/routes/storySync.js +147 -0
- package/dist/story-generator/componentDiscovery.js +222 -0
- package/dist/story-generator/configLoader.js +482 -0
- package/dist/story-generator/generateStory.js +19 -0
- package/dist/story-generator/gitignoreManager.js +182 -0
- package/dist/story-generator/inMemoryStoryService.js +128 -0
- package/dist/story-generator/productionGitignoreManager.js +333 -0
- package/dist/story-generator/promptGenerator.js +201 -0
- package/dist/story-generator/storySync.js +201 -0
- package/dist/story-ui.config.js +114 -0
- package/dist/story-ui.config.loader.js +205 -0
- package/package.json +80 -0
- package/templates/README.md +32 -0
- package/templates/StoryUI/StoryUIPanel.stories.tsx +28 -0
- package/templates/StoryUI/StoryUIPanel.tsx +870 -0
- package/templates/StoryUI/index.tsx +2 -0
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
const MCP_API = 'http://localhost:4001/mcp/generate-story';
|
|
4
|
+
const SYNC_API = 'http://localhost:4001/mcp/sync';
|
|
5
|
+
const LOCAL_STORAGE_KEY = 'story_ui_chat_history_v2'; // Updated version for sync
|
|
6
|
+
const MAX_RECENT_CHATS = 20;
|
|
7
|
+
|
|
8
|
+
interface Message {
|
|
9
|
+
role: 'user' | 'ai';
|
|
10
|
+
content: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ChatSession {
|
|
14
|
+
id: string; // fileName or hash
|
|
15
|
+
title: string;
|
|
16
|
+
fileName: string;
|
|
17
|
+
conversation: Message[];
|
|
18
|
+
lastUpdated: number;
|
|
19
|
+
isValid?: boolean; // Whether the story still exists
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Professional, self-contained styles - no external dependencies
|
|
23
|
+
const STYLES = {
|
|
24
|
+
// Typography - Professional font stack like ChatGPT
|
|
25
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
|
26
|
+
|
|
27
|
+
// Base container with CSS reset
|
|
28
|
+
container: {
|
|
29
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
|
30
|
+
fontSize: '14px',
|
|
31
|
+
lineHeight: '1.5',
|
|
32
|
+
color: '#ffffff',
|
|
33
|
+
margin: 0,
|
|
34
|
+
padding: 0,
|
|
35
|
+
boxSizing: 'border-box' as const,
|
|
36
|
+
display: 'flex',
|
|
37
|
+
height: '600px',
|
|
38
|
+
background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)',
|
|
39
|
+
borderRadius: '12px',
|
|
40
|
+
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
|
41
|
+
overflow: 'hidden',
|
|
42
|
+
border: '1px solid rgba(255, 255, 255, 0.1)',
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
// Sidebar styles
|
|
46
|
+
sidebar: {
|
|
47
|
+
width: '280px',
|
|
48
|
+
background: 'linear-gradient(180deg, #0f172a 0%, #1e293b 100%)',
|
|
49
|
+
borderRight: '1px solid rgba(255, 255, 255, 0.1)',
|
|
50
|
+
display: 'flex',
|
|
51
|
+
flexDirection: 'column' as const,
|
|
52
|
+
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
sidebarCollapsed: {
|
|
56
|
+
width: '60px',
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
sidebarToggle: {
|
|
60
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
|
61
|
+
background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
|
62
|
+
color: '#ffffff',
|
|
63
|
+
border: 'none',
|
|
64
|
+
borderRadius: '8px',
|
|
65
|
+
margin: '16px',
|
|
66
|
+
padding: '12px 16px',
|
|
67
|
+
fontWeight: '600',
|
|
68
|
+
fontSize: '14px',
|
|
69
|
+
cursor: 'pointer',
|
|
70
|
+
transition: 'all 0.2s ease',
|
|
71
|
+
display: 'flex',
|
|
72
|
+
alignItems: 'center',
|
|
73
|
+
justifyContent: 'center',
|
|
74
|
+
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
newChatButton: {
|
|
78
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
|
79
|
+
width: '100%',
|
|
80
|
+
padding: '12px 16px',
|
|
81
|
+
marginBottom: '12px',
|
|
82
|
+
borderRadius: '8px',
|
|
83
|
+
border: '1px solid rgba(59, 130, 246, 0.3)',
|
|
84
|
+
background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
|
85
|
+
color: '#ffffff',
|
|
86
|
+
fontWeight: '600',
|
|
87
|
+
fontSize: '14px',
|
|
88
|
+
cursor: 'pointer',
|
|
89
|
+
transition: 'all 0.2s ease',
|
|
90
|
+
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.2)',
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
chatItem: {
|
|
94
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
|
95
|
+
width: '100%',
|
|
96
|
+
textAlign: 'left' as const,
|
|
97
|
+
padding: '12px 40px 12px 16px',
|
|
98
|
+
borderRadius: '8px',
|
|
99
|
+
border: '1px solid transparent',
|
|
100
|
+
background: 'rgba(255, 255, 255, 0.05)',
|
|
101
|
+
color: '#e2e8f0',
|
|
102
|
+
fontWeight: '400',
|
|
103
|
+
fontSize: '13px',
|
|
104
|
+
cursor: 'pointer',
|
|
105
|
+
overflow: 'hidden',
|
|
106
|
+
textOverflow: 'ellipsis',
|
|
107
|
+
whiteSpace: 'nowrap' as const,
|
|
108
|
+
transition: 'all 0.2s ease',
|
|
109
|
+
marginBottom: '4px',
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
chatItemActive: {
|
|
113
|
+
border: '1px solid rgba(59, 130, 246, 0.5)',
|
|
114
|
+
background: 'rgba(59, 130, 246, 0.1)',
|
|
115
|
+
color: '#60a5fa',
|
|
116
|
+
fontWeight: '500',
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
// Main chat area
|
|
120
|
+
mainArea: {
|
|
121
|
+
flex: 1,
|
|
122
|
+
display: 'flex',
|
|
123
|
+
flexDirection: 'column' as const,
|
|
124
|
+
background: 'rgba(255, 255, 255, 0.02)',
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
header: {
|
|
128
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
|
129
|
+
color: '#f8fafc',
|
|
130
|
+
margin: '0',
|
|
131
|
+
padding: '24px 24px 16px 24px',
|
|
132
|
+
fontSize: '20px',
|
|
133
|
+
fontWeight: '600',
|
|
134
|
+
letterSpacing: '-0.01em',
|
|
135
|
+
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
|
|
136
|
+
WebkitBackgroundClip: 'text',
|
|
137
|
+
WebkitTextFillColor: 'transparent',
|
|
138
|
+
backgroundClip: 'text',
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
chatContainer: {
|
|
142
|
+
flex: 1,
|
|
143
|
+
overflowY: 'auto' as const,
|
|
144
|
+
background: 'rgba(0, 0, 0, 0.2)',
|
|
145
|
+
borderRadius: '12px',
|
|
146
|
+
padding: '20px',
|
|
147
|
+
margin: '0 24px 16px 24px',
|
|
148
|
+
border: '1px solid rgba(255, 255, 255, 0.1)',
|
|
149
|
+
backdropFilter: 'blur(10px)',
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
emptyState: {
|
|
153
|
+
color: '#94a3b8',
|
|
154
|
+
textAlign: 'center' as const,
|
|
155
|
+
marginTop: '60px',
|
|
156
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
emptyStateTitle: {
|
|
160
|
+
fontSize: '16px',
|
|
161
|
+
fontWeight: '500',
|
|
162
|
+
marginBottom: '8px',
|
|
163
|
+
color: '#cbd5e1',
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
emptyStateSubtitle: {
|
|
167
|
+
fontSize: '13px',
|
|
168
|
+
color: '#64748b',
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
// Message bubbles
|
|
172
|
+
messageContainer: {
|
|
173
|
+
display: 'flex',
|
|
174
|
+
marginBottom: '16px',
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
userMessage: {
|
|
178
|
+
background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
|
179
|
+
color: '#ffffff',
|
|
180
|
+
borderRadius: '18px 18px 4px 18px',
|
|
181
|
+
padding: '12px 16px',
|
|
182
|
+
maxWidth: '80%',
|
|
183
|
+
marginLeft: 'auto',
|
|
184
|
+
fontSize: '14px',
|
|
185
|
+
lineHeight: '1.5',
|
|
186
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
|
187
|
+
boxShadow: '0 2px 12px rgba(59, 130, 246, 0.3)',
|
|
188
|
+
wordWrap: 'break-word' as const,
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
aiMessage: {
|
|
192
|
+
background: 'rgba(255, 255, 255, 0.95)',
|
|
193
|
+
color: '#1f2937',
|
|
194
|
+
borderRadius: '18px 18px 18px 4px',
|
|
195
|
+
padding: '12px 16px',
|
|
196
|
+
maxWidth: '80%',
|
|
197
|
+
fontSize: '14px',
|
|
198
|
+
lineHeight: '1.5',
|
|
199
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
|
200
|
+
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.1)',
|
|
201
|
+
wordWrap: 'break-word' as const,
|
|
202
|
+
whiteSpace: 'pre-wrap' as const,
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
loadingMessage: {
|
|
206
|
+
background: 'rgba(255, 255, 255, 0.9)',
|
|
207
|
+
color: '#6b7280',
|
|
208
|
+
borderRadius: '18px 18px 18px 4px',
|
|
209
|
+
padding: '12px 16px',
|
|
210
|
+
fontSize: '14px',
|
|
211
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
|
212
|
+
display: 'flex',
|
|
213
|
+
alignItems: 'center',
|
|
214
|
+
gap: '8px',
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
// Input form
|
|
218
|
+
inputForm: {
|
|
219
|
+
display: 'flex',
|
|
220
|
+
alignItems: 'center',
|
|
221
|
+
gap: '12px',
|
|
222
|
+
margin: '0 24px 24px 24px',
|
|
223
|
+
padding: '16px',
|
|
224
|
+
background: 'rgba(255, 255, 255, 0.05)',
|
|
225
|
+
borderRadius: '12px',
|
|
226
|
+
border: '1px solid rgba(255, 255, 255, 0.1)',
|
|
227
|
+
backdropFilter: 'blur(10px)',
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
textInput: {
|
|
231
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
|
232
|
+
flex: 1,
|
|
233
|
+
padding: '12px 16px',
|
|
234
|
+
borderRadius: '8px',
|
|
235
|
+
border: '1px solid rgba(255, 255, 255, 0.2)',
|
|
236
|
+
fontSize: '14px',
|
|
237
|
+
color: '#1f2937',
|
|
238
|
+
background: '#ffffff',
|
|
239
|
+
outline: 'none',
|
|
240
|
+
transition: 'all 0.2s ease',
|
|
241
|
+
boxSizing: 'border-box' as const,
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
sendButton: {
|
|
245
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
|
246
|
+
padding: '12px 20px',
|
|
247
|
+
borderRadius: '8px',
|
|
248
|
+
border: 'none',
|
|
249
|
+
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
|
250
|
+
color: '#ffffff',
|
|
251
|
+
fontWeight: '600',
|
|
252
|
+
fontSize: '14px',
|
|
253
|
+
cursor: 'pointer',
|
|
254
|
+
transition: 'all 0.2s ease',
|
|
255
|
+
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
sendButtonDisabled: {
|
|
259
|
+
background: 'rgba(107, 114, 128, 0.5)',
|
|
260
|
+
cursor: 'not-allowed',
|
|
261
|
+
boxShadow: 'none',
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
errorMessage: {
|
|
265
|
+
color: '#ef4444',
|
|
266
|
+
margin: '0 24px 16px 24px',
|
|
267
|
+
padding: '12px 16px',
|
|
268
|
+
background: 'rgba(239, 68, 68, 0.1)',
|
|
269
|
+
borderRadius: '8px',
|
|
270
|
+
border: '1px solid rgba(239, 68, 68, 0.3)',
|
|
271
|
+
fontSize: '13px',
|
|
272
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
deleteButton: {
|
|
276
|
+
position: 'absolute' as const,
|
|
277
|
+
right: '8px',
|
|
278
|
+
top: '50%',
|
|
279
|
+
transform: 'translateY(-50%)',
|
|
280
|
+
width: '20px',
|
|
281
|
+
height: '20px',
|
|
282
|
+
borderRadius: '4px',
|
|
283
|
+
border: 'none',
|
|
284
|
+
background: 'rgba(239, 68, 68, 0.8)',
|
|
285
|
+
color: '#ffffff',
|
|
286
|
+
fontSize: '12px',
|
|
287
|
+
fontWeight: 'bold',
|
|
288
|
+
cursor: 'pointer',
|
|
289
|
+
display: 'flex',
|
|
290
|
+
alignItems: 'center',
|
|
291
|
+
justifyContent: 'center',
|
|
292
|
+
transition: 'all 0.2s ease',
|
|
293
|
+
zIndex: 10,
|
|
294
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
function loadChats(): ChatSession[] {
|
|
299
|
+
try {
|
|
300
|
+
const raw = localStorage.getItem(LOCAL_STORAGE_KEY);
|
|
301
|
+
if (!raw) return [];
|
|
302
|
+
return JSON.parse(raw) as ChatSession[];
|
|
303
|
+
} catch {
|
|
304
|
+
return [];
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function saveChats(chats: ChatSession[]) {
|
|
309
|
+
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(chats));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function removeDuplicateChats(chats: ChatSession[]): ChatSession[] {
|
|
313
|
+
const seen = new Set<string>();
|
|
314
|
+
const seenTitles = new Map<string, ChatSession>();
|
|
315
|
+
|
|
316
|
+
return chats.filter(chat => {
|
|
317
|
+
// Remove exact ID duplicates
|
|
318
|
+
if (seen.has(chat.id)) {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
seen.add(chat.id);
|
|
322
|
+
|
|
323
|
+
// For title duplicates, keep the most recent one
|
|
324
|
+
const existingChat = seenTitles.get(chat.title);
|
|
325
|
+
if (existingChat) {
|
|
326
|
+
if (chat.lastUpdated > existingChat.lastUpdated) {
|
|
327
|
+
// Remove the older chat and keep this newer one
|
|
328
|
+
const oldIndex = chats.findIndex(c => c.id === existingChat.id);
|
|
329
|
+
if (oldIndex !== -1) {
|
|
330
|
+
seen.delete(existingChat.id);
|
|
331
|
+
}
|
|
332
|
+
seenTitles.set(chat.title, chat);
|
|
333
|
+
return true;
|
|
334
|
+
} else {
|
|
335
|
+
// Keep the existing newer chat, skip this one
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
} else {
|
|
339
|
+
seenTitles.set(chat.title, chat);
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function syncWithActualStories(): Promise<ChatSession[]> {
|
|
346
|
+
try {
|
|
347
|
+
// Get actual stories from the server
|
|
348
|
+
const response = await fetch(`${SYNC_API}/stories`);
|
|
349
|
+
const data = await response.json();
|
|
350
|
+
|
|
351
|
+
if (!data.success) {
|
|
352
|
+
console.warn('Failed to sync with actual stories:', data.error);
|
|
353
|
+
return loadChats();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const actualStories = data.stories;
|
|
357
|
+
const existingChats = loadChats();
|
|
358
|
+
|
|
359
|
+
// More robust matching: check multiple ID formats to prevent duplicates
|
|
360
|
+
const validChats = existingChats.filter(chat => {
|
|
361
|
+
return actualStories.some((story: { id: string; fileName: string }) => {
|
|
362
|
+
// Direct ID match
|
|
363
|
+
if (story.id === chat.id) return true;
|
|
364
|
+
|
|
365
|
+
// Filename match
|
|
366
|
+
if (story.fileName === chat.fileName) return true;
|
|
367
|
+
|
|
368
|
+
// Check if chat ID matches story filename (old format)
|
|
369
|
+
if (story.fileName === chat.id) return true;
|
|
370
|
+
|
|
371
|
+
// Check if story ID matches chat filename (common mismatch)
|
|
372
|
+
if (story.id === chat.fileName) return true;
|
|
373
|
+
|
|
374
|
+
return false;
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Only add stories that truly don't have chat sessions after robust checking
|
|
379
|
+
const newChats: ChatSession[] = actualStories
|
|
380
|
+
.filter((story: { id: string; fileName: string }) => {
|
|
381
|
+
return !existingChats.some(chat => {
|
|
382
|
+
// Check all possible matches to avoid creating duplicates
|
|
383
|
+
return chat.id === story.id ||
|
|
384
|
+
chat.fileName === story.fileName ||
|
|
385
|
+
chat.id === story.fileName ||
|
|
386
|
+
chat.fileName === story.id;
|
|
387
|
+
});
|
|
388
|
+
})
|
|
389
|
+
.map((story: { id: string; title: string; fileName: string; createdAt: string }) => ({
|
|
390
|
+
id: story.id,
|
|
391
|
+
title: story.title,
|
|
392
|
+
fileName: story.fileName,
|
|
393
|
+
conversation: [
|
|
394
|
+
{ role: 'ai' as const, content: `Story "${story.title}" was found.\nGenerated: ${new Date(story.createdAt).toLocaleString()}` }
|
|
395
|
+
],
|
|
396
|
+
lastUpdated: new Date(story.createdAt).getTime(),
|
|
397
|
+
isValid: true
|
|
398
|
+
}));
|
|
399
|
+
|
|
400
|
+
// Mark all chats as valid and combine
|
|
401
|
+
const combinedChats = [...validChats.map(chat => ({ ...chat, isValid: true })), ...newChats];
|
|
402
|
+
|
|
403
|
+
// Remove any duplicates that might have been created
|
|
404
|
+
const syncedChats = removeDuplicateChats(combinedChats);
|
|
405
|
+
|
|
406
|
+
// Save the synchronized chats
|
|
407
|
+
saveChats(syncedChats);
|
|
408
|
+
|
|
409
|
+
console.log('Sync completed:', {
|
|
410
|
+
totalStories: actualStories.length,
|
|
411
|
+
existingChats: existingChats.length,
|
|
412
|
+
validChats: validChats.length,
|
|
413
|
+
newChats: newChats.length,
|
|
414
|
+
finalChats: syncedChats.length
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
return syncedChats;
|
|
418
|
+
} catch (error) {
|
|
419
|
+
console.warn('Failed to sync with server:', error);
|
|
420
|
+
return loadChats();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function deleteStoryAndChat(storyId: string): Promise<boolean> {
|
|
425
|
+
try {
|
|
426
|
+
const response = await fetch(`${SYNC_API}/stories/${storyId}`, {
|
|
427
|
+
method: 'DELETE'
|
|
428
|
+
});
|
|
429
|
+
const data = await response.json();
|
|
430
|
+
|
|
431
|
+
if (data.success) {
|
|
432
|
+
// Remove from localStorage
|
|
433
|
+
const chats = loadChats().filter(chat => chat.id !== storyId);
|
|
434
|
+
saveChats(chats);
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return false;
|
|
439
|
+
} catch (error) {
|
|
440
|
+
console.warn('Failed to delete story:', error);
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const StoryUIPanel: React.FC = () => {
|
|
446
|
+
const [conversation, setConversation] = useState<Message[]>([]);
|
|
447
|
+
const [input, setInput] = useState('');
|
|
448
|
+
const [loading, setLoading] = useState(false);
|
|
449
|
+
const [error, setError] = useState<string | null>(null);
|
|
450
|
+
const [recentChats, setRecentChats] = useState<ChatSession[]>([]);
|
|
451
|
+
const [activeChatId, setActiveChatId] = useState<string | null>(null);
|
|
452
|
+
const [activeTitle, setActiveTitle] = useState<string>('');
|
|
453
|
+
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
454
|
+
const chatEndRef = useRef<HTMLDivElement | null>(null);
|
|
455
|
+
|
|
456
|
+
// Load and sync chats on mount
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
const initializeChats = async () => {
|
|
459
|
+
const syncedChats = await syncWithActualStories();
|
|
460
|
+
const sortedChats = syncedChats.sort((a, b) => b.lastUpdated - a.lastUpdated).slice(0, MAX_RECENT_CHATS);
|
|
461
|
+
setRecentChats(sortedChats);
|
|
462
|
+
|
|
463
|
+
if (sortedChats.length > 0) {
|
|
464
|
+
setConversation(sortedChats[0].conversation);
|
|
465
|
+
setActiveChatId(sortedChats[0].id);
|
|
466
|
+
setActiveTitle(sortedChats[0].title);
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
initializeChats();
|
|
471
|
+
}, []);
|
|
472
|
+
|
|
473
|
+
// Scroll to bottom on new message
|
|
474
|
+
useEffect(() => {
|
|
475
|
+
if (chatEndRef.current) {
|
|
476
|
+
chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
|
477
|
+
}
|
|
478
|
+
}, [conversation, loading]);
|
|
479
|
+
|
|
480
|
+
const handleSend = async (e?: React.FormEvent) => {
|
|
481
|
+
if (e) e.preventDefault();
|
|
482
|
+
if (!input.trim()) return;
|
|
483
|
+
setError(null);
|
|
484
|
+
setLoading(true);
|
|
485
|
+
const newConversation = [...conversation, { role: 'user' as const, content: input }];
|
|
486
|
+
setConversation(newConversation);
|
|
487
|
+
setInput('');
|
|
488
|
+
try {
|
|
489
|
+
const res = await fetch(MCP_API, {
|
|
490
|
+
method: 'POST',
|
|
491
|
+
headers: { 'Content-Type': 'application/json' },
|
|
492
|
+
body: JSON.stringify({
|
|
493
|
+
prompt: input,
|
|
494
|
+
conversation: newConversation,
|
|
495
|
+
fileName: activeChatId || undefined,
|
|
496
|
+
}),
|
|
497
|
+
});
|
|
498
|
+
const data = await res.json();
|
|
499
|
+
if (!res.ok || !data.success) throw new Error(data.error || 'Story generation failed');
|
|
500
|
+
|
|
501
|
+
// Create user-friendly response message instead of showing raw markup
|
|
502
|
+
let responseMessage: string;
|
|
503
|
+
if (data.isUpdate) {
|
|
504
|
+
responseMessage = `✅ Updated your story: "${data.title}"\n\nI've made the requested changes while keeping the same layout structure. You can view the updated component in Storybook.`;
|
|
505
|
+
} else {
|
|
506
|
+
responseMessage = `✅ Created new story: "${data.title}"\n\nI've generated the component with the requested features. You can view it in Storybook where you'll see both the rendered component and its markup in the Docs tab.`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const aiMsg = { role: 'ai' as const, content: responseMessage };
|
|
510
|
+
const updatedConversation = [...newConversation, aiMsg];
|
|
511
|
+
setConversation(updatedConversation);
|
|
512
|
+
|
|
513
|
+
// Determine if this is an update or new chat
|
|
514
|
+
// Check if we have an active chat AND the backend indicates this is an update
|
|
515
|
+
const isUpdate = activeChatId && conversation.length > 0 && (
|
|
516
|
+
data.isUpdate ||
|
|
517
|
+
data.fileName === activeChatId ||
|
|
518
|
+
// Also check if fileName matches any existing chat's fileName
|
|
519
|
+
recentChats.some(chat => chat.fileName === data.fileName && chat.id === activeChatId)
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
console.log('Update detection:', {
|
|
523
|
+
activeChatId,
|
|
524
|
+
conversationLength: conversation.length,
|
|
525
|
+
dataIsUpdate: data.isUpdate,
|
|
526
|
+
dataFileName: data.fileName,
|
|
527
|
+
isUpdate
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
if (isUpdate) {
|
|
531
|
+
// Update existing chat session
|
|
532
|
+
const chatTitle = activeTitle; // Keep existing title for updates
|
|
533
|
+
const updatedSession: ChatSession = {
|
|
534
|
+
id: activeChatId,
|
|
535
|
+
title: chatTitle,
|
|
536
|
+
fileName: data.fileName || activeChatId,
|
|
537
|
+
conversation: updatedConversation,
|
|
538
|
+
lastUpdated: Date.now(),
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const chats = loadChats();
|
|
542
|
+
const chatIndex = chats.findIndex(c => c.id === activeChatId);
|
|
543
|
+
if (chatIndex !== -1) {
|
|
544
|
+
chats[chatIndex] = updatedSession;
|
|
545
|
+
}
|
|
546
|
+
saveChats(chats);
|
|
547
|
+
setRecentChats(chats);
|
|
548
|
+
console.log('Updated existing chat:', activeChatId);
|
|
549
|
+
} else {
|
|
550
|
+
// Create new chat session - use storyId from backend for consistency
|
|
551
|
+
const chatId = data.storyId || data.fileName || data.outPath || Date.now().toString();
|
|
552
|
+
const chatTitle = data.title || input;
|
|
553
|
+
setActiveChatId(chatId);
|
|
554
|
+
setActiveTitle(chatTitle);
|
|
555
|
+
|
|
556
|
+
const newSession: ChatSession = {
|
|
557
|
+
id: chatId,
|
|
558
|
+
title: chatTitle,
|
|
559
|
+
fileName: data.fileName || '',
|
|
560
|
+
conversation: updatedConversation,
|
|
561
|
+
lastUpdated: Date.now(),
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const chats = loadChats().filter(c => c.id !== chatId);
|
|
565
|
+
chats.unshift(newSession);
|
|
566
|
+
if (chats.length > MAX_RECENT_CHATS) {
|
|
567
|
+
chats.splice(MAX_RECENT_CHATS);
|
|
568
|
+
}
|
|
569
|
+
saveChats(chats);
|
|
570
|
+
setRecentChats(chats);
|
|
571
|
+
console.log('Created new chat:', chatId);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
} catch (err: unknown) {
|
|
575
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
576
|
+
setError(errorMessage);
|
|
577
|
+
const errorConversation = [...newConversation, { role: 'ai' as const, content: `Error: ${errorMessage}` }];
|
|
578
|
+
setConversation(errorConversation);
|
|
579
|
+
|
|
580
|
+
// IMPORTANT: Create/update chat session even on error so retries continue the same conversation
|
|
581
|
+
const isUpdate = activeChatId && conversation.length > 0;
|
|
582
|
+
|
|
583
|
+
if (isUpdate) {
|
|
584
|
+
// Update existing chat with error
|
|
585
|
+
const updatedSession: ChatSession = {
|
|
586
|
+
id: activeChatId,
|
|
587
|
+
title: activeTitle,
|
|
588
|
+
fileName: activeChatId,
|
|
589
|
+
conversation: errorConversation,
|
|
590
|
+
lastUpdated: Date.now(),
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const chats = loadChats();
|
|
594
|
+
const chatIndex = chats.findIndex(c => c.id === activeChatId);
|
|
595
|
+
if (chatIndex !== -1) {
|
|
596
|
+
chats[chatIndex] = updatedSession;
|
|
597
|
+
}
|
|
598
|
+
saveChats(chats);
|
|
599
|
+
setRecentChats(chats);
|
|
600
|
+
} else {
|
|
601
|
+
// Create new chat session for error (so retries can continue it)
|
|
602
|
+
const chatId = `error-${Date.now()}`;
|
|
603
|
+
const chatTitle = input.length > 30 ? input.substring(0, 30) + '...' : input;
|
|
604
|
+
setActiveChatId(chatId);
|
|
605
|
+
setActiveTitle(chatTitle);
|
|
606
|
+
|
|
607
|
+
const newSession: ChatSession = {
|
|
608
|
+
id: chatId,
|
|
609
|
+
title: chatTitle,
|
|
610
|
+
fileName: '',
|
|
611
|
+
conversation: errorConversation,
|
|
612
|
+
lastUpdated: Date.now(),
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const chats = loadChats();
|
|
616
|
+
chats.unshift(newSession);
|
|
617
|
+
if (chats.length > MAX_RECENT_CHATS) {
|
|
618
|
+
chats.splice(MAX_RECENT_CHATS);
|
|
619
|
+
}
|
|
620
|
+
saveChats(chats);
|
|
621
|
+
setRecentChats(chats);
|
|
622
|
+
}
|
|
623
|
+
} finally {
|
|
624
|
+
setLoading(false);
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
const handleSelectChat = (chat: ChatSession) => {
|
|
629
|
+
setConversation(chat.conversation);
|
|
630
|
+
setActiveChatId(chat.id);
|
|
631
|
+
setActiveTitle(chat.title);
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const handleNewChat = () => {
|
|
635
|
+
setConversation([]);
|
|
636
|
+
setActiveChatId(null);
|
|
637
|
+
setActiveTitle('');
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const handleDeleteChat = async (chatId: string, e: React.MouseEvent) => {
|
|
641
|
+
e.stopPropagation(); // Prevent selecting the chat
|
|
642
|
+
|
|
643
|
+
if (confirm('Delete this story and chat? This action cannot be undone.')) {
|
|
644
|
+
const success = await deleteStoryAndChat(chatId);
|
|
645
|
+
|
|
646
|
+
if (success) {
|
|
647
|
+
// Update local state
|
|
648
|
+
const updatedChats = recentChats.filter(chat => chat.id !== chatId);
|
|
649
|
+
setRecentChats(updatedChats);
|
|
650
|
+
|
|
651
|
+
// If we deleted the active chat, switch to another or clear
|
|
652
|
+
if (activeChatId === chatId) {
|
|
653
|
+
if (updatedChats.length > 0) {
|
|
654
|
+
setConversation(updatedChats[0].conversation);
|
|
655
|
+
setActiveChatId(updatedChats[0].id);
|
|
656
|
+
setActiveTitle(updatedChats[0].title);
|
|
657
|
+
} else {
|
|
658
|
+
handleNewChat();
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
} else {
|
|
662
|
+
alert('Failed to delete story. Please try again.');
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
return (
|
|
668
|
+
<div style={STYLES.container}>
|
|
669
|
+
{/* Sidebar */}
|
|
670
|
+
<div style={{
|
|
671
|
+
...STYLES.sidebar,
|
|
672
|
+
...(sidebarOpen ? {} : STYLES.sidebarCollapsed),
|
|
673
|
+
}}>
|
|
674
|
+
<button
|
|
675
|
+
onClick={() => setSidebarOpen(o => !o)}
|
|
676
|
+
style={{
|
|
677
|
+
...STYLES.sidebarToggle,
|
|
678
|
+
...(sidebarOpen ? {} : { width: '40px', height: '40px', padding: '0' }),
|
|
679
|
+
}}
|
|
680
|
+
title={sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}
|
|
681
|
+
onMouseEnter={(e) => {
|
|
682
|
+
e.currentTarget.style.transform = 'scale(1.05)';
|
|
683
|
+
e.currentTarget.style.boxShadow = '0 4px 16px rgba(59, 130, 246, 0.4)';
|
|
684
|
+
}}
|
|
685
|
+
onMouseLeave={(e) => {
|
|
686
|
+
e.currentTarget.style.transform = 'scale(1)';
|
|
687
|
+
e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.3)';
|
|
688
|
+
}}
|
|
689
|
+
>
|
|
690
|
+
{sidebarOpen ? '☰ Chats' : '☰'}
|
|
691
|
+
</button>
|
|
692
|
+
{sidebarOpen && (
|
|
693
|
+
<div style={{ flex: 1, overflowY: 'auto', padding: '0 16px 16px 16px' }}>
|
|
694
|
+
<button
|
|
695
|
+
onClick={handleNewChat}
|
|
696
|
+
style={STYLES.newChatButton}
|
|
697
|
+
onMouseEnter={(e) => {
|
|
698
|
+
e.currentTarget.style.transform = 'translateY(-1px)';
|
|
699
|
+
e.currentTarget.style.boxShadow = '0 4px 16px rgba(59, 130, 246, 0.4)';
|
|
700
|
+
}}
|
|
701
|
+
onMouseLeave={(e) => {
|
|
702
|
+
e.currentTarget.style.transform = 'translateY(0)';
|
|
703
|
+
e.currentTarget.style.boxShadow = '0 2px 8px rgba(59, 130, 246, 0.2)';
|
|
704
|
+
}}
|
|
705
|
+
>
|
|
706
|
+
+ New Chat
|
|
707
|
+
</button>
|
|
708
|
+
{recentChats.length > 0 && (
|
|
709
|
+
<div style={{
|
|
710
|
+
color: '#64748b',
|
|
711
|
+
fontSize: '12px',
|
|
712
|
+
marginBottom: '8px',
|
|
713
|
+
fontWeight: '500',
|
|
714
|
+
textTransform: 'uppercase',
|
|
715
|
+
letterSpacing: '0.05em',
|
|
716
|
+
fontFamily: STYLES.fontFamily,
|
|
717
|
+
}}>
|
|
718
|
+
Recent
|
|
719
|
+
</div>
|
|
720
|
+
)}
|
|
721
|
+
{recentChats.map(chat => (
|
|
722
|
+
<div
|
|
723
|
+
key={chat.id}
|
|
724
|
+
style={{
|
|
725
|
+
position: 'relative',
|
|
726
|
+
marginBottom: '4px',
|
|
727
|
+
}}
|
|
728
|
+
>
|
|
729
|
+
<button
|
|
730
|
+
onClick={() => handleSelectChat(chat)}
|
|
731
|
+
style={{
|
|
732
|
+
...STYLES.chatItem,
|
|
733
|
+
...(chat.id === activeChatId ? STYLES.chatItemActive : {}),
|
|
734
|
+
}}
|
|
735
|
+
title={chat.title}
|
|
736
|
+
onMouseEnter={(e) => {
|
|
737
|
+
if (chat.id !== activeChatId) {
|
|
738
|
+
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
|
739
|
+
}
|
|
740
|
+
}}
|
|
741
|
+
onMouseLeave={(e) => {
|
|
742
|
+
if (chat.id !== activeChatId) {
|
|
743
|
+
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)';
|
|
744
|
+
}
|
|
745
|
+
}}
|
|
746
|
+
>
|
|
747
|
+
{chat.title}
|
|
748
|
+
</button>
|
|
749
|
+
<button
|
|
750
|
+
onClick={(e) => handleDeleteChat(chat.id, e)}
|
|
751
|
+
style={STYLES.deleteButton}
|
|
752
|
+
title="Delete story and chat"
|
|
753
|
+
onMouseEnter={(e) => {
|
|
754
|
+
e.currentTarget.style.background = '#ef4444';
|
|
755
|
+
e.currentTarget.style.transform = 'translateY(-50%) scale(1.1)';
|
|
756
|
+
}}
|
|
757
|
+
onMouseLeave={(e) => {
|
|
758
|
+
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.8)';
|
|
759
|
+
e.currentTarget.style.transform = 'translateY(-50%) scale(1)';
|
|
760
|
+
}}
|
|
761
|
+
>
|
|
762
|
+
×
|
|
763
|
+
</button>
|
|
764
|
+
</div>
|
|
765
|
+
))}
|
|
766
|
+
</div>
|
|
767
|
+
)}
|
|
768
|
+
</div>
|
|
769
|
+
|
|
770
|
+
{/* Main chat area */}
|
|
771
|
+
<div style={STYLES.mainArea}>
|
|
772
|
+
<h2 style={STYLES.header}>Story UI: AI Story Generator</h2>
|
|
773
|
+
|
|
774
|
+
<div style={STYLES.chatContainer}>
|
|
775
|
+
{conversation.length === 0 && (
|
|
776
|
+
<div style={STYLES.emptyState}>
|
|
777
|
+
<div style={STYLES.emptyStateTitle}>Start a conversation to generate a Storybook story</div>
|
|
778
|
+
<div style={STYLES.emptyStateSubtitle}>
|
|
779
|
+
(e.g. "Build a login form with two fields and a button")
|
|
780
|
+
</div>
|
|
781
|
+
</div>
|
|
782
|
+
)}
|
|
783
|
+
|
|
784
|
+
{conversation.map((msg, i) => (
|
|
785
|
+
<div key={i} style={{
|
|
786
|
+
...STYLES.messageContainer,
|
|
787
|
+
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
|
788
|
+
}}>
|
|
789
|
+
<div style={msg.role === 'user' ? STYLES.userMessage : STYLES.aiMessage}>
|
|
790
|
+
{msg.content}
|
|
791
|
+
</div>
|
|
792
|
+
</div>
|
|
793
|
+
))}
|
|
794
|
+
|
|
795
|
+
{loading && (
|
|
796
|
+
<div style={{ ...STYLES.messageContainer, justifyContent: 'flex-start' }}>
|
|
797
|
+
<div style={STYLES.loadingMessage}>
|
|
798
|
+
<div style={{
|
|
799
|
+
width: '6px',
|
|
800
|
+
height: '6px',
|
|
801
|
+
backgroundColor: '#6b7280',
|
|
802
|
+
borderRadius: '50%',
|
|
803
|
+
animation: 'pulse 1.5s ease-in-out infinite',
|
|
804
|
+
}}></div>
|
|
805
|
+
Generating...
|
|
806
|
+
</div>
|
|
807
|
+
</div>
|
|
808
|
+
)}
|
|
809
|
+
|
|
810
|
+
<div ref={chatEndRef} />
|
|
811
|
+
</div>
|
|
812
|
+
|
|
813
|
+
<form onSubmit={handleSend} style={STYLES.inputForm}>
|
|
814
|
+
<input
|
|
815
|
+
type="text"
|
|
816
|
+
value={input}
|
|
817
|
+
onChange={e => setInput(e.target.value)}
|
|
818
|
+
placeholder="Describe your UI or give feedback..."
|
|
819
|
+
style={STYLES.textInput}
|
|
820
|
+
disabled={loading}
|
|
821
|
+
onFocus={(e) => {
|
|
822
|
+
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)';
|
|
823
|
+
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(59, 130, 246, 0.1)';
|
|
824
|
+
}}
|
|
825
|
+
onBlur={(e) => {
|
|
826
|
+
e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.2)';
|
|
827
|
+
e.currentTarget.style.boxShadow = 'none';
|
|
828
|
+
}}
|
|
829
|
+
/>
|
|
830
|
+
<button
|
|
831
|
+
type="submit"
|
|
832
|
+
disabled={loading || !input.trim()}
|
|
833
|
+
style={{
|
|
834
|
+
...STYLES.sendButton,
|
|
835
|
+
...(loading || !input.trim() ? STYLES.sendButtonDisabled : {}),
|
|
836
|
+
}}
|
|
837
|
+
onMouseEnter={(e) => {
|
|
838
|
+
if (!loading && input.trim()) {
|
|
839
|
+
e.currentTarget.style.transform = 'translateY(-1px)';
|
|
840
|
+
e.currentTarget.style.boxShadow = '0 4px 16px rgba(16, 185, 129, 0.4)';
|
|
841
|
+
}
|
|
842
|
+
}}
|
|
843
|
+
onMouseLeave={(e) => {
|
|
844
|
+
if (!loading && input.trim()) {
|
|
845
|
+
e.currentTarget.style.transform = 'translateY(0)';
|
|
846
|
+
e.currentTarget.style.boxShadow = '0 2px 8px rgba(16, 185, 129, 0.3)';
|
|
847
|
+
}
|
|
848
|
+
}}
|
|
849
|
+
>
|
|
850
|
+
{loading ? '...' : 'Send'}
|
|
851
|
+
</button>
|
|
852
|
+
</form>
|
|
853
|
+
|
|
854
|
+
{error && <div style={STYLES.errorMessage}>{error}</div>}
|
|
855
|
+
</div>
|
|
856
|
+
|
|
857
|
+
{/* Add keyframes animation for loading pulse */}
|
|
858
|
+
<style>
|
|
859
|
+
{`
|
|
860
|
+
@keyframes pulse {
|
|
861
|
+
0%, 100% { opacity: 0.4; }
|
|
862
|
+
50% { opacity: 1; }
|
|
863
|
+
}
|
|
864
|
+
`}
|
|
865
|
+
</style>
|
|
866
|
+
</div>
|
|
867
|
+
);
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
export default StoryUIPanel;
|