@tpitre/story-ui 3.7.0 → 3.9.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.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Runtime Validator for Story UI
3
+ *
4
+ * Validates that generated stories actually load and render in Storybook
5
+ * by making HTTP requests to the running Storybook instance after HMR processes
6
+ * the new story file.
7
+ *
8
+ * This catches runtime errors that static validation cannot detect, such as:
9
+ * - "importers[path] is not a function" - Storybook CSF loader errors
10
+ * - Module resolution failures
11
+ * - Runtime component errors
12
+ */
13
+ export interface RuntimeValidationResult {
14
+ success: boolean;
15
+ storyExists: boolean;
16
+ renderError?: string;
17
+ errorType?: 'module_error' | 'render_error' | 'not_found' | 'timeout' | 'connection_error';
18
+ details?: string;
19
+ }
20
+ export interface RuntimeValidatorConfig {
21
+ storybookUrl: string;
22
+ hmrWaitMs?: number;
23
+ fetchTimeoutMs?: number;
24
+ retryAttempts?: number;
25
+ retryDelayMs?: number;
26
+ }
27
+ /**
28
+ * Get the Storybook URL based on environment configuration
29
+ */
30
+ export declare function getStorybookUrl(): string | null;
31
+ /**
32
+ * Check if runtime validation is enabled
33
+ */
34
+ export declare function isRuntimeValidationEnabled(): boolean;
35
+ /**
36
+ * Convert a story title to the Storybook story ID prefix format
37
+ * e.g., "Simple Card" with prefix "Generated/" -> "generated-simple-card"
38
+ * Note: This returns the prefix only, without the story export name
39
+ */
40
+ export declare function titleToStoryIdPrefix(title: string, storyPrefix?: string): string;
41
+ /**
42
+ * Extract the actual title from generated story content
43
+ * Looks for: title: 'Generated/Something' or title: "Generated/Something"
44
+ */
45
+ export declare function extractTitleFromStory(storyContent: string): string | null;
46
+ /**
47
+ * Convert a full story title (like "Generated/Button Click Counter") to story ID prefix
48
+ */
49
+ export declare function fullTitleToStoryIdPrefix(fullTitle: string): string;
50
+ /**
51
+ * Validate that a story loads and renders correctly in Storybook
52
+ *
53
+ * @param storyContent - The generated story content (used to extract the actual title)
54
+ * @param fallbackTitle - Fallback title if extraction fails (e.g., "Simple Card")
55
+ * @param storyPrefix - The story prefix from config (e.g., "Generated/")
56
+ * @param customConfig - Runtime validator configuration
57
+ * @returns Validation result with success status and any errors
58
+ */
59
+ export declare function validateStoryRuntime(storyContent: string, fallbackTitle: string, storyPrefix?: string, customConfig?: Partial<RuntimeValidatorConfig>): Promise<RuntimeValidationResult>;
60
+ /**
61
+ * Format runtime validation errors for the self-healing prompt
62
+ */
63
+ export declare function formatRuntimeErrorForHealing(result: RuntimeValidationResult): string;
64
+ //# sourceMappingURL=runtimeValidator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtimeValidator.d.ts","sourceRoot":"","sources":["../../story-generator/runtimeValidator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,OAAO,CAAC;IACjB,WAAW,EAAE,OAAO,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,cAAc,GAAG,cAAc,GAAG,WAAW,GAAG,SAAS,GAAG,kBAAkB,CAAC;IAC3F,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,sBAAsB;IACrC,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAeD;;GAEG;AACH,wBAAgB,eAAe,IAAI,MAAM,GAAG,IAAI,CAmB/C;AAED;;GAEG;AACH,wBAAgB,0BAA0B,IAAI,OAAO,CAcpD;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,GAAE,MAAqB,GAAG,MAAM,CAQ9F;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAOzE;AAED;;GAEG;AACH,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAKlE;AAiJD;;;;;;;;GAQG;AACH,wBAAsB,oBAAoB,CACxC,YAAY,EAAE,MAAM,EACpB,aAAa,EAAE,MAAM,EACrB,WAAW,GAAE,MAAqB,EAClC,YAAY,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,GAC7C,OAAO,CAAC,uBAAuB,CAAC,CA6FlC;AAED;;GAEG;AACH,wBAAgB,4BAA4B,CAAC,MAAM,EAAE,uBAAuB,GAAG,MAAM,CAoDpF"}
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Runtime Validator for Story UI
3
+ *
4
+ * Validates that generated stories actually load and render in Storybook
5
+ * by making HTTP requests to the running Storybook instance after HMR processes
6
+ * the new story file.
7
+ *
8
+ * This catches runtime errors that static validation cannot detect, such as:
9
+ * - "importers[path] is not a function" - Storybook CSF loader errors
10
+ * - Module resolution failures
11
+ * - Runtime component errors
12
+ */
13
+ import { logger } from './logger.js';
14
+ // Known Storybook runtime error patterns
15
+ const RUNTIME_ERROR_PATTERNS = [
16
+ { pattern: /importers\[.*?\] is not a function/i, type: 'module_error', description: 'CSF module loader error' },
17
+ { pattern: /Cannot read propert.*of undefined/i, type: 'render_error', description: 'Component render error' },
18
+ { pattern: /is not defined/i, type: 'render_error', description: 'Undefined variable error' },
19
+ { pattern: /Module not found/i, type: 'module_error', description: 'Module resolution error' },
20
+ { pattern: /Failed to resolve import/i, type: 'module_error', description: 'Import resolution error' },
21
+ { pattern: /SyntaxError/i, type: 'module_error', description: 'Runtime syntax error' },
22
+ { pattern: /Unexpected token/i, type: 'module_error', description: 'Parse error' },
23
+ { pattern: /ReferenceError/i, type: 'render_error', description: 'Reference error' },
24
+ { pattern: /TypeError/i, type: 'render_error', description: 'Type error' },
25
+ ];
26
+ /**
27
+ * Get the Storybook URL based on environment configuration
28
+ */
29
+ export function getStorybookUrl() {
30
+ // Priority 1: Explicit storybookUrl in environment
31
+ if (process.env.STORYBOOK_URL) {
32
+ return process.env.STORYBOOK_URL;
33
+ }
34
+ // Priority 2: Proxy mode - use internal Storybook port
35
+ if (process.env.STORYBOOK_PROXY_ENABLED === 'true') {
36
+ const proxyPort = process.env.STORYBOOK_PROXY_PORT || '6006';
37
+ return `http://localhost:${proxyPort}`;
38
+ }
39
+ // Priority 3: Explicit Storybook port
40
+ if (process.env.STORYBOOK_PORT) {
41
+ return `http://localhost:${process.env.STORYBOOK_PORT}`;
42
+ }
43
+ // Priority 4: Default local Storybook
44
+ return 'http://localhost:6006';
45
+ }
46
+ /**
47
+ * Check if runtime validation is enabled
48
+ */
49
+ export function isRuntimeValidationEnabled() {
50
+ // Enabled by default if we can determine a Storybook URL
51
+ // Can be explicitly disabled with STORYBOOK_RUNTIME_VALIDATION=false
52
+ if (process.env.STORYBOOK_RUNTIME_VALIDATION === 'false') {
53
+ return false;
54
+ }
55
+ // In proxy mode, always enable since we know Storybook is accessible
56
+ if (process.env.STORYBOOK_PROXY_ENABLED === 'true') {
57
+ return true;
58
+ }
59
+ // Otherwise, enable if explicitly set to true
60
+ return process.env.STORYBOOK_RUNTIME_VALIDATION === 'true';
61
+ }
62
+ /**
63
+ * Convert a story title to the Storybook story ID prefix format
64
+ * e.g., "Simple Card" with prefix "Generated/" -> "generated-simple-card"
65
+ * Note: This returns the prefix only, without the story export name
66
+ */
67
+ export function titleToStoryIdPrefix(title, storyPrefix = 'Generated/') {
68
+ // Remove prefix and convert to kebab case
69
+ const fullTitle = storyPrefix + title;
70
+ const kebabTitle = fullTitle
71
+ .toLowerCase()
72
+ .replace(/[^a-z0-9]+/g, '-')
73
+ .replace(/^-|-$/g, '');
74
+ return kebabTitle;
75
+ }
76
+ /**
77
+ * Extract the actual title from generated story content
78
+ * Looks for: title: 'Generated/Something' or title: "Generated/Something"
79
+ */
80
+ export function extractTitleFromStory(storyContent) {
81
+ const titleMatch = storyContent.match(/title:\s*['"]([^'"]+)['"]/);
82
+ if (titleMatch) {
83
+ // Return the full title (including prefix like "Generated/")
84
+ return titleMatch[1];
85
+ }
86
+ return null;
87
+ }
88
+ /**
89
+ * Convert a full story title (like "Generated/Button Click Counter") to story ID prefix
90
+ */
91
+ export function fullTitleToStoryIdPrefix(fullTitle) {
92
+ return fullTitle
93
+ .toLowerCase()
94
+ .replace(/[^a-z0-9]+/g, '-')
95
+ .replace(/^-|-$/g, '');
96
+ }
97
+ /**
98
+ * Sleep utility
99
+ */
100
+ function sleep(ms) {
101
+ return new Promise(resolve => setTimeout(resolve, ms));
102
+ }
103
+ /**
104
+ * Fetch with timeout
105
+ */
106
+ async function fetchWithTimeout(url, timeoutMs) {
107
+ const controller = new AbortController();
108
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
109
+ try {
110
+ const response = await fetch(url, { signal: controller.signal });
111
+ clearTimeout(timeoutId);
112
+ return response;
113
+ }
114
+ catch (error) {
115
+ clearTimeout(timeoutId);
116
+ if (error.name === 'AbortError') {
117
+ throw new Error(`Request timed out after ${timeoutMs}ms`);
118
+ }
119
+ throw error;
120
+ }
121
+ }
122
+ /**
123
+ * Check if stories exist in Storybook's index that match the given title prefix
124
+ * Returns the first matching story ID for iframe validation
125
+ */
126
+ async function checkStoryInIndex(storyIdPrefix, storybookUrl, config) {
127
+ const indexUrl = `${storybookUrl}/index.json`;
128
+ const timeout = config.fetchTimeoutMs || 5000;
129
+ try {
130
+ const response = await fetchWithTimeout(indexUrl, timeout);
131
+ if (!response.ok) {
132
+ return { exists: false, error: `Index returned ${response.status}` };
133
+ }
134
+ const index = await response.json();
135
+ // Storybook 7+ uses 'entries', older versions use 'stories'
136
+ const stories = index.entries || index.stories || {};
137
+ // Find story IDs that match our prefix (not docs entries)
138
+ const matchingIds = Object.keys(stories).filter(id => {
139
+ // Skip docs entries - we want actual story entries
140
+ if (id.endsWith('--docs'))
141
+ return false;
142
+ // Check if the ID starts with our prefix
143
+ return id.startsWith(storyIdPrefix + '--');
144
+ });
145
+ if (matchingIds.length > 0) {
146
+ return { exists: true, matchingStoryId: matchingIds[0] };
147
+ }
148
+ return { exists: false };
149
+ }
150
+ catch (error) {
151
+ return { exists: false, error: error.message };
152
+ }
153
+ }
154
+ /**
155
+ * Check the story iframe for runtime errors
156
+ */
157
+ async function checkStoryIframe(storyId, storybookUrl, config) {
158
+ const iframeUrl = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story`;
159
+ const timeout = config.fetchTimeoutMs || 5000;
160
+ try {
161
+ const response = await fetchWithTimeout(iframeUrl, timeout);
162
+ if (!response.ok) {
163
+ return {
164
+ success: false,
165
+ error: `Story iframe returned ${response.status}`,
166
+ errorType: 'not_found'
167
+ };
168
+ }
169
+ const html = await response.text();
170
+ // Check for known error patterns in the HTML response
171
+ for (const { pattern, type, description } of RUNTIME_ERROR_PATTERNS) {
172
+ if (pattern.test(html)) {
173
+ // Extract the actual error message if possible
174
+ const match = html.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i) ||
175
+ html.match(/Error:?\s*([^\n<]+)/i);
176
+ const errorDetail = match ? match[1].trim().substring(0, 200) : description;
177
+ return {
178
+ success: false,
179
+ error: errorDetail,
180
+ errorType: type
181
+ };
182
+ }
183
+ }
184
+ // Check for Storybook error boundary markers
185
+ // Note: We need to check for VISIBLE errors, not just the error display template
186
+ // The 'sb-show-errordisplay' class is added to body when an error is actually shown
187
+ // IMPORTANT: Must use regex to check for class attribute, not just includes()
188
+ // because ':not(.sb-show-errordisplay)' exists in CSS selectors
189
+ const hasVisibleError = /class="[^"]*sb-show-errordisplay[^"]*"/i.test(html);
190
+ // Check for actual error content in the error display elements (non-empty)
191
+ const hasErrorContent = /<h1[^>]*id="error-message"[^>]*>[^<]+<\/h1>/i.test(html) ||
192
+ /<code[^>]*id="error-stack"[^>]*>[^<]+<\/code>/i.test(html);
193
+ // Check for specific error text (not in CSS context)
194
+ const hasDocsError = />\s*DocsRenderer error/i.test(html);
195
+ const hasStoryError = /class="[^"]*story-error[^"]*"/i.test(html);
196
+ if (hasVisibleError || hasErrorContent || hasDocsError || hasStoryError) {
197
+ // Try to extract the actual error message
198
+ const errorMsgMatch = html.match(/<h1[^>]*id="error-message"[^>]*>([^<]+)<\/h1>/i);
199
+ const errorDetail = errorMsgMatch ? errorMsgMatch[1].trim() : 'Storybook error boundary triggered';
200
+ return {
201
+ success: false,
202
+ error: errorDetail,
203
+ errorType: 'render_error'
204
+ };
205
+ }
206
+ return { success: true };
207
+ }
208
+ catch (error) {
209
+ if (error.message.includes('timed out')) {
210
+ return { success: false, error: error.message, errorType: 'timeout' };
211
+ }
212
+ return { success: false, error: error.message, errorType: 'connection_error' };
213
+ }
214
+ }
215
+ /**
216
+ * Validate that a story loads and renders correctly in Storybook
217
+ *
218
+ * @param storyContent - The generated story content (used to extract the actual title)
219
+ * @param fallbackTitle - Fallback title if extraction fails (e.g., "Simple Card")
220
+ * @param storyPrefix - The story prefix from config (e.g., "Generated/")
221
+ * @param customConfig - Runtime validator configuration
222
+ * @returns Validation result with success status and any errors
223
+ */
224
+ export async function validateStoryRuntime(storyContent, fallbackTitle, storyPrefix = 'Generated/', customConfig) {
225
+ // Check if runtime validation is enabled
226
+ if (!isRuntimeValidationEnabled()) {
227
+ logger.debug('Runtime validation disabled, skipping');
228
+ return { success: true, storyExists: true };
229
+ }
230
+ const storybookUrl = getStorybookUrl();
231
+ if (!storybookUrl) {
232
+ logger.warn('Could not determine Storybook URL for runtime validation');
233
+ return { success: true, storyExists: true, details: 'Storybook URL not configured' };
234
+ }
235
+ const config = {
236
+ storybookUrl,
237
+ hmrWaitMs: 3000,
238
+ fetchTimeoutMs: 5000,
239
+ retryAttempts: 3,
240
+ retryDelayMs: 1000,
241
+ ...customConfig
242
+ };
243
+ // Extract the actual title from the story content, or use fallback
244
+ const extractedTitle = extractTitleFromStory(storyContent);
245
+ let storyIdPrefix;
246
+ if (extractedTitle) {
247
+ // Use the exact title from the generated code
248
+ storyIdPrefix = fullTitleToStoryIdPrefix(extractedTitle);
249
+ logger.debug(`Extracted title from story: "${extractedTitle}" -> prefix: "${storyIdPrefix}"`);
250
+ }
251
+ else {
252
+ // Fall back to constructing from the provided title
253
+ storyIdPrefix = titleToStoryIdPrefix(fallbackTitle, storyPrefix);
254
+ logger.debug(`Using fallback title: "${fallbackTitle}" -> prefix: "${storyIdPrefix}"`);
255
+ }
256
+ logger.info(`Runtime validation: checking stories with prefix "${storyIdPrefix}" at ${storybookUrl}`);
257
+ // Wait for HMR to process the new file
258
+ logger.debug(`Waiting ${config.hmrWaitMs}ms for HMR to process...`);
259
+ await sleep(config.hmrWaitMs);
260
+ // Step 1: Check if story appears in the index (with retries for HMR timing)
261
+ let matchingStoryId;
262
+ let lastIndexError;
263
+ for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
264
+ const indexResult = await checkStoryInIndex(storyIdPrefix, storybookUrl, config);
265
+ if (indexResult.exists && indexResult.matchingStoryId) {
266
+ matchingStoryId = indexResult.matchingStoryId;
267
+ logger.debug(`Found matching story: "${matchingStoryId}"`);
268
+ break;
269
+ }
270
+ lastIndexError = indexResult.error;
271
+ if (attempt < config.retryAttempts) {
272
+ logger.debug(`Story not found in index (attempt ${attempt}/${config.retryAttempts}), waiting...`);
273
+ await sleep(config.retryDelayMs);
274
+ }
275
+ }
276
+ if (!matchingStoryId) {
277
+ logger.warn(`Stories with prefix "${storyIdPrefix}" not found in Storybook index after ${config.retryAttempts} attempts`);
278
+ return {
279
+ success: false,
280
+ storyExists: false,
281
+ errorType: 'not_found',
282
+ renderError: lastIndexError || 'Story not found in Storybook index - HMR may not have processed the file',
283
+ details: `Story ID prefix: ${storyIdPrefix}`
284
+ };
285
+ }
286
+ // Step 2: Load the story iframe and check for runtime errors
287
+ const iframeResult = await checkStoryIframe(matchingStoryId, storybookUrl, config);
288
+ if (!iframeResult.success) {
289
+ logger.error(`Runtime error detected in story "${matchingStoryId}": ${iframeResult.error}`);
290
+ return {
291
+ success: false,
292
+ storyExists: true,
293
+ renderError: iframeResult.error,
294
+ errorType: iframeResult.errorType,
295
+ details: `Story ID: ${matchingStoryId}, URL: ${storybookUrl}/iframe.html?id=${matchingStoryId}`
296
+ };
297
+ }
298
+ logger.info(`Runtime validation passed for story "${matchingStoryId}"`);
299
+ return {
300
+ success: true,
301
+ storyExists: true
302
+ };
303
+ }
304
+ /**
305
+ * Format runtime validation errors for the self-healing prompt
306
+ */
307
+ export function formatRuntimeErrorForHealing(result) {
308
+ if (result.success)
309
+ return '';
310
+ const parts = [];
311
+ parts.push(`RUNTIME ERROR: The generated story failed to load in Storybook.`);
312
+ if (result.renderError) {
313
+ parts.push(`Error: ${result.renderError}`);
314
+ }
315
+ if (result.errorType === 'module_error') {
316
+ parts.push(`This is a module/import error. Common causes:`);
317
+ parts.push(`- Invalid CSF (Component Story Format) structure`);
318
+ parts.push(`- Missing or malformed default export (meta)`);
319
+ parts.push(`- Story exports that conflict with Storybook internals`);
320
+ parts.push(`- Invalid import statements`);
321
+ parts.push(`\nEnsure the story follows this exact structure:`);
322
+ parts.push(`\`\`\`tsx`);
323
+ parts.push(`import type { Meta, StoryObj } from '@storybook/react';`);
324
+ parts.push(`import { Component } from '@design-system/core';`);
325
+ parts.push(``);
326
+ parts.push(`const meta: Meta<typeof Component> = {`);
327
+ parts.push(` title: 'Generated/Story Title',`);
328
+ parts.push(` component: Component,`);
329
+ parts.push(`};`);
330
+ parts.push(``);
331
+ parts.push(`export default meta;`);
332
+ parts.push(`type Story = StoryObj<typeof meta>;`);
333
+ parts.push(``);
334
+ parts.push(`export const Default: Story = {`);
335
+ parts.push(` render: () => <Component />,`);
336
+ parts.push(`};`);
337
+ parts.push(`\`\`\``);
338
+ }
339
+ else if (result.errorType === 'render_error') {
340
+ parts.push(`This is a component render error. Common causes:`);
341
+ parts.push(`- Using undefined variables or components`);
342
+ parts.push(`- Invalid props passed to components`);
343
+ parts.push(`- Missing required props`);
344
+ parts.push(`- Incorrect component composition`);
345
+ }
346
+ else if (result.errorType === 'not_found') {
347
+ parts.push(`The story was not found in Storybook's index. This usually means:`);
348
+ parts.push(`- The file has syntax errors that prevent Storybook from parsing it`);
349
+ parts.push(`- The story title/path doesn't match expected format`);
350
+ parts.push(`- The default export is missing or invalid`);
351
+ }
352
+ if (result.details) {
353
+ parts.push(`\nDetails: ${result.details}`);
354
+ }
355
+ return parts.join('\n');
356
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"StoryUIPanel.d.ts","sourceRoot":"","sources":["../../../templates/StoryUI/StoryUIPanel.tsx"],"names":[],"mappings":"AAymDA,iBAAS,YAAY,4CAuyCpB;AAED,eAAe,YAAY,CAAC;AAC5B,OAAO,EAAE,YAAY,EAAE,CAAC"}
1
+ {"version":3,"file":"StoryUIPanel.d.ts","sourceRoot":"","sources":["../../../templates/StoryUI/StoryUIPanel.tsx"],"names":[],"mappings":"AAymDA,iBAAS,YAAY,4CAygDpB;AAED,eAAe,YAAY,CAAC;AAC5B,OAAO,EAAE,YAAY,EAAE,CAAC"}
@@ -1241,6 +1241,8 @@ function StoryUIPanel() {
1241
1241
  const [attachedImages, setAttachedImages] = useState([]);
1242
1242
  const [considerations, setConsiderations] = useState('');
1243
1243
  const [orphanStories, setOrphanStories] = useState([]);
1244
+ const [selectedStoryIds, setSelectedStoryIds] = useState(new Set());
1245
+ const [isBulkDeleting, setIsBulkDeleting] = useState(false);
1244
1246
  const chatEndRef = useRef(null);
1245
1247
  const inputRef = useRef(null);
1246
1248
  const fileInputRef = useRef(null);
@@ -1961,6 +1963,92 @@ function StoryUIPanel() {
1961
1963
  }
1962
1964
  }
