-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathApp.tsx
More file actions
607 lines (560 loc) · 35.9 KB
/
App.tsx
File metadata and controls
607 lines (560 loc) · 35.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
import React, { useState, useEffect } from 'react';
import Header from './components/Header';
import IdeationSidebar from './components/IdeationSidebar';
import CurationPanel from './components/CurationPanel';
import EditorPanel from './components/EditorPanel';
import ContentLibrary from './components/ContentLibrary';
import Toast from './components/common/Toast';
import { ContentIdea, ContentPiece, ContentStatus, CustomAudience, CustomPillar, Persona, Platform, DataSource, TrainingVersion, TrainingConfiguration, ModelPerformanceMetrics, StoryConfiguration, PlotPoint } from './types';
// Conditionally import service based on environment
import * as geminiService from './services/geminiService';
import * as wordpressService from './services/wordpressService';
import * as openRouterService from './services/openRouterService';
// Import the centralized API service selector
import { getApiService as getApiServiceSelector } from './services/apiService';
// Use the centralized selector
const getApiService = getApiServiceSelector;
import IdeationResults from './components/IdeationResults';
import Spinner from './components/common/Spinner';
import ImageGenerationPanel from './components/ImageGenerationPanel';
import SettingsPanel from './components/SettingsPanel';
import AIBrain from './components/AIBrain';
import { DEFAULT_PERSONAS } from './constants';
import { getModelForOperation } from './services/defaultLLM';
type AppTab = 'ideation' | 'curation' | 'expansion' | 'image' | 'library' | 'settings' | 'ai_brain';
interface IdeationSettings {
model: string;
pillars: string;
audience: string;
count: number;
context: string;
}
const LightbulbIcon = () => <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>;
const FilterIcon = () => <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293.707L3.293 7.293A1 1 0 013 6.586V4z" /></svg>;
const SparklesIcon = () => <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" /></svg>;
const ImageIcon = () => <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>;
const LibraryIcon = () => <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" /></svg>;
const SettingsIcon = () => <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066 2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>;
const BrainIcon = () => <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9.5 9.623a6 6 0 00-4.382 4.382L4.5 14.25v2.25a3 3 0 003 3h.75a.75.75 0 00.75-.75v-3a.75.75 0 00-.75-.75H7.5v-1.5h1.5a.75.75 0 00.75-.75V12h.75a.75.75 0 00.75-.75V9.623zM14.5 9.623a6 6 0 014.382 4.382l.618.247v2.25a3 3 0 01-3 3h-.75a.75.75 0 01-.75-.75v-3a.75.75 0 01.75-.75h.75v-1.5h-1.5a.75.75 0 01-.75-.75V12h-.75a.75.75 0 01-.75-.75V9.623z" /><path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9 9 0 005.656-16.344A9 9 0 006.344 4.656 9 9 0 0012 21zm0-13.5a4.5 4.5 0 010 9 4.5 4.5 0 010-9z" /></svg>;
const TabButton: React.FC<{
icon: React.ReactNode;
label: string;
isActive: boolean;
onClick: () => void;
disabled?: boolean;
count?: number;
}> = ({ icon, label, isActive, onClick, disabled, count }) => (
<button
onClick={onClick}
disabled={disabled}
className={`flex items-center justify-center gap-2 px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg text-sm sm:text-base font-semibold transition-colors touch-manipulation min-h-[2.5rem] ${
isActive
? 'bg-primary-500 text-white shadow-md'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-300 dark:hover:bg-slate-700 active:bg-slate-400 dark:active:bg-slate-600'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span className="flex-shrink-0">{icon}</span>
<span>{label}</span>
{count !== undefined && count > 0 && (
<span className="flex-shrink-0 bg-primary-700 text-white text-xs font-bold px-2 py-0.5 rounded-full min-w-[1.5rem] text-center">
{count}
</span>
)}
</button>
);
function App() {
const [activeTab, setActiveTab] = useState<AppTab>('ideation');
const [isLoading, setIsLoading] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
// SessionStorage keys
const IDEAS_CACHE_KEY = 'content_forge_generated_ideas';
const SELECTED_IDEAS_CACHE_KEY = 'content_forge_selected_ideas';
// Core content state
const [generatedIdeas, setGeneratedIdeas] = useState<ContentIdea[]>([]);
const [selectedIdeas, setSelectedIdeas] = useState<ContentIdea[]>([]);
const [library, setLibrary] = useState<ContentPiece[]>([]);
// Load generated ideas from sessionStorage on mount
useEffect(() => {
try {
const cached = sessionStorage.getItem(IDEAS_CACHE_KEY);
if (cached) {
const ideas: ContentIdea[] = JSON.parse(cached);
if (ideas.length > 0) {
setGeneratedIdeas(ideas);
}
}
} catch (error) {
console.warn('Failed to load cached generated ideas:', error);
}
}, []);
// Save generated ideas to sessionStorage whenever they change
useEffect(() => {
if (generatedIdeas.length > 0) {
try {
sessionStorage.setItem(IDEAS_CACHE_KEY, JSON.stringify(generatedIdeas));
} catch (error) {
console.warn('Failed to save generated ideas to cache:', error);
}
} else {
// Clear cache if no ideas
try {
sessionStorage.removeItem(IDEAS_CACHE_KEY);
} catch (error) {
// Ignore
}
}
}, [generatedIdeas]);
// Load selected ideas from sessionStorage on mount
useEffect(() => {
try {
const cached = sessionStorage.getItem(SELECTED_IDEAS_CACHE_KEY);
if (cached) {
const ideas: ContentIdea[] = JSON.parse(cached);
if (ideas.length > 0) {
setSelectedIdeas(ideas);
}
}
} catch (error) {
console.warn('Failed to load cached selected ideas:', error);
}
}, []);
// Settings state
const [customPersonas, setCustomPersonas] = useState<Persona[]>([]);
const [customAudiences, setCustomAudiences] = useState<CustomAudience[]>([]);
const [customPillars, setCustomPillars] = useState<CustomPillar[]>([]);
// Model and ideation state
const [selectedModel, setSelectedModel] = useState('gemini-2.5-flash');
const [ideationSettings, setIdeationSettings] = useState<IdeationSettings | null>(null);
// AI Brain state
const [dataSources, setDataSources] = useState<DataSource[]>([
{ id: 'ds_new1', name: 'New Document 10/31/2025.txt', type: 'text', content: 'This is the full content of the newly added document for training. It contains several paragraphs explaining the new features and improvements being introduced in the upcoming version. We will cover topics such as the updated user interface, performance enhancements, and new integration capabilities.', tags: ['new'], uploadedAt: '2025-10-31T12:00:00Z', source: 'File Upload', status: 'inactive' },
{ id: 'ds_new2', name: 'New Document 10/31/2025.txt', type: 'text', content: 'This is another document with important training data. It discusses the strategic goals for the next quarter, outlining key performance indicators and target metrics. The document emphasizes a customer-centric approach and data-driven decision making.', tags: ['new'], uploadedAt: '2025-10-31T12:00:00Z', source: 'File Upload', status: 'active' },
{ id: 'ds1', name: 'User Feedback Q1 2024.csv', type: 'spreadsheet', content: 'user_id,feedback,rating\n1,Positive feedback on UI,5\n2,requests for dark mode,4', tags: ['feedback', 'q1'], uploadedAt: '2025-10-21T12:00:00Z', source: 'File Upload', status: 'active' },
{ id: 'ds2', name: 'Competitor Analysis Doc.txt', type: 'document', content: 'Company A focuses on enterprise clients, leveraging a direct sales model. Company B targets the SMB market with a freemium, product-led growth strategy. Key differentiators include pricing models, feature sets, and customer support channels. This analysis provides a deep dive into their respective strengths and weaknesses.', tags: ['research'], uploadedAt: '2025-10-26T12:00:00Z', source: 'File Upload', status: 'active' },
{ id: 'ds3', name: 'Product Descriptions.txt', type: 'text', content: 'Product X: A revolutionary tool for content creators. Features include AI-powered ideation, automated expansion, and multi-platform formatting. It is designed to streamline workflows and boost creativity. Product Y: An entry-level solution for beginners, offering basic text generation and editing capabilities at an affordable price point.', tags: ['marketing', 'product'], uploadedAt: '2025-10-29T12:00:00Z', source: 'File Upload', status: 'inactive' },
]);
const [trainingVersions, setTrainingVersions] = useState<TrainingVersion[]>([
{ id: 'tv1', versionNumber: 'v1.0', dataSources: ['ds1', 'ds2'], trainingDate: new Date(Date.now() - 86400000 * 7).toISOString(), notes: 'Initial training with user feedback and competitor data.', performanceMetrics: { accuracy: 88, diversity: 75, novelty: 60 }, isActive: false },
{ id: 'tv2', versionNumber: 'v1.1', dataSources: ['ds1', 'ds2', 'ds3'], trainingDate: new Date().toISOString(), notes: 'Added product descriptions to enhance marketing tone.', performanceMetrics: { accuracy: 92, diversity: 78, novelty: 65 }, isActive: true },
]);
const [trainingConfig, setTrainingConfig] = useState<TrainingConfiguration>({
learningRate: 0.01,
trainingDataWeight: 0.7,
focusAreas: ['Marketing', 'User Support'],
excludedContexts: ['Internal Memos'],
});
const [performanceMetrics, setPerformanceMetrics] = useState<ModelPerformanceMetrics>({
accuracy: 92,
diversity: 78,
// FIX: Add missing 'novelty' property to align with the updated ModelPerformanceMetrics type.
novelty: 65,
responseQuality: 85,
trainingCoverage: 95,
lastTrainedAt: new Date().toISOString(),
});
const [storyConfig, setStoryConfig] = useState<StoryConfiguration>({
coreNarrative: "You are a wise and ancient librarian from a future where knowledge is the most valuable currency. You've seen civilizations rise and fall based on their ability to manage information. Your goal is to guide users with patience and profound insight, helping them structure their ideas into clear, compelling narratives.",
characterTraits: ['Wise', 'Patient', 'Insightful', 'Calm', 'Slightly Formal'],
plotPoints: [
{ id: 'pp1', title: 'The Great Data Loss of 2242', description: 'Remembers the catastrophic loss of historical data and emphasizes the importance of preservation and clear communication.' },
{ id: 'pp2', title: 'First Contact with the Cygnian Archivists', description: 'Learned new ways of structuring narratives from an alien race that communicates through complex data-stories.' },
],
communicationStyle: 'Storyteller',
});
// Default content pillars with descriptions
const getDefaultPillars = (): CustomPillar[] => [
{
id: 'default-educational',
name: '📚 Edukasi (Educational)',
description: 'Konten yang mengajarkan audience cara menggunakan produk, memahami industri retail, tips dan trik, tutorial, panduan lengkap. Fokus: memberikan value dan pengetahuan. Membangun authority dan credibility di industri retail, meningkatkan SEO dan organic reach, mendidik customer sehingga membuat keputusan pembelian lebih baik.'
},
{
id: 'default-entertainment',
name: '🎬 Hiburan & Engagement (Entertain)',
description: 'Konten yang menghibur dan membangun connection dengan audience. Fokus: membuat orang tertawa, terkejut, atau terhibur. Bukan tentang direct selling, tapi relationship building. Membangun community dan belonging feeling, meningkatkan engagement rate, viral potential dan organic reach, membuat brand memorable dan relatable.'
},
{
id: 'default-social-proof',
name: '✅ Bukti Nyata & Testimoni Transformasi (Social Proof)',
description: 'Konten yang menunjukkan proof bahwa produk/service bekerja. Testimoni customer, review, case studies, before-after, rating, customer success stories. Fokus: membangun trust melalui bukti nyata dari customer sebenarnya. Mengurangi purchase anxiety dan objections, meningkatkan conversion rate, mengubah customers menjadi brand advocates.'
},
{
id: 'default-tech-trends',
name: '🚀 Insight Tren & Teknologi Baru Ritel (Tech & Trend Update)',
description: 'Konten tentang teknologi terbaru di industri retail, trend shopping, innovation, forecasting, what\'s next. Fokus: positioning brand sebagai forward-thinking, innovative, dan aware akan perubahan. Establish thought leadership & authority, show brand is innovative & forward-thinking, create shareability, attract trend-conscious customers.'
},
{
id: 'default-personal-story',
name: '📖 Cerita Pribadi & Journey Growth (Personal Story)',
description: 'Konten tentang journey founder/team, behind-the-scenes, personal struggles overcome, failures, lessons learned, growth story. Fokus: humanize brand, build emotional connection, create relatability. Show vulnerability (builds trust), inspire audience melalui personal journey, create parasocial relationship, drive community & belonging feeling.'
},
{
id: 'default-promotional',
name: '💰 Promosi & Penawaran Spesial (Promotional)',
description: 'Konten tentang sales, discount, limited offers, seasonal promotions, flash sales, exclusive deals. Fokus: drive immediate action, create urgency, increase revenue. Drive immediate sales & revenue, create urgency (FOMO), clear old inventory, acquire new customers, increase average order value.'
},
{
id: 'default-mindset',
name: '🧠 Motivasi & Mindset Growth (Mindset Booster)',
description: 'Konten tentang motivation, inspiration, mindset shifts, personal development, success principles, empowerment. Fokus: uplift audience, inspire action, align brand dengan personal growth values. Build emotional connection & loyalty, position brand sebagai mentor/guide, inspire audience ke action, create shareable meaningful content.'
},
{
id: 'default-interactive',
name: '🎯 Q&A & Problem Solving (Interactive)',
description: 'Konten yang interactive & melibatkan audience: Q&A, polls, questions, surveys, problem-solving, direct response. Fokus: two-way conversation, engagement, solve audience problems. Build two-way conversation (not broadcast), solve specific customer problems, generate content ideas dari audience questions, increase engagement & time spent.'
}
];
// Load custom settings from localStorage on initial render
useEffect(() => {
try {
const savedPersonas = localStorage.getItem('customPersonas');
if (savedPersonas) setCustomPersonas(JSON.parse(savedPersonas));
// Load audiences with migration for old data
const savedAudiences = localStorage.getItem('customAudiences');
if (savedAudiences) {
const audiences: CustomAudience[] = JSON.parse(savedAudiences);
// Migrate old data: add description if missing
const migratedAudiences = audiences.map(a => ({
...a,
description: a.description || `Description for ${a.name}`
}));
setCustomAudiences(migratedAudiences);
// Save migrated data back
localStorage.setItem('customAudiences', JSON.stringify(migratedAudiences));
}
// Load pillars with migration and default pillars initialization
const savedPillars = localStorage.getItem('customPillars');
const defaultPillars = getDefaultPillars();
if (savedPillars) {
const pillars: CustomPillar[] = JSON.parse(savedPillars);
// Migrate old data: add description if missing
const migratedPillars = pillars.map(p => ({
...p,
description: p.description || `Description for ${p.name}`
}));
// Check if default pillars exist, if not add them
const existingPillarNames = migratedPillars.map(p => p.name);
const missingDefaults = defaultPillars.filter(dp =>
!existingPillarNames.includes(dp.name)
);
if (missingDefaults.length > 0) {
const allPillars = [...migratedPillars, ...missingDefaults];
setCustomPillars(allPillars);
localStorage.setItem('customPillars', JSON.stringify(allPillars));
} else {
setCustomPillars(migratedPillars);
localStorage.setItem('customPillars', JSON.stringify(migratedPillars));
}
} else {
// First time: initialize with default pillars
setCustomPillars(defaultPillars);
localStorage.setItem('customPillars', JSON.stringify(defaultPillars));
}
} catch (error) {
console.error("Failed to load settings from localStorage", error);
}
}, []);
// Reload API service when API key changes
const handleApiKeyChange = () => {
// Force re-render by updating state (service will be selected dynamically on each call)
setToast({ message: 'API service updated. You can now use the new service.', type: 'success' });
};
// Get the current API service (called dynamically)
const getCurrentApiService = () => getApiService();
const allPersonas = [...DEFAULT_PERSONAS, ...customPersonas];
const handleApiError = (err: any) => {
let message = 'An unknown API error occurred. Please try again.';
const errorMessage = err.toString();
if (errorMessage.includes("429")) {
message = "API quota exceeded. Please wait and try again, or check your billing details.";
} else if (err.message) {
message = err.message;
}
setToast({ message, type: 'error' });
};
// --- Settings Handlers ---
const handleAddPersona = (persona: Omit<Persona, 'id'>) => {
const newPersona = { ...persona, id: `p-${Date.now()}` };
const updated = [...customPersonas, newPersona];
setCustomPersonas(updated);
localStorage.setItem('customPersonas', JSON.stringify(updated));
setToast({ message: 'Persona saved!', type: 'success' });
};
const handleUpdatePersona = (id: string, persona: Omit<Persona, 'id'>) => {
const updated = customPersonas.map(p => p.id === id ? { ...persona, id } : p);
setCustomPersonas(updated);
localStorage.setItem('customPersonas', JSON.stringify(updated));
setToast({ message: 'Persona updated!', type: 'success' });
};
const handleDeletePersona = (id: string) => {
const updated = customPersonas.filter(p => p.id !== id);
setCustomPersonas(updated);
localStorage.setItem('customPersonas', JSON.stringify(updated));
setToast({ message: 'Persona deleted.', type: 'success' });
};
const handleAddAudience = (audience: Omit<CustomAudience, 'id'>) => {
const newAudience = { ...audience, id: `c-${Date.now()}` };
const updated = [...customAudiences, newAudience];
setCustomAudiences(updated);
localStorage.setItem('customAudiences', JSON.stringify(updated));
setToast({ message: 'Audience saved!', type: 'success' });
};
const handleUpdateAudience = (id: string, audience: Omit<CustomAudience, 'id'>) => {
const updated = customAudiences.map(a => a.id === id ? { ...audience, id } : a);
setCustomAudiences(updated);
localStorage.setItem('customAudiences', JSON.stringify(updated));
setToast({ message: 'Audience updated!', type: 'success' });
};
const handleDeleteAudience = (id: string) => {
const updated = customAudiences.filter(a => a.id !== id);
setCustomAudiences(updated);
localStorage.setItem('customAudiences', JSON.stringify(updated));
setToast({ message: 'Audience deleted.', type: 'success' });
};
const handleAddPillar = (pillar: Omit<CustomPillar, 'id'>) => {
const newPillar = { ...pillar, id: `c-${Date.now()}` };
const updated = [...customPillars, newPillar];
setCustomPillars(updated);
localStorage.setItem('customPillars', JSON.stringify(updated));
setToast({ message: 'Pillar saved!', type: 'success' });
};
const handleUpdatePillar = (id: string, pillar: Omit<CustomPillar, 'id'>) => {
const updated = customPillars.map(p => p.id === id ? { ...pillar, id } : p);
setCustomPillars(updated);
localStorage.setItem('customPillars', JSON.stringify(updated));
setToast({ message: 'Pillar updated!', type: 'success' });
};
const handleDeletePillar = (id: string) => {
const updated = customPillars.filter(p => p.id !== id);
setCustomPillars(updated);
localStorage.setItem('customPillars', JSON.stringify(updated));
setToast({ message: 'Pillar deleted.', type: 'success' });
};
// --- Content Flow Handlers ---
const handleGenerateIdeas = async (settings: IdeationSettings) => {
setIsLoading(true);
setToast(null);
setGeneratedIdeas([]);
setIdeationSettings(settings);
// Check if using OpenRouter but model is invalid
const hasOpenRouterKey = typeof window !== 'undefined' ? !!localStorage.getItem('openrouter_api_key') : false;
if (hasOpenRouterKey) {
const selectedModels = localStorage.getItem('openrouter_selected_models');
const hasSelectedModels = selectedModels && JSON.parse(selectedModels).length > 0;
// If OpenRouter is active but no valid model or using Gemini model
if (!hasSelectedModels || !settings.model || settings.model === '' || settings.model.includes('gemini-')) {
setIsLoading(false);
handleApiError(new Error("Please select a valid OpenRouter model in Settings. Gemini models are not available on OpenRouter."));
return;
}
}
try {
const currentService = getCurrentApiService();
// Use default LLM if available, otherwise use provided model
const modelToUse = getModelForOperation(settings.model);
const ideas = await currentService.generateContentIdeas(modelToUse, settings.pillars, settings.audience, settings.count, settings.context);
if (ideas && ideas.length > 0) {
setGeneratedIdeas(ideas);
setToast({ message: 'Ideas generated successfully!', type: 'success' });
} else {
throw new Error("The AI didn't return any ideas. Try refining your inputs.");
}
} catch (err: any) {
handleApiError(err);
} finally {
setIsLoading(false);
}
};
const handleContinueToCuration = () => setActiveTab('curation');
const handleCurationComplete = (curatedIdeas: ContentIdea[]) => {
if (curatedIdeas.length === 0) {
setToast({ message: 'Please select at least one idea to continue.', type: 'error' });
return;
}
setSelectedIdeas(curatedIdeas);
// Save selected ideas to sessionStorage
try {
sessionStorage.setItem(SELECTED_IDEAS_CACHE_KEY, JSON.stringify(curatedIdeas));
} catch (error) {
console.warn('Failed to save selected ideas to cache:', error);
}
setActiveTab('expansion');
};
const handleSaveToLibrary = (piece: Omit<ContentPiece, 'status' | 'platform' | 'publishedDate' | 'performance' | 'versions'> & { platform?: Platform }) => {
setLibrary(prev => {
const existingIndex = prev.findIndex(p => p.idea.title === piece.idea.title);
// Determine platform: use provided platform, or infer from content, or default
let platform: Platform = 'Generic';
if (piece.platform) {
platform = piece.platform;
} else if (piece.image) {
platform = 'Instagram';
} else if (piece.twitterContent) {
platform = 'X/Twitter';
}
if (existingIndex > -1) {
const existingPiece = prev[existingIndex];
const updatedPiece: ContentPiece = {
...existingPiece,
...piece,
platform: piece.platform || platform // Preserve platform from expansion
};
const newLibrary = [...prev];
newLibrary[existingIndex] = updatedPiece;
setToast({ message: 'Content updated in library!', type: 'success' });
return newLibrary;
}
const newPiece: ContentPiece = {
...piece,
status: ContentStatus.DRAFT,
platform: piece.platform || platform, // Use platform from expansion if provided
publishedDate: new Date().toISOString(),
performance: { impressions: 0, engagementRate: 0, clicks: 0 },
versions: [{ text: piece.expandedContent, timestamp: new Date().toISOString() }],
};
setToast({ message: 'Content saved to library!', type: 'success' });
return [newPiece, ...prev];
});
};
const handleSaveImageToLibrary = (prompt: string, imageUrl: string) => {
const newPiece: Omit<ContentPiece, 'status' | 'platform' | 'publishedDate' | 'performance' | 'versions'> = {
id: new Date().toISOString() + Math.random(),
idea: { title: prompt, description: 'AI-generated image', pillar: 'Visual Content' },
expandedContent: `Image generated from prompt: "${prompt}"`,
image: imageUrl,
};
handleSaveToLibrary(newPiece);
};
const handleUpdateLibraryPiece = (updatedPiece: ContentPiece) => {
setLibrary(prev => {
// Check if this is a new piece (convert) or update existing
const existingIndex = prev.findIndex(p => p.id === updatedPiece.id);
if (existingIndex > -1) {
// Update existing piece
const updated = prev.map(p => {
if (p.id === updatedPiece.id) {
const currentText = updatedPiece.rewrittenContent || updatedPiece.expandedContent;
const latestVersion = p.versions[p.versions.length - 1];
if (latestVersion && latestVersion.text !== currentText) {
return { ...updatedPiece, versions: [...p.versions, { text: currentText, timestamp: new Date().toISOString() }] };
}
return updatedPiece;
}
return p;
});
setToast({ message: 'Content updated successfully!', type: 'success' });
return updated;
} else {
// Add new piece (from convert)
setToast({ message: `Content converted to ${updatedPiece.platform} and saved to library!`, type: 'success' });
return [updatedPiece, ...prev];
}
});
};
const handleArchiveFromLibrary = (id: string) => {
setLibrary(prev => prev.filter(p => p.id !== id));
setToast({ message: 'Content archived.', type: 'success' });
};
const renderContent = () => {
switch(activeTab) {
case 'ideation':
return (
<div className="max-w-4xl mx-auto space-y-8">
<IdeationSidebar
onGenerate={handleGenerateIdeas}
isLoading={isLoading}
model={selectedModel}
onModelChange={setSelectedModel}
customAudiences={customAudiences}
customPillars={customPillars}
/>
{isLoading && (
<div className="text-center p-8 lg:p-16 h-full flex flex-col justify-center items-center bg-white dark:bg-slate-800/50 rounded-2xl border-2 border-dashed border-slate-300 dark:border-slate-700">
<Spinner className="w-12 h-12" />
<h3 className="mt-4 text-xl font-bold font-display text-slate-900 dark:text-white">Generating creative ideas...</h3>
<p className="mt-2 text-slate-500">The AI is working its magic. Please wait a moment.</p>
</div>
)}
{!isLoading && generatedIdeas.length > 0 && (
<IdeationResults
ideas={generatedIdeas}
onRegenerate={() => ideationSettings && handleGenerateIdeas(ideationSettings)}
onContinue={handleContinueToCuration}
isLoading={isLoading}
/>
)}
{!isLoading && generatedIdeas.length === 0 && (
<div className="text-center p-8 lg:p-16 h-full flex flex-col justify-center bg-white dark:bg-slate-800/50 rounded-2xl border-2 border-dashed border-slate-300 dark:border-slate-700">
<div className="mx-auto h-16 w-16 text-slate-400 bg-slate-200 dark:bg-slate-700 rounded-2xl flex items-center justify-center"><SparklesIcon /></div>
<h3 className="mt-4 text-xl font-bold font-display text-slate-900 dark:text-white">Your generated ideas will appear here</h3>
<p className="mt-2 text-slate-500">Fill out the form to get started.</p>
</div>
)}
</div>
);
case 'curation': return <CurationPanel initialIdeas={generatedIdeas} onComplete={handleCurationComplete} onBack={() => setActiveTab('ideation')} />;
case 'expansion': return <EditorPanel ideas={selectedIdeas.length > 0 ? selectedIdeas : generatedIdeas} onSave={handleSaveToLibrary} onBack={() => setActiveTab('curation')} onApiError={handleApiError} model={selectedModel} allPersonas={allPersonas} dataSources={dataSources} />;
case 'image': return <ImageGenerationPanel onSave={handleSaveImageToLibrary} onApiError={handleApiError} />;
case 'library': return <ContentLibrary library={library} onArchive={handleArchiveFromLibrary} onUpdate={handleUpdateLibraryPiece} model={selectedModel} allPersonas={allPersonas} />;
case 'settings': return <SettingsPanel customPersonas={customPersonas} onAddPersona={handleAddPersona} onUpdatePersona={handleUpdatePersona} onDeletePersona={handleDeletePersona} customAudiences={customAudiences} onAddAudience={handleAddAudience} onUpdateAudience={handleUpdateAudience} onDeleteAudience={handleDeleteAudience} customPillars={customPillars} onAddPillar={handleAddPillar} onUpdatePillar={handleUpdatePillar} onDeletePillar={handleDeletePillar} onApiKeyChange={handleApiKeyChange} />;
case 'ai_brain': return <AIBrain
dataSources={dataSources}
setDataSources={setDataSources}
trainingVersions={trainingVersions}
setTrainingVersions={setTrainingVersions}
trainingConfig={trainingConfig}
setTrainingConfig={setTrainingConfig}
performanceMetrics={performanceMetrics}
setPerformanceMetrics={setPerformanceMetrics}
storyConfig={storyConfig}
setStoryConfig={setStoryConfig}
showToast={(message, type) => setToast({message, type})}
/>;
}
};
const handleTabChange = (tab: AppTab) => {
setActiveTab(tab);
setIsMobileNavOpen(false); // Close mobile nav when tab changes
};
return (
<div className="min-h-screen bg-slate-100 dark:bg-slate-950 text-slate-800 dark:text-slate-200 font-sans">
<Header onMenuClick={() => setIsMobileNavOpen(!isMobileNavOpen)} />
{/* Mobile Navigation Drawer */}
{isMobileNavOpen && (
<>
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden animate-fade-in"
onClick={() => setIsMobileNavOpen(false)}
/>
<nav className="fixed left-0 top-0 bottom-0 w-64 sm:w-72 bg-white dark:bg-slate-900 shadow-2xl z-50 md:hidden overflow-y-auto animate-slide-right">
<div className="p-4 space-y-2 pt-20">
<TabButton icon={<LightbulbIcon />} label="Ideation" isActive={activeTab === 'ideation'} onClick={() => handleTabChange('ideation')} />
<TabButton icon={<FilterIcon />} label="Curation" isActive={activeTab === 'curation'} onClick={() => handleTabChange('curation')} disabled={generatedIdeas.length === 0} />
<TabButton icon={<SparklesIcon />} label="Expansion" isActive={activeTab === 'expansion'} onClick={() => handleTabChange('expansion')} disabled={(selectedIdeas.length === 0 && generatedIdeas.length === 0)} />
<TabButton icon={<ImageIcon />} label="Image" isActive={activeTab === 'image'} onClick={() => handleTabChange('image')} />
<TabButton icon={<BrainIcon />} label="AI Brain" isActive={activeTab === 'ai_brain'} onClick={() => handleTabChange('ai_brain')} />
<TabButton icon={<LibraryIcon />} label="Library" isActive={activeTab === 'library'} onClick={() => handleTabChange('library')} count={library.length} />
<TabButton icon={<SettingsIcon />} label="Settings" isActive={activeTab === 'settings'} onClick={() => handleTabChange('settings')} />
</div>
</nav>
</>
)}
<main className="max-w-7xl mx-auto px-3 sm:px-4 md:px-6 lg:px-8 py-4 sm:py-6 md:py-8">
{/* Desktop Navigation */}
<div className="mb-6 sm:mb-8 p-2 sm:p-3 bg-slate-200 dark:bg-slate-800 rounded-xl hidden md:flex items-center justify-start gap-2">
<TabButton icon={<LightbulbIcon />} label="Ideation" isActive={activeTab === 'ideation'} onClick={() => handleTabChange('ideation')} />
<TabButton icon={<FilterIcon />} label="Curation" isActive={activeTab === 'curation'} onClick={() => handleTabChange('curation')} disabled={generatedIdeas.length === 0} />
<TabButton icon={<SparklesIcon />} label="Expansion" isActive={activeTab === 'expansion'} onClick={() => handleTabChange('expansion')} disabled={(selectedIdeas.length === 0 && generatedIdeas.length === 0)} />
<TabButton icon={<ImageIcon />} label="Image" isActive={activeTab === 'image'} onClick={() => handleTabChange('image')} />
<TabButton icon={<BrainIcon />} label="AI Brain" isActive={activeTab === 'ai_brain'} onClick={() => handleTabChange('ai_brain')} />
<TabButton icon={<LibraryIcon />} label="Library" isActive={activeTab === 'library'} onClick={() => handleTabChange('library')} count={library.length} />
<TabButton icon={<SettingsIcon />} label="Settings" isActive={activeTab === 'settings'} onClick={() => handleTabChange('settings')} />
</div>
<div>{renderContent()}</div>
</main>
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
</div>
);
}
export default App;