1963
1965
  };
1966
+ // Toggle story selection for bulk operations
1967
+ const toggleStorySelection = (storyId) => {
1968
+ setSelectedStoryIds(prev => {
1969
+ const newSet = new Set(prev);
1970
+ if (newSet.has(storyId)) {
1971
+ newSet.delete(storyId);
1972
+ }
1973
+ else {
1974
+ newSet.add(storyId);
1975
+ }
1976
+ return newSet;
1977
+ });
1978
+ };
1979
+ // Select/deselect all stories
1980
+ const toggleSelectAll = () => {
1981
+ if (selectedStoryIds.size === orphanStories.length) {
1982
+ setSelectedStoryIds(new Set());
1983
+ }
1984
+ else {
1985
+ setSelectedStoryIds(new Set(orphanStories.map(s => s.id)));
1986
+ }
1987
+ };
1988
+ // Bulk delete selected stories
1989
+ const handleBulkDelete = async () => {
1990
+ if (selectedStoryIds.size === 0)
1991
+ return;
1992
+ const count = selectedStoryIds.size;
1993
+ if (!confirm(`Delete ${count} selected ${count === 1 ? 'story' : 'stories'}? This action cannot be undone.`)) {
1994
+ return;
1995
+ }
1996
+ setIsBulkDeleting(true);
1997
+ try {
1998
+ const response = await fetch(`${STORIES_API}/delete-bulk`, {
1999
+ method: 'POST',
2000
+ headers: { 'Content-Type': 'application/json' },
2001
+ body: JSON.stringify({ ids: Array.from(selectedStoryIds) }),
2002
+ });
2003
+ if (response.ok) {
2004
+ const result = await response.json();
2005
+ // Remove deleted stories from state
2006
+ setOrphanStories(prev => prev.filter(s => !selectedStoryIds.has(s.id)));
2007
+ setSelectedStoryIds(new Set());
2008
+ console.log(`Deleted ${result.deleted?.length || count} stories`);
2009
+ }
2010
+ else {
2011
+ alert('Failed to delete some stories. Please try again.');
2012
+ }
2013
+ }
2014
+ catch (err) {
2015
+ console.error('Error bulk deleting stories:', err);
2016
+ alert('Failed to delete stories. Please try again.');
2017
+ }
2018
+ finally {
2019
+ setIsBulkDeleting(false);
2020
+ }
2021
+ };
2022
+ // Clear all generated stories
2023
+ const handleClearAll = async () => {
2024
+ if (orphanStories.length === 0)
2025
+ return;
2026
+ if (!confirm(`Delete ALL ${orphanStories.length} generated stories? This action cannot be undone.`)) {
2027
+ return;
2028
+ }
2029
+ setIsBulkDeleting(true);
2030
+ try {
2031
+ const response = await fetch(STORIES_API, {
2032
+ method: 'DELETE',
2033
+ });
2034
+ if (response.ok) {
2035
+ const result = await response.json();
2036
+ setOrphanStories([]);
2037
+ setSelectedStoryIds(new Set());
2038
+ console.log(`Cleared ${result.deleted || 'all'} stories`);
2039
+ }
2040
+ else {
2041
+ alert('Failed to clear stories. Please try again.');
2042
+ }
2043
+ }
2044
+ catch (err) {
2045
+ console.error('Error clearing stories:', err);
2046
+ alert('Failed to clear stories. Please try again.');
2047
+ }
2048
+ finally {
2049
+ setIsBulkDeleting(false);
2050
+ }
2051
+ };
1964
2052
  return (_jsxs("div", { className: "story-ui-panel", style: STYLES.container, children: [_jsxs("div", { style: {
1965
2053
  ...STYLES.sidebar,
1966
2054
  ...(sidebarOpen ? {} : STYLES.sidebarCollapsed),
@@ -2001,28 +2089,107 @@ function StoryUIPanel() {
2001
2089
  if (deleteBtn)
2002
2090
  deleteBtn.style.opacity = '0';
2003
2091
  }, children: [_jsx("div", { style: STYLES.chatItemTitle, children: chat.title }), _jsx("div", { style: STYLES.chatItemTime, children: formatTime(chat.lastUpdated) }), _jsx("button", { className: "delete-btn", onClick: (e) => handleDeleteChat(chat.id, e), style: STYLES.deleteButton, title: "Delete chat", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" }), _jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" })] }) })] }, chat.id))), orphanStories.length > 0 && (_jsxs(_Fragment, { children: [_jsx("div", { style: {
2004
- color: '#64748b',
2005
- fontSize: '12px',
2092
+ display: 'flex',
2093
+ alignItems: 'center',
2094
+ justifyContent: 'space-between',
2006
2095
  marginTop: '16px',
2007
2096
  marginBottom: '8px',
2008
- fontWeight: '500',
2009
- textTransform: 'uppercase',
2010
- letterSpacing: '0.05em',
2011
- }, children: "Generated Files" }), orphanStories.map(story => (_jsxs("div", { style: {
2097
+ }, children: _jsxs("div", { style: {
2098
+ display: 'flex',
2099
+ alignItems: 'center',
2100
+ gap: '8px',
2101
+ }, children: [_jsx("input", { type: "checkbox", checked: selectedStoryIds.size === orphanStories.length && orphanStories.length > 0, onChange: toggleSelectAll, style: {
2102
+ width: '14px',
2103
+ height: '14px',
2104
+ cursor: 'pointer',
2105
+ accentColor: '#3b82f6',
2106
+ }, title: selectedStoryIds.size === orphanStories.length ? 'Deselect all' : 'Select all' }), _jsxs("span", { style: {
2107
+ color: '#64748b',
2108
+ fontSize: '12px',
2109
+ fontWeight: '500',
2110
+ textTransform: 'uppercase',
2111
+ letterSpacing: '0.05em',
2112
+ }, children: ["Generated Files (", orphanStories.length, ")"] })] }) }), selectedStoryIds.size > 0 && (_jsx("div", { style: {
2113
+ display: 'flex',
2114
+ gap: '8px',
2115
+ marginBottom: '12px',
2116
+ }, children: _jsx("button", { onClick: handleBulkDelete, disabled: isBulkDeleting, style: {
2117
+ flex: 1,
2118
+ padding: '6px 10px',
2119
+ fontSize: '11px',
2120
+ fontWeight: '500',
2121
+ background: 'rgba(239, 68, 68, 0.15)',
2122
+ color: '#f87171',
2123
+ border: '1px solid rgba(239, 68, 68, 0.3)',
2124
+ borderRadius: '6px',
2125
+ cursor: isBulkDeleting ? 'not-allowed' : 'pointer',
2126
+ opacity: isBulkDeleting ? 0.6 : 1,
2127
+ transition: 'all 0.15s ease',
2128
+ }, onMouseEnter: (e) => {
2129
+ if (!isBulkDeleting) {
2130
+ e.currentTarget.style.background = 'rgba(239, 68, 68, 0.25)';
2131
+ }
2132
+ }, onMouseLeave: (e) => {
2133
+ e.currentTarget.style.background = 'rgba(239, 68, 68, 0.15)';
2134
+ }, children: isBulkDeleting ? 'Deleting...' : `Delete Selected (${selectedStoryIds.size})` }) })), _jsx("div", { style: {
2135
+ display: 'flex',
2136
+ gap: '8px',
2137
+ marginBottom: '12px',
2138
+ }, children: _jsx("button", { onClick: handleClearAll, disabled: isBulkDeleting || orphanStories.length === 0, style: {
2139
+ flex: 1,
2140
+ padding: '6px 10px',
2141
+ fontSize: '11px',
2142
+ fontWeight: '500',
2143
+ background: 'rgba(100, 116, 139, 0.15)',
2144
+ color: '#94a3b8',
2145
+ border: '1px solid rgba(100, 116, 139, 0.3)',
2146
+ borderRadius: '6px',
2147
+ cursor: (isBulkDeleting || orphanStories.length === 0) ? 'not-allowed' : 'pointer',
2148
+ opacity: (isBulkDeleting || orphanStories.length === 0) ? 0.6 : 1,
2149
+ transition: 'all 0.15s ease',
2150
+ }, onMouseEnter: (e) => {
2151
+ if (!isBulkDeleting && orphanStories.length > 0) {
2152
+ e.currentTarget.style.background = 'rgba(239, 68, 68, 0.15)';
2153
+ e.currentTarget.style.color = '#f87171';
2154
+ e.currentTarget.style.borderColor = 'rgba(239, 68, 68, 0.3)';
2155
+ }
2156
+ }, onMouseLeave: (e) => {
2157
+ e.currentTarget.style.background = 'rgba(100, 116, 139, 0.15)';
2158
+ e.currentTarget.style.color = '#94a3b8';
2159
+ e.currentTarget.style.borderColor = 'rgba(100, 116, 139, 0.3)';
2160
+ }, children: "Clear All Stories" }) }), orphanStories.map(story => (_jsxs("div", { style: {
2012
2161
  ...STYLES.chatItem,
2013
- background: 'rgba(251, 191, 36, 0.1)',
2014
- borderLeft: '3px solid rgba(251, 191, 36, 0.5)',
2162
+ background: selectedStoryIds.has(story.id)
2163
+ ? 'rgba(59, 130, 246, 0.15)'
2164
+ : 'rgba(251, 191, 36, 0.1)',
2165
+ borderLeft: selectedStoryIds.has(story.id)
2166
+ ? '3px solid rgba(59, 130, 246, 0.5)'
2167
+ : '3px solid rgba(251, 191, 36, 0.5)',
2168
+ display: 'flex',
2169
+ alignItems: 'flex-start',
2170
+ gap: '8px',
2015
2171
  }, onMouseEnter: (e) => {
2016
- e.currentTarget.style.background = 'rgba(251, 191, 36, 0.15)';
2172
+ if (!selectedStoryIds.has(story.id)) {
2173
+ e.currentTarget.style.background = 'rgba(251, 191, 36, 0.15)';
2174
+ }
2017
2175
  const deleteBtn = e.currentTarget.querySelector('.delete-orphan-btn');
2018
2176
  if (deleteBtn)
2019
2177
  deleteBtn.style.opacity = '1';
2020
2178
  }, onMouseLeave: (e) => {
2021
- e.currentTarget.style.background = 'rgba(251, 191, 36, 0.1)';
2179
+ if (!selectedStoryIds.has(story.id)) {
2180
+ e.currentTarget.style.background = 'rgba(251, 191, 36, 0.1)';
2181
+ }
2022
2182
  const deleteBtn = e.currentTarget.querySelector('.delete-orphan-btn');
2023
2183
  if (deleteBtn)
2024
2184
  deleteBtn.style.opacity = '0';
2025
- }, children: [_jsx("div", { style: STYLES.chatItemTitle, children: story.title }), _jsx("div", { style: { ...STYLES.chatItemTime, fontSize: '11px' }, children: story.fileName }), _jsx("button", { className: "delete-orphan-btn", onClick: async (e) => {
2185
+ }, children: [_jsx("input", { type: "checkbox", checked: selectedStoryIds.has(story.id), onChange: () => toggleStorySelection(story.id), onClick: (e) => e.stopPropagation(), style: {
2186
+ width: '14px',
2187
+ height: '14px',
2188
+ cursor: 'pointer',
2189
+ accentColor: '#3b82f6',
2190
+ marginTop: '2px',
2191
+ flexShrink: 0,
2192
+ } }), _jsxs("div", { style: { flex: 1, minWidth: 0 }, children: [_jsx("div", { style: STYLES.chatItemTitle, children: story.title }), _jsx("div", { style: { ...STYLES.chatItemTime, fontSize: '11px' }, children: story.fileName })] }), _jsx("button", { className: "delete-orphan-btn", onClick: async (e) => {
2026
2193
  e.stopPropagation();
2027
2194
  try {
2028
2195
  const response = await fetch(`${STORIES_API}/${story.id}`, {
@@ -2030,6 +2197,11 @@ function StoryUIPanel() {
2030
2197
  });
2031
2198
  if (response.ok) {
2032
2199
  setOrphanStories(prev => prev.filter(s => s.id !== story.id));
2200
+ setSelectedStoryIds(prev => {
2201
+ const newSet = new Set(prev);
2202
+ newSet.delete(story.id);
2203
+ return newSet;
2204
+ });
2033
2205
  }
2034
2206
  else {
2035
2207
  console.error('Failed to delete orphan story');