story writer backend migration complete, Blog writer SEO and story writer backend migration complete, Blog writer SEO and story writer frontend migration complete
This commit is contained in:
@@ -235,7 +235,14 @@ export const BlogWriter: React.FC = () => {
|
||||
});
|
||||
|
||||
// CopilotKit suggestions management - extracted to useCopilotSuggestions
|
||||
const hasContent = React.useMemo(() => Object.keys(sections).length > 0, [sections]);
|
||||
// Check if sections exist AND have actual content (not just empty strings)
|
||||
const hasContent = React.useMemo(() => {
|
||||
const sectionKeys = Object.keys(sections);
|
||||
if (sectionKeys.length === 0) return false;
|
||||
// Check if at least one section has actual content
|
||||
const sectionsWithContent = Object.values(sections).filter(c => c && c.trim().length > 0);
|
||||
return sectionsWithContent.length > 0;
|
||||
}, [sections]);
|
||||
const {
|
||||
suggestions,
|
||||
setSuggestionsRef,
|
||||
|
||||
@@ -122,6 +122,7 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
onTitleSelect={onTitleSelect}
|
||||
onCustomTitle={onCustomTitle}
|
||||
research={research}
|
||||
/>
|
||||
<EnhancedOutlineEditor
|
||||
outline={outline}
|
||||
|
||||
@@ -84,10 +84,31 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
|
||||
// Store current page URL so we can redirect back after OAuth completes
|
||||
// This MUST be stored before calling handleConnect to ensure it's available after redirect
|
||||
// We ALWAYS override any existing redirect URL since we know the exact page we're on (Blog Writer)
|
||||
const currentUrl = window.location.href;
|
||||
// Build the redirect URL to ensure it includes the phase (publish) and works with both localhost and ngrok
|
||||
const currentPath = window.location.pathname;
|
||||
const currentHash = window.location.hash || '#publish'; // Default to publish phase if no hash
|
||||
const currentSearch = window.location.search;
|
||||
|
||||
// Determine the correct origin - if using ngrok, use ngrok origin; otherwise use current origin
|
||||
// This ensures consistency between where OAuth starts and where callback happens
|
||||
const NGROK_ORIGIN = 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
|
||||
const isUsingNgrok = window.location.origin.includes('localhost') ||
|
||||
window.location.origin.includes('127.0.0.1') ||
|
||||
window.location.origin === NGROK_ORIGIN;
|
||||
const redirectOrigin = isUsingNgrok ? NGROK_ORIGIN : window.location.origin;
|
||||
|
||||
// Build redirect URL with normalized origin
|
||||
const redirectUrl = `${redirectOrigin}${currentPath}${currentHash}${currentSearch}`;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem('wix_oauth_redirect', currentUrl);
|
||||
console.log('[WixConnectModal] Stored redirect URL (overriding any existing):', currentUrl);
|
||||
// Always override any existing redirect URL when connecting from Blog Writer
|
||||
sessionStorage.setItem('wix_oauth_redirect', redirectUrl);
|
||||
console.log('[WixConnectModal] Stored redirect URL (overriding any existing):', {
|
||||
redirectUrl,
|
||||
currentOrigin: window.location.origin,
|
||||
redirectOrigin,
|
||||
isUsingNgrok
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[WixConnectModal] Failed to store redirect URL:', e);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,180 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { debug } from '../../../utils/debug';
|
||||
import { blogWriterApi, BlogSEOActionableRecommendation } from '../../../services/blogWriterApi';
|
||||
import { blogWriterCache } from '../../../services/blogWriterCache';
|
||||
|
||||
const registerContentKey = (map: Map<string, string>, key: any, content?: string) => {
|
||||
if (key === undefined || key === null) {
|
||||
return;
|
||||
}
|
||||
const trimmed = String(key).trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const safeContent = content !== undefined && content !== null ? String(content) : '';
|
||||
map.set(trimmed, safeContent);
|
||||
map.set(trimmed.toLowerCase(), safeContent);
|
||||
};
|
||||
|
||||
const getIdCandidatesForSection = (section: any, index: number): string[] => {
|
||||
const rawCandidates = [
|
||||
section?.id,
|
||||
section?.section_id,
|
||||
section?.sectionId,
|
||||
section?.sectionID,
|
||||
section?.heading_id,
|
||||
`section_${index + 1}`,
|
||||
`Section ${index + 1}`,
|
||||
`section${index + 1}`,
|
||||
`s${index + 1}`,
|
||||
`S${index + 1}`,
|
||||
`${index + 1}`,
|
||||
];
|
||||
|
||||
const normalized = rawCandidates
|
||||
.map((value) => (value === undefined || value === null ? '' : String(value).trim()))
|
||||
.filter(Boolean);
|
||||
|
||||
return Array.from(new Set(normalized));
|
||||
};
|
||||
|
||||
const buildExistingContentMap = (sectionsRecord: Record<string, string>): Map<string, string> => {
|
||||
const map = new Map<string, string>();
|
||||
if (!sectionsRecord) {
|
||||
return map;
|
||||
}
|
||||
Object.entries(sectionsRecord).forEach(([key, value]) => {
|
||||
registerContentKey(map, key, value ?? '');
|
||||
});
|
||||
return map;
|
||||
};
|
||||
|
||||
const buildResponseContentMaps = (responseSections: any[]): { byId: Map<string, string>; byHeading: Map<string, string> } => {
|
||||
const byId = new Map<string, string>();
|
||||
const byHeading = new Map<string, string>();
|
||||
|
||||
if (!responseSections) {
|
||||
return { byId, byHeading };
|
||||
}
|
||||
|
||||
responseSections.forEach((section, index) => {
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
const content = section?.content;
|
||||
const normalizedContent = content !== undefined && content !== null ? String(content).trim() : '';
|
||||
if (!normalizedContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
registerContentKey(byId, section?.id, normalizedContent);
|
||||
registerContentKey(byId, section?.section_id, normalizedContent);
|
||||
registerContentKey(byId, section?.sectionId, normalizedContent);
|
||||
registerContentKey(byId, section?.sectionID, normalizedContent);
|
||||
registerContentKey(byId, `section_${index + 1}`, normalizedContent);
|
||||
registerContentKey(byId, `Section ${index + 1}`, normalizedContent);
|
||||
registerContentKey(byId, `section${index + 1}`, normalizedContent);
|
||||
registerContentKey(byId, `s${index + 1}`, normalizedContent);
|
||||
registerContentKey(byId, `S${index + 1}`, normalizedContent);
|
||||
registerContentKey(byId, `${index + 1}`, normalizedContent);
|
||||
|
||||
const heading = section?.heading || section?.title;
|
||||
if (heading) {
|
||||
registerContentKey(byHeading, heading, normalizedContent);
|
||||
}
|
||||
});
|
||||
|
||||
return { byId, byHeading };
|
||||
};
|
||||
|
||||
const getPrimaryKeyForOutlineSection = (outlineSection: any, index: number): string => {
|
||||
const candidates = getIdCandidatesForSection(outlineSection, index);
|
||||
if (candidates.length > 0) {
|
||||
return candidates[0];
|
||||
}
|
||||
const fallbackHeading = outlineSection?.heading || outlineSection?.title;
|
||||
if (fallbackHeading) {
|
||||
const trimmed = String(fallbackHeading).trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return `section_${index + 1}`;
|
||||
};
|
||||
|
||||
const resolveContentForOutlineSection = (
|
||||
outlineSection: any,
|
||||
index: number,
|
||||
responseSections: any[],
|
||||
responseById: Map<string, string>,
|
||||
responseByHeading: Map<string, string>,
|
||||
existingContentMap: Map<string, string>
|
||||
): { content: string; matchedKey: string } => {
|
||||
const idCandidates = getIdCandidatesForSection(outlineSection, index);
|
||||
|
||||
for (const candidate of idCandidates) {
|
||||
if (responseById.has(candidate)) {
|
||||
return { content: responseById.get(candidate) || '', matchedKey: candidate };
|
||||
}
|
||||
const lower = candidate.toLowerCase();
|
||||
if (responseById.has(lower)) {
|
||||
return { content: responseById.get(lower) || '', matchedKey: candidate };
|
||||
}
|
||||
}
|
||||
|
||||
const heading = outlineSection?.heading || outlineSection?.title;
|
||||
if (heading) {
|
||||
const headingKey = String(heading).trim();
|
||||
if (headingKey) {
|
||||
const lowerHeading = headingKey.toLowerCase();
|
||||
if (responseByHeading.has(lowerHeading)) {
|
||||
return { content: responseByHeading.get(lowerHeading) || '', matchedKey: headingKey };
|
||||
}
|
||||
if (responseByHeading.has(headingKey)) {
|
||||
return { content: responseByHeading.get(headingKey) || '', matchedKey: headingKey };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const responseSection = responseSections?.[index];
|
||||
if (responseSection?.content) {
|
||||
const normalizedContent = String(responseSection.content).trim();
|
||||
if (normalizedContent) {
|
||||
return {
|
||||
content: normalizedContent,
|
||||
matchedKey: idCandidates[0] || getPrimaryKeyForOutlineSection(outlineSection, index),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of idCandidates) {
|
||||
if (existingContentMap.has(candidate)) {
|
||||
return { content: existingContentMap.get(candidate) || '', matchedKey: candidate };
|
||||
}
|
||||
const lower = candidate.toLowerCase();
|
||||
if (existingContentMap.has(lower)) {
|
||||
return { content: existingContentMap.get(lower) || '', matchedKey: candidate };
|
||||
}
|
||||
}
|
||||
|
||||
if (heading) {
|
||||
const headingKey = String(heading).trim();
|
||||
if (headingKey) {
|
||||
const lowerHeading = headingKey.toLowerCase();
|
||||
if (existingContentMap.has(lowerHeading)) {
|
||||
return { content: existingContentMap.get(lowerHeading) || '', matchedKey: headingKey };
|
||||
}
|
||||
if (existingContentMap.has(headingKey)) {
|
||||
return { content: existingContentMap.get(headingKey) || '', matchedKey: headingKey };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: '',
|
||||
matchedKey: idCandidates[0] || getPrimaryKeyForOutlineSection(outlineSection, index),
|
||||
};
|
||||
};
|
||||
|
||||
interface UseSEOManagerProps {
|
||||
sections: Record<string, string>;
|
||||
@@ -47,8 +221,34 @@ export const useSEOManager = ({
|
||||
// Helper: run same checks as analyzeSEO and open modal
|
||||
const runSEOAnalysisDirect = useCallback((): string => {
|
||||
const hasSections = !!sections && Object.keys(sections).length > 0;
|
||||
// Check if sections have actual content (not just empty strings)
|
||||
let sectionsWithContent = hasSections ? Object.values(sections).filter(c => c && c.trim().length > 0) : [];
|
||||
let hasValidContent = sectionsWithContent.length > 0;
|
||||
|
||||
// If sections don't exist in state, check cache (similar to how content generation checks cache)
|
||||
if (!hasValidContent && outline && outline.length > 0) {
|
||||
try {
|
||||
const outlineIds = outline.map(s => String(s.id));
|
||||
const cachedContent = blogWriterCache.getCachedContent(outlineIds);
|
||||
if (cachedContent && Object.keys(cachedContent).length > 0) {
|
||||
sectionsWithContent = Object.values(cachedContent).filter(c => c && c.trim().length > 0);
|
||||
hasValidContent = sectionsWithContent.length > 0;
|
||||
if (hasValidContent) {
|
||||
debug.log('[BlogWriter] Using cached content for SEO analysis', { sections: Object.keys(cachedContent).length });
|
||||
// Update sections state with cached content
|
||||
setSections(cachedContent);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debug.log('[BlogWriter] Error checking cache for SEO analysis', e);
|
||||
}
|
||||
}
|
||||
|
||||
const hasResearch = !!research && !!(research as any).keyword_analysis;
|
||||
if (!hasSections) return "No blog content available for SEO analysis. Please generate content first.";
|
||||
|
||||
if (!hasValidContent) {
|
||||
return "No blog content available for SEO analysis. Please generate content first. Content generation may still be in progress - please wait for it to complete.";
|
||||
}
|
||||
if (!hasResearch) return "Research data is required for SEO analysis. Please run research first.";
|
||||
// Prevent rapid re-opens
|
||||
const now = Date.now();
|
||||
@@ -69,7 +269,7 @@ export const useSEOManager = ({
|
||||
debug.log('[BlogWriter] SEO modal opened (direct)');
|
||||
}
|
||||
return "Running SEO analysis of your blog content. This will analyze content structure, keyword optimization, readability, and provide actionable recommendations.";
|
||||
}, [sections, research, isSEOAnalysisModalOpen, contentConfirmed, setContentConfirmed]);
|
||||
}, [sections, research, outline, isSEOAnalysisModalOpen, contentConfirmed, setContentConfirmed, setSections]);
|
||||
|
||||
const handleApplySeoRecommendations = useCallback(async (
|
||||
recommendations: BlogSEOActionableRecommendation[]
|
||||
@@ -78,11 +278,29 @@ export const useSEOManager = ({
|
||||
throw new Error('An outline is required before applying recommendations.');
|
||||
}
|
||||
|
||||
const sectionPayload = outline.map((section) => ({
|
||||
id: section.id,
|
||||
heading: section.heading,
|
||||
content: sections[section.id] ?? '',
|
||||
}));
|
||||
const existingContentMap = buildExistingContentMap(sections || {});
|
||||
const emptyMap = new Map<string, string>();
|
||||
|
||||
const sectionPayload = outline.map((section, index) => {
|
||||
const existingMatch = resolveContentForOutlineSection(
|
||||
section,
|
||||
index,
|
||||
[],
|
||||
emptyMap,
|
||||
emptyMap,
|
||||
existingContentMap
|
||||
);
|
||||
const payloadContentRaw = existingMatch.content ?? sections?.[section?.id] ?? '';
|
||||
const payloadContent = payloadContentRaw !== undefined && payloadContentRaw !== null ? String(payloadContentRaw) : '';
|
||||
const rawIdentifier = section?.id || section?.section_id || section?.sectionId || section?.sectionID || `section_${index + 1}`;
|
||||
const identifier = String(rawIdentifier).trim();
|
||||
|
||||
return {
|
||||
id: identifier,
|
||||
heading: section.heading,
|
||||
content: payloadContent,
|
||||
};
|
||||
});
|
||||
|
||||
const response = await blogWriterApi.applySeoRecommendations({
|
||||
title: selectedTitle || outline[0]?.heading || 'Untitled Blog',
|
||||
@@ -100,43 +318,59 @@ export const useSEOManager = ({
|
||||
throw new Error('Recommendation response did not include updated sections.');
|
||||
}
|
||||
|
||||
// Update sections - create new object reference to trigger React re-render
|
||||
const newSections: Record<string, string> = {};
|
||||
response.sections.forEach((section) => {
|
||||
if (section.id && section.content) {
|
||||
newSections[section.id] = section.content;
|
||||
}
|
||||
const { byId: responseById, byHeading: responseByHeading } = buildResponseContentMaps(response.sections);
|
||||
|
||||
const normalizedSections: Record<string, string> = {};
|
||||
const sectionKeysForCache: string[] = [];
|
||||
|
||||
outline.forEach((section, index) => {
|
||||
const { content: resolvedContent, matchedKey } = resolveContentForOutlineSection(
|
||||
section,
|
||||
index,
|
||||
response.sections,
|
||||
responseById,
|
||||
responseByHeading,
|
||||
existingContentMap
|
||||
);
|
||||
|
||||
const finalContent = (resolvedContent ?? '').trim();
|
||||
const contentToUse = finalContent || '';
|
||||
const primaryKey = getPrimaryKeyForOutlineSection(section, index);
|
||||
|
||||
normalizedSections[primaryKey] = contentToUse;
|
||||
sectionKeysForCache.push(primaryKey);
|
||||
});
|
||||
|
||||
// Validate we have sections before updating
|
||||
if (Object.keys(newSections).length === 0) {
|
||||
|
||||
const uniqueSectionKeys = Array.from(new Set(sectionKeysForCache));
|
||||
|
||||
if (uniqueSectionKeys.length === 0) {
|
||||
throw new Error('No valid sections received from SEO recommendations application.');
|
||||
}
|
||||
|
||||
// Validate sections have actual content
|
||||
const sectionsWithContent = Object.values(newSections).filter(c => c && c.trim().length > 0);
|
||||
|
||||
const sectionsWithContent = Object.values(normalizedSections).filter(c => c && c.trim().length > 0);
|
||||
if (sectionsWithContent.length === 0) {
|
||||
throw new Error('SEO recommendations resulted in empty sections. Please try again.');
|
||||
}
|
||||
|
||||
// Log detailed section info for debugging
|
||||
const sectionIds = Object.keys(newSections);
|
||||
const sectionSizes = sectionIds.map(id => ({ id, length: newSections[id]?.length || 0 }));
|
||||
debug.log('[BlogWriter] Applied SEO recommendations: sections updated', {
|
||||
sectionCount: sectionIds.length,
|
||||
|
||||
debug.log('[BlogWriter] Applied SEO recommendations: sections normalized', {
|
||||
sectionCount: uniqueSectionKeys.length,
|
||||
sectionsWithContent: sectionsWithContent.length,
|
||||
sectionIds: sectionIds,
|
||||
sectionSizes: sectionSizes,
|
||||
totalContentLength: Object.values(newSections).reduce((sum, c) => sum + (c?.length || 0), 0)
|
||||
sectionKeys: uniqueSectionKeys,
|
||||
totalContentLength: Object.values(normalizedSections).reduce((sum, c) => sum + (c?.length || 0), 0)
|
||||
});
|
||||
|
||||
// Update sections state
|
||||
setSections(newSections);
|
||||
|
||||
|
||||
setSections(normalizedSections);
|
||||
|
||||
try {
|
||||
blogWriterCache.cacheContent(normalizedSections, uniqueSectionKeys);
|
||||
} catch (cacheError) {
|
||||
debug.log('[BlogWriter] Failed to cache SEO-applied content', cacheError);
|
||||
}
|
||||
|
||||
// Force a delay to ensure React processes the state update before proceeding
|
||||
// This gives React time to re-render with new sections before phase navigation checks
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
|
||||
setContinuityRefresh(Date.now());
|
||||
setFlowAnalysisCompleted(false);
|
||||
setFlowAnalysisResults(null);
|
||||
@@ -154,7 +388,7 @@ export const useSEOManager = ({
|
||||
// But we'll stay in SEO phase to show updated content
|
||||
setSeoRecommendationsApplied(true);
|
||||
debug.log('[BlogWriter] seoRecommendationsApplied set to true');
|
||||
|
||||
|
||||
// Ensure we stay in SEO phase to show updated content
|
||||
// Force navigation to SEO phase if we're not already there (safeguard)
|
||||
if (currentPhase !== 'seo') {
|
||||
@@ -163,7 +397,7 @@ export const useSEOManager = ({
|
||||
} else {
|
||||
debug.log('[BlogWriter] Already in SEO phase, staying to show updated content');
|
||||
}
|
||||
}, [outline, sections, selectedTitle, research, setSections, setSelectedTitle, setContinuityRefresh, setFlowAnalysisCompleted, setFlowAnalysisResults, setSeoAnalysis, currentPhase, navigateToPhase]);
|
||||
}, [outline, research, sections, selectedTitle, setSections, setContinuityRefresh, setFlowAnalysisCompleted, setFlowAnalysisResults, setSelectedTitle, setSeoAnalysis, setSeoRecommendationsApplied, currentPhase, navigateToPhase]);
|
||||
|
||||
// Handle SEO analysis completion
|
||||
const handleSEOAnalysisComplete = useCallback((analysis: any) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../../services/blogWriterApi';
|
||||
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage, blogWriterApi } from '../../services/blogWriterApi';
|
||||
import EnhancedOutlineInsights from './EnhancedOutlineInsights';
|
||||
import OutlineIntelligenceChips from './OutlineIntelligenceChips';
|
||||
import ImageGeneratorModal from '../ImageGen/ImageGeneratorModal';
|
||||
@@ -38,6 +38,9 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
key_points: '',
|
||||
target_words: 300
|
||||
});
|
||||
const [showRefineModal, setShowRefineModal] = useState(false);
|
||||
const [refineFeedback, setRefineFeedback] = useState('');
|
||||
const [isRefining, setIsRefining] = useState(false);
|
||||
|
||||
const toggleExpanded = (sectionId: string) => {
|
||||
const newExpanded = new Set(expandedSections);
|
||||
@@ -89,12 +92,53 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefineOutline = async () => {
|
||||
if (!refineFeedback.trim()) {
|
||||
alert('Please provide feedback on how you would like to refine the outline.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRefining(true);
|
||||
try {
|
||||
// Use the parent's onRefine callback which handles the API call and state update
|
||||
// The callback expects: operation, sectionId, payload
|
||||
await onRefine('refine', undefined, { feedback: refineFeedback.trim() });
|
||||
|
||||
setRefineFeedback('');
|
||||
setShowRefineModal(false);
|
||||
|
||||
// Show success message
|
||||
const toast = document.createElement('div');
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
`;
|
||||
toast.textContent = '✅ Outline refined successfully!';
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => document.body.removeChild(toast), 3000);
|
||||
} catch (error) {
|
||||
console.error('Failed to refine outline:', error);
|
||||
alert('Failed to refine outline. Please try again.');
|
||||
} finally {
|
||||
setIsRefining(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTotalWords = () => {
|
||||
return outline.reduce((total, section) => total + (section.target_words || 0), 0);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
@@ -153,24 +197,45 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddSection(!showAddSection)}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
➕ Add Section
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => setShowRefineModal(true)}
|
||||
style={{
|
||||
backgroundColor: '#7b1fa2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
title="Refine the outline structure based on your feedback"
|
||||
>
|
||||
🔧 Refine Outline
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddSection(!showAddSection)}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
➕ Add Section
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -656,6 +721,120 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refine Outline Modal */}
|
||||
{showRefineModal && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
width: '90%',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h2 style={{ margin: '0 0 8px 0', color: '#1f2937', fontSize: '24px', fontWeight: '600' }}>
|
||||
🔧 Refine Outline
|
||||
</h2>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
Provide feedback on how you'd like to improve the outline structure
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#333'
|
||||
}}>
|
||||
Your Feedback
|
||||
</label>
|
||||
<textarea
|
||||
value={refineFeedback}
|
||||
onChange={(e) => setRefineFeedback(e.target.value)}
|
||||
placeholder="E.g., Add a section about best practices, merge sections 2 and 3, expand the introduction..."
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '120px',
|
||||
padding: '12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowRefineModal(false);
|
||||
setRefineFeedback('');
|
||||
}}
|
||||
disabled={isRefining}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#666',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: isRefining ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRefineOutline}
|
||||
disabled={isRefining || !refineFeedback.trim()}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: isRefining || !refineFeedback.trim() ? '#9ca3af' : '#7b1fa2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: isRefining || !refineFeedback.trim() ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
{isRefining ? (
|
||||
<>
|
||||
<span>⏳</span>
|
||||
<span>Refining...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>🔧</span>
|
||||
<span>Refine Outline</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogOutlineSection } from '../../services/blogWriterApi';
|
||||
import { BlogOutlineSection, BlogResearchResponse, blogWriterApi } from '../../services/blogWriterApi';
|
||||
|
||||
interface EnhancedTitleSelectorProps {
|
||||
titleOptions: string[];
|
||||
@@ -9,6 +9,8 @@ interface EnhancedTitleSelectorProps {
|
||||
sections: BlogOutlineSection[];
|
||||
researchTitles?: string[];
|
||||
aiGeneratedTitles?: string[];
|
||||
research?: BlogResearchResponse;
|
||||
onTitlesGenerated?: (titles: string[]) => void;
|
||||
}
|
||||
|
||||
const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
|
||||
@@ -18,10 +20,15 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
|
||||
onCustomTitle,
|
||||
sections,
|
||||
researchTitles = [],
|
||||
aiGeneratedTitles = []
|
||||
aiGeneratedTitles = [],
|
||||
research,
|
||||
onTitlesGenerated
|
||||
}) => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [customTitle, setCustomTitle] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [generatedTitles, setGeneratedTitles] = useState<string[]>([]);
|
||||
const [generationProgress, setGenerationProgress] = useState<string>('');
|
||||
|
||||
const handleTitleSelect = (title: string) => {
|
||||
onTitleSelect(title);
|
||||
@@ -36,6 +43,57 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateSEOTitles = async () => {
|
||||
if (!research || !sections.length || isGenerating) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setGenerationProgress('Analyzing research data and outline structure...');
|
||||
|
||||
try {
|
||||
const keywordAnalysis = research.keyword_analysis || {};
|
||||
const primaryKeywords = keywordAnalysis.primary || [];
|
||||
const secondaryKeywords = keywordAnalysis.secondary || [];
|
||||
const contentAngles = research.suggested_angles || [];
|
||||
const searchIntent = keywordAnalysis.search_intent || 'informational';
|
||||
|
||||
// Simulate progress updates
|
||||
setTimeout(() => setGenerationProgress('Extracting keywords and content angles...'), 500);
|
||||
setTimeout(() => setGenerationProgress('Generating SEO-optimized titles with AI...'), 1500);
|
||||
|
||||
const result = await blogWriterApi.generateSEOTitles({
|
||||
research,
|
||||
outline: sections,
|
||||
primary_keywords: primaryKeywords,
|
||||
secondary_keywords: secondaryKeywords,
|
||||
content_angles: contentAngles,
|
||||
search_intent: searchIntent,
|
||||
word_count: sections.reduce((sum, s) => sum + (s.target_words || 0), 0)
|
||||
});
|
||||
|
||||
setGenerationProgress('Finalizing titles...');
|
||||
|
||||
if (result.success && result.titles) {
|
||||
setTimeout(() => {
|
||||
setGeneratedTitles(result.titles);
|
||||
setGenerationProgress('');
|
||||
if (onTitlesGenerated) {
|
||||
onTitlesGenerated(result.titles);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate SEO titles:', error);
|
||||
setGenerationProgress('');
|
||||
alert('Failed to generate SEO titles. Please try again.');
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setIsGenerating(false);
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const getSectionSummary = () => {
|
||||
return sections.map(section => ({
|
||||
title: section.heading,
|
||||
@@ -66,35 +124,39 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
|
||||
margin: '0',
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: '1.4',
|
||||
maxHeight: '60px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical'
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '600px'
|
||||
}}>
|
||||
{selectedTitle || 'No title selected'}
|
||||
{(selectedTitle || 'No title selected').length > 150
|
||||
? (selectedTitle || 'No title selected').substring(0, 150) + '...'
|
||||
: (selectedTitle || 'No title selected')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
✨ ALwrity it
|
||||
</button>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
position: 'relative'
|
||||
}}
|
||||
title="Open title suggestions. Click 'Generate 5 SEO-Optimized Titles' in the modal to create premium titles (50-65 characters) optimized for search engines using your research data and outline."
|
||||
>
|
||||
✨ ALwrity it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -165,63 +227,163 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section Information */}
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#333' }}>
|
||||
📋 Current Outline Summary
|
||||
</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>Total Sections</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>{sections.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>Total Words</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
|
||||
{sections.reduce((sum, section) => sum + (section.target_words || 0), 0)}
|
||||
{/* Generate SEO Titles Button */}
|
||||
{research && sections.length > 0 && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<button
|
||||
onClick={handleGenerateSEOTitles}
|
||||
disabled={isGenerating}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 24px',
|
||||
backgroundColor: isGenerating ? '#9ca3af' : '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
cursor: isGenerating ? 'not-allowed' : 'pointer',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
transition: 'all 0.2s ease',
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isGenerating) {
|
||||
e.currentTarget.style.backgroundColor = '#1565c0';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isGenerating) {
|
||||
e.currentTarget.style.backgroundColor = '#1976d2';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<span>⏳</span>
|
||||
<span>{generationProgress || 'Generating SEO Titles...'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>✨</span>
|
||||
<span>Generate 5 SEO-Optimized Titles</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isGenerating && (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: '#e5e7eb',
|
||||
borderRadius: '2px',
|
||||
marginTop: '12px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
backgroundColor: '#1976d2',
|
||||
borderRadius: '2px',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
width: '100%'
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>Subheadings</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
|
||||
{sections.reduce((sum, section) => sum + section.subheadings.length, 0)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isGenerating && generationProgress && (
|
||||
<p style={{
|
||||
margin: '8px 0 0 0',
|
||||
color: '#6b7280',
|
||||
fontSize: '13px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{generationProgress}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section Details */}
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Section Details:</h5>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
{sectionSummary.map((section, index) => (
|
||||
<div key={index} style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: '500' }}>{section.title}</span>
|
||||
<div style={{ display: 'flex', gap: '16px', fontSize: '12px', color: '#666' }}>
|
||||
<span>{section.wordCount} words</span>
|
||||
<span>{section.subheadings} subheadings</span>
|
||||
<span>{section.keyPoints} key points</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title Options */}
|
||||
<div style={{ display: 'grid', gap: '24px' }}>
|
||||
{/* Generated SEO Titles */}
|
||||
{generatedTitles.length > 0 && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: '#dcfce7',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px'
|
||||
}}>
|
||||
🎯
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 4px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>
|
||||
SEO-Optimized Titles
|
||||
</h4>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
Premium titles optimized for search engines (50-65 characters)
|
||||
</p>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
backgroundColor: '#16a34a',
|
||||
color: 'white',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{generatedTitles.length}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: '10px' }}>
|
||||
{generatedTitles.map((title, index) => (
|
||||
<button
|
||||
key={`seo-${index}`}
|
||||
onClick={() => handleTitleSelect(title)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px 20px',
|
||||
border: selectedTitle === title ? '2px solid #16a34a' : '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: selectedTitle === title ? '#f0fdf4' : 'white',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontSize: '15px',
|
||||
color: '#1f2937',
|
||||
transition: 'all 0.2s ease',
|
||||
lineHeight: '1.4',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = '#f9fafb';
|
||||
e.currentTarget.style.borderColor = '#d1d5db';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = 'white';
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
}
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Research Content Angles */}
|
||||
{researchTitles.length > 0 && (
|
||||
<div>
|
||||
@@ -274,7 +436,9 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
|
||||
color: '#1f2937',
|
||||
transition: 'all 0.2s ease',
|
||||
lineHeight: '1.4',
|
||||
wordBreak: 'break-word'
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
@@ -348,7 +512,9 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
|
||||
color: '#1f2937',
|
||||
transition: 'all 0.2s ease',
|
||||
lineHeight: '1.4',
|
||||
wordBreak: 'break-word'
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
@@ -452,6 +618,61 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Information */}
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginTop: '24px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#333' }}>
|
||||
📋 Current Outline Summary
|
||||
</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>Total Sections</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>{sections.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>Total Words</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
|
||||
{sections.reduce((sum, section) => sum + (section.target_words || 0), 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>Subheadings</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
|
||||
{sections.reduce((sum, section) => sum + section.subheadings.length, 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Details */}
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Section Details:</h5>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
{sectionSummary.map((section, index) => (
|
||||
<div key={index} style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: '500' }}>{section.title}</span>
|
||||
<div style={{ display: 'flex', gap: '16px', fontSize: '12px', color: '#666' }}>
|
||||
<span>{section.wordCount} words</span>
|
||||
<span>{section.subheadings} subheadings</span>
|
||||
<span>{section.keyPoints} key points</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -31,7 +31,11 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
setForceUpdate(prev => prev + 1); // Force re-render
|
||||
},
|
||||
onComplete: (result) => {
|
||||
console.info('[ResearchAction] ✅ Research completed', { hasResult: !!result });
|
||||
console.info('[ResearchAction] ✅ Research completed (onComplete callback)', {
|
||||
hasResult: !!result,
|
||||
resultKeys: result ? Object.keys(result) : [],
|
||||
status: polling.currentStatus
|
||||
});
|
||||
|
||||
if (result && result.keywords) {
|
||||
researchCache.cacheResult(
|
||||
@@ -45,7 +49,10 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
// Reset navigation tracking when research completes
|
||||
hasNavigatedRef.current = false;
|
||||
|
||||
// Call parent callback first
|
||||
onResearchComplete?.(result);
|
||||
|
||||
// Close modal immediately when research completes
|
||||
setCurrentTaskId(null);
|
||||
setCurrentMessage('');
|
||||
setShowProgressModal(false);
|
||||
@@ -60,26 +67,47 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal when research completes (status becomes 'completed' or polling stops with result)
|
||||
// Set of statuses that indicate successful completion
|
||||
const COMPLETED_STATUSES = React.useMemo(
|
||||
() => new Set(['completed', 'success', 'succeeded', 'finished']),
|
||||
[]
|
||||
);
|
||||
|
||||
// Close modal when research completes (status becomes a completed state or polling stops with a result)
|
||||
useEffect(() => {
|
||||
if (showProgressModal && (
|
||||
polling.currentStatus === 'completed' ||
|
||||
(!polling.isPolling && polling.result && polling.currentStatus !== 'failed')
|
||||
)) {
|
||||
const normalizedStatus = (polling.currentStatus || '').toLowerCase();
|
||||
const isCompleted = COMPLETED_STATUSES.has(normalizedStatus);
|
||||
|
||||
// Check if we have a result (indicates completion even if status isn't updated yet)
|
||||
const hasResult = !!polling.result;
|
||||
|
||||
// Check if polling stopped and we have a result, or status indicates completion
|
||||
const shouldClose = showProgressModal && (
|
||||
isCompleted ||
|
||||
(hasResult && normalizedStatus !== 'failed') ||
|
||||
(!polling.isPolling && hasResult && normalizedStatus !== 'failed')
|
||||
);
|
||||
|
||||
if (shouldClose) {
|
||||
console.info('[ResearchAction] Closing modal - research completed', {
|
||||
status: polling.currentStatus,
|
||||
isPolling: polling.isPolling,
|
||||
hasResult: !!polling.result
|
||||
hasResult: hasResult,
|
||||
normalizedStatus: normalizedStatus,
|
||||
isCompleted: isCompleted
|
||||
});
|
||||
// Small delay to show completion message before closing
|
||||
const timer = setTimeout(() => {
|
||||
setShowProgressModal(false);
|
||||
setCurrentTaskId(null);
|
||||
setCurrentMessage('');
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
// Close modal immediately when research completes
|
||||
setShowProgressModal(false);
|
||||
setCurrentTaskId(null);
|
||||
setCurrentMessage('');
|
||||
}
|
||||
}, [polling.currentStatus, polling.isPolling, polling.result, showProgressModal]);
|
||||
}, [
|
||||
COMPLETED_STATUSES,
|
||||
polling.currentStatus,
|
||||
polling.isPolling,
|
||||
polling.result,
|
||||
showProgressModal
|
||||
]);
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'showResearchForm',
|
||||
@@ -256,7 +284,7 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
<>
|
||||
{showProgressModal && (
|
||||
<ResearchProgressModal
|
||||
open={showProgressModal && polling.currentStatus !== 'completed'}
|
||||
open={showProgressModal}
|
||||
title={"Research in progress"}
|
||||
status={polling.currentStatus}
|
||||
messages={polling.progressMessages}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
interface ResearchProgressModalProps {
|
||||
open: boolean;
|
||||
@@ -9,6 +9,269 @@ interface ResearchProgressModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tone = 'info' | 'active' | 'success' | 'warning' | 'error';
|
||||
type StageState = 'upcoming' | 'active' | 'done' | 'error';
|
||||
|
||||
const statusThemes: Record<
|
||||
string,
|
||||
{ label: string; description: string; color: string; background: string }
|
||||
> = {
|
||||
pending: {
|
||||
label: 'Queued',
|
||||
description: 'Preparing the research workflow…',
|
||||
color: '#1f2937',
|
||||
background: '#e5e7eb'
|
||||
},
|
||||
running: {
|
||||
label: 'In Progress',
|
||||
description: 'Gathering sources and extracting insights.',
|
||||
color: '#1d4ed8',
|
||||
background: '#dbeafe'
|
||||
},
|
||||
completed: {
|
||||
label: 'Completed',
|
||||
description: 'Research results are ready to review.',
|
||||
color: '#047857',
|
||||
background: '#d1fae5'
|
||||
},
|
||||
success: {
|
||||
label: 'Completed',
|
||||
description: 'Research results are ready to review.',
|
||||
color: '#047857',
|
||||
background: '#d1fae5'
|
||||
},
|
||||
succeeded: {
|
||||
label: 'Completed',
|
||||
description: 'Research results are ready to review.',
|
||||
color: '#047857',
|
||||
background: '#d1fae5'
|
||||
},
|
||||
finished: {
|
||||
label: 'Completed',
|
||||
description: 'Research results are ready to review.',
|
||||
color: '#047857',
|
||||
background: '#d1fae5'
|
||||
},
|
||||
failed: {
|
||||
label: 'Needs Attention',
|
||||
description: 'We hit an issue while running research.',
|
||||
color: '#b91c1c',
|
||||
background: '#fee2e2'
|
||||
}
|
||||
};
|
||||
|
||||
const toneStyles: Record<Tone, { bg: string; border: string; text: string }> = {
|
||||
info: { bg: '#f8fafc', border: '#e2e8f0', text: '#0f172a' },
|
||||
active: { bg: '#eff6ff', border: '#bfdbfe', text: '#1d4ed8' },
|
||||
success: { bg: '#ecfdf5', border: '#bbf7d0', text: '#047857' },
|
||||
warning: { bg: '#fff7ed', border: '#fed7aa', text: '#c2410c' },
|
||||
error: { bg: '#fef2f2', border: '#fecaca', text: '#b91c1c' }
|
||||
};
|
||||
|
||||
const stageDefinitions = [
|
||||
{
|
||||
id: 'cache',
|
||||
label: 'Cache Check',
|
||||
description: 'Looking for saved research results to speed things up.',
|
||||
icon: '🗂️',
|
||||
keywords: ['cache', 'cached', 'stored']
|
||||
},
|
||||
{
|
||||
id: 'discovery',
|
||||
label: 'Source Discovery',
|
||||
description: 'Exploring trusted sources across the web.',
|
||||
icon: '🔎',
|
||||
keywords: ['search', 'source', 'gather', 'google', 'discover']
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
label: 'Insight Extraction',
|
||||
description: 'Extracting data points, statistics, and quotes.',
|
||||
icon: '🧠',
|
||||
keywords: ['analysis', 'analyz', 'extract', 'insight', 'processing']
|
||||
},
|
||||
{
|
||||
id: 'assembly',
|
||||
label: 'Structuring Findings',
|
||||
description: 'Packaging insights and preparing summaries.',
|
||||
icon: '📝',
|
||||
keywords: ['assembling', 'structuring', 'summary', 'completed', 'ready', 'post-processing']
|
||||
}
|
||||
] as const;
|
||||
|
||||
type StageId = (typeof stageDefinitions)[number]['id'];
|
||||
|
||||
interface MessageMeta {
|
||||
timestamp: string;
|
||||
timeLabel: string;
|
||||
raw: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: string;
|
||||
tone: Tone;
|
||||
stage: StageId | null;
|
||||
}
|
||||
|
||||
const completionStatuses = new Set(['completed', 'success', 'succeeded', 'finished']);
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).format(new Date(timestamp));
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
};
|
||||
|
||||
const inferStage = (text: string): StageId | null => {
|
||||
const lower = text.toLowerCase();
|
||||
for (const stage of stageDefinitions) {
|
||||
if (stage.keywords.some(keyword => lower.includes(keyword))) {
|
||||
return stage.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const friendlyMappings: Array<{
|
||||
keywords: string[];
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: string;
|
||||
tone: Tone;
|
||||
stage?: StageId;
|
||||
}> = [
|
||||
{
|
||||
keywords: ['checking cache', 'cache'],
|
||||
title: 'Checking existing research cache',
|
||||
subtitle: 'Looking for previously generated insights so we can respond instantly.',
|
||||
icon: '🗂️',
|
||||
tone: 'info',
|
||||
stage: 'cache'
|
||||
},
|
||||
{
|
||||
keywords: ['found cached research', 'loading cached'],
|
||||
title: 'Loaded cached research results',
|
||||
subtitle: 'Serving saved insights to keep things fast.',
|
||||
icon: '⚡',
|
||||
tone: 'success',
|
||||
stage: 'cache'
|
||||
},
|
||||
{
|
||||
keywords: ['starting research'],
|
||||
title: 'Launching fresh research',
|
||||
subtitle: 'Bootstrapping the workflow and validating your request.',
|
||||
icon: '🚀',
|
||||
tone: 'active',
|
||||
stage: 'discovery'
|
||||
},
|
||||
{
|
||||
keywords: ['search', 'query', 'sources', 'web'],
|
||||
title: 'Collecting authoritative sources',
|
||||
subtitle: 'Evaluating top-ranked pages, studies, and reports.',
|
||||
icon: '🔎',
|
||||
tone: 'active',
|
||||
stage: 'discovery'
|
||||
},
|
||||
{
|
||||
keywords: ['extracting', 'analyzing', 'analysis', 'insight'],
|
||||
title: 'Extracting key insights',
|
||||
subtitle: 'Summarising statistics, trends, and quotes that matter.',
|
||||
icon: '🧠',
|
||||
tone: 'active',
|
||||
stage: 'analysis'
|
||||
},
|
||||
{
|
||||
keywords: ['assembling', 'compiling', 'structuring', 'post-processing'],
|
||||
title: 'Structuring the research package',
|
||||
subtitle: 'Organising findings into ready-to-use sections.',
|
||||
icon: '🧩',
|
||||
tone: 'info',
|
||||
stage: 'assembly'
|
||||
},
|
||||
{
|
||||
keywords: ['completed successfully', 'research completed', 'ready'],
|
||||
title: 'Research completed successfully',
|
||||
subtitle: 'All insights are ready for the outline phase.',
|
||||
icon: '✅',
|
||||
tone: 'success',
|
||||
stage: 'assembly'
|
||||
},
|
||||
{
|
||||
keywords: ['failed', 'error', 'limit exceeded'],
|
||||
title: 'Research encountered an issue',
|
||||
subtitle: 'Review the error message below and try again.',
|
||||
icon: '⚠️',
|
||||
tone: 'error'
|
||||
}
|
||||
];
|
||||
|
||||
const sanitizeTitle = (text: string) => text.replace(/^[^a-zA-Z0-9]+/, '').trim();
|
||||
|
||||
const mapMessageToMeta = (message: { timestamp: string; message: string }): MessageMeta => {
|
||||
const raw = message.message || '';
|
||||
const lower = raw.toLowerCase();
|
||||
|
||||
const mapping = friendlyMappings.find(entry =>
|
||||
entry.keywords.some(keyword => lower.includes(keyword))
|
||||
);
|
||||
|
||||
if (mapping) {
|
||||
return {
|
||||
timestamp: message.timestamp,
|
||||
timeLabel: formatTime(message.timestamp),
|
||||
raw,
|
||||
title: mapping.title,
|
||||
subtitle: mapping.subtitle,
|
||||
icon: mapping.icon,
|
||||
tone: mapping.tone,
|
||||
stage: mapping.stage ?? inferStage(raw)
|
||||
};
|
||||
}
|
||||
|
||||
const stage = inferStage(raw);
|
||||
|
||||
return {
|
||||
timestamp: message.timestamp,
|
||||
timeLabel: formatTime(message.timestamp),
|
||||
raw,
|
||||
title: sanitizeTitle(raw) || 'Update received',
|
||||
icon: '📝',
|
||||
tone: 'info',
|
||||
stage
|
||||
};
|
||||
};
|
||||
|
||||
const stageStateCopy: Record<StageState, { label: string; color: string; background: string; border: string }> = {
|
||||
upcoming: {
|
||||
label: 'Pending',
|
||||
color: '#6b7280',
|
||||
background: '#f3f4f6',
|
||||
border: '#e5e7eb'
|
||||
},
|
||||
active: {
|
||||
label: 'In Progress',
|
||||
color: '#2563eb',
|
||||
background: '#eff6ff',
|
||||
border: '#bfdbfe'
|
||||
},
|
||||
done: {
|
||||
label: 'Completed',
|
||||
color: '#047857',
|
||||
background: '#ecfdf5',
|
||||
border: '#bbf7d0'
|
||||
},
|
||||
error: {
|
||||
label: 'Needs Attention',
|
||||
color: '#b91c1c',
|
||||
background: '#fee2e2',
|
||||
border: '#fecaca'
|
||||
}
|
||||
};
|
||||
|
||||
const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
|
||||
open,
|
||||
title = 'Research in progress',
|
||||
@@ -17,63 +280,176 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
|
||||
error,
|
||||
onClose
|
||||
}) => {
|
||||
if (!open) return null;
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const normalizedStatus = (status || '').toLowerCase();
|
||||
const statusKey = error ? 'failed' : normalizedStatus;
|
||||
const statusInfo = statusThemes[statusKey] || statusThemes.pending;
|
||||
|
||||
const processedMessages = useMemo(() => {
|
||||
if (!messages || messages.length === 0) {
|
||||
return [] as MessageMeta[];
|
||||
}
|
||||
return messages.map(mapMessageToMeta);
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, [processedMessages.length]);
|
||||
|
||||
const latestMessage = processedMessages.length > 0 ? processedMessages[processedMessages.length - 1] : null;
|
||||
|
||||
const stagesWithState = useMemo(() => {
|
||||
const states: StageState[] = stageDefinitions.map(() => 'upcoming');
|
||||
let highestCompletedIndex = -1;
|
||||
|
||||
processedMessages.forEach(meta => {
|
||||
if (!meta.stage) {
|
||||
return;
|
||||
}
|
||||
const idx = stageDefinitions.findIndex(stage => stage.id === meta.stage);
|
||||
if (idx === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta.tone === 'error' || /error|failed/i.test(meta.raw)) {
|
||||
states[idx] = 'error';
|
||||
} else {
|
||||
states[idx] = 'done';
|
||||
if (idx > highestCompletedIndex) {
|
||||
highestCompletedIndex = idx;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
const firstPending = states.findIndex(state => state === 'upcoming');
|
||||
if (firstPending !== -1 && !completionStatuses.has(normalizedStatus)) {
|
||||
states[firstPending] = 'active';
|
||||
} else if (completionStatuses.has(normalizedStatus)) {
|
||||
for (let i = 0; i < states.length; i += 1) {
|
||||
if (states[i] !== 'error') {
|
||||
states[i] = 'done';
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (highestCompletedIndex >= 0) {
|
||||
states[highestCompletedIndex] = 'error';
|
||||
}
|
||||
|
||||
return stageDefinitions.map((stage, index) => ({
|
||||
...stage,
|
||||
state: states[index]
|
||||
}));
|
||||
}, [error, normalizedStatus, processedMessages]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 2000
|
||||
}}>
|
||||
<div style={{
|
||||
width: '92%',
|
||||
maxWidth: 900,
|
||||
maxHeight: '82vh',
|
||||
background: 'white',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 24px 60px rgba(0,0,0,0.3)',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{/* Header with background illustration */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
padding: '28px 28px 24px 28px',
|
||||
background: '#f8fafc'
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: 'url(/blog-writer-bg.png)',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundSize: '38% auto',
|
||||
opacity: 0.12
|
||||
}} />
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="research-progress-title"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.55)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 2000,
|
||||
padding: '24px'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 940,
|
||||
maxHeight: '82vh',
|
||||
background: '#ffffff',
|
||||
borderRadius: 18,
|
||||
boxShadow: '0 28px 80px rgba(15, 23, 42, 0.25)',
|
||||
border: '1px solid #e2e8f0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '28px 32px 24px 32px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: 'url(/blog-writer-bg.png)',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundSize: '35% auto',
|
||||
opacity: 0.12,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 20, color: '#111827' }}>{title}</h3>
|
||||
<p style={{ margin: '6px 0 0 0', color: '#6b7280', fontSize: 13 }}>We are gathering sources, extracting insights, and preparing high‑quality research.</p>
|
||||
{status && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#374151' }}>Status: {status}</div>
|
||||
)}
|
||||
<h3 id="research-progress-title" style={{ margin: 0, fontSize: 22, color: '#0f172a' }}>
|
||||
{title}
|
||||
</h3>
|
||||
<p style={{ margin: '8px 0 0 0', color: '#475569', fontSize: 14 }}>
|
||||
We are gathering sources, extracting insights, and assembling a research brief tailored to your topic.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 14px',
|
||||
borderRadius: 999,
|
||||
background: statusInfo.background,
|
||||
color: statusInfo.color,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
border: `1px solid ${statusInfo.color}1A`
|
||||
}}
|
||||
>
|
||||
<span>{statusInfo.label}</span>
|
||||
<span style={{ fontSize: 12, color: '#475569', fontWeight: 500 }}>{statusInfo.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 10,
|
||||
padding: '8px 12px',
|
||||
background: '#ffffff',
|
||||
border: '1px solid #cbd5f5',
|
||||
borderRadius: 12,
|
||||
padding: '10px 14px',
|
||||
cursor: 'pointer',
|
||||
color: '#374151'
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#1f2937',
|
||||
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.08)',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
Close
|
||||
@@ -81,29 +457,157 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages list */}
|
||||
<div style={{ padding: 20 }}>
|
||||
<div style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
background: '#ffffff'
|
||||
}}>
|
||||
<div style={{ maxHeight: '48vh', overflowY: 'auto' }}>
|
||||
{messages.length === 0 && (
|
||||
<div style={{ padding: 16, color: '#6b7280', fontSize: 14 }}>Awaiting progress updates…</div>
|
||||
)}
|
||||
{messages.map((m, idx) => (
|
||||
<div key={idx} style={{ display: 'flex', gap: 12, padding: '12px 16px', borderTop: idx === 0 ? 'none' : '1px solid #f3f4f6' }}>
|
||||
<div style={{ color: '#9ca3af', minWidth: 120, fontSize: 12 }}>{new Date(m.timestamp).toLocaleTimeString()}</div>
|
||||
<div style={{ color: '#374151', fontSize: 14 }}>{m.message}</div>
|
||||
<div style={{ padding: '24px 32px', overflow: 'auto' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
marginBottom: 20
|
||||
}}
|
||||
>
|
||||
{stagesWithState.map(stage => {
|
||||
const copy = stageStateCopy[stage.state];
|
||||
return (
|
||||
<div
|
||||
key={stage.id}
|
||||
style={{
|
||||
flex: '1 1 180px',
|
||||
minWidth: 180,
|
||||
borderRadius: 14,
|
||||
padding: '14px 16px',
|
||||
background: copy.background,
|
||||
border: `1px solid ${copy.border}`,
|
||||
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.6)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 600, color: '#0f172a' }}>
|
||||
<span style={{ fontSize: 22 }}>{stage.icon}</span>
|
||||
<span>{stage.label}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 12.5, color: '#475569' }}>{stage.description}</div>
|
||||
<div style={{ marginTop: 12, fontSize: 12, fontWeight: 600, color: copy.color }}>{copy.label}</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{latestMessage && (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
padding: '18px 20px',
|
||||
border: `1px solid ${toneStyles[latestMessage.tone].border}`,
|
||||
background: toneStyles[latestMessage.tone].bg,
|
||||
marginBottom: 20,
|
||||
boxShadow: '0 4px 16px rgba(15, 23, 42, 0.08)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
|
||||
<div style={{ fontSize: 28 }}>{latestMessage.icon}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: 16
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 17, fontWeight: 600, color: '#0f172a' }}>{latestMessage.title}</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b' }}>{latestMessage.timeLabel}</div>
|
||||
</div>
|
||||
{latestMessage.subtitle && (
|
||||
<div style={{ marginTop: 6, fontSize: 13.5, color: '#334155' }}>{latestMessage.subtitle}</div>
|
||||
)}
|
||||
{latestMessage.raw && (
|
||||
<div style={{ marginTop: 10, fontSize: 12.5, color: '#64748b' }}>{latestMessage.raw}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 16,
|
||||
padding: '18px 0',
|
||||
maxHeight: '32vh',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
padding: '0 20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12
|
||||
}}
|
||||
>
|
||||
{processedMessages.length === 0 && (
|
||||
<div style={{ padding: '10px 0', color: '#6b7280', fontSize: 14 }}>
|
||||
Awaiting progress updates…
|
||||
</div>
|
||||
)}
|
||||
{processedMessages.map((meta, index) => {
|
||||
const styles = toneStyles[meta.tone];
|
||||
return (
|
||||
<div
|
||||
key={`${meta.timestamp}-${index}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 14,
|
||||
padding: '12px 14px',
|
||||
borderRadius: 12,
|
||||
background: styles.bg,
|
||||
border: `1px solid ${styles.border}`
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 22 }}>{meta.icon}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: 12
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, color: styles.text, fontSize: 14 }}>{meta.title}</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b' }}>{meta.timeLabel}</div>
|
||||
</div>
|
||||
{meta.subtitle && (
|
||||
<div style={{ marginTop: 4, fontSize: 13, color: '#475569' }}>{meta.subtitle}</div>
|
||||
)}
|
||||
{meta.raw && (
|
||||
<div style={{ marginTop: 6, fontSize: 12.5, color: '#6b7280' }}>{meta.raw}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ marginTop: 12, color: '#b91c1c', fontSize: 13 }}>Error: {error}</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 18,
|
||||
padding: '12px 16px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #fecaca',
|
||||
background: '#fef2f2',
|
||||
color: '#b91c1c',
|
||||
fontSize: 13.5
|
||||
}}
|
||||
>
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,4 +617,3 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
|
||||
|
||||
export default ResearchProgressModal;
|
||||
|
||||
|
||||
|
||||
@@ -191,29 +191,72 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
}, [isOpen, blogContent?.length, researchData]);
|
||||
|
||||
const runSEOAnalysis = useCallback(async (forceRefresh = false) => {
|
||||
// Prevent multiple simultaneous calls
|
||||
if (isAnalyzing && !forceRefresh) {
|
||||
console.log('⏸️ SEO analysis already in progress, skipping duplicate call');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsAnalyzing(true);
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
setProgressMessage('Starting SEO analysis...');
|
||||
setProgressMessage('Checking cache for previous SEO analysis...');
|
||||
|
||||
// Cache check
|
||||
const hash = contentHash || (await hashContent(`${blogTitle || ''}\n${blogContent}`));
|
||||
// Cache check - always check cache first unless force refresh is requested
|
||||
// Compute hash if not already available
|
||||
let hash = contentHash;
|
||||
if (!hash) {
|
||||
hash = await hashContent(`${blogTitle || ''}\n${blogContent}`);
|
||||
// Update state for future use
|
||||
setContentHash(hash);
|
||||
}
|
||||
const cacheKey = getSeoCacheKey(hash, blogTitle);
|
||||
console.log('🔍 Checking SEO cache', {
|
||||
cacheKey,
|
||||
hasHash: !!hash,
|
||||
forceRefresh,
|
||||
hashLength: hash?.length,
|
||||
titleLength: blogTitle?.length,
|
||||
contentLength: blogContent?.length
|
||||
});
|
||||
|
||||
if (!forceRefresh) {
|
||||
const cached = typeof window !== 'undefined' ? window.localStorage.getItem(cacheKey) : null;
|
||||
if (cached) {
|
||||
const parsed = JSON.parse(cached);
|
||||
setAnalysisResult(parsed as SEOAnalysisResult);
|
||||
setIsAnalyzing(false);
|
||||
// Notify parent that analysis is complete (from cache)
|
||||
if (onAnalysisComplete) {
|
||||
onAnalysisComplete(parsed as SEOAnalysisResult);
|
||||
try {
|
||||
const parsed = JSON.parse(cached) as SEOAnalysisResult;
|
||||
// Validate cached data has required fields
|
||||
if (parsed && typeof parsed.overall_score === 'number' && parsed.category_scores) {
|
||||
console.log('✅ Using cached SEO analysis', { cacheKey, overall_score: parsed.overall_score });
|
||||
setAnalysisResult(parsed);
|
||||
setIsAnalyzing(false);
|
||||
setProgress(100);
|
||||
setProgressMessage('SEO analysis loaded from cache');
|
||||
// Notify parent that analysis is complete (from cache)
|
||||
if (onAnalysisComplete) {
|
||||
onAnalysisComplete(parsed);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
console.warn('⚠️ Cached SEO analysis data is invalid, will fetch fresh analysis');
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('⚠️ Failed to parse cached SEO analysis, will fetch fresh analysis', parseError);
|
||||
// Remove invalid cache entry
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(cacheKey);
|
||||
}
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
console.log('ℹ️ No cached SEO analysis found, will fetch from API', { cacheKey });
|
||||
}
|
||||
} else {
|
||||
console.log('🔄 Force refresh requested, skipping cache check');
|
||||
}
|
||||
|
||||
setProgressMessage('Starting SEO analysis...');
|
||||
|
||||
// Simulated progress
|
||||
const progressStages = [
|
||||
{ progress: 20, message: 'Extracting keywords from research data...' },
|
||||
@@ -297,14 +340,17 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
|
||||
setAnalysisResult(convertedResult);
|
||||
|
||||
// Save to cache
|
||||
// Save to cache - use the same cacheKey that was used for checking
|
||||
try {
|
||||
const h = hash || (await hashContent(`${blogTitle || ''}\n${blogContent}`));
|
||||
const key = getSeoCacheKey(h, blogTitle);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(key, JSON.stringify(convertedResult));
|
||||
// Use the same hash and cacheKey from the cache check section
|
||||
// This ensures consistency between cache check and save
|
||||
if (typeof window !== 'undefined' && cacheKey) {
|
||||
window.localStorage.setItem(cacheKey, JSON.stringify(convertedResult));
|
||||
console.log('💾 SEO analysis cached', { cacheKey, overall_score: convertedResult.overall_score });
|
||||
}
|
||||
} catch {}
|
||||
} catch (cacheError) {
|
||||
console.warn('⚠️ Failed to cache SEO analysis', cacheError);
|
||||
}
|
||||
|
||||
setIsAnalyzing(false);
|
||||
|
||||
@@ -340,21 +386,37 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
}
|
||||
}, [blogContent, blogTitle, researchData, contentHash, onAnalysisComplete]);
|
||||
|
||||
// Precompute hash when modal opens
|
||||
// Precompute hash when modal opens and trigger cache check
|
||||
// Use a ref to prevent multiple simultaneous calls
|
||||
const hasRunAnalysisRef = React.useRef(false);
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (isOpen && !hasRunAnalysisRef.current) {
|
||||
hasRunAnalysisRef.current = true;
|
||||
(async () => {
|
||||
const h = await hashContent(`${blogTitle || ''}\n${blogContent}`);
|
||||
setContentHash(h);
|
||||
// After hash is computed, check cache if we don't have analysis result yet
|
||||
if (!analysisResult) {
|
||||
// Small delay to ensure hash is set in state
|
||||
setTimeout(() => {
|
||||
runSEOAnalysis();
|
||||
}, 100);
|
||||
}
|
||||
})();
|
||||
} else if (!isOpen) {
|
||||
// Reset hash and flag when modal closes
|
||||
setContentHash('');
|
||||
hasRunAnalysisRef.current = false;
|
||||
}
|
||||
}, [isOpen, blogContent, blogTitle]);
|
||||
}, [isOpen, blogContent, blogTitle, analysisResult, runSEOAnalysis]);
|
||||
|
||||
// Fallback: if modal opens and hash is already computed, check cache immediately
|
||||
useEffect(() => {
|
||||
if (isOpen && !analysisResult) {
|
||||
if (isOpen && !analysisResult && contentHash && !hasRunAnalysisRef.current) {
|
||||
hasRunAnalysisRef.current = true;
|
||||
runSEOAnalysis();
|
||||
}
|
||||
}, [isOpen, analysisResult, runSEOAnalysis]);
|
||||
}, [isOpen, analysisResult, contentHash, runSEOAnalysis]);
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return 'success.main';
|
||||
|
||||
@@ -146,19 +146,6 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Auto-generate metadata when modal opens (only once)
|
||||
const hasAutoGeneratedRef = React.useRef(false);
|
||||
useEffect(() => {
|
||||
if (isOpen && blogContent && !hasAutoGeneratedRef.current) {
|
||||
hasAutoGeneratedRef.current = true;
|
||||
generateMetadata(false); // Auto-generate from cache or API
|
||||
}
|
||||
if (!isOpen) {
|
||||
hasAutoGeneratedRef.current = false; // Reset when modal closes
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]); // Only trigger when modal opens
|
||||
|
||||
const generateMetadata = useCallback(async (forceRefresh = false) => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
@@ -169,10 +156,15 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
|
||||
console.log('🚀 Starting SEO metadata generation...', { forceRefresh });
|
||||
|
||||
// Calculate content hash for caching
|
||||
const hash = await hashContent(`${blogTitle || ''}\n${blogContent}`);
|
||||
setContentHash(hash);
|
||||
// Calculate content hash for caching - use existing hash if available
|
||||
let hash = contentHash;
|
||||
if (!hash) {
|
||||
hash = await hashContent(`${blogTitle || ''}\n${blogContent}`);
|
||||
// Update state for future use
|
||||
setContentHash(hash);
|
||||
}
|
||||
const cacheKey = getMetadataCacheKey(hash, blogTitle);
|
||||
console.log('🔍 Checking SEO metadata cache', { cacheKey, hasHash: !!hash, forceRefresh });
|
||||
|
||||
// Check cache first (unless force refresh)
|
||||
if (!forceRefresh && typeof window !== 'undefined') {
|
||||
@@ -180,15 +172,32 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached) as SEOMetadataResult;
|
||||
console.log('✅ Using cached SEO metadata');
|
||||
setMetadataResult(parsed);
|
||||
setEditableMetadata(parsed);
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
// Validate cached data has required fields
|
||||
if (parsed && parsed.success !== undefined) {
|
||||
console.log('✅ Using cached SEO metadata', { cacheKey, success: parsed.success });
|
||||
setMetadataResult(parsed);
|
||||
setEditableMetadata(parsed);
|
||||
setIsGenerating(false);
|
||||
// Notify parent that metadata is available
|
||||
if (onMetadataGenerated) {
|
||||
onMetadataGenerated(parsed);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
console.warn('⚠️ Cached SEO metadata data is invalid, will fetch fresh metadata');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse cached metadata:', e);
|
||||
console.warn('⚠️ Failed to parse cached SEO metadata, will fetch fresh metadata', e);
|
||||
// Remove invalid cache entry
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(cacheKey);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ No cached SEO metadata found, will fetch from API', { cacheKey });
|
||||
}
|
||||
} else {
|
||||
console.log('🔄 Force refresh requested, skipping cache check');
|
||||
}
|
||||
|
||||
// Make API call to generate metadata
|
||||
@@ -203,7 +212,43 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
const result = response.data;
|
||||
console.log('✅ SEO metadata generation response:', result);
|
||||
|
||||
if (!result.success) {
|
||||
// Check if the response indicates a subscription error (even if HTTP status is 200)
|
||||
if (!result.success && result.error) {
|
||||
const errorMessage = result.error;
|
||||
// Check if error message indicates subscription limit (429/402)
|
||||
if (errorMessage.includes('Token limit') ||
|
||||
errorMessage.includes('limit would be exceeded') ||
|
||||
errorMessage.includes('usage limit') ||
|
||||
errorMessage.includes('subscription')) {
|
||||
console.log('SEOMetadataModal: Detected subscription error in response data', {
|
||||
error: errorMessage,
|
||||
data: result
|
||||
});
|
||||
|
||||
// Create a mock error object with subscription error data
|
||||
const mockError = {
|
||||
response: {
|
||||
status: 429, // Treat as 429 for subscription error
|
||||
data: {
|
||||
error: errorMessage,
|
||||
message: result.message || errorMessage,
|
||||
provider: result.provider || 'unknown',
|
||||
usage_info: result.usage_info || {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handled = await triggerSubscriptionError(mockError);
|
||||
if (handled) {
|
||||
console.log('SEOMetadataModal: Global subscription error handler triggered successfully');
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
} else {
|
||||
console.warn('SEOMetadataModal: Global subscription error handler did not handle the error');
|
||||
}
|
||||
}
|
||||
|
||||
// If not a subscription error, throw the error normally
|
||||
throw new Error(result.error || 'Metadata generation failed');
|
||||
}
|
||||
|
||||
@@ -226,15 +271,51 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
|
||||
// Check if this is a subscription error (429/402) and trigger global subscription modal
|
||||
const status = err?.response?.status;
|
||||
const errorMessage = err?.message || err?.response?.data?.error || '';
|
||||
|
||||
// Check HTTP status code first
|
||||
if (status === 429 || status === 402) {
|
||||
console.log('SEOMetadataModal: Detected subscription error, triggering global handler', {
|
||||
console.log('SEOMetadataModal: Detected subscription error (HTTP status), triggering global handler', {
|
||||
status,
|
||||
data: err?.response?.data
|
||||
});
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
console.log('SEOMetadataModal: Global subscription error handler triggered successfully');
|
||||
// Don't set local error - let the global modal handle it
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
} else {
|
||||
console.warn('SEOMetadataModal: Global subscription error handler did not handle the error');
|
||||
}
|
||||
}
|
||||
|
||||
// Also check error message for subscription-related errors (in case API returns 200 with error in body)
|
||||
if (errorMessage.includes('Token limit') ||
|
||||
errorMessage.includes('limit would be exceeded') ||
|
||||
errorMessage.includes('usage limit') ||
|
||||
errorMessage.includes('subscription') ||
|
||||
errorMessage.includes('429')) {
|
||||
console.log('SEOMetadataModal: Detected subscription error (error message), triggering global handler', {
|
||||
errorMessage,
|
||||
err
|
||||
});
|
||||
|
||||
// Create a mock error object with subscription error data
|
||||
const mockError = {
|
||||
response: {
|
||||
status: 429,
|
||||
data: {
|
||||
error: errorMessage,
|
||||
message: errorMessage,
|
||||
provider: err?.response?.data?.provider || 'unknown',
|
||||
usage_info: err?.response?.data?.usage_info || {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handled = await triggerSubscriptionError(mockError);
|
||||
if (handled) {
|
||||
console.log('SEOMetadataModal: Global subscription error handler triggered successfully (from error message)');
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
} else {
|
||||
@@ -247,7 +328,34 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [blogContent, blogTitle, researchData, outline, seoAnalysis]);
|
||||
}, [blogContent, blogTitle, researchData, outline, seoAnalysis, contentHash, onMetadataGenerated]);
|
||||
|
||||
// Precompute hash when modal opens and trigger cache check
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
(async () => {
|
||||
const h = await hashContent(`${blogTitle || ''}\n${blogContent}`);
|
||||
setContentHash(h);
|
||||
// After hash is computed, check cache if we don't have metadata result yet
|
||||
if (!metadataResult) {
|
||||
// Small delay to ensure hash is set in state
|
||||
setTimeout(() => {
|
||||
generateMetadata(false);
|
||||
}, 100);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
// Reset hash when modal closes
|
||||
setContentHash('');
|
||||
}
|
||||
}, [isOpen, blogContent, blogTitle, metadataResult, generateMetadata]);
|
||||
|
||||
// Fallback: if modal opens and hash is already computed, check cache immediately
|
||||
useEffect(() => {
|
||||
if (isOpen && !metadataResult && contentHash) {
|
||||
generateMetadata(false);
|
||||
}
|
||||
}, [isOpen, metadataResult, contentHash, generateMetadata]);
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
|
||||
setTabValue(newValue);
|
||||
|
||||
@@ -123,7 +123,10 @@ export const useSuggestions = ({
|
||||
});
|
||||
} else if (outline.length > 0 && outlineConfirmed) {
|
||||
// Outline confirmed, focus on content generation and optimization
|
||||
if (hasContent && !contentConfirmed) {
|
||||
// Follow the same pattern as research/outline phases - show suggestions based on state
|
||||
// Don't block on hasContent check - let the actions handle validation
|
||||
if (!contentConfirmed) {
|
||||
// Content exists but not confirmed yet - show options to work with content
|
||||
items.push({
|
||||
title: '🔄 ReWrite Blog',
|
||||
message: 'I want to rewrite my blog with different approach, tone, or focus'
|
||||
@@ -136,7 +139,8 @@ export const useSuggestions = ({
|
||||
title: 'Next: Run SEO Analysis',
|
||||
message: 'Please analyze the blog content for SEO. Run the analyzeSEO action right away and do not ask for confirmation.'
|
||||
});
|
||||
} else if (hasContent && contentConfirmed) {
|
||||
} else {
|
||||
// Content confirmed - show SEO workflow suggestions
|
||||
if (!seoAnalysis) {
|
||||
// Prompt to run SEO analysis first
|
||||
items.push({
|
||||
@@ -189,22 +193,6 @@ export const useSuggestions = ({
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No content yet, but outline is confirmed - show content generation options
|
||||
if (hasContent) {
|
||||
// Content exists but not confirmed - show confirmation and SEO options
|
||||
items.push({
|
||||
title: 'Next: Run SEO Analysis',
|
||||
message: 'Please analyze the blog content for SEO. Run the analyzeSEO action right away and do not ask for confirmation.'
|
||||
});
|
||||
items.push({
|
||||
title: '📊 Content Analysis',
|
||||
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
|
||||
});
|
||||
} else {
|
||||
// No content at all - show generation option (only if no content exists)
|
||||
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { createTheme, ThemeProvider, Paper, IconButton, TextField, Tooltip, CircularProgress, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, Divider } from '@mui/material';
|
||||
import {
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { BlogOutlineSection, BlogResearchResponse } from '../../../services/blogWriterApi';
|
||||
import { BlogOutlineSection, BlogResearchResponse, blogWriterApi } from '../../../services/blogWriterApi';
|
||||
import BlogSection from './BlogSection';
|
||||
|
||||
// Helper to create a consistent theme
|
||||
@@ -48,10 +48,14 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
sectionImages = {}
|
||||
}) => {
|
||||
const [blogTitle, setBlogTitle] = useState(initialTitle || 'Your Amazing Blog Title');
|
||||
const [introduction, setIntroduction] = useState('Click "Generate Introduction" to create a compelling opening for your blog post based on your content and research.');
|
||||
const [sections, setSections] = useState<any[]>([]);
|
||||
const [isTitleLoading, setIsTitleLoading] = useState(false);
|
||||
const [isIntroductionLoading, setIsIntroductionLoading] = useState(false);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<any>>(new Set());
|
||||
const [showTitleModal, setShowTitleModal] = useState(false);
|
||||
const [showIntroductionModal, setShowIntroductionModal] = useState(false);
|
||||
const [generatedIntroductions, setGeneratedIntroductions] = useState<string[]>([]);
|
||||
|
||||
// Initialize sections from outline or use parent sections
|
||||
useEffect(() => {
|
||||
@@ -74,6 +78,61 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
}
|
||||
}, [outline, parentSections]);
|
||||
|
||||
// Update sections when parentSections content changes (e.g., after SEO recommendations are applied)
|
||||
// This effect specifically watches for content changes in parentSections and updates the corresponding sections
|
||||
// Use a ref to track the previous parentSections content to detect actual content changes
|
||||
const prevParentSectionsRef = useRef<string>('');
|
||||
const prevContinuityRefreshRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!parentSections || !outline || outline.length === 0) return;
|
||||
|
||||
// Create a stringified version of parentSections for comparison
|
||||
const parentSectionsString = JSON.stringify(parentSections);
|
||||
const continuityRefreshChanged = continuityRefresh !== prevContinuityRefreshRef.current;
|
||||
|
||||
// Update if content changed OR continuityRefresh changed (forced refresh)
|
||||
if (parentSectionsString === prevParentSectionsRef.current && !continuityRefreshChanged) {
|
||||
return; // No changes detected
|
||||
}
|
||||
|
||||
prevParentSectionsRef.current = parentSectionsString;
|
||||
prevContinuityRefreshRef.current = continuityRefresh;
|
||||
|
||||
setSections(prevSections => {
|
||||
// Update sections with new content from parentSections
|
||||
const updatedSections = prevSections.map(section => {
|
||||
// Try multiple ID formats to match sections (string, number, or stringified number)
|
||||
const sectionIdStr = String(section.id);
|
||||
const parentContent = parentSections[section.id] ||
|
||||
parentSections[sectionIdStr] ||
|
||||
parentSections[Number(section.id)];
|
||||
|
||||
// Update if parent has content for this section ID and it's different
|
||||
if (parentContent !== undefined && parentContent !== section.content) {
|
||||
console.log(`[BlogEditor] Updating section ${section.id} with new content (length: ${parentContent.length})`);
|
||||
return {
|
||||
...section,
|
||||
content: parentContent
|
||||
};
|
||||
}
|
||||
return section;
|
||||
});
|
||||
|
||||
// Check if any sections were actually updated
|
||||
const hasUpdates = updatedSections.some((section, index) =>
|
||||
section.content !== prevSections[index]?.content
|
||||
);
|
||||
|
||||
// Notify parent component of content update if changes were made
|
||||
if (onContentUpdate && hasUpdates) {
|
||||
onContentUpdate(updatedSections);
|
||||
}
|
||||
|
||||
return updatedSections;
|
||||
});
|
||||
}, [parentSections, outline, continuityRefresh, onContentUpdate]);
|
||||
|
||||
// Initialize title from parent when provided
|
||||
useEffect(() => {
|
||||
if (initialTitle && initialTitle.trim().length > 0) {
|
||||
@@ -91,6 +150,51 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
setShowTitleModal(false);
|
||||
}, []);
|
||||
|
||||
const handleGenerateIntroductions = useCallback(async () => {
|
||||
if (!research || !outline.length || isIntroductionLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsIntroductionLoading(true);
|
||||
try {
|
||||
const keywordAnalysis = research.keyword_analysis || {};
|
||||
const primaryKeywords = keywordAnalysis.primary || [];
|
||||
const searchIntent = keywordAnalysis.search_intent || 'informational';
|
||||
|
||||
// Build sections_content from current sections
|
||||
const sectionsContent: Record<string, string> = {};
|
||||
sections.forEach(section => {
|
||||
if (section.content) {
|
||||
sectionsContent[section.id] = section.content;
|
||||
}
|
||||
});
|
||||
|
||||
const result = await blogWriterApi.generateIntroductions({
|
||||
blog_title: blogTitle,
|
||||
research,
|
||||
outline,
|
||||
sections_content: sectionsContent,
|
||||
primary_keywords: primaryKeywords,
|
||||
search_intent: searchIntent
|
||||
});
|
||||
|
||||
if (result.success && result.introductions) {
|
||||
setGeneratedIntroductions(result.introductions);
|
||||
setShowIntroductionModal(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate introductions:', error);
|
||||
alert('Failed to generate introductions. Please try again.');
|
||||
} finally {
|
||||
setIsIntroductionLoading(false);
|
||||
}
|
||||
}, [research, outline, sections, blogTitle, isIntroductionLoading]);
|
||||
|
||||
const handleIntroductionSelect = useCallback((selectedIntroduction: string) => {
|
||||
setIntroduction(selectedIntroduction);
|
||||
setShowIntroductionModal(false);
|
||||
}, []);
|
||||
|
||||
const toggleSectionExpansion = useCallback((sectionId: any) => {
|
||||
setExpandedSections(prev => {
|
||||
const newSet = new Set(prev);
|
||||
@@ -139,9 +243,37 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-500 text-sm">
|
||||
This is where your blog's subtitle or a brief one-line description will appear. It's editable too!
|
||||
</p>
|
||||
<div className="mt-3 group/intro">
|
||||
<div className="flex items-start gap-2">
|
||||
<p
|
||||
className="flex-1 text-gray-600 text-sm leading-relaxed cursor-pointer hover:bg-gray-50 p-2 rounded-md transition-colors duration-200"
|
||||
onClick={() => {
|
||||
const newIntro = prompt('Edit introduction:', introduction);
|
||||
if (newIntro !== null && newIntro.trim()) {
|
||||
setIntroduction(newIntro.trim());
|
||||
}
|
||||
}}
|
||||
title="Click to edit introduction"
|
||||
>
|
||||
{introduction}
|
||||
</p>
|
||||
<div className="opacity-0 group-hover/intro:opacity-100 transition-opacity duration-300">
|
||||
<Tooltip title="✨ Generate Introduction">
|
||||
<IconButton
|
||||
onClick={handleGenerateIntroductions}
|
||||
disabled={isIntroductionLoading || !research || !outline.length}
|
||||
size="small"
|
||||
>
|
||||
{isIntroductionLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
<AutoAwesomeIcon className="text-blue-500" fontSize="small"/>
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Divider sx={{ mt: 3, opacity: 0.3 }} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -301,6 +433,71 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Introduction Selection Modal */}
|
||||
<Dialog
|
||||
open={showIntroductionModal}
|
||||
onClose={() => setShowIntroductionModal(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Typography variant="h6" component="div" sx={{ fontWeight: 'bold' }}>
|
||||
Choose Your Blog Introduction
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1, color: 'text.secondary' }}>
|
||||
Select one of the AI-generated introductions below. Each offers a different approach to hooking your readers.
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{generatedIntroductions.map((intro, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
mb: 3,
|
||||
p: 2,
|
||||
border: '1px solid',
|
||||
borderColor: index === 0 ? 'primary.main' : index === 1 ? 'secondary.main' : 'success.main',
|
||||
borderRadius: 2,
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onClick={() => handleIntroductionSelect(intro)}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
mb: 1,
|
||||
color: index === 0 ? 'primary.main' : index === 1 ? 'secondary.main' : 'success.main'
|
||||
}}
|
||||
>
|
||||
{index === 0 ? '📌 Option 1: Problem-Focused' : index === 1 ? '✨ Option 2: Benefit-Focused' : '📊 Option 3: Story/Statistic-Focused'}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
lineHeight: 1.7,
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}
|
||||
>
|
||||
{intro}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowIntroductionModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -340,7 +340,7 @@ const MainDashboard: React.FC = () => {
|
||||
<AnalyticsInsights />
|
||||
|
||||
{/* Billing & Usage Dashboard */}
|
||||
<EnhancedBillingDashboard />
|
||||
<EnhancedBillingDashboard terminalTheme={true} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -55,9 +55,10 @@ export const usePlatformConnections = () => {
|
||||
try {
|
||||
// Store current page URL BEFORE redirecting (critical for proper redirect back)
|
||||
// This ensures we can redirect back to the correct page (e.g., Blog Writer) after OAuth
|
||||
// Only store if not already set (allows WixConnectModal to override if needed)
|
||||
// WixConnectModal will always override when connecting from Blog Writer
|
||||
const currentUrl = window.location.href;
|
||||
try {
|
||||
// Only store if not already set (allows WixConnectModal to override if needed)
|
||||
if (!sessionStorage.getItem('wix_oauth_redirect')) {
|
||||
sessionStorage.setItem('wix_oauth_redirect', currentUrl);
|
||||
console.log('[Wix OAuth] Stored redirect URL:', currentUrl);
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Tasks Needing Intervention Component
|
||||
* Displays tasks that have been marked for human intervention with actionable information.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Alert,
|
||||
Button,
|
||||
Chip,
|
||||
Collapse,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Warning as WarningIcon,
|
||||
Error as ErrorIcon,
|
||||
Refresh as RefreshIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
PlayArrow as PlayArrowIcon,
|
||||
Info as InfoIcon
|
||||
} from '@mui/icons-material';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { apiClient } from '../../api/client';
|
||||
import { TerminalTypography, terminalColors } from './terminalTheme';
|
||||
|
||||
const InterventionContainer = styled(Box)({
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.8)',
|
||||
border: '2px solid #ff9800',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
});
|
||||
|
||||
const TaskCard = styled(Box)({
|
||||
backgroundColor: 'rgba(10, 10, 10, 0.6)',
|
||||
border: '1px solid #ff9800',
|
||||
borderRadius: '6px',
|
||||
padding: '12px',
|
||||
marginBottom: '12px',
|
||||
'&:last-child': {
|
||||
marginBottom: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const ActionButton = styled(Button)({
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
color: '#00ff00',
|
||||
border: '1px solid #00ff00',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '0.875rem',
|
||||
padding: '6px 16px',
|
||||
textTransform: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.2)',
|
||||
boxShadow: '0 0 10px rgba(0, 255, 0, 0.3)',
|
||||
},
|
||||
'&:disabled': {
|
||||
backgroundColor: 'rgba(0, 68, 0, 0.3)',
|
||||
color: '#004400',
|
||||
borderColor: '#004400',
|
||||
}
|
||||
});
|
||||
|
||||
const StatusChip = styled(Chip)(({ severity }: { severity: 'error' | 'warning' }) => ({
|
||||
backgroundColor: severity === 'error' ? 'rgba(244, 67, 54, 0.2)' : 'rgba(255, 152, 0, 0.2)',
|
||||
color: severity === 'error' ? '#f44336' : '#ff9800',
|
||||
border: `1px solid ${severity === 'error' ? '#f44336' : '#ff9800'}`,
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
}));
|
||||
|
||||
interface TaskNeedingIntervention {
|
||||
task_id: number;
|
||||
task_type: string;
|
||||
user_id: string;
|
||||
platform?: string;
|
||||
website_url?: string;
|
||||
failure_pattern: {
|
||||
consecutive_failures: number;
|
||||
recent_failures: number;
|
||||
failure_reason: string;
|
||||
last_failure_time: string | null;
|
||||
error_patterns: string[];
|
||||
};
|
||||
failure_reason: string | null;
|
||||
last_failure: string | null;
|
||||
}
|
||||
|
||||
interface TasksNeedingInterventionProps {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
const TasksNeedingIntervention: React.FC<TasksNeedingInterventionProps> = ({ userId }) => {
|
||||
const [tasks, setTasks] = useState<TaskNeedingIntervention[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedTasks, setExpandedTasks] = useState<Set<number>>(new Set());
|
||||
const [triggeringTasks, setTriggeringTasks] = useState<Set<number>>(new Set());
|
||||
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
tasks: TaskNeedingIntervention[];
|
||||
count: number;
|
||||
}>(`/api/scheduler/tasks-needing-intervention/${userId}`);
|
||||
|
||||
if (response.data.success) {
|
||||
setTasks(response.data.tasks || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching tasks needing intervention:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
// Refresh every 2 minutes
|
||||
const interval = setInterval(fetchTasks, 120000);
|
||||
return () => clearInterval(interval);
|
||||
}, [userId]);
|
||||
|
||||
const toggleExpand = (taskId: number) => {
|
||||
const newExpanded = new Set(expandedTasks);
|
||||
if (newExpanded.has(taskId)) {
|
||||
newExpanded.delete(taskId);
|
||||
} else {
|
||||
newExpanded.add(taskId);
|
||||
}
|
||||
setExpandedTasks(newExpanded);
|
||||
};
|
||||
|
||||
const handleManualTrigger = async (task: TaskNeedingIntervention) => {
|
||||
try {
|
||||
setTriggeringTasks(prev => new Set(prev).add(task.task_id));
|
||||
|
||||
// Determine task type for API
|
||||
let taskType = task.task_type;
|
||||
if (task.task_type.includes('_insights')) {
|
||||
// Extract platform from task_type (e.g., "gsc_insights" -> "gsc_insights")
|
||||
taskType = task.task_type;
|
||||
}
|
||||
|
||||
await apiClient.post(`/api/scheduler/tasks/${taskType}/${task.task_id}/manual-trigger`);
|
||||
|
||||
// Show success toast
|
||||
showToast('Task triggered successfully. It will run shortly.', 'success');
|
||||
|
||||
// Refresh the list after a short delay
|
||||
setTimeout(() => {
|
||||
fetchTasks();
|
||||
}, 2000);
|
||||
} catch (error: any) {
|
||||
console.error('Error triggering task:', error);
|
||||
showToast(
|
||||
error.response?.data?.detail || 'Failed to trigger task. Please try again.',
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
setTriggeringTasks(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(task.task_id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getTaskDisplayName = (task: TaskNeedingIntervention): string => {
|
||||
if (task.task_type === 'oauth_token_monitoring') {
|
||||
return `OAuth ${task.platform?.toUpperCase() || 'Unknown'}`;
|
||||
} else if (task.task_type === 'website_analysis') {
|
||||
const url = task.website_url || 'Unknown';
|
||||
return `Website Analysis (${url.length > 40 ? url.substring(0, 40) + '...' : url})`;
|
||||
} else if (task.task_type.includes('_insights')) {
|
||||
return `${task.platform?.toUpperCase() || 'Unknown'} Insights`;
|
||||
}
|
||||
return task.task_type;
|
||||
};
|
||||
|
||||
const getFailureReasonDisplay = (reason: string): { label: string; severity: 'error' | 'warning'; action: string } => {
|
||||
switch (reason) {
|
||||
case 'api_limit':
|
||||
return {
|
||||
label: 'API Limit Exceeded',
|
||||
severity: 'error',
|
||||
action: 'Your API quota has been exceeded. Wait for quota reset or upgrade your plan, then manually trigger the task.'
|
||||
};
|
||||
case 'auth_error':
|
||||
return {
|
||||
label: 'Authentication Error',
|
||||
severity: 'warning',
|
||||
action: 'Your credentials may have expired. Please reconnect the platform in onboarding, then manually trigger the task.'
|
||||
};
|
||||
case 'network_error':
|
||||
return {
|
||||
label: 'Network Error',
|
||||
severity: 'warning',
|
||||
action: 'Network connectivity issues detected. Check your connection and manually trigger the task when resolved.'
|
||||
};
|
||||
case 'config_error':
|
||||
return {
|
||||
label: 'Configuration Error',
|
||||
severity: 'warning',
|
||||
action: 'Task configuration is invalid. Please check task settings and manually trigger after fixing.'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: 'Unknown Error',
|
||||
severity: 'error',
|
||||
action: 'An unexpected error occurred. Review the error details below and manually trigger after resolving the issue.'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null): string => {
|
||||
if (!dateString) return 'Unknown';
|
||||
try {
|
||||
return new Date(dateString).toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<InterventionContainer>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<CircularProgress size={20} sx={{ color: '#ff9800' }} />
|
||||
<TerminalTypography variant="body2" sx={{ color: '#ff9800' }}>
|
||||
Loading tasks needing intervention...
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
</InterventionContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return null; // Don't show section if no tasks need intervention
|
||||
}
|
||||
|
||||
return (
|
||||
<InterventionContainer>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" marginBottom={2}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<WarningIcon sx={{ color: '#ff9800', fontSize: '24px' }} />
|
||||
<TerminalTypography variant="h6" sx={{ color: '#ff9800', fontWeight: 'bold' }}>
|
||||
Tasks Needing Intervention ({tasks.length})
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
<Tooltip title="Refresh">
|
||||
<IconButton
|
||||
onClick={fetchTasks}
|
||||
sx={{
|
||||
color: '#ff9800',
|
||||
border: '1px solid #ff9800',
|
||||
'&:hover': { backgroundColor: 'rgba(255, 152, 0, 0.1)' }
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<TerminalTypography variant="body2" sx={{ color: '#ff9800', opacity: 0.8, marginBottom: 2 }}>
|
||||
These tasks have failed repeatedly and require manual intervention. Review the details and take appropriate action.
|
||||
</TerminalTypography>
|
||||
|
||||
{tasks.map((task) => {
|
||||
const reasonInfo = getFailureReasonDisplay(task.failure_pattern.failure_reason);
|
||||
const isExpanded = expandedTasks.has(task.task_id);
|
||||
const isTriggering = triggeringTasks.has(task.task_id);
|
||||
|
||||
return (
|
||||
<TaskCard key={task.task_id}>
|
||||
<Box display="flex" alignItems="flex-start" justifyContent="space-between" gap={2}>
|
||||
<Box flex={1}>
|
||||
<Box display="flex" alignItems="center" gap={1} marginBottom={1}>
|
||||
<TerminalTypography variant="subtitle1" sx={{ color: '#ff9800', fontWeight: 'bold' }}>
|
||||
{getTaskDisplayName(task)}
|
||||
</TerminalTypography>
|
||||
<StatusChip
|
||||
label={reasonInfo.label}
|
||||
severity={reasonInfo.severity}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={`${task.failure_pattern.consecutive_failures} consecutive failures`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.2)',
|
||||
color: '#f44336',
|
||||
border: '1px solid #f44336',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TerminalTypography variant="body2" sx={{ color: '#ff9800', opacity: 0.9, marginBottom: 1 }}>
|
||||
<InfoIcon sx={{ fontSize: '14px', verticalAlign: 'middle', marginRight: 0.5 }} />
|
||||
{reasonInfo.action}
|
||||
</TerminalTypography>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={2} marginTop={1}>
|
||||
<TerminalTypography variant="caption" sx={{ color: '#ff9800', opacity: 0.7 }}>
|
||||
Last failure: {formatDate(task.last_failure)}
|
||||
</TerminalTypography>
|
||||
<IconButton
|
||||
onClick={() => toggleExpand(task.task_id)}
|
||||
size="small"
|
||||
sx={{ color: '#ff9800' }}
|
||||
>
|
||||
{isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<Box marginTop={2} padding={2} sx={{ backgroundColor: 'rgba(0, 0, 0, 0.3)', borderRadius: '4px' }}>
|
||||
<TerminalTypography variant="caption" sx={{ color: '#ff9800', display: 'block', marginBottom: 1 }}>
|
||||
<strong>Failure Details:</strong>
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ color: '#ff9800', opacity: 0.8, display: 'block', marginBottom: 1 }}>
|
||||
• Consecutive failures: {task.failure_pattern.consecutive_failures}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ color: '#ff9800', opacity: 0.8, display: 'block', marginBottom: 1 }}>
|
||||
• Recent failures (7 days): {task.failure_pattern.recent_failures}
|
||||
</TerminalTypography>
|
||||
{task.failure_reason && (
|
||||
<TerminalTypography variant="caption" sx={{ color: '#ff9800', opacity: 0.8, display: 'block', marginBottom: 1 }}>
|
||||
• Error: {task.failure_reason.substring(0, 200)}
|
||||
{task.failure_reason.length > 200 ? '...' : ''}
|
||||
</TerminalTypography>
|
||||
)}
|
||||
{task.failure_pattern.error_patterns.length > 0 && (
|
||||
<Box marginTop={1}>
|
||||
<TerminalTypography variant="caption" sx={{ color: '#ff9800', display: 'block', marginBottom: 0.5 }}>
|
||||
<strong>Error Patterns:</strong>
|
||||
</TerminalTypography>
|
||||
{task.failure_pattern.error_patterns.map((pattern, idx) => (
|
||||
<TerminalTypography
|
||||
key={idx}
|
||||
variant="caption"
|
||||
sx={{ color: '#ff9800', opacity: 0.7, display: 'block', fontFamily: 'monospace', fontSize: '0.7rem' }}
|
||||
>
|
||||
• {pattern}
|
||||
</TerminalTypography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" flexDirection="column" gap={1}>
|
||||
<ActionButton
|
||||
variant="outlined"
|
||||
startIcon={isTriggering ? <CircularProgress size={16} sx={{ color: '#00ff00' }} /> : <PlayArrowIcon />}
|
||||
onClick={() => handleManualTrigger(task)}
|
||||
disabled={isTriggering}
|
||||
size="small"
|
||||
>
|
||||
{isTriggering ? 'Triggering...' : 'Trigger Now'}
|
||||
</ActionButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</TaskCard>
|
||||
);
|
||||
})}
|
||||
</InterventionContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// Toast notification helper
|
||||
function showToast(message: string, type: 'success' | 'error' | 'info' = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
const bgColors = {
|
||||
error: '#f44336',
|
||||
warning: '#ff9800',
|
||||
info: '#2196f3',
|
||||
success: '#4caf50'
|
||||
};
|
||||
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
z-index: 10000;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
background-color: ${bgColors[type] || bgColors.info};
|
||||
word-wrap: break-word;
|
||||
`;
|
||||
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
const duration = type === 'error' ? 7000 : 5000;
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(toast)) {
|
||||
document.body.removeChild(toast);
|
||||
}
|
||||
}, 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
export default TasksNeedingIntervention;
|
||||
|
||||
122
frontend/src/components/StoryWriter/PhaseNavigation.tsx
Normal file
122
frontend/src/components/StoryWriter/PhaseNavigation.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import { Box, Paper, Stepper, Step, StepLabel, StepButton, Typography, IconButton, Tooltip } from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { StoryPhase } from '../../hooks/useStoryWriterPhaseNavigation';
|
||||
|
||||
interface PhaseNavigationProps {
|
||||
phases: StoryPhase[];
|
||||
currentPhase: string;
|
||||
onPhaseClick: (phaseId: string) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
phases,
|
||||
currentPhase,
|
||||
onPhaseClick,
|
||||
onReset,
|
||||
}) => {
|
||||
const activeStep = phases.findIndex((p) => p.id === currentPhase);
|
||||
|
||||
const handleReset = () => {
|
||||
if (window.confirm('Are you sure you want to restart? This will clear all your story data and start from the beginning.')) {
|
||||
if (onReset) {
|
||||
onReset();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
backgroundColor: '#F7F3E9', // Warm cream/parchment color
|
||||
color: '#2C2416', // Dark brown text for readability
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)',
|
||||
}}
|
||||
>
|
||||
{onReset && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
|
||||
<Tooltip title="Restart Story (Clear all data and start from beginning)">
|
||||
<IconButton
|
||||
onClick={handleReset}
|
||||
sx={{
|
||||
color: '#5D4037',
|
||||
'&:hover': {
|
||||
backgroundColor: '#E8E5D3',
|
||||
color: '#1A1611',
|
||||
},
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
<Stepper activeStep={activeStep} alternativeLabel>
|
||||
{phases.map((phase) => (
|
||||
<Step key={phase.id} completed={phase.completed} disabled={phase.disabled}>
|
||||
<StepButton
|
||||
onClick={() => !phase.disabled && onPhaseClick(phase.id)}
|
||||
disabled={phase.disabled}
|
||||
sx={{
|
||||
'& .MuiStepLabel-root': {
|
||||
cursor: phase.disabled ? 'not-allowed' : 'pointer',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<StepLabel
|
||||
StepIconComponent={() => (
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: phase.current
|
||||
? 'primary.main'
|
||||
: phase.completed
|
||||
? 'success.main'
|
||||
: phase.disabled
|
||||
? 'grey.300'
|
||||
: 'grey.200',
|
||||
color: phase.current || phase.completed ? 'white' : 'text.secondary',
|
||||
fontSize: '1.2rem',
|
||||
fontWeight: phase.current ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{phase.icon}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: phase.current ? 600 : 400,
|
||||
color: phase.disabled ? '#9E9E9E' : '#2C2416', // Dark brown text
|
||||
}}
|
||||
>
|
||||
{phase.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: phase.disabled ? '#9E9E9E' : '#5D4037', // Medium brown for secondary text
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
{phase.description}
|
||||
</Typography>
|
||||
</StepLabel>
|
||||
</StepButton>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhaseNavigation;
|
||||
360
frontend/src/components/StoryWriter/Phases/StoryExport.tsx
Normal file
360
frontend/src/components/StoryWriter/Phases/StoryExport.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Alert,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
LinearProgress,
|
||||
} from '@mui/material';
|
||||
import VideoLibraryIcon from '@mui/icons-material/VideoLibrary';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
|
||||
import { storyWriterApi } from '../../../services/storyWriterApi';
|
||||
|
||||
interface StoryExportProps {
|
||||
state: ReturnType<typeof useStoryWriterState>;
|
||||
}
|
||||
|
||||
const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
|
||||
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
|
||||
const [videoProgress, setVideoProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCopyToClipboard = () => {
|
||||
if (state.storyContent) {
|
||||
navigator.clipboard.writeText(state.storyContent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (state.storyContent) {
|
||||
const blob = new Blob([state.storyContent], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `story-${Date.now()}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateVideo = async () => {
|
||||
if (!state.outlineScenes || state.outlineScenes.length === 0) {
|
||||
setError('Please generate a structured outline first');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.sceneImages || state.sceneImages.size === 0) {
|
||||
setError('Please generate images for scenes first');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.sceneAudio || state.sceneAudio.size === 0) {
|
||||
setError('Please generate audio for scenes first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingVideo(true);
|
||||
setError(null);
|
||||
setVideoProgress(0);
|
||||
|
||||
try {
|
||||
// Prepare image and audio URLs in scene order
|
||||
const imageUrls: string[] = [];
|
||||
const audioUrls: string[] = [];
|
||||
const scenes = state.outlineScenes;
|
||||
|
||||
for (const scene of scenes) {
|
||||
const sceneNumber = scene.scene_number || scenes.indexOf(scene) + 1;
|
||||
const imageUrl = state.sceneImages?.get(sceneNumber);
|
||||
const audioUrl = state.sceneAudio?.get(sceneNumber);
|
||||
|
||||
if (imageUrl && audioUrl) {
|
||||
imageUrls.push(imageUrl);
|
||||
audioUrls.push(audioUrl);
|
||||
} else {
|
||||
throw new Error(`Missing image or audio for scene ${sceneNumber}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageUrls.length !== scenes.length || audioUrls.length !== scenes.length) {
|
||||
throw new Error('Number of images and audio files must match number of scenes');
|
||||
}
|
||||
|
||||
// Generate video
|
||||
const response = await storyWriterApi.generateStoryVideo({
|
||||
scenes: scenes,
|
||||
image_urls: imageUrls,
|
||||
audio_urls: audioUrls,
|
||||
story_title: state.storySetting || 'Story',
|
||||
fps: state.videoFps,
|
||||
transition_duration: state.videoTransitionDuration,
|
||||
});
|
||||
|
||||
if (response.success && response.video) {
|
||||
state.setStoryVideo(response.video.video_url);
|
||||
state.setError(null);
|
||||
setVideoProgress(100);
|
||||
} else {
|
||||
throw new Error('Failed to generate video');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate video';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsGeneratingVideo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadVideo = () => {
|
||||
if (state.storyVideo) {
|
||||
const videoUrl = storyWriterApi.getVideoUrl(state.storyVideo);
|
||||
const a = document.createElement('a');
|
||||
a.href = videoUrl;
|
||||
a.download = `story-video-${Date.now()}.mp4`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
mt: 2,
|
||||
backgroundColor: '#F7F3E9', // Warm cream/parchment color
|
||||
color: '#2C2416', // Dark brown text for readability
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600, color: '#1A1611' }}>
|
||||
Export Story
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 4, color: '#5D4037' }}>
|
||||
Your story is complete! You can copy it to clipboard or download it as a text file.
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!state.storyContent ? (
|
||||
<Alert severity="info">
|
||||
No story content available. Please complete the writing phase first.
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{/* Story Summary */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ color: '#1A1611' }}>
|
||||
Story Summary
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#FAF9F6', // Slightly lighter cream for summary box
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#2C2416' }}>
|
||||
<strong>Setting:</strong> {state.storySetting || 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#2C2416' }}>
|
||||
<strong>Characters:</strong> {state.characters || 'N/A'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#2C2416' }}>
|
||||
<strong>Style:</strong> {state.writingStyle} | <strong>Tone:</strong> {state.storyTone}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#2C2416' }}>
|
||||
<strong>POV:</strong> {state.narrativePOV} | <strong>Audience:</strong> {state.audienceAgeGroup}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Premise */}
|
||||
{state.premise && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ color: '#1A1611' }}>
|
||||
Premise
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={state.premise}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#1A1611',
|
||||
'& fieldset': {
|
||||
borderColor: '#8D6E63',
|
||||
borderWidth: '1.5px',
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#1A1611',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Outline */}
|
||||
{state.outline && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ color: '#1A1611' }}>
|
||||
Outline
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={6}
|
||||
value={state.outline}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#1A1611',
|
||||
'& fieldset': {
|
||||
borderColor: '#8D6E63',
|
||||
borderWidth: '1.5px',
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#1A1611',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Story Content */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ color: '#1A1611' }}>
|
||||
Complete Story
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={20}
|
||||
value={state.storyContent}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#1A1611',
|
||||
'& fieldset': {
|
||||
borderColor: '#8D6E63',
|
||||
borderWidth: '1.5px',
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#1A1611',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Video Generation */}
|
||||
{state.isOutlineStructured && state.outlineScenes && (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ color: '#1A1611' }}>
|
||||
Video Generation
|
||||
</Typography>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
Generate a video from your story scenes with images and audio narration.
|
||||
{(!state.sceneImages || state.sceneImages.size === 0) && ' Generate images first.'}
|
||||
{(!state.sceneAudio || state.sceneAudio.size === 0) && ' Generate audio first.'}
|
||||
</Alert>
|
||||
|
||||
{isGeneratingVideo && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<LinearProgress variant="determinate" value={videoProgress} sx={{ mb: 1 }} />
|
||||
<Typography variant="body2" sx={{ color: '#5D4037' }}>
|
||||
Generating video... {videoProgress}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{state.storyVideo && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<video
|
||||
controls
|
||||
src={storyWriterApi.getVideoUrl(state.storyVideo)}
|
||||
style={{ width: '100%', maxHeight: '500px' }}
|
||||
>
|
||||
Your browser does not support the video element.
|
||||
</video>
|
||||
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: '#5D4037' }}>
|
||||
Generated story video
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<VideoLibraryIcon />}
|
||||
onClick={handleGenerateVideo}
|
||||
disabled={
|
||||
isGeneratingVideo ||
|
||||
!state.outlineScenes ||
|
||||
!state.sceneImages ||
|
||||
state.sceneImages.size === 0 ||
|
||||
!state.sceneAudio ||
|
||||
state.sceneAudio.size === 0
|
||||
}
|
||||
>
|
||||
{isGeneratingVideo ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Generating Video...
|
||||
</>
|
||||
) : (
|
||||
'Generate Video'
|
||||
)}
|
||||
</Button>
|
||||
{state.storyVideo && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={handleDownloadVideo}
|
||||
>
|
||||
Download Video
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Export Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<Button variant="outlined" onClick={handleCopyToClipboard}>
|
||||
Copy to Clipboard
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleDownload}>
|
||||
Download as Text File
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoryExport;
|
||||
970
frontend/src/components/StoryWriter/Phases/StoryOutline.tsx
Normal file
970
frontend/src/components/StoryWriter/Phases/StoryOutline.tsx
Normal file
@@ -0,0 +1,970 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Chip,
|
||||
Grid,
|
||||
Card,
|
||||
CardMedia,
|
||||
CardContent,
|
||||
} from '@mui/material';
|
||||
import GlobalStyles from '@mui/material/GlobalStyles';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
|
||||
import { storyWriterApi, StoryScene } from '../../../services/storyWriterApi';
|
||||
import { aiApiClient } from '../../../api/client';
|
||||
|
||||
const MotionBox = motion(Box);
|
||||
|
||||
// Define cubic bezier easing arrays as const to preserve tuple types
|
||||
const easeInOut = [0.22, 0.61, 0.36, 1] as const;
|
||||
const easeOut = [0.4, 0, 1, 1] as const;
|
||||
|
||||
const leftPageVariants = {
|
||||
enter: (direction: number) => ({
|
||||
rotateY: direction === 0 ? 0 : direction > 0 ? -20 : 20,
|
||||
x: direction === 0 ? 0 : direction > 0 ? -80 : 80,
|
||||
opacity: direction === 0 ? 1 : 0,
|
||||
transformOrigin: 'center',
|
||||
}),
|
||||
center: {
|
||||
rotateY: 0,
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
transformOrigin: 'center',
|
||||
transition: { duration: 0.55, ease: easeInOut },
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
rotateY: direction === 0 ? 0 : direction > 0 ? 15 : -15,
|
||||
x: direction === 0 ? 0 : direction > 0 ? 60 : -60,
|
||||
opacity: direction === 0 ? 1 : 0,
|
||||
transformOrigin: 'center',
|
||||
transition: { duration: 0.4, ease: easeOut },
|
||||
}),
|
||||
};
|
||||
|
||||
const rightPageVariants = {
|
||||
enter: (direction: number) => ({
|
||||
rotateY: direction === 0 ? 0 : direction > 0 ? 25 : -25,
|
||||
x: direction === 0 ? 0 : direction > 0 ? 110 : -110,
|
||||
opacity: direction === 0 ? 1 : 0,
|
||||
transformOrigin: direction >= 0 ? 'right center' : 'left center',
|
||||
}),
|
||||
center: {
|
||||
rotateY: 0,
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
transformOrigin: 'center',
|
||||
transition: { duration: 0.55, ease: easeInOut },
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
rotateY: direction === 0 ? 0 : direction > 0 ? -25 : 25,
|
||||
x: direction === 0 ? 0 : direction > 0 ? -90 : 90,
|
||||
opacity: direction === 0 ? 1 : 0,
|
||||
transformOrigin: direction >= 0 ? 'left center' : 'right center',
|
||||
transition: { duration: 0.4, ease: easeOut },
|
||||
}),
|
||||
};
|
||||
|
||||
interface StoryOutlineProps {
|
||||
state: ReturnType<typeof useStoryWriterState>;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isGeneratingImages, setIsGeneratingImages] = useState(false);
|
||||
const [isGeneratingAudio, setIsGeneratingAudio] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentSceneIndex, setCurrentSceneIndex] = useState(0);
|
||||
const [pageDirection, setPageDirection] = useState(0);
|
||||
const [imageLoadError, setImageLoadError] = useState<Set<number>>(new Set());
|
||||
const [imageBlobUrls, setImageBlobUrls] = useState<Map<number, string>>(new Map());
|
||||
|
||||
// Use state from hook instead of local state
|
||||
const sceneImages = state.sceneImages || new Map<number, string>();
|
||||
const sceneAudio = state.sceneAudio || new Map<number, string>();
|
||||
|
||||
const scenes = state.outlineScenes || [];
|
||||
const hasScenes = state.isOutlineStructured && scenes.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (hasScenes) {
|
||||
setCurrentSceneIndex(0);
|
||||
setPageDirection(0);
|
||||
}
|
||||
}, [hasScenes]);
|
||||
|
||||
const currentScene = hasScenes ? scenes[currentSceneIndex] : null;
|
||||
const canGoPrev = currentSceneIndex > 0;
|
||||
const canGoNext = hasScenes ? currentSceneIndex < scenes.length - 1 : false;
|
||||
|
||||
// Get the current scene's image URL
|
||||
const currentSceneNumber = currentScene?.scene_number || currentSceneIndex + 1;
|
||||
const currentSceneImageUrl = sceneImages.get(currentSceneNumber);
|
||||
const hasImageLoadError = imageLoadError.has(currentSceneNumber);
|
||||
|
||||
// Fetch image as blob with authentication
|
||||
useEffect(() => {
|
||||
if (!currentSceneImageUrl || hasImageLoadError || imageBlobUrls.has(currentSceneNumber)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadImage = async () => {
|
||||
try {
|
||||
// Use relative URL path directly (aiApiClient will add base URL and auth)
|
||||
const imageUrl = currentSceneImageUrl.startsWith('/')
|
||||
? currentSceneImageUrl
|
||||
: `/${currentSceneImageUrl}`;
|
||||
// Use aiApiClient to get authenticated response with blob
|
||||
const response = await aiApiClient.get(imageUrl, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const blob = response.data;
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
setImageBlobUrls((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(currentSceneNumber, blobUrl);
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to load image:', err);
|
||||
setImageLoadError((prev) => new Set(prev).add(currentSceneNumber));
|
||||
}
|
||||
};
|
||||
|
||||
loadImage();
|
||||
}, [currentSceneNumber, currentSceneImageUrl, hasImageLoadError]);
|
||||
|
||||
// Cleanup blob URLs when component unmounts or scenes change
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Revoke all blob URLs on unmount
|
||||
imageBlobUrls.forEach((blobUrl) => {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const currentSceneImageFullUrl = imageBlobUrls.get(currentSceneNumber) || null;
|
||||
|
||||
// Reset image load error when scene changes
|
||||
useEffect(() => {
|
||||
setImageLoadError((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(currentSceneNumber);
|
||||
return next;
|
||||
});
|
||||
}, [currentSceneNumber]);
|
||||
|
||||
const handlePrevScene = () => {
|
||||
if (canGoPrev) {
|
||||
setPageDirection(-1);
|
||||
setCurrentSceneIndex((prev) => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextScene = () => {
|
||||
if (canGoNext) {
|
||||
setPageDirection(1);
|
||||
setCurrentSceneIndex((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateOutline = async () => {
|
||||
if (!state.premise) {
|
||||
setError('Please generate a premise first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const request = state.getRequest();
|
||||
const response = await storyWriterApi.generateOutline(state.premise, request);
|
||||
|
||||
if (response.success && response.outline) {
|
||||
// Handle structured outline (scenes) or plain text outline
|
||||
if (response.is_structured && Array.isArray(response.outline)) {
|
||||
// Structured outline with scenes
|
||||
const scenes = response.outline as StoryScene[];
|
||||
state.setOutlineScenes(scenes);
|
||||
state.setIsOutlineStructured(true);
|
||||
// Also store as formatted text for backward compatibility
|
||||
const formattedOutline = scenes.map((scene, idx) =>
|
||||
`Scene ${scene.scene_number || idx + 1}: ${scene.title}\n${scene.description}`
|
||||
).join('\n\n');
|
||||
state.setOutline(formattedOutline);
|
||||
} else {
|
||||
// Plain text outline
|
||||
state.setOutline(typeof response.outline === 'string' ? response.outline : String(response.outline));
|
||||
state.setOutlineScenes(null);
|
||||
state.setIsOutlineStructured(false);
|
||||
}
|
||||
state.setError(null);
|
||||
} else {
|
||||
throw new Error(typeof response.outline === 'string' ? response.outline : 'Failed to generate outline');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate outline';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (state.outline || state.outlineScenes) {
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateImages = async () => {
|
||||
if (!state.outlineScenes || state.outlineScenes.length === 0) {
|
||||
setError('Please generate a structured outline first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingImages(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await storyWriterApi.generateSceneImages({
|
||||
scenes: state.outlineScenes,
|
||||
provider: state.imageProvider || undefined,
|
||||
width: state.imageWidth,
|
||||
height: state.imageHeight,
|
||||
model: state.imageModel || undefined,
|
||||
});
|
||||
|
||||
if (response.success && response.images) {
|
||||
// Store image URLs by scene number
|
||||
const imagesMap = new Map<number, string>();
|
||||
response.images.forEach((image) => {
|
||||
if (image.image_url && !image.error) {
|
||||
imagesMap.set(image.scene_number, image.image_url);
|
||||
}
|
||||
});
|
||||
state.setSceneImages(imagesMap);
|
||||
state.setError(null);
|
||||
} else {
|
||||
throw new Error('Failed to generate images');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate images';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsGeneratingImages(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateAudio = async () => {
|
||||
if (!state.outlineScenes || state.outlineScenes.length === 0) {
|
||||
setError('Please generate a structured outline first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingAudio(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await storyWriterApi.generateSceneAudio({
|
||||
scenes: state.outlineScenes,
|
||||
provider: state.audioProvider,
|
||||
lang: state.audioLang,
|
||||
slow: state.audioSlow,
|
||||
rate: state.audioRate,
|
||||
});
|
||||
|
||||
if (response.success && response.audio_files) {
|
||||
// Store audio URLs by scene number
|
||||
const audioMap = new Map<number, string>();
|
||||
response.audio_files.forEach((audio) => {
|
||||
if (audio.audio_url && !audio.error) {
|
||||
audioMap.set(audio.scene_number, audio.audio_url);
|
||||
}
|
||||
});
|
||||
state.setSceneAudio(audioMap);
|
||||
state.setError(null);
|
||||
} else {
|
||||
throw new Error('Failed to generate audio');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate audio';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsGeneratingAudio(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Render structured scenes
|
||||
const renderStructuredScenes = () => {
|
||||
if (!state.outlineScenes || state.outlineScenes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ mb: 2, color: '#1A1611' }}>
|
||||
Story Scenes ({state.outlineScenes.length} scenes)
|
||||
</Typography>
|
||||
{state.outlineScenes.map((scene: StoryScene, index: number) => (
|
||||
<Accordion
|
||||
key={index}
|
||||
sx={{
|
||||
mb: 2,
|
||||
backgroundColor: '#FAF9F6', // Slightly lighter cream for accordions
|
||||
'&:before': {
|
||||
display: 'none', // Remove default border
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
|
||||
Scene {scene.scene_number || index + 1}: {scene.title}
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" sx={{ color: '#5D4037' }} gutterBottom>
|
||||
<strong>Description:</strong>
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2, color: '#2C2416' }}>
|
||||
{scene.description}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" sx={{ color: '#5D4037' }} gutterBottom>
|
||||
<strong>Image Prompt:</strong>
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={scene.image_prompt}
|
||||
disabled
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#1A1611',
|
||||
'& fieldset': {
|
||||
borderColor: '#8D6E63',
|
||||
borderWidth: '1.5px',
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#1A1611',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{sceneImages && sceneImages.has(scene.scene_number || index + 1) && (
|
||||
<Card
|
||||
sx={{
|
||||
mt: 2,
|
||||
backgroundColor: '#FAF9F6', // Slightly lighter cream for cards
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.08)',
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="200"
|
||||
image={storyWriterApi.getImageUrl(sceneImages.get(scene.scene_number || index + 1) || '')}
|
||||
alt={`Scene ${scene.scene_number || index + 1}: ${scene.title}`}
|
||||
sx={{ objectFit: 'contain' }}
|
||||
/>
|
||||
<CardContent>
|
||||
<Typography variant="caption" sx={{ color: '#5D4037' }}>
|
||||
Generated image for Scene {scene.scene_number || index + 1}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" sx={{ color: '#5D4037' }} gutterBottom>
|
||||
<strong>Audio Narration:</strong>
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={scene.audio_narration}
|
||||
disabled
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#1A1611',
|
||||
'& fieldset': {
|
||||
borderColor: '#8D6E63',
|
||||
borderWidth: '1.5px',
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#1A1611',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{sceneAudio && sceneAudio.has(scene.scene_number || index + 1) && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<audio
|
||||
controls
|
||||
src={storyWriterApi.getAudioUrl(sceneAudio.get(scene.scene_number || index + 1) || '')}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: '#5D4037' }}>
|
||||
Generated audio for Scene {scene.scene_number || index + 1}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{scene.character_descriptions && scene.character_descriptions.length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" sx={{ color: '#5D4037' }} gutterBottom>
|
||||
<strong>Characters:</strong>
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{scene.character_descriptions.map((char, idx) => (
|
||||
<Chip key={idx} label={char} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{scene.key_events && scene.key_events.length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" sx={{ color: '#5D4037' }} gutterBottom>
|
||||
<strong>Key Events:</strong>
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2, mb: 0 }}>
|
||||
{scene.key_events.map((event, idx) => (
|
||||
<li key={idx}>
|
||||
<Typography variant="body2" sx={{ color: '#2C2416' }}>{event}</Typography>
|
||||
</li>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
mt: 2,
|
||||
backgroundColor: '#F7F3E9', // Warm cream/parchment color
|
||||
color: '#2C2416', // Dark brown text for readability
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)',
|
||||
}}
|
||||
>
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
'.tw-shadow-book': {
|
||||
boxShadow: '0 36px 80px rgba(45, 30, 15, 0.35)',
|
||||
},
|
||||
'.tw-rounded-book': {
|
||||
borderRadius: '20px',
|
||||
},
|
||||
'.tw-page-accent': {
|
||||
background: 'linear-gradient(120deg, #f9e6c8, #f2d8b4)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600, color: '#1A1611' }}>
|
||||
Story Outline
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 4, color: '#5D4037' }}>
|
||||
Generate and review your story outline based on the premise. You can regenerate it or proceed to writing.
|
||||
</Typography>
|
||||
|
||||
{state.isOutlineStructured && (
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
Structured outline with {state.outlineScenes?.length || 0} scenes generated. Each scene includes image prompts and audio narration.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!state.premise && (
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
Please generate a premise first in the Setup phase.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{(state.outline || state.outlineScenes) ? (
|
||||
<>
|
||||
{hasScenes ? (
|
||||
<>
|
||||
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'center' }}>
|
||||
<Box
|
||||
className="tw-shadow-book tw-rounded-book"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
minHeight: 520,
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
borderRadius: '20px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 36px 80px rgba(45, 30, 15, 0.35)',
|
||||
background: 'linear-gradient(120deg, #fff9ef 0%, #f5e1c7 45%, #fff9ef 100%)',
|
||||
border: '1px solid rgba(120, 90, 60, 0.28)',
|
||||
transform: 'perspective(2200px) rotateX(2deg)',
|
||||
mx: 'auto',
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: '-10px -24px 28px',
|
||||
background:
|
||||
'radial-gradient(circle at 25% 20%, rgba(255,255,255,0.45) 0%, rgba(255,255,255,0) 42%), radial-gradient(circle at 75% 82%, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0) 46%)',
|
||||
filter: 'blur(20px)',
|
||||
zIndex: -2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Book spine */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: '50%',
|
||||
width: '2px',
|
||||
background: 'linear-gradient(180deg, rgba(120, 90, 60, 0.5) 0%, rgba(120, 90, 60, 0.08) 100%)',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
|
||||
<AnimatePresence initial={false} custom={pageDirection}>
|
||||
{/* Single container wrapping both pages for page turn animation */}
|
||||
<MotionBox
|
||||
key={`pages-${currentSceneIndex}`}
|
||||
custom={pageDirection}
|
||||
variants={{
|
||||
enter: () => ({
|
||||
opacity: 0,
|
||||
}),
|
||||
center: {
|
||||
opacity: 1,
|
||||
},
|
||||
exit: () => ({
|
||||
opacity: 0,
|
||||
}),
|
||||
}}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Left page */}
|
||||
<MotionBox
|
||||
key={`meta-${currentSceneIndex}`}
|
||||
role="button"
|
||||
aria-label="Previous scene"
|
||||
onClick={handlePrevScene}
|
||||
custom={pageDirection}
|
||||
variants={leftPageVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
sx={{
|
||||
flexBasis: { xs: '100%', md: '48%' },
|
||||
maxWidth: { xs: '100%', md: '48%' },
|
||||
padding: { xs: 3, md: 4, lg: 5 },
|
||||
pr: { xs: 3, md: 5, lg: 6 },
|
||||
borderRight: '1px solid rgba(120, 90, 60, 0.18)',
|
||||
cursor: canGoPrev ? 'pointer' : 'default',
|
||||
background:
|
||||
'linear-gradient(100deg, rgba(255,255,255,0.82) 0%, rgba(250,240,225,0.95) 50%, rgba(242,226,204,0.9) 100%)',
|
||||
boxShadow: 'inset -18px 0 30px rgba(160, 120, 90, 0.18)',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'&:hover': canGoPrev
|
||||
? {
|
||||
transform: 'translateX(-4px) rotate(-0.3deg)',
|
||||
boxShadow: 'inset -24px 0 50px rgba(145, 110, 72, 0.25)',
|
||||
}
|
||||
: undefined,
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 18,
|
||||
bottom: 18,
|
||||
right: '-12px',
|
||||
width: 24,
|
||||
background:
|
||||
'linear-gradient(180deg, rgba(220,190,150,0.25) 0%, rgba(200,160,120,0) 50%, rgba(220,190,150,0.25) 100%)',
|
||||
filter: 'blur(5px)',
|
||||
opacity: 0.8,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: '0 0 auto' }}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{ color: '#7a5335', letterSpacing: 4, fontWeight: 600, display: 'block' }}
|
||||
>
|
||||
Scene {currentScene?.scene_number || currentSceneIndex + 1} of {scenes.length}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
mt: 1,
|
||||
color: '#2C2416',
|
||||
fontFamily: `'Playfair Display', serif`,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
pr: 2,
|
||||
}}
|
||||
>
|
||||
{currentScene?.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: '1 1 auto',
|
||||
overflowY: 'auto',
|
||||
mt: 3,
|
||||
display: 'grid',
|
||||
gridTemplateRows: currentSceneImageFullUrl ? 'auto 1fr auto auto' : 'auto auto auto 1fr',
|
||||
alignContent: 'start',
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
{currentSceneImageFullUrl ? (
|
||||
<>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ color: '#7a5335', textTransform: 'uppercase', letterSpacing: 1, mb: 1.5 }}
|
||||
>
|
||||
Scene Illustration
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 8px 20px rgba(0, 0, 0, 0.18), 0 4px 8px rgba(0, 0, 0, 0.12)',
|
||||
border: '3px solid rgba(120, 90, 60, 0.25)',
|
||||
backgroundColor: '#fff',
|
||||
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px) scale(1.01)',
|
||||
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.25), 0 6px 12px rgba(0, 0, 0, 0.18)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={currentSceneImageFullUrl}
|
||||
alt={currentScene?.title || `Scene ${currentSceneNumber} illustration`}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
objectFit: 'contain',
|
||||
minHeight: '300px',
|
||||
maxHeight: '500px',
|
||||
}}
|
||||
onError={() => {
|
||||
// Mark this scene's image as failed to load
|
||||
setImageLoadError((prev) => new Set(prev).add(currentSceneNumber));
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ color: '#7a5335', textTransform: 'uppercase', letterSpacing: 1, mb: 1 }}
|
||||
>
|
||||
Image Prompt
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#3f3224', lineHeight: 1.7 }}>
|
||||
{currentScene?.image_prompt}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ color: '#7a5335', textTransform: 'uppercase', letterSpacing: 1, mb: 1 }}
|
||||
>
|
||||
Audio Narration
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#3f3224', lineHeight: 1.7 }}>
|
||||
{currentScene?.audio_narration}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{currentScene?.character_descriptions && currentScene?.character_descriptions.length > 0 && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ color: '#7a5335', textTransform: 'uppercase', letterSpacing: 1, mb: 1 }}
|
||||
>
|
||||
Characters
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5 }}>
|
||||
{currentScene.character_descriptions.map((char: string, idx: number) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={char}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'linear-gradient(120deg, #f9e6c8, #f2d8b4)',
|
||||
color: '#5a3922',
|
||||
fontWeight: 500,
|
||||
border: '1px solid rgba(120, 90, 60, 0.35)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{currentScene?.key_events && currentScene?.key_events.length > 0 && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ color: '#7a5335', textTransform: 'uppercase', letterSpacing: 1, mb: 1 }}
|
||||
>
|
||||
Key Events
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2.5, color: '#3f3224', mb: 0, lineHeight: 1.7 }}>
|
||||
{currentScene.key_events.map((event: string, idx: number) => (
|
||||
<li key={idx}>
|
||||
<Typography variant="body2">{event}</Typography>
|
||||
</li>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: '#7a5335' }}>
|
||||
Click to turn back
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#a37b55' }}>
|
||||
{canGoPrev ? '← Previous scene' : 'Start of outline'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MotionBox>
|
||||
|
||||
{/* Right page */}
|
||||
<MotionBox
|
||||
key={`story-${currentSceneIndex}`}
|
||||
role="button"
|
||||
aria-label="Next scene"
|
||||
onClick={handleNextScene}
|
||||
custom={pageDirection}
|
||||
variants={rightPageVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
sx={{
|
||||
flexBasis: { xs: '100%', md: '52%' },
|
||||
maxWidth: { xs: '100%', md: '52%' },
|
||||
padding: { xs: 3, md: 4, lg: 5 },
|
||||
pl: { xs: 3, md: 5, lg: 6 },
|
||||
cursor: canGoNext ? 'pointer' : 'default',
|
||||
background:
|
||||
'linear-gradient(260deg, rgba(255,255,255,0.88) 0%, rgba(249,236,215,0.96) 45%, rgba(243,226,206,0.92) 100%)',
|
||||
boxShadow: 'inset 18px 0 30px rgba(160, 120, 90, 0.18)',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'&:hover': canGoNext
|
||||
? {
|
||||
transform: 'translateX(4px) rotate(0.3deg)',
|
||||
boxShadow: 'inset 24px 0 50px rgba(145, 110, 72, 0.25)',
|
||||
}
|
||||
: undefined,
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 18,
|
||||
bottom: 18,
|
||||
left: '-12px',
|
||||
width: 24,
|
||||
background:
|
||||
'linear-gradient(180deg, rgba(220,190,150,0.25) 0%, rgba(200,160,120,0) 50%, rgba(220,190,150,0.25) 100%)',
|
||||
filter: 'blur(5px)',
|
||||
opacity: 0.8,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: '#2C2416',
|
||||
fontSize: '1.08rem',
|
||||
lineHeight: 1.9,
|
||||
fontFamily: `'Merriweather', serif`,
|
||||
whiteSpace: 'pre-line',
|
||||
textAlign: 'justify',
|
||||
textJustify: 'inter-word',
|
||||
textIndent: '2em',
|
||||
hyphens: 'auto',
|
||||
pr: { xs: 0, md: 1.5 },
|
||||
}}
|
||||
>
|
||||
{currentScene?.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: '#7a5335' }}>
|
||||
Click to turn page
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#a37b55' }}>
|
||||
{canGoNext ? 'Next scene →' : 'End of outline'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MotionBox>
|
||||
</MotionBox>
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
|
||||
<Typography variant="caption" sx={{ color: '#7a5335' }}>
|
||||
Page {currentSceneIndex + 1} of {scenes.length}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={12}
|
||||
value={state.outline || ''}
|
||||
onChange={(e) => state.setOutline(e.target.value)}
|
||||
label="Story Outline"
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGenerateOutline}
|
||||
disabled={isGenerating || !state.premise}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Regenerating...
|
||||
</>
|
||||
) : (
|
||||
'Regenerate Outline'
|
||||
)}
|
||||
</Button>
|
||||
{state.isOutlineStructured && state.outlineScenes && (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ImageIcon />}
|
||||
onClick={handleGenerateImages}
|
||||
disabled={isGeneratingImages || !state.outlineScenes || state.outlineScenes.length === 0}
|
||||
>
|
||||
{isGeneratingImages ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Generating Images...
|
||||
</>
|
||||
) : (
|
||||
'Generate Images'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<VolumeUpIcon />}
|
||||
onClick={handleGenerateAudio}
|
||||
disabled={isGeneratingAudio || !state.outlineScenes || state.outlineScenes.length === 0}
|
||||
>
|
||||
{isGeneratingAudio ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Generating Audio...
|
||||
</>
|
||||
) : (
|
||||
'Generate Audio'
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleContinue}
|
||||
disabled={(!state.outline && !state.outlineScenes) || isGenerating || isGeneratingImages || isGeneratingAudio}
|
||||
>
|
||||
Continue to Writing
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
{state.premise
|
||||
? 'Generating outline... If this message persists, please return to Setup and try again.'
|
||||
: 'Please generate a premise first.'}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoryOutline;
|
||||
111
frontend/src/components/StoryWriter/Phases/StoryPremise.tsx
Normal file
111
frontend/src/components/StoryWriter/Phases/StoryPremise.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
|
||||
import { storyWriterApi } from '../../../services/storyWriterApi';
|
||||
|
||||
interface StoryPremiseProps {
|
||||
state: ReturnType<typeof useStoryWriterState>;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
const StoryPremise: React.FC<StoryPremiseProps> = ({ state, onNext }) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const request = state.getRequest();
|
||||
const response = await storyWriterApi.generatePremise(request);
|
||||
|
||||
if (response.success && response.premise) {
|
||||
state.setPremise(response.premise);
|
||||
state.setError(null);
|
||||
} else {
|
||||
throw new Error(response.premise || 'Failed to generate premise');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate premise';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (state.premise) {
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 4, mt: 2 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600 }}>
|
||||
Story Premise
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
|
||||
Review and refine your story premise. You can regenerate it or proceed to create the outline.
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{state.premise ? (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={8}
|
||||
value={state.premise}
|
||||
onChange={(e) => state.setPremise(e.target.value)}
|
||||
label="Story Premise"
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleRegenerate}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Regenerating...
|
||||
</>
|
||||
) : (
|
||||
'Regenerate Premise'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleContinue}
|
||||
disabled={!state.premise || isGenerating}
|
||||
>
|
||||
Continue to Outline
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
No premise generated yet. Please go back to Setup and generate a premise first.
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoryPremise;
|
||||
@@ -0,0 +1,499 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Typography,
|
||||
Alert,
|
||||
Box,
|
||||
CircularProgress,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
Card,
|
||||
CardContent,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
} from '@mui/material';
|
||||
import { InfoOutlined } from '@mui/icons-material';
|
||||
import { storyWriterApi, StorySetupOption } from '../../../../services/storyWriterApi';
|
||||
import { triggerSubscriptionError } from '../../../../api/client';
|
||||
import { useStoryWriterState } from '../../../../hooks/useStoryWriterState';
|
||||
import { STORY_IDEA_PLACEHOLDERS } from './constants';
|
||||
import { textFieldStyles, cardStyles } from './styles';
|
||||
import {
|
||||
WRITING_STYLES,
|
||||
STORY_TONES,
|
||||
NARRATIVE_POVS,
|
||||
AUDIENCE_AGE_GROUPS,
|
||||
CONTENT_RATINGS,
|
||||
ENDING_PREFERENCES,
|
||||
} from './constants';
|
||||
import { CustomValuesSetters } from './types';
|
||||
|
||||
interface AIStorySetupModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
state: ReturnType<typeof useStoryWriterState>;
|
||||
customValuesSetters: CustomValuesSetters;
|
||||
}
|
||||
|
||||
export const AIStorySetupModal: React.FC<AIStorySetupModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
state,
|
||||
customValuesSetters,
|
||||
}) => {
|
||||
const [storyIdea, setStoryIdea] = useState('');
|
||||
const [isGeneratingSetup, setIsGeneratingSetup] = useState(false);
|
||||
const [setupOptions, setSetupOptions] = useState<StorySetupOption[]>([]);
|
||||
const [selectedOption, setSelectedOption] = useState<number | null>(null);
|
||||
const [setupError, setSetupError] = useState<string | null>(null);
|
||||
const [placeholderIndex, setPlaceholderIndex] = useState(0);
|
||||
const [currentPlaceholder, setCurrentPlaceholder] = useState('');
|
||||
const typingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const charIndexRef = useRef(0);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Rotating placeholder effect for story idea textarea
|
||||
useEffect(() => {
|
||||
// Cleanup function
|
||||
const cleanup = () => {
|
||||
if (typingIntervalRef.current) {
|
||||
clearInterval(typingIntervalRef.current);
|
||||
typingIntervalRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Stop all effects if modal is closed or user has entered text
|
||||
if (!open || storyIdea.trim() !== '') {
|
||||
cleanup();
|
||||
setCurrentPlaceholder('');
|
||||
charIndexRef.current = 0;
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
// Start typing animation for current placeholder
|
||||
const placeholder = STORY_IDEA_PLACEHOLDERS[placeholderIndex];
|
||||
charIndexRef.current = 0;
|
||||
setCurrentPlaceholder('');
|
||||
|
||||
// Type out characters one by one
|
||||
typingIntervalRef.current = setInterval(() => {
|
||||
// Check if we should stop
|
||||
if (storyIdea.trim() !== '' || !open) {
|
||||
cleanup();
|
||||
setCurrentPlaceholder('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue typing
|
||||
if (charIndexRef.current < placeholder.length) {
|
||||
setCurrentPlaceholder(placeholder.substring(0, charIndexRef.current + 1));
|
||||
charIndexRef.current += 1;
|
||||
} else {
|
||||
// Finished typing current placeholder
|
||||
cleanup();
|
||||
|
||||
// Wait 4 seconds then move to next placeholder
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (storyIdea.trim() === '' && open) {
|
||||
setPlaceholderIndex((prev) => (prev + 1) % STORY_IDEA_PLACEHOLDERS.length);
|
||||
}
|
||||
}, 4000);
|
||||
}
|
||||
}, 30);
|
||||
|
||||
return cleanup;
|
||||
}, [open, placeholderIndex, storyIdea]);
|
||||
|
||||
const handleGenerateSetup = async () => {
|
||||
if (!storyIdea.trim()) {
|
||||
setSetupError('Please enter a story idea');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingSetup(true);
|
||||
setSetupError(null);
|
||||
|
||||
try {
|
||||
const response = await storyWriterApi.generateStorySetup({
|
||||
story_idea: storyIdea,
|
||||
});
|
||||
|
||||
if (response.success && response.options && response.options.length === 3) {
|
||||
setSetupOptions(response.options);
|
||||
|
||||
// Extract custom values from all options and add them to custom values lists
|
||||
const newCustomWritingStyles = new Set<string>();
|
||||
const newCustomStoryTones = new Set<string>();
|
||||
const newCustomNarrativePOVs = new Set<string>();
|
||||
const newCustomAudienceAgeGroups = new Set<string>();
|
||||
const newCustomContentRatings = new Set<string>();
|
||||
const newCustomEndingPreferences = new Set<string>();
|
||||
|
||||
response.options.forEach((option) => {
|
||||
// Check if values are custom (not in predefined lists)
|
||||
if (!WRITING_STYLES.includes(option.writing_style)) {
|
||||
newCustomWritingStyles.add(option.writing_style);
|
||||
}
|
||||
if (!STORY_TONES.includes(option.story_tone)) {
|
||||
newCustomStoryTones.add(option.story_tone);
|
||||
}
|
||||
if (!NARRATIVE_POVS.includes(option.narrative_pov)) {
|
||||
newCustomNarrativePOVs.add(option.narrative_pov);
|
||||
}
|
||||
if (!AUDIENCE_AGE_GROUPS.includes(option.audience_age_group)) {
|
||||
newCustomAudienceAgeGroups.add(option.audience_age_group);
|
||||
}
|
||||
if (!CONTENT_RATINGS.includes(option.content_rating)) {
|
||||
newCustomContentRatings.add(option.content_rating);
|
||||
}
|
||||
if (!ENDING_PREFERENCES.includes(option.ending_preference)) {
|
||||
newCustomEndingPreferences.add(option.ending_preference);
|
||||
}
|
||||
});
|
||||
|
||||
// Update custom values state (merge with existing)
|
||||
customValuesSetters.setCustomWritingStyles((prev) =>
|
||||
[...prev, ...Array.from(newCustomWritingStyles)].filter((v, i, arr) => arr.indexOf(v) === i)
|
||||
);
|
||||
customValuesSetters.setCustomStoryTones((prev) =>
|
||||
[...prev, ...Array.from(newCustomStoryTones)].filter((v, i, arr) => arr.indexOf(v) === i)
|
||||
);
|
||||
customValuesSetters.setCustomNarrativePOVs((prev) =>
|
||||
[...prev, ...Array.from(newCustomNarrativePOVs)].filter((v, i, arr) => arr.indexOf(v) === i)
|
||||
);
|
||||
customValuesSetters.setCustomAudienceAgeGroups((prev) =>
|
||||
[...prev, ...Array.from(newCustomAudienceAgeGroups)].filter((v, i, arr) => arr.indexOf(v) === i)
|
||||
);
|
||||
customValuesSetters.setCustomContentRatings((prev) =>
|
||||
[...prev, ...Array.from(newCustomContentRatings)].filter((v, i, arr) => arr.indexOf(v) === i)
|
||||
);
|
||||
customValuesSetters.setCustomEndingPreferences((prev) =>
|
||||
[...prev, ...Array.from(newCustomEndingPreferences)].filter((v, i, arr) => arr.indexOf(v) === i)
|
||||
);
|
||||
} else {
|
||||
throw new Error('Failed to generate story setup options');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Story setup generation failed:', err);
|
||||
|
||||
// Check if this is a subscription error (429/402) and trigger global subscription modal
|
||||
const status = err?.response?.status;
|
||||
if (status === 429 || status === 402) {
|
||||
console.log('StorySetup: Detected subscription error, triggering global handler', {
|
||||
status,
|
||||
data: err?.response?.data,
|
||||
});
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
console.log('StorySetup: Global subscription error handler triggered successfully');
|
||||
// Don't set local error - let the global modal handle it
|
||||
setIsGeneratingSetup(false);
|
||||
return;
|
||||
} else {
|
||||
console.warn('StorySetup: Global subscription error handler did not handle the error');
|
||||
}
|
||||
}
|
||||
|
||||
// For non-subscription errors, show local error message
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate story setup options';
|
||||
setSetupError(errorMessage);
|
||||
} finally {
|
||||
setIsGeneratingSetup(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectOption = (index: number) => {
|
||||
setSelectedOption(index);
|
||||
};
|
||||
|
||||
const handleApplyOption = () => {
|
||||
if (selectedOption === null || !setupOptions[selectedOption]) {
|
||||
setSetupError('Please select an option');
|
||||
return;
|
||||
}
|
||||
|
||||
const option = setupOptions[selectedOption];
|
||||
|
||||
// Extract and add custom values to dropdowns if they don't exist
|
||||
if (!WRITING_STYLES.includes(option.writing_style)) {
|
||||
customValuesSetters.setCustomWritingStyles((prev) =>
|
||||
prev.includes(option.writing_style) ? prev : [...prev, option.writing_style]
|
||||
);
|
||||
}
|
||||
if (!STORY_TONES.includes(option.story_tone)) {
|
||||
customValuesSetters.setCustomStoryTones((prev) =>
|
||||
prev.includes(option.story_tone) ? prev : [...prev, option.story_tone]
|
||||
);
|
||||
}
|
||||
if (!NARRATIVE_POVS.includes(option.narrative_pov)) {
|
||||
customValuesSetters.setCustomNarrativePOVs((prev) =>
|
||||
prev.includes(option.narrative_pov) ? prev : [...prev, option.narrative_pov]
|
||||
);
|
||||
}
|
||||
if (!AUDIENCE_AGE_GROUPS.includes(option.audience_age_group)) {
|
||||
customValuesSetters.setCustomAudienceAgeGroups((prev) =>
|
||||
prev.includes(option.audience_age_group) ? prev : [...prev, option.audience_age_group]
|
||||
);
|
||||
}
|
||||
if (!CONTENT_RATINGS.includes(option.content_rating)) {
|
||||
customValuesSetters.setCustomContentRatings((prev) =>
|
||||
prev.includes(option.content_rating) ? prev : [...prev, option.content_rating]
|
||||
);
|
||||
}
|
||||
if (!ENDING_PREFERENCES.includes(option.ending_preference)) {
|
||||
customValuesSetters.setCustomEndingPreferences((prev) =>
|
||||
prev.includes(option.ending_preference) ? prev : [...prev, option.ending_preference]
|
||||
);
|
||||
}
|
||||
|
||||
// Apply the selected option to the form
|
||||
state.setPersona(option.persona);
|
||||
state.setStorySetting(option.story_setting);
|
||||
state.setCharacters(option.character_input);
|
||||
state.setPlotElements(option.plot_elements);
|
||||
state.setWritingStyle(option.writing_style);
|
||||
state.setStoryTone(option.story_tone);
|
||||
state.setNarrativePOV(option.narrative_pov);
|
||||
// Normalize audience_age_group value (migrate old format if needed, but preserve custom values)
|
||||
const normalizedAgeGroup =
|
||||
option.audience_age_group === 'Adults'
|
||||
? 'Adults (18+)'
|
||||
: option.audience_age_group === 'Children'
|
||||
? 'Children (5-12)'
|
||||
: option.audience_age_group === 'Young Adults'
|
||||
? 'Young Adults (13-17)'
|
||||
: option.audience_age_group;
|
||||
state.setAudienceAgeGroup(normalizedAgeGroup);
|
||||
state.setContentRating(option.content_rating);
|
||||
state.setEndingPreference(option.ending_preference);
|
||||
|
||||
// Apply story length if provided
|
||||
if (option.story_length) {
|
||||
state.setStoryLength(option.story_length);
|
||||
}
|
||||
|
||||
// Apply premise if provided
|
||||
if (option.premise) {
|
||||
state.setPremise(option.premise);
|
||||
}
|
||||
|
||||
// Apply image/video/audio settings if provided
|
||||
if (option.image_provider !== undefined) {
|
||||
state.setImageProvider(option.image_provider || null);
|
||||
}
|
||||
if (option.image_width !== undefined) {
|
||||
state.setImageWidth(option.image_width);
|
||||
}
|
||||
if (option.image_height !== undefined) {
|
||||
state.setImageHeight(option.image_height);
|
||||
}
|
||||
if (option.image_model !== undefined) {
|
||||
state.setImageModel(option.image_model || null);
|
||||
}
|
||||
if (option.video_fps !== undefined) {
|
||||
state.setVideoFps(option.video_fps);
|
||||
}
|
||||
if (option.video_transition_duration !== undefined) {
|
||||
state.setVideoTransitionDuration(option.video_transition_duration);
|
||||
}
|
||||
if (option.audio_provider !== undefined) {
|
||||
state.setAudioProvider(option.audio_provider);
|
||||
}
|
||||
if (option.audio_lang !== undefined) {
|
||||
state.setAudioLang(option.audio_lang);
|
||||
}
|
||||
if (option.audio_slow !== undefined) {
|
||||
state.setAudioSlow(option.audio_slow);
|
||||
}
|
||||
if (option.audio_rate !== undefined) {
|
||||
state.setAudioRate(option.audio_rate);
|
||||
}
|
||||
|
||||
// Close modal
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setStoryIdea('');
|
||||
setSetupOptions([]);
|
||||
setSelectedOption(null);
|
||||
setSetupError(null);
|
||||
setPlaceholderIndex(0);
|
||||
setCurrentPlaceholder('');
|
||||
charIndexRef.current = 0;
|
||||
// Cleanup intervals
|
||||
if (typingIntervalRef.current) {
|
||||
clearInterval(typingIntervalRef.current);
|
||||
typingIntervalRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Generate Story Setup With Alwrity AI</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ mb: 2, color: '#5D4037' }}>
|
||||
Enter your story idea or basic information. The more details you provide, the better story setups will be generated.
|
||||
</Typography>
|
||||
|
||||
{setupError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setSetupError(null)}>
|
||||
{setupError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={6}
|
||||
label="Story Idea"
|
||||
placeholder={currentPlaceholder || "Enter your story idea, characters, setting, plot elements, or any other relevant information..."}
|
||||
value={storyIdea}
|
||||
onChange={(e) => setStoryIdea(e.target.value)}
|
||||
sx={{ ...textFieldStyles, mb: 3 }}
|
||||
helperText="Provide as much detail as possible. Include characters, setting, plot, themes, or any story elements you want to explore."
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Story Idea Input
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Enter your story idea or concept. The more details you provide, the better the AI can generate tailored story setup options. Include:
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div">
|
||||
• Main characters and their roles
|
||||
<br />
|
||||
• Setting and time period
|
||||
<br />
|
||||
• Key plot points or conflicts
|
||||
<br />
|
||||
• Themes or messages
|
||||
<br />
|
||||
• Genre or style preferences
|
||||
<br />
|
||||
• Any specific story elements you want
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1, fontStyle: 'italic', color: '#5D4037' }}>
|
||||
Watch the placeholder examples cycle through for inspiration!
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<IconButton size="small" edge="end">
|
||||
<InfoOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{isGeneratingSetup && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 3 }}>
|
||||
<CircularProgress size={24} sx={{ mr: 2 }} />
|
||||
<Typography sx={{ color: '#2C2416' }}>Generating story setup options...</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{setupOptions.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ mb: 2, fontWeight: 600, color: '#1A1611' }}>
|
||||
Select one of the following options:
|
||||
</Typography>
|
||||
<RadioGroup
|
||||
value={selectedOption !== null ? selectedOption.toString() : ''}
|
||||
onChange={(e) => handleSelectOption(Number(e.target.value))}
|
||||
>
|
||||
{setupOptions.map((option, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
sx={{
|
||||
mb: 2,
|
||||
...cardStyles,
|
||||
border: selectedOption === index ? 2 : 1,
|
||||
borderColor: selectedOption === index ? 'primary.main' : 'divider',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
},
|
||||
}}
|
||||
onClick={() => handleSelectOption(index)}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Radio value={index} checked={selectedOption === index} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: '#1A1611' }}>
|
||||
Option {index + 1}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#5D4037' }}>
|
||||
<strong>Persona:</strong> {option.persona}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#5D4037' }}>
|
||||
<strong>Setting:</strong> {option.story_setting}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#5D4037' }}>
|
||||
<strong>Characters:</strong> {option.character_input}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#5D4037' }}>
|
||||
<strong>Plot Elements:</strong> {option.plot_elements}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#5D4037' }}>
|
||||
<strong>Style:</strong> {option.writing_style} | <strong>Tone:</strong> {option.story_tone} | <strong>POV:</strong> {option.narrative_pov}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#5D4037' }}>
|
||||
<strong>Audience:</strong> {option.audience_age_group} | <strong>Rating:</strong> {option.content_rating} | <strong>Ending:</strong> {option.ending_preference}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1, fontStyle: 'italic', color: '#5D4037' }}>
|
||||
<strong>Reasoning:</strong> {option.reasoning}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
{setupOptions.length === 0 ? (
|
||||
<Button
|
||||
onClick={handleGenerateSetup}
|
||||
disabled={!storyIdea.trim() || isGeneratingSetup}
|
||||
variant="contained"
|
||||
>
|
||||
{isGeneratingSetup ? 'Generating...' : 'Generate Options'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleApplyOption} disabled={selectedOption === null} variant="contained">
|
||||
Apply Selected Option
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Grid, Typography, Box, FormControlLabel, Checkbox } from '@mui/material';
|
||||
import { SectionProps } from './types';
|
||||
|
||||
export const FeatureCheckboxesSection: React.FC<SectionProps> = ({ state }) => {
|
||||
return (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Story Features
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={state.enableExplainer}
|
||||
onChange={(e) => state.setEnableExplainer(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Explainer"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={state.enableIllustration}
|
||||
onChange={(e) => state.setEnableIllustration(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Illustration"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={state.enableVideoNarration}
|
||||
onChange={(e) => state.setEnableVideoNarration(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Story Video & Narration"
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import { TextField, Tooltip, IconButton, InputAdornment, Box, Typography } from '@mui/material';
|
||||
import { InfoOutlined } from '@mui/icons-material';
|
||||
|
||||
interface TooltipContent {
|
||||
title: string;
|
||||
description: string;
|
||||
examples?: string[];
|
||||
}
|
||||
|
||||
interface FormFieldWithTooltipProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
required?: boolean;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
type?: string;
|
||||
tooltip: TooltipContent;
|
||||
sx?: any;
|
||||
inputProps?: any;
|
||||
}
|
||||
|
||||
export const FormFieldWithTooltip: React.FC<FormFieldWithTooltipProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
helperText,
|
||||
required = false,
|
||||
multiline = false,
|
||||
rows,
|
||||
type,
|
||||
tooltip,
|
||||
sx,
|
||||
inputProps,
|
||||
}) => {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
helperText={helperText}
|
||||
required={required}
|
||||
multiline={multiline}
|
||||
rows={rows}
|
||||
type={type}
|
||||
sx={sx}
|
||||
InputProps={{
|
||||
...inputProps,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
{tooltip.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{tooltip.description}
|
||||
</Typography>
|
||||
{tooltip.examples && tooltip.examples.length > 0 && (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Examples:
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div">
|
||||
{tooltip.examples.map((example, index) => (
|
||||
<React.Fragment key={index}>
|
||||
• {example}
|
||||
{index < tooltip.examples!.length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<IconButton size="small" edge="end">
|
||||
<InfoOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Grid,
|
||||
TextField,
|
||||
MenuItem,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Slider,
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { SectionProps } from './types';
|
||||
import { textFieldStyles, accordionStyles } from './styles';
|
||||
import { IMAGE_PROVIDERS, AUDIO_PROVIDERS, COMMON_IMAGE_SIZES } from './constants';
|
||||
|
||||
export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) => {
|
||||
return (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Generation Settings
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 3, color: '#5D4037' }}>
|
||||
Configure image, video, and audio generation options for your story.
|
||||
</Typography>
|
||||
|
||||
{/* Image Generation Settings */}
|
||||
<Accordion sx={accordionStyles}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
|
||||
Image Generation Settings
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
select
|
||||
label="Image Provider"
|
||||
value={state.imageProvider || ''}
|
||||
onChange={(e) => state.setImageProvider(e.target.value || null)}
|
||||
helperText="Select the image generation provider. Leave as 'Auto' to use the default."
|
||||
sx={textFieldStyles}
|
||||
>
|
||||
{IMAGE_PROVIDERS.map((provider) => (
|
||||
<MenuItem key={provider.value} value={provider.value}>
|
||||
{provider.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
select
|
||||
label="Image Size"
|
||||
value={`${state.imageWidth}x${state.imageHeight}`}
|
||||
onChange={(e) => {
|
||||
const [width, height] = e.target.value.split('x').map(Number);
|
||||
state.setImageWidth(width);
|
||||
state.setImageHeight(height);
|
||||
}}
|
||||
helperText="Select a common image size or set custom dimensions below."
|
||||
sx={textFieldStyles}
|
||||
>
|
||||
{COMMON_IMAGE_SIZES.map((size) => (
|
||||
<MenuItem key={`${size.width}x${size.height}`} value={`${size.width}x${size.height}`}>
|
||||
{size.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label="Image Width"
|
||||
value={state.imageWidth}
|
||||
onChange={(e) => state.setImageWidth(Number(e.target.value))}
|
||||
inputProps={{ min: 256, max: 2048, step: 64 }}
|
||||
helperText="Image width in pixels (256-2048)"
|
||||
sx={textFieldStyles}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label="Image Height"
|
||||
value={state.imageHeight}
|
||||
onChange={(e) => state.setImageHeight(Number(e.target.value))}
|
||||
inputProps={{ min: 256, max: 2048, step: 64 }}
|
||||
helperText="Image height in pixels (256-2048)"
|
||||
sx={textFieldStyles}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Image Model (Optional)"
|
||||
value={state.imageModel || ''}
|
||||
onChange={(e) => state.setImageModel(e.target.value || null)}
|
||||
placeholder="Leave empty to use default model"
|
||||
helperText="Specific model to use for image generation (optional)"
|
||||
sx={textFieldStyles}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Video Generation Settings */}
|
||||
<Accordion sx={accordionStyles}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
|
||||
Video Generation Settings
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label="Frames Per Second (FPS)"
|
||||
value={state.videoFps}
|
||||
onChange={(e) => state.setVideoFps(Number(e.target.value))}
|
||||
inputProps={{ min: 15, max: 60, step: 1 }}
|
||||
helperText="Video frame rate (15-60 fps). Higher values create smoother video but larger files."
|
||||
sx={textFieldStyles}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Transition Duration: {state.videoTransitionDuration.toFixed(1)}s
|
||||
</Typography>
|
||||
<Slider
|
||||
value={state.videoTransitionDuration}
|
||||
onChange={(_, value) => state.setVideoTransitionDuration(value as number)}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
marks={[
|
||||
{ value: 0, label: '0s' },
|
||||
{ value: 1, label: '1s' },
|
||||
{ value: 2, label: '2s' },
|
||||
]}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: '#5D4037' }}>
|
||||
Duration of transitions between scenes in seconds
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Audio Generation Settings */}
|
||||
<Accordion sx={accordionStyles}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
|
||||
Audio Generation Settings
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
select
|
||||
label="Audio Provider"
|
||||
value={state.audioProvider}
|
||||
onChange={(e) => state.setAudioProvider(e.target.value)}
|
||||
helperText="Text-to-speech provider for narration"
|
||||
sx={textFieldStyles}
|
||||
>
|
||||
{AUDIO_PROVIDERS.map((provider) => (
|
||||
<MenuItem key={provider.value} value={provider.value}>
|
||||
{provider.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Language Code"
|
||||
value={state.audioLang}
|
||||
onChange={(e) => state.setAudioLang(e.target.value)}
|
||||
placeholder="en"
|
||||
helperText="Language code for text-to-speech (e.g., 'en' for English, 'es' for Spanish)"
|
||||
sx={textFieldStyles}
|
||||
/>
|
||||
</Grid>
|
||||
{state.audioProvider === 'gtts' && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={state.audioSlow}
|
||||
onChange={(e) => state.setAudioSlow(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Slow Speech (gTTS only)"
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{state.audioProvider === 'pyttsx3' && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Speech Rate: {state.audioRate} words/min
|
||||
</Typography>
|
||||
<Slider
|
||||
value={state.audioRate}
|
||||
onChange={(_, value) => state.setAudioRate(value as number)}
|
||||
min={50}
|
||||
max={300}
|
||||
step={10}
|
||||
marks={[
|
||||
{ value: 50, label: '50' },
|
||||
{ value: 150, label: '150' },
|
||||
{ value: 300, label: '300' },
|
||||
]}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: '#5D4037' }}>
|
||||
Speech rate in words per minute (pyttsx3 only)
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { TextField, MenuItem, Tooltip, IconButton, InputAdornment, Box, Typography } from '@mui/material';
|
||||
import { InfoOutlined } from '@mui/icons-material';
|
||||
|
||||
interface TooltipContent {
|
||||
title: string;
|
||||
description: string;
|
||||
examples?: Array<{ label: string; description: string }>;
|
||||
}
|
||||
|
||||
interface SelectFieldWithTooltipProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
||||
helperText?: string;
|
||||
options: string[];
|
||||
customValues?: string[];
|
||||
tooltip: TooltipContent;
|
||||
sx?: any;
|
||||
}
|
||||
|
||||
export const SelectFieldWithTooltip: React.FC<SelectFieldWithTooltipProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
helperText,
|
||||
options,
|
||||
customValues = [],
|
||||
tooltip,
|
||||
sx,
|
||||
}) => {
|
||||
const allOptions = [...options, ...customValues];
|
||||
const isCustom = (option: string) => customValues.includes(option);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
select
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
helperText={helperText}
|
||||
sx={sx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
{tooltip.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{tooltip.description}
|
||||
</Typography>
|
||||
{tooltip.examples && tooltip.examples.length > 0 && (
|
||||
<>
|
||||
<Typography variant="body2" component="div">
|
||||
{tooltip.examples.map((example, index) => (
|
||||
<React.Fragment key={index}>
|
||||
• <strong>{example.label}</strong>: {example.description}
|
||||
{index < tooltip.examples!.length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<IconButton size="small" edge="end">
|
||||
<InfoOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{allOptions.map((option) => (
|
||||
<MenuItem key={option} value={option}>
|
||||
{option}
|
||||
{isCustom(option) && (
|
||||
<Typography component="span" variant="caption" sx={{ ml: 1, color: 'primary.main', fontStyle: 'italic' }}>
|
||||
(AI Generated)
|
||||
</Typography>
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import React from 'react';
|
||||
import { Grid } from '@mui/material';
|
||||
import { SelectFieldWithTooltip } from './SelectFieldWithTooltip';
|
||||
import { SectionProps } from './types';
|
||||
import {
|
||||
WRITING_STYLES,
|
||||
STORY_TONES,
|
||||
NARRATIVE_POVS,
|
||||
AUDIENCE_AGE_GROUPS,
|
||||
CONTENT_RATINGS,
|
||||
ENDING_PREFERENCES,
|
||||
STORY_LENGTHS,
|
||||
} from './constants';
|
||||
|
||||
interface StoryConfigurationSectionProps extends SectionProps {
|
||||
normalizedAudienceAgeGroup: string;
|
||||
}
|
||||
|
||||
export const StoryConfigurationSection: React.FC<StoryConfigurationSectionProps> = ({
|
||||
state,
|
||||
customValues,
|
||||
textFieldStyles,
|
||||
normalizedAudienceAgeGroup,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Writing Style */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<SelectFieldWithTooltip
|
||||
label="Writing Style"
|
||||
value={state.writingStyle}
|
||||
onChange={(e) => state.setWritingStyle(e.target.value)}
|
||||
helperText="Choose the narrative style and prose approach"
|
||||
options={WRITING_STYLES}
|
||||
customValues={customValues.customWritingStyles}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Writing Style',
|
||||
description: 'Select the narrative style that best fits your story. This affects sentence structure, vocabulary, and overall prose approach.',
|
||||
examples: [
|
||||
{ label: 'Formal', description: 'Structured, academic, precise language' },
|
||||
{ label: 'Casual', description: 'Conversational, relaxed, everyday language' },
|
||||
{ label: 'Poetic', description: 'Lyrical, metaphorical, rich imagery' },
|
||||
{ label: 'Humorous', description: 'Witty, playful, comedic tone' },
|
||||
{ label: 'Narrative', description: 'Traditional storytelling style' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Story Tone */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<SelectFieldWithTooltip
|
||||
label="Story Tone"
|
||||
value={state.storyTone}
|
||||
onChange={(e) => state.setStoryTone(e.target.value)}
|
||||
helperText="Set the emotional atmosphere and mood of your story"
|
||||
options={STORY_TONES}
|
||||
customValues={customValues.customStoryTones}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Story Tone',
|
||||
description: 'The tone determines the emotional atmosphere and overall mood of your story. It affects how readers feel while reading.',
|
||||
examples: [
|
||||
{ label: 'Dark', description: 'Serious, grim, somber atmosphere' },
|
||||
{ label: 'Uplifting', description: 'Positive, hopeful, inspiring' },
|
||||
{ label: 'Suspenseful', description: 'Tense, thrilling, edge-of-seat' },
|
||||
{ label: 'Whimsical', description: 'Playful, fanciful, lighthearted' },
|
||||
{ label: 'Mysterious', description: 'Enigmatic, puzzling, intriguing' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Narrative POV */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<SelectFieldWithTooltip
|
||||
label="Narrative Point of View"
|
||||
value={state.narrativePOV}
|
||||
onChange={(e) => state.setNarrativePOV(e.target.value)}
|
||||
helperText="Choose the perspective from which the story is told"
|
||||
options={NARRATIVE_POVS}
|
||||
customValues={customValues.customNarrativePOVs}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Narrative Point of View',
|
||||
description: "Select the perspective from which your story is narrated. This determines how much readers know about characters and events.",
|
||||
examples: [
|
||||
{ label: 'First Person', description: '"I" perspective, limited to one character\'s thoughts' },
|
||||
{ label: 'Third Person Limited', description: '"He/She" perspective, follows one character closely' },
|
||||
{ label: 'Third Person Omniscient', description: '"He/She" perspective, knows all characters\' thoughts' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Audience Age Group */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<SelectFieldWithTooltip
|
||||
label="Audience Age Group"
|
||||
value={normalizedAudienceAgeGroup}
|
||||
onChange={(e) => state.setAudienceAgeGroup(e.target.value)}
|
||||
helperText="Target age group for your story"
|
||||
options={AUDIENCE_AGE_GROUPS}
|
||||
customValues={customValues.customAudienceAgeGroups}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Audience Age Group',
|
||||
description: 'Select the primary target age group. This affects language complexity, themes, and content appropriateness.',
|
||||
examples: [
|
||||
{ label: 'Children (5-12)', description: 'Simple language, clear themes, age-appropriate content' },
|
||||
{ label: 'Young Adults (13-17)', description: 'Moderate complexity, coming-of-age themes' },
|
||||
{ label: 'Adults (18+)', description: 'Complex themes, mature content allowed' },
|
||||
{ label: 'All Ages', description: 'Universal appeal, family-friendly' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Content Rating */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<SelectFieldWithTooltip
|
||||
label="Content Rating"
|
||||
value={state.contentRating}
|
||||
onChange={(e) => state.setContentRating(e.target.value)}
|
||||
helperText="Set the content rating based on themes and material"
|
||||
options={CONTENT_RATINGS}
|
||||
customValues={customValues.customContentRatings}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Content Rating',
|
||||
description: 'Select the appropriate content rating based on themes, language, violence, and mature content in your story.',
|
||||
examples: [
|
||||
{ label: 'G', description: 'General audience, all ages appropriate' },
|
||||
{ label: 'PG', description: 'Parental guidance suggested, mild themes' },
|
||||
{ label: 'PG-13', description: 'Parents strongly cautioned, some mature content' },
|
||||
{ label: 'R', description: 'Restricted, mature themes and content' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Ending Preference */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<SelectFieldWithTooltip
|
||||
label="Ending Preference"
|
||||
value={state.endingPreference}
|
||||
onChange={(e) => state.setEndingPreference(e.target.value)}
|
||||
helperText="Choose how you want your story to conclude"
|
||||
options={ENDING_PREFERENCES}
|
||||
customValues={customValues.customEndingPreferences}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Ending Preference',
|
||||
description: 'Select the type of ending you want for your story. This guides the resolution and final emotional impact.',
|
||||
examples: [
|
||||
{ label: 'Happy', description: 'Positive resolution, characters succeed' },
|
||||
{ label: 'Tragic', description: 'Sad or bittersweet conclusion' },
|
||||
{ label: 'Cliffhanger', description: 'Open ending, sequel potential' },
|
||||
{ label: 'Twist', description: 'Unexpected revelation or turn' },
|
||||
{ label: 'Open-ended', description: 'Ambiguous, reader interpretation' },
|
||||
{ label: 'Bittersweet', description: 'Mixed emotions, realistic outcome' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Story Length */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<SelectFieldWithTooltip
|
||||
label="Story Length"
|
||||
value={state.storyLength}
|
||||
onChange={(e) => state.setStoryLength(e.target.value)}
|
||||
helperText="Choose the target length for your story"
|
||||
options={STORY_LENGTHS}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Story Length',
|
||||
description: 'Select the target length for your story. This controls how detailed and extensive the generated story will be.',
|
||||
examples: [
|
||||
{ label: 'Short (>1000 words)', description: 'Brief, concise story' },
|
||||
{ label: 'Medium (>5000 words)', description: 'Standard length story with good detail' },
|
||||
{ label: 'Long (>10000 words)', description: 'Extended, detailed story with rich development' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import React from 'react';
|
||||
import { Grid, TextField, Button, Box, CircularProgress } from '@mui/material';
|
||||
import { FormFieldWithTooltip } from './FormFieldWithTooltip';
|
||||
import { SectionProps } from './types';
|
||||
|
||||
interface StoryParametersSectionProps extends SectionProps {
|
||||
isRegeneratingPremise: boolean;
|
||||
onRegeneratePremise: () => void;
|
||||
}
|
||||
|
||||
export const StoryParametersSection: React.FC<StoryParametersSectionProps> = ({
|
||||
state,
|
||||
textFieldStyles,
|
||||
isRegeneratingPremise,
|
||||
onRegeneratePremise,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Persona */}
|
||||
<Grid item xs={12}>
|
||||
<FormFieldWithTooltip
|
||||
label="Persona"
|
||||
value={state.persona}
|
||||
onChange={(e) => state.setPersona(e.target.value)}
|
||||
placeholder="Describe the author persona (e.g., 'A fantasy writer who loves intricate world-building')"
|
||||
helperText="Define the author's voice, style, and perspective that will guide the story's narrative"
|
||||
required
|
||||
multiline
|
||||
rows={2}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Persona',
|
||||
description: "The persona defines the author's voice and writing style. This shapes how the story is told, the language used, and the overall narrative approach.",
|
||||
examples: [
|
||||
"A fantasy writer who loves intricate world-building and epic quests",
|
||||
"A mystery novelist who specializes in psychological thrillers",
|
||||
"A science fiction author who explores existential themes",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Story Setting */}
|
||||
<Grid item xs={12}>
|
||||
<FormFieldWithTooltip
|
||||
label="Story Setting"
|
||||
value={state.storySetting}
|
||||
onChange={(e) => state.setStorySetting(e.target.value)}
|
||||
placeholder="Describe the setting (e.g., 'A medieval kingdom with magic')"
|
||||
helperText="Define the time, place, and environment where your story takes place"
|
||||
required
|
||||
multiline
|
||||
rows={2}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Story Setting',
|
||||
description: 'The setting establishes the world, time period, and physical environment of your story. Include details about geography, culture, technology, and any unique elements.',
|
||||
examples: [
|
||||
"A medieval kingdom with magic and dragons",
|
||||
"A cyberpunk city in 2087 where corporations rule",
|
||||
"A small coastal town in the 1950s with a dark secret",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Characters */}
|
||||
<Grid item xs={12}>
|
||||
<FormFieldWithTooltip
|
||||
label="Characters"
|
||||
value={state.characters}
|
||||
onChange={(e) => state.setCharacters(e.target.value)}
|
||||
placeholder="Describe the main characters (e.g., 'A young wizard apprentice and her mentor')"
|
||||
helperText="Describe the main characters, their roles, relationships, and key traits"
|
||||
required
|
||||
multiline
|
||||
rows={2}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Characters',
|
||||
description: "Define your main characters, their roles in the story, relationships with each other, and key personality traits or backgrounds that drive the narrative.",
|
||||
examples: [
|
||||
"A young wizard apprentice and her wise mentor",
|
||||
"A detective with amnesia and a mysterious informant",
|
||||
"A retired space explorer and their estranged daughter",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Plot Elements */}
|
||||
<Grid item xs={12}>
|
||||
<FormFieldWithTooltip
|
||||
label="Plot Elements"
|
||||
value={state.plotElements}
|
||||
onChange={(e) => state.setPlotElements(e.target.value)}
|
||||
placeholder="Describe key plot elements (e.g., 'A quest to find a lost artifact, betrayal, redemption')"
|
||||
helperText="Outline the main events, conflicts, themes, and story arcs that drive the narrative"
|
||||
required
|
||||
multiline
|
||||
rows={3}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Plot Elements',
|
||||
description: 'Describe the key events, conflicts, themes, and story arcs. Include main challenges, obstacles, and the central conflict that drives your story forward.',
|
||||
examples: [
|
||||
"A quest to find a lost artifact, betrayal, redemption",
|
||||
"A murder mystery, conspiracy, memory loss",
|
||||
"Return to a changed world, uncovering hidden truths, rebellion",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Premise */}
|
||||
<Grid item xs={12}>
|
||||
<FormFieldWithTooltip
|
||||
label="Story Premise"
|
||||
value={state.premise || ''}
|
||||
onChange={(e) => state.setPremise(e.target.value)}
|
||||
placeholder="Enter or generate a brief premise for your story (1-2 sentences)"
|
||||
helperText="A brief summary of your story concept (1-2 sentences). This will be used to generate the story outline."
|
||||
multiline
|
||||
rows={3}
|
||||
sx={textFieldStyles}
|
||||
tooltip={{
|
||||
title: 'Story Premise',
|
||||
description: 'The premise is a brief summary (1-2 sentences) that captures the core concept of your story. It should describe who, where, and what the main challenge or adventure is. This will be used to generate the detailed story outline.',
|
||||
examples: [
|
||||
"A young wizard must find a lost artifact to save her kingdom from darkness.",
|
||||
"A detective with amnesia must solve a murder mystery to uncover their own past.",
|
||||
"A retired space explorer returns to Earth to discover it has changed beyond recognition.",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ mt: 1, display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={onRegeneratePremise}
|
||||
disabled={isRegeneratingPremise || !state.persona || !state.storySetting || !state.characters || !state.plotElements}
|
||||
startIcon={isRegeneratingPremise ? <CircularProgress size={16} /> : null}
|
||||
>
|
||||
{isRegeneratingPremise ? 'Regenerating...' : 'Regenerate Premise'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Story setup constants
|
||||
|
||||
export const WRITING_STYLES = [
|
||||
'Formal',
|
||||
'Casual',
|
||||
'Poetic',
|
||||
'Humorous',
|
||||
'Academic',
|
||||
'Journalistic',
|
||||
'Narrative',
|
||||
];
|
||||
|
||||
export const STORY_TONES = [
|
||||
'Dark',
|
||||
'Uplifting',
|
||||
'Suspenseful',
|
||||
'Whimsical',
|
||||
'Melancholic',
|
||||
'Mysterious',
|
||||
'Romantic',
|
||||
'Adventurous',
|
||||
];
|
||||
|
||||
export const NARRATIVE_POVS = [
|
||||
'First Person',
|
||||
'Third Person Limited',
|
||||
'Third Person Omniscient',
|
||||
];
|
||||
|
||||
export const AUDIENCE_AGE_GROUPS = [
|
||||
'Children (5-12)',
|
||||
'Young Adults (13-17)',
|
||||
'Adults (18+)',
|
||||
'All Ages',
|
||||
];
|
||||
|
||||
export const CONTENT_RATINGS = ['G', 'PG', 'PG-13', 'R'];
|
||||
|
||||
export const ENDING_PREFERENCES = [
|
||||
'Happy',
|
||||
'Tragic',
|
||||
'Cliffhanger',
|
||||
'Twist',
|
||||
'Open-ended',
|
||||
'Bittersweet',
|
||||
];
|
||||
|
||||
export const STORY_LENGTHS = [
|
||||
'Short (>1000 words)',
|
||||
'Medium (>5000 words)',
|
||||
'Long (>10000 words)',
|
||||
];
|
||||
|
||||
export const IMAGE_PROVIDERS = [
|
||||
{ value: '', label: 'Auto (Default)' },
|
||||
{ value: 'gemini', label: 'Gemini' },
|
||||
{ value: 'huggingface', label: 'HuggingFace' },
|
||||
{ value: 'stability', label: 'Stability AI' },
|
||||
];
|
||||
|
||||
export const AUDIO_PROVIDERS = [
|
||||
{ value: 'gtts', label: 'Google TTS (gTTS)' },
|
||||
{ value: 'pyttsx3', label: 'pyttsx3' },
|
||||
];
|
||||
|
||||
export const COMMON_IMAGE_SIZES = [
|
||||
{ width: 512, height: 512, label: '512x512 (Square)' },
|
||||
{ width: 768, height: 768, label: '768x768 (Square)' },
|
||||
{ width: 1024, height: 1024, label: '1024x1024 (Square)' },
|
||||
{ width: 1024, height: 768, label: '1024x768 (Landscape)' },
|
||||
{ width: 768, height: 1024, label: '768x1024 (Portrait)' },
|
||||
];
|
||||
|
||||
export const STORY_IDEA_PLACEHOLDERS = [
|
||||
"A young wizard discovers a magical artifact in an ancient forest. The artifact holds the power to restore balance to a dying realm, but it comes with a terrible cost. The wizard must choose between saving the world and losing everything they hold dear.",
|
||||
"In a cyberpunk future where memories can be bought and sold, a detective with no past must solve a murder that threatens to expose a conspiracy spanning decades. The deeper they dig, the more they realize their own memories might have been stolen.",
|
||||
"A retired space explorer returns to their home planet after 50 years, only to find it has been transformed into a utopian society that erases all traces of the past. They must uncover the truth about what happened while avoiding the watchful eyes of the perfect world they helped create.",
|
||||
];
|
||||
|
||||
257
frontend/src/components/StoryWriter/Phases/StorySetup/index.tsx
Normal file
257
frontend/src/components/StoryWriter/Phases/StorySetup/index.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Paper, Typography, Box, Button, Alert, Grid, CircularProgress } from '@mui/material';
|
||||
import { useStoryWriterState } from '../../../../hooks/useStoryWriterState';
|
||||
import { storyWriterApi, StoryScene } from '../../../../services/storyWriterApi';
|
||||
import { triggerSubscriptionError } from '../../../../api/client';
|
||||
import { StoryParametersSection } from './StoryParametersSection';
|
||||
import { StoryConfigurationSection } from './StoryConfigurationSection';
|
||||
import { FeatureCheckboxesSection } from './FeatureCheckboxesSection';
|
||||
import { GenerationSettingsSection } from './GenerationSettingsSection';
|
||||
import { AIStorySetupModal } from './AIStorySetupModal';
|
||||
import { textFieldStyles, paperStyles } from './styles';
|
||||
import { AUDIENCE_AGE_GROUPS } from './constants';
|
||||
import { StorySetupProps, CustomValuesState, CustomValuesSetters } from './types';
|
||||
|
||||
const StorySetup: React.FC<StorySetupProps> = ({ state, onNext }) => {
|
||||
const [isRegeneratingPremise, setIsRegeneratingPremise] = useState(false);
|
||||
const [isGeneratingOutline, setIsGeneratingOutline] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// Track custom values from AI-generated options
|
||||
const [customWritingStyles, setCustomWritingStyles] = useState<string[]>([]);
|
||||
const [customStoryTones, setCustomStoryTones] = useState<string[]>([]);
|
||||
const [customNarrativePOVs, setCustomNarrativePOVs] = useState<string[]>([]);
|
||||
const [customAudienceAgeGroups, setCustomAudienceAgeGroups] = useState<string[]>([]);
|
||||
const [customContentRatings, setCustomContentRatings] = useState<string[]>([]);
|
||||
const [customEndingPreferences, setCustomEndingPreferences] = useState<string[]>([]);
|
||||
|
||||
const customValues: CustomValuesState = {
|
||||
customWritingStyles,
|
||||
customStoryTones,
|
||||
customNarrativePOVs,
|
||||
customAudienceAgeGroups,
|
||||
customContentRatings,
|
||||
customEndingPreferences,
|
||||
};
|
||||
|
||||
const handleGenerateOutlineAndProceed = async () => {
|
||||
if (!state.premise) {
|
||||
setError('Please generate a premise before generating the outline');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingOutline(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const request = state.getRequest();
|
||||
const response = await storyWriterApi.generateOutline(state.premise, request);
|
||||
|
||||
if (response.success && response.outline) {
|
||||
if (response.is_structured && Array.isArray(response.outline)) {
|
||||
const scenes = response.outline as StoryScene[];
|
||||
state.setOutlineScenes(scenes);
|
||||
state.setIsOutlineStructured(true);
|
||||
const formattedOutline = scenes
|
||||
.map((scene, idx) => `Scene ${scene.scene_number || idx + 1}: ${scene.title}\n${scene.description}`)
|
||||
.join('\n\n');
|
||||
state.setOutline(formattedOutline);
|
||||
} else {
|
||||
state.setOutline(typeof response.outline === 'string' ? response.outline : String(response.outline));
|
||||
state.setOutlineScenes(null);
|
||||
state.setIsOutlineStructured(false);
|
||||
}
|
||||
state.setError(null);
|
||||
onNext();
|
||||
} else {
|
||||
throw new Error(typeof response.outline === 'string' ? response.outline : 'Failed to generate outline');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
if (status === 429 || status === 402) {
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
setIsGeneratingOutline(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate outline';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsGeneratingOutline(false);
|
||||
}
|
||||
};
|
||||
|
||||
const customValuesSetters: CustomValuesSetters = {
|
||||
setCustomWritingStyles,
|
||||
setCustomStoryTones,
|
||||
setCustomNarrativePOVs,
|
||||
setCustomAudienceAgeGroups,
|
||||
setCustomContentRatings,
|
||||
setCustomEndingPreferences,
|
||||
};
|
||||
|
||||
// Get normalized audienceAgeGroup value (fallback to default if invalid, but preserve custom values)
|
||||
const allAudienceAgeGroups = [...AUDIENCE_AGE_GROUPS, ...customAudienceAgeGroups];
|
||||
const normalizedAudienceAgeGroup = allAudienceAgeGroups.includes(state.audienceAgeGroup)
|
||||
? state.audienceAgeGroup
|
||||
: state.audienceAgeGroup === 'Adults'
|
||||
? 'Adults (18+)'
|
||||
: state.audienceAgeGroup === 'Children'
|
||||
? 'Children (5-12)'
|
||||
: state.audienceAgeGroup === 'Young Adults'
|
||||
? 'Young Adults (13-17)'
|
||||
: state.audienceAgeGroup || 'Adults (18+)'; // Preserve custom values instead of defaulting
|
||||
|
||||
// Fix invalid audienceAgeGroup values on mount and when state changes (but preserve custom values)
|
||||
useEffect(() => {
|
||||
// Only normalize if it's an old format value, not a custom value
|
||||
if (
|
||||
state.audienceAgeGroup &&
|
||||
state.audienceAgeGroup !== normalizedAudienceAgeGroup &&
|
||||
!allAudienceAgeGroups.includes(state.audienceAgeGroup) &&
|
||||
(state.audienceAgeGroup === 'Adults' ||
|
||||
state.audienceAgeGroup === 'Children' ||
|
||||
state.audienceAgeGroup === 'Young Adults')
|
||||
) {
|
||||
state.setAudienceAgeGroup(normalizedAudienceAgeGroup);
|
||||
}
|
||||
}, [state.audienceAgeGroup, normalizedAudienceAgeGroup, state.setAudienceAgeGroup, allAudienceAgeGroups]);
|
||||
|
||||
const handleRegeneratePremise = async () => {
|
||||
// Validate required fields
|
||||
if (!state.persona || !state.storySetting || !state.characters || !state.plotElements) {
|
||||
setError('Please fill in all required fields (Persona, Setting, Characters, Plot Elements)');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRegeneratingPremise(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const request = state.getRequest();
|
||||
const response = await storyWriterApi.generatePremise(request);
|
||||
|
||||
if (response.success && response.premise) {
|
||||
state.setPremise(response.premise);
|
||||
state.setError(null);
|
||||
} else {
|
||||
throw new Error(response.premise || 'Failed to generate premise');
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Check if this is a subscription error (429/402) and trigger global subscription modal
|
||||
const status = err?.response?.status;
|
||||
if (status === 429 || status === 402) {
|
||||
console.log('StorySetup: Detected subscription error in regenerate premise, triggering global handler', {
|
||||
status,
|
||||
data: err?.response?.data,
|
||||
});
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
console.log('StorySetup: Global subscription error handler triggered successfully');
|
||||
setIsRegeneratingPremise(false);
|
||||
return;
|
||||
} else {
|
||||
console.warn('StorySetup: Global subscription error handler did not handle the error');
|
||||
}
|
||||
}
|
||||
|
||||
// For non-subscription errors, show local error message
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate premise';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsRegeneratingPremise(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={paperStyles}>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600, color: '#1A1611' }}>
|
||||
Story Setup
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 4, color: '#5D4037' }}>
|
||||
Configure your story parameters and premise. Fill in the required fields and click "Next: Generate Outline" to continue.
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* AI Story Setup Button */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Button variant="outlined" color="primary" size="large" onClick={() => setIsModalOpen(true)} sx={{ mb: 2 }}>
|
||||
Generate Story Setup With Alwrity AI
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Story Parameters Section */}
|
||||
<StoryParametersSection
|
||||
state={state}
|
||||
customValues={customValues}
|
||||
textFieldStyles={textFieldStyles}
|
||||
isRegeneratingPremise={isRegeneratingPremise}
|
||||
onRegeneratePremise={handleRegeneratePremise}
|
||||
/>
|
||||
|
||||
{/* Story Configuration Section */}
|
||||
<StoryConfigurationSection
|
||||
state={state}
|
||||
customValues={customValues}
|
||||
textFieldStyles={textFieldStyles}
|
||||
normalizedAudienceAgeGroup={normalizedAudienceAgeGroup}
|
||||
/>
|
||||
|
||||
{/* Feature Checkboxes Section */}
|
||||
<FeatureCheckboxesSection state={state} customValues={customValues} textFieldStyles={textFieldStyles} />
|
||||
</Grid>
|
||||
|
||||
{/* Generation Settings Section */}
|
||||
<GenerationSettingsSection state={state} customValues={customValues} textFieldStyles={textFieldStyles} />
|
||||
|
||||
{/* Generate Button */}
|
||||
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={handleGenerateOutlineAndProceed}
|
||||
disabled={
|
||||
!state.persona ||
|
||||
!state.storySetting ||
|
||||
!state.characters ||
|
||||
!state.plotElements ||
|
||||
!state.premise ||
|
||||
isGeneratingOutline
|
||||
}
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
{isGeneratingOutline ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Generating Outline...
|
||||
</>
|
||||
) : (
|
||||
'Generate Outline'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* AI Story Setup Modal */}
|
||||
<AIStorySetupModal
|
||||
open={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
state={state}
|
||||
customValuesSetters={customValuesSetters}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorySetup;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Shared styles for Story Setup components
|
||||
|
||||
export const textFieldStyles = {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#1A1611',
|
||||
'& fieldset': {
|
||||
borderColor: '#8D6E63',
|
||||
borderWidth: '1.5px',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: '#5D4037',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: '#3E2723',
|
||||
borderWidth: '2px',
|
||||
},
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: '#3E2723',
|
||||
fontWeight: 500,
|
||||
'&.Mui-focused': {
|
||||
color: '#1A1611',
|
||||
fontWeight: 600,
|
||||
},
|
||||
'&.Mui-required': {
|
||||
'&::after': {
|
||||
color: '#D32F2F',
|
||||
},
|
||||
},
|
||||
},
|
||||
'& .MuiFormHelperText-root': {
|
||||
color: '#5D4037',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 400,
|
||||
marginTop: '4px',
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#1A1611',
|
||||
'&::placeholder': {
|
||||
color: '#8D6E63',
|
||||
opacity: 0.7,
|
||||
},
|
||||
},
|
||||
'& .MuiSelect-select': {
|
||||
color: '#1A1611',
|
||||
},
|
||||
'& .MuiMenuItem-root': {
|
||||
color: '#1A1611',
|
||||
'&:hover': {
|
||||
backgroundColor: '#F7F3E9',
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: '#E8E5D3',
|
||||
'&:hover': {
|
||||
backgroundColor: '#E8E5D3',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const paperStyles = {
|
||||
p: 4,
|
||||
mt: 2,
|
||||
backgroundColor: '#F7F3E9', // Warm cream/parchment color
|
||||
color: '#2C2416', // Dark brown text for readability
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)',
|
||||
};
|
||||
|
||||
export const accordionStyles = {
|
||||
mb: 2,
|
||||
backgroundColor: '#FAF9F6', // Slightly lighter cream for accordions
|
||||
'&:before': {
|
||||
display: 'none', // Remove default border
|
||||
},
|
||||
};
|
||||
|
||||
export const cardStyles = {
|
||||
backgroundColor: '#FAF9F6', // Slightly lighter cream for cards
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.08)',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Type definitions for Story Setup components
|
||||
|
||||
import { useStoryWriterState } from '../../../../hooks/useStoryWriterState';
|
||||
|
||||
export interface StorySetupProps {
|
||||
state: ReturnType<typeof useStoryWriterState>;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export interface CustomValuesState {
|
||||
customWritingStyles: string[];
|
||||
customStoryTones: string[];
|
||||
customNarrativePOVs: string[];
|
||||
customAudienceAgeGroups: string[];
|
||||
customContentRatings: string[];
|
||||
customEndingPreferences: string[];
|
||||
}
|
||||
|
||||
export interface CustomValuesSetters {
|
||||
setCustomWritingStyles: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setCustomStoryTones: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setCustomNarrativePOVs: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setCustomAudienceAgeGroups: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setCustomContentRatings: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setCustomEndingPreferences: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
export interface SectionProps {
|
||||
state: ReturnType<typeof useStoryWriterState>;
|
||||
customValues: CustomValuesState;
|
||||
textFieldStyles: any;
|
||||
}
|
||||
|
||||
292
frontend/src/components/StoryWriter/Phases/StoryWriting.tsx
Normal file
292
frontend/src/components/StoryWriter/Phases/StoryWriting.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
|
||||
import { storyWriterApi } from '../../../services/storyWriterApi';
|
||||
import { triggerSubscriptionError } from '../../../api/client';
|
||||
|
||||
interface StoryWritingProps {
|
||||
state: ReturnType<typeof useStoryWriterState>;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
// Helper function to check if story is short
|
||||
const isShortStory = (storyLength: string | null | undefined): boolean => {
|
||||
if (!storyLength) return false;
|
||||
const storyLengthLower = storyLength.toLowerCase();
|
||||
return storyLengthLower.includes('short') || storyLengthLower.includes('1000');
|
||||
};
|
||||
|
||||
const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isContinuing, setIsContinuing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleGenerateStart = async () => {
|
||||
if (!state.premise || (!state.outline && !state.outlineScenes)) {
|
||||
setError('Please generate a premise and outline first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const request = state.getRequest();
|
||||
// Use structured scenes if available, otherwise use text outline
|
||||
const outline = state.isOutlineStructured && state.outlineScenes
|
||||
? state.outlineScenes
|
||||
: (state.outline || '');
|
||||
|
||||
const response = await storyWriterApi.generateStoryStart(
|
||||
state.premise,
|
||||
outline,
|
||||
request
|
||||
);
|
||||
|
||||
if (response.success && response.story) {
|
||||
state.setStoryContent(response.story);
|
||||
state.setIsComplete(response.is_complete);
|
||||
state.setError(null);
|
||||
} else {
|
||||
throw new Error(response.story || 'Failed to generate story');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Story start generation failed:', err);
|
||||
|
||||
// Check if this is a subscription error (429/402) and trigger global subscription modal
|
||||
const status = err?.response?.status;
|
||||
if (status === 429 || status === 402) {
|
||||
console.log('StoryWriting: Detected subscription error, triggering global handler', {
|
||||
status,
|
||||
data: err?.response?.data
|
||||
});
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
console.log('StoryWriting: Global subscription error handler triggered successfully');
|
||||
// Don't set local error - let the global modal handle it
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
} else {
|
||||
console.warn('StoryWriting: Global subscription error handler did not handle the error');
|
||||
}
|
||||
}
|
||||
|
||||
// For non-subscription errors, show local error message
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate story';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (!state.premise || (!state.outline && !state.outlineScenes) || !state.storyContent) {
|
||||
setError('Please generate story content first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsContinuing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const request = state.getRequest();
|
||||
// Use structured scenes if available, otherwise use text outline
|
||||
const outline = state.isOutlineStructured && state.outlineScenes
|
||||
? state.outlineScenes
|
||||
: (state.outline || '');
|
||||
|
||||
const continueRequest = {
|
||||
...request,
|
||||
premise: state.premise,
|
||||
outline: outline,
|
||||
story_text: state.storyContent,
|
||||
};
|
||||
|
||||
const response = await storyWriterApi.continueStory(continueRequest);
|
||||
|
||||
if (response.success && response.continuation) {
|
||||
// Check if continuation is IAMDONE marker
|
||||
const isDone = response.is_complete || /IAMDONE/i.test(response.continuation);
|
||||
|
||||
// Strip IAMDONE marker if present for cleaner display
|
||||
const cleanContinuation = response.continuation.replace(/IAMDONE/gi, '').trim();
|
||||
|
||||
// Only append continuation if it's not just IAMDONE or empty
|
||||
if (cleanContinuation) {
|
||||
state.setStoryContent((state.storyContent || '') + '\n\n' + cleanContinuation);
|
||||
}
|
||||
|
||||
// Set completion status
|
||||
state.setIsComplete(isDone);
|
||||
|
||||
// If story is complete, show success message
|
||||
if (isDone) {
|
||||
console.log('Story is complete. Word count target reached.');
|
||||
}
|
||||
|
||||
state.setError(null);
|
||||
} else {
|
||||
throw new Error(response.continuation || 'Failed to continue story');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Story continuation failed:', err);
|
||||
|
||||
// Check if this is a subscription error (429/402) and trigger global subscription modal
|
||||
const status = err?.response?.status;
|
||||
if (status === 429 || status === 402) {
|
||||
console.log('StoryWriting: Detected subscription error in continuation, triggering global handler', {
|
||||
status,
|
||||
data: err?.response?.data
|
||||
});
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
console.log('StoryWriting: Global subscription error handler triggered successfully');
|
||||
// Don't set local error - let the global modal handle it
|
||||
setIsContinuing(false);
|
||||
return;
|
||||
} else {
|
||||
console.warn('StoryWriting: Global subscription error handler did not handle the error');
|
||||
}
|
||||
}
|
||||
|
||||
// For non-subscription errors, show local error message
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to continue story';
|
||||
setError(errorMessage);
|
||||
state.setError(errorMessage);
|
||||
} finally {
|
||||
setIsContinuing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinueToExport = () => {
|
||||
if (state.storyContent && state.isComplete) {
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
mt: 2,
|
||||
backgroundColor: '#F7F3E9', // Warm cream/parchment color
|
||||
color: '#2C2416', // Dark brown text for readability
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600, color: '#1A1611' }}>
|
||||
Story Writing
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2, color: '#5D4037' }}>
|
||||
Generate your story content. You can generate the starting section and continue writing until the story is complete.
|
||||
</Typography>
|
||||
{state.storyContent && (
|
||||
<Typography variant="body2" sx={{ mb: 4, color: '#5D4037', fontStyle: 'italic' }}>
|
||||
Current word count: {state.storyContent.split(/\s+/).filter(word => word.length > 0).length} words
|
||||
{state.storyLength && (
|
||||
<> (Target: {state.storyLength.includes('1000') ? '>1000' : state.storyLength.includes('5000') ? '>5000' : '>10000'} words)</>
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{(!state.premise || (!state.outline && !state.outlineScenes)) && (
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
Please generate a premise and outline first.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{state.storyContent ? (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={20}
|
||||
value={state.storyContent}
|
||||
onChange={(e) => state.setStoryContent(e.target.value)}
|
||||
label="Story Content"
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* Only show Continue Writing button for medium/long stories that are not complete */}
|
||||
{!state.isComplete && !isShortStory(state.storyLength) && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleContinue}
|
||||
disabled={isContinuing || !state.storyContent}
|
||||
>
|
||||
{isContinuing ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Continuing...
|
||||
</>
|
||||
) : (
|
||||
'Continue Writing'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{/* Show completion message if story is complete */}
|
||||
{state.isComplete && (
|
||||
<Alert severity="success" sx={{ flex: 1, minWidth: '200px' }}>
|
||||
Story is complete! You can proceed to export.
|
||||
</Alert>
|
||||
)}
|
||||
{/* Show info message for short stories that are not complete yet */}
|
||||
{!state.isComplete && isShortStory(state.storyLength) && (
|
||||
<Alert severity="info" sx={{ flex: 1, minWidth: '200px' }}>
|
||||
Short stories are generated in one call. If the story is incomplete, please regenerate it.
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleContinueToExport}
|
||||
disabled={!state.storyContent || !state.isComplete}
|
||||
>
|
||||
Continue to Export
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box>
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
{state.premise && (state.outline || state.outlineScenes)
|
||||
? 'Click "Generate Story" to start writing your story.'
|
||||
: 'Please generate a premise and outline first.'}
|
||||
</Alert>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleGenerateStart}
|
||||
disabled={isGenerating || !state.premise || (!state.outline && !state.outlineScenes)}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
'Generate Story'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoryWriting;
|
||||
120
frontend/src/components/StoryWriter/StoryWriter.tsx
Normal file
120
frontend/src/components/StoryWriter/StoryWriter.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { Box, Container, Typography, useTheme } from '@mui/material';
|
||||
import { useStoryWriterState } from '../../hooks/useStoryWriterState';
|
||||
import { useStoryWriterPhaseNavigation } from '../../hooks/useStoryWriterPhaseNavigation';
|
||||
import StorySetup from './Phases/StorySetup';
|
||||
import StoryOutline from './Phases/StoryOutline';
|
||||
import StoryWriting from './Phases/StoryWriting';
|
||||
import StoryExport from './Phases/StoryExport';
|
||||
import PhaseNavigation from './PhaseNavigation';
|
||||
|
||||
export const StoryWriter: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
// State management
|
||||
const state = useStoryWriterState();
|
||||
|
||||
// Phase navigation
|
||||
const {
|
||||
phases,
|
||||
currentPhase,
|
||||
navigateToPhase,
|
||||
} = useStoryWriterPhaseNavigation({
|
||||
hasPremise: !!state.premise,
|
||||
hasOutline: !!state.outline,
|
||||
hasStoryContent: !!state.storyContent,
|
||||
isComplete: state.isComplete,
|
||||
});
|
||||
|
||||
// Reset handler
|
||||
const handleReset = () => {
|
||||
// Reset story state (this also clears localStorage)
|
||||
state.resetState();
|
||||
// Simplest approach: reload the page to ensure a clean slate
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
// Render phase content
|
||||
const renderPhaseContent = () => {
|
||||
switch (currentPhase) {
|
||||
case 'setup':
|
||||
return <StorySetup state={state} onNext={() => navigateToPhase('outline')} />;
|
||||
case 'outline':
|
||||
return <StoryOutline state={state} onNext={() => navigateToPhase('writing')} />;
|
||||
case 'writing':
|
||||
return <StoryWriting state={state} onNext={() => navigateToPhase('export')} />;
|
||||
case 'export':
|
||||
return <StoryExport state={state} />;
|
||||
default:
|
||||
return <StorySetup state={state} onNext={() => navigateToPhase('outline')} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)',
|
||||
padding: theme.spacing(4),
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'url("data:image/svg+xml,%3Csvg width="80" height="80" viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="0.03"%3E%3Ccircle cx="40" cy="40" r="3"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
background: 'radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%)',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Container
|
||||
maxWidth="xl"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h3" component="h1" gutterBottom>
|
||||
Story Writer
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Create compelling stories with AI assistance
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Phase Navigation */}
|
||||
<PhaseNavigation
|
||||
phases={phases}
|
||||
currentPhase={currentPhase}
|
||||
onPhaseClick={navigateToPhase}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
|
||||
{/* Phase Content */}
|
||||
<Box sx={{ mt: 4 }}>
|
||||
{renderPhaseContent()}
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoryWriter;
|
||||
2
frontend/src/components/StoryWriter/index.ts
Normal file
2
frontend/src/components/StoryWriter/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as StoryWriter } from './StoryWriter';
|
||||
export { default as PhaseNavigation } from './PhaseNavigation';
|
||||
@@ -58,16 +58,49 @@ const WixCallbackPage: React.FC = () => {
|
||||
}
|
||||
} catch {}
|
||||
// Fallback redirect for same-tab flow - check if we have a stored redirect URL
|
||||
const redirectUrl = sessionStorage.getItem('wix_oauth_redirect');
|
||||
let redirectUrl = sessionStorage.getItem('wix_oauth_redirect');
|
||||
console.log('[Wix Callback] Checking redirect URL:', redirectUrl);
|
||||
|
||||
if (redirectUrl) {
|
||||
// Normalize the redirect URL to use the current origin if it's different
|
||||
// This handles cases where localhost redirect URL is used but callback is on ngrok (or vice versa)
|
||||
try {
|
||||
const urlObj = new URL(redirectUrl);
|
||||
const currentOrigin = window.location.origin;
|
||||
|
||||
// If the stored redirect URL has a different origin, update it to current origin
|
||||
// This ensures the redirect works regardless of localhost vs ngrok
|
||||
if (urlObj.origin !== currentOrigin) {
|
||||
redirectUrl = `${currentOrigin}${urlObj.pathname}${urlObj.hash}${urlObj.search}`;
|
||||
console.log('[Wix Callback] Normalized redirect URL to current origin:', {
|
||||
original: sessionStorage.getItem('wix_oauth_redirect'),
|
||||
normalized: redirectUrl,
|
||||
currentOrigin
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Wix Callback] Failed to normalize redirect URL, using as-is:', e);
|
||||
}
|
||||
|
||||
console.log('[Wix Callback] Redirecting to stored URL:', redirectUrl);
|
||||
sessionStorage.removeItem('wix_oauth_redirect');
|
||||
// Use replace to avoid adding to history
|
||||
window.location.replace(redirectUrl);
|
||||
} else {
|
||||
// Default to onboarding if no redirect URL stored
|
||||
console.warn('[Wix Callback] No redirect URL found, defaulting to onboarding');
|
||||
window.location.replace('/onboarding?step=5&wix_connected=true');
|
||||
// Check if we're coming from blog writer by checking referrer or other indicators
|
||||
// If we can't determine the source, default to blog writer publish phase
|
||||
const referrer = document.referrer;
|
||||
const isFromBlogWriter = referrer.includes('/blog-writer') ||
|
||||
window.location.search.includes('from=blog-writer');
|
||||
|
||||
if (isFromBlogWriter) {
|
||||
console.log('[Wix Callback] Detected blog writer context, redirecting to blog writer publish phase');
|
||||
window.location.replace('/blog-writer#publish');
|
||||
} else {
|
||||
// Default to onboarding if no redirect URL stored and not from blog writer
|
||||
console.warn('[Wix Callback] No redirect URL found, defaulting to onboarding');
|
||||
window.location.replace('/onboarding?step=5&wix_connected=true');
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'OAuth callback failed');
|
||||
|
||||
@@ -28,15 +28,33 @@ import {
|
||||
calculateUsagePercentage
|
||||
} from '../../services/billingService';
|
||||
|
||||
// Terminal Theme
|
||||
import {
|
||||
TerminalCard,
|
||||
TerminalCardContent,
|
||||
TerminalTypography,
|
||||
TerminalChip,
|
||||
TerminalChipSuccess,
|
||||
TerminalChipError,
|
||||
TerminalChipWarning,
|
||||
terminalColors
|
||||
} from '../SchedulerDashboard/terminalTheme';
|
||||
|
||||
interface BillingOverviewProps {
|
||||
usageStats: UsageStats;
|
||||
onRefresh: () => void;
|
||||
terminalTheme?: boolean;
|
||||
}
|
||||
|
||||
const BillingOverview: React.FC<BillingOverviewProps> = ({
|
||||
usageStats,
|
||||
onRefresh
|
||||
onRefresh,
|
||||
terminalTheme = false
|
||||
}) => {
|
||||
// Conditional component selection based on terminal theme
|
||||
const CardComponent = terminalTheme ? TerminalCard : Card;
|
||||
const CardContentComponent = terminalTheme ? TerminalCardContent : CardContent;
|
||||
const TypographyComponent = terminalTheme ? TerminalTypography : Typography;
|
||||
// Debug logs removed to reduce console noise
|
||||
|
||||
const costUsagePercentage = calculateUsagePercentage(
|
||||
@@ -47,9 +65,53 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
|
||||
// Debug logs removed to reduce console noise
|
||||
|
||||
const getStatusChip = () => {
|
||||
const status = usageStats.usage_status;
|
||||
const status: string = usageStats.usage_status;
|
||||
const icon = getUsageStatusIcon(status);
|
||||
|
||||
// Helper function to format status label
|
||||
const formatStatusLabel = (statusStr: string): string => {
|
||||
return statusStr.charAt(0).toUpperCase() + statusStr.slice(1).replace('_', ' ');
|
||||
};
|
||||
|
||||
if (terminalTheme) {
|
||||
if (status === 'active') {
|
||||
return (
|
||||
<TerminalChipSuccess
|
||||
icon={<span>{icon}</span>}
|
||||
label={formatStatusLabel(status)}
|
||||
size="small"
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
/>
|
||||
);
|
||||
} else if (status === 'warning') {
|
||||
return (
|
||||
<TerminalChipWarning
|
||||
icon={<span>{icon}</span>}
|
||||
label={formatStatusLabel(status)}
|
||||
size="small"
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
/>
|
||||
);
|
||||
} else if (status === 'limit_reached') {
|
||||
return (
|
||||
<TerminalChipError
|
||||
icon={<span>{icon}</span>}
|
||||
label={formatStatusLabel(status)}
|
||||
size="small"
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TerminalChip
|
||||
icon={<span>{icon}</span>}
|
||||
label={formatStatusLabel(status)}
|
||||
size="small"
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let chipColor: 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' = 'default';
|
||||
if (status === 'active') chipColor = 'success';
|
||||
else if (status === 'warning') chipColor = 'warning';
|
||||
@@ -58,7 +120,7 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
|
||||
return (
|
||||
<Chip
|
||||
icon={<span>{icon}</span>}
|
||||
label={status.charAt(0).toUpperCase() + status.slice(1).replace('_', ' ')}
|
||||
label={formatStatusLabel(status)}
|
||||
color={chipColor}
|
||||
size="small"
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
@@ -66,6 +128,25 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const cardStyles = terminalTheme
|
||||
? {
|
||||
height: '100%',
|
||||
backgroundColor: terminalColors.background,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
borderRadius: 3,
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden' as const
|
||||
}
|
||||
: {
|
||||
height: '100%',
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3,
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden' as const
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -73,34 +154,24 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
|
||||
transition={{ duration: 0.4 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<CardComponent sx={cardStyles}>
|
||||
{/* Header */}
|
||||
<CardContent sx={{ pb: 1 }}>
|
||||
<CardContentComponent sx={{ pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1, fontWeight: 'bold' }}>
|
||||
<DollarSign size={20} />
|
||||
<TypographyComponent variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1, fontWeight: 'bold' }}>
|
||||
<DollarSign size={20} color={terminalTheme ? terminalColors.text : undefined} />
|
||||
Billing Overview
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
<Tooltip title="View your current billing status, usage metrics, and subscription plan details">
|
||||
<Info size={16} color="rgba(255,255,255,0.7)" />
|
||||
<Info size={16} color={terminalTheme ? terminalColors.textSecondary : "rgba(255,255,255,0.7)"} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Refresh data">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onRefresh}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': { color: 'primary.main' }
|
||||
color: terminalTheme ? terminalColors.text : 'text.secondary',
|
||||
'&:hover': { color: terminalTheme ? terminalColors.secondary : 'primary.main' }
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
@@ -112,9 +183,9 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{getStatusChip()}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</CardContentComponent>
|
||||
|
||||
<CardContent sx={{ pt: 0 }}>
|
||||
<CardContentComponent sx={{ pt: 0 }}>
|
||||
{/* Current Cost */}
|
||||
<Box sx={{ mb: 3, textAlign: 'center' }}>
|
||||
<motion.div
|
||||
@@ -122,20 +193,20 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<Typography
|
||||
<TypographyComponent
|
||||
variant="h3"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: '#ffffff',
|
||||
textShadow: '0 2px 4px rgba(0,0,0,0.3)',
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)',
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
{formatCurrency(usageStats.total_cost)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.8)' }}>
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{ color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.8)' }}>
|
||||
Total Cost This Month
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
@@ -143,34 +214,34 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Tooltip title="Total number of API requests made this billing period">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
<TypographyComponent variant="body2" sx={{ color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)' }}>
|
||||
API Calls
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{ fontWeight: 'bold', color: terminalTheme ? terminalColors.text : '#ffffff' }}>
|
||||
{formatNumber(usageStats.total_calls)}
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Total tokens processed across all API providers (input + output tokens)">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
<TypographyComponent variant="body2" sx={{ color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)' }}>
|
||||
Tokens Used
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{ fontWeight: 'bold', color: terminalTheme ? terminalColors.text : '#ffffff' }}>
|
||||
{formatNumber(usageStats.total_tokens)}
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Average response time for API requests in the last 24 hours">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
<TypographyComponent variant="body2" sx={{ color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)' }}>
|
||||
Avg Response Time
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{ fontWeight: 'bold', color: terminalTheme ? terminalColors.text : '#ffffff' }}>
|
||||
{usageStats.avg_response_time.toFixed(0)}ms
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
@@ -179,12 +250,12 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
|
||||
{usageStats.limits.limits.monthly_cost > 0 && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<TypographyComponent variant="body2" sx={{ color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)' }}>
|
||||
Monthly Cost Limit
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{ fontWeight: 'bold', color: terminalTheme ? terminalColors.text : '#ffffff' }}>
|
||||
{formatPercentage(costUsagePercentage)}
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
@@ -192,17 +263,20 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
backgroundColor: terminalTheme ? terminalColors.backgroundLight : 'rgba(255,255,255,0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: costUsagePercentage > 80 ? '#ef4444' :
|
||||
costUsagePercentage > 60 ? '#f59e0b' : '#22c55e',
|
||||
backgroundColor: terminalTheme
|
||||
? (costUsagePercentage > 80 ? terminalColors.error :
|
||||
costUsagePercentage > 60 ? terminalColors.warning : terminalColors.success)
|
||||
: (costUsagePercentage > 80 ? '#ef4444' :
|
||||
costUsagePercentage > 60 ? '#f59e0b' : '#22c55e'),
|
||||
borderRadius: 4,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
<TypographyComponent variant="caption" sx={{ mt: 0.5, display: 'block', color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)' }}>
|
||||
{formatCurrency(usageStats.total_cost)} of {formatCurrency(usageStats.limits.limits.monthly_cost)} limit
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -210,69 +284,73 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
backgroundColor: terminalTheme ? terminalColors.backgroundLight : 'rgba(255,255,255,0.05)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
border: terminalTheme ? `1px solid ${terminalColors.border}` : '1px solid rgba(255,255,255,0.1)'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: 'rgba(255,255,255,0.8)' }}>
|
||||
<TypographyComponent variant="body2" sx={{ mb: 1, color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.8)' }}>
|
||||
Current Plan
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 1, color: '#ffffff' }}>
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="h6" sx={{ fontWeight: 'bold', mb: 1, color: terminalTheme ? terminalColors.text : '#ffffff' }}>
|
||||
{usageStats.limits.plan_name}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="caption" sx={{ color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)' }}>
|
||||
{usageStats.limits.tier.charAt(0).toUpperCase() + usageStats.limits.tier.slice(1)} Tier
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'space-around' }}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||
<TypographyComponent variant="h6" sx={{ fontWeight: 'bold', color: terminalTheme ? terminalColors.text : 'primary.main' }}>
|
||||
{usageStats.usage_percentages.gemini_calls.toFixed(0)}%
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="caption" sx={{ color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)' }}>
|
||||
Gemini Usage
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: 'secondary.main' }}>
|
||||
<TypographyComponent variant="h6" sx={{ fontWeight: 'bold', color: terminalTheme ? terminalColors.text : 'secondary.main' }}>
|
||||
{usageStats.error_rate.toFixed(1)}%
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="caption" sx={{ color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)' }}>
|
||||
Error Rate
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</CardContentComponent>
|
||||
|
||||
{/* Decorative Elements */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -50,
|
||||
right: -50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
background: 'radial-gradient(circle, rgba(102, 126, 234, 0.1) 0%, transparent 70%)',
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: -30,
|
||||
left: -30,
|
||||
width: 60,
|
||||
height: 60,
|
||||
background: 'radial-gradient(circle, rgba(118, 75, 162, 0.1) 0%, transparent 70%)',
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
{/* Decorative Elements - only show in non-terminal theme */}
|
||||
{!terminalTheme && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -50,
|
||||
right: -50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
background: 'radial-gradient(circle, rgba(102, 126, 234, 0.1) 0%, transparent 70%)',
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: -30,
|
||||
left: -30,
|
||||
width: 60,
|
||||
height: 60,
|
||||
background: 'radial-gradient(circle, rgba(118, 75, 162, 0.1) 0%, transparent 70%)',
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardComponent>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,18 +25,36 @@ import { SystemHealth } from '../../types/monitoring';
|
||||
import { billingService } from '../../services/billingService';
|
||||
import { monitoringService } from '../../services/monitoringService';
|
||||
import { onApiEvent } from '../../utils/apiEvents';
|
||||
import { showToastNotification } from '../../utils/toastNotifications';
|
||||
|
||||
// Terminal Theme
|
||||
import {
|
||||
TerminalCard,
|
||||
TerminalCardContent,
|
||||
TerminalTypography,
|
||||
TerminalChip,
|
||||
TerminalChipError,
|
||||
TerminalChipWarning,
|
||||
terminalColors
|
||||
} from '../SchedulerDashboard/terminalTheme';
|
||||
|
||||
interface CompactBillingDashboardProps {
|
||||
userId?: string;
|
||||
terminalTheme?: boolean;
|
||||
}
|
||||
|
||||
const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userId }) => {
|
||||
const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userId, terminalTheme = false }) => {
|
||||
// Conditional component selection based on terminal theme
|
||||
const CardComponent = terminalTheme ? TerminalCard : Card;
|
||||
const CardContentComponent = terminalTheme ? TerminalCardContent : CardContent;
|
||||
const TypographyComponent = terminalTheme ? TerminalTypography : Typography;
|
||||
const ChipComponent = terminalTheme ? TerminalChip : Chip;
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
|
||||
const [systemHealth, setSystemHealth] = useState<SystemHealth | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
const fetchData = async (showSuccessToast: boolean = false) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -48,8 +66,21 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
|
||||
|
||||
setDashboardData(billingData);
|
||||
setSystemHealth(healthData);
|
||||
|
||||
// Show success toast only if explicitly requested (user-initiated refresh)
|
||||
if (showSuccessToast && billingData && healthData) {
|
||||
showToastNotification(
|
||||
'Billing data refreshed successfully',
|
||||
'success',
|
||||
{ duration: 3000 }
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch data');
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch data';
|
||||
setError(errorMessage);
|
||||
|
||||
// Always show error toast for failures
|
||||
showToastNotification(errorMessage, 'error', { duration: 5000 });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -80,35 +111,55 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
|
||||
const formatNumber = (num: number) => num.toLocaleString();
|
||||
|
||||
if (loading && !dashboardData) {
|
||||
const loadingCardStyles = terminalTheme
|
||||
? {
|
||||
backgroundColor: terminalColors.background,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
borderRadius: 3
|
||||
}
|
||||
: {
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3
|
||||
};
|
||||
|
||||
return (
|
||||
<Card sx={{
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3
|
||||
}}>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.8)' }}>Loading billing data...</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CardComponent sx={loadingCardStyles}>
|
||||
<CardContentComponent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<TypographyComponent sx={{ color: terminalTheme ? terminalColors.text : 'rgba(255,255,255,0.8)' }}>
|
||||
Loading billing data...
|
||||
</TypographyComponent>
|
||||
</CardContentComponent>
|
||||
</CardComponent>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorCardStyles = terminalTheme
|
||||
? {
|
||||
backgroundColor: terminalColors.background,
|
||||
border: `1px solid ${terminalColors.error}`,
|
||||
borderRadius: 3
|
||||
}
|
||||
: {
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3
|
||||
};
|
||||
|
||||
return (
|
||||
<Card sx={{
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3
|
||||
}}>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography sx={{ color: '#ff6b6b' }}>Error: {error}</Typography>
|
||||
<IconButton onClick={fetchData} sx={{ mt: 1 }}>
|
||||
<CardComponent sx={errorCardStyles}>
|
||||
<CardContentComponent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<TypographyComponent sx={{ color: terminalTheme ? terminalColors.error : '#ff6b6b' }}>
|
||||
Error: {error}
|
||||
</TypographyComponent>
|
||||
<IconButton onClick={() => fetchData(true)} sx={{ mt: 1, color: terminalTheme ? terminalColors.text : 'inherit' }}>
|
||||
<RefreshCw size={16} />
|
||||
</IconButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContentComponent>
|
||||
</CardComponent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,36 +167,55 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
|
||||
|
||||
const { current_usage, limits, alerts } = dashboardData;
|
||||
|
||||
const mainCardStyles = terminalTheme
|
||||
? {
|
||||
backgroundColor: terminalColors.background,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
borderRadius: 4,
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden' as const,
|
||||
boxShadow: '0 0 15px rgba(0, 255, 0, 0.2)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '1px',
|
||||
background: `linear-gradient(90deg, transparent, ${terminalColors.border}, transparent)`,
|
||||
zIndex: 1
|
||||
}
|
||||
}
|
||||
: {
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0.08) 100%)',
|
||||
backdropFilter: 'blur(15px)',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
borderRadius: 4,
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden' as const,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.1)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '1px',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)',
|
||||
zIndex: 1
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0.08) 100%)',
|
||||
backdropFilter: 'blur(15px)',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
borderRadius: 4,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.1)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '1px',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)',
|
||||
zIndex: 1
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardComponent sx={mainCardStyles}>
|
||||
{/* Header - Removed to save space */}
|
||||
|
||||
<CardContent sx={{ pt: 2 }}>
|
||||
<CardContentComponent sx={{ pt: 2 }}>
|
||||
{/* Compact Overview */}
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
{/* Total Cost */}
|
||||
@@ -170,46 +240,71 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
p: 2.5,
|
||||
background: 'linear-gradient(135deg, rgba(74, 222, 128, 0.12) 0%, rgba(34, 197, 94, 0.08) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(74, 222, 128, 0.25)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(74, 222, 128, 0.2)',
|
||||
border: '1px solid rgba(74, 222, 128, 0.4)'
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, #4ade80, #22c55e)',
|
||||
zIndex: 1
|
||||
}
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 0 15px ${terminalColors.border}40`,
|
||||
borderColor: terminalColors.secondary
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: terminalColors.border,
|
||||
zIndex: 1
|
||||
}
|
||||
} : {
|
||||
background: 'linear-gradient(135deg, rgba(74, 222, 128, 0.12) 0%, rgba(34, 197, 94, 0.08) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(74, 222, 128, 0.25)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(74, 222, 128, 0.2)',
|
||||
border: '1px solid rgba(74, 222, 128, 0.4)'
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, #4ade80, #22c55e)',
|
||||
zIndex: 1
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<Typography variant="h5" sx={{
|
||||
<TypographyComponent variant="h5" sx={{
|
||||
fontWeight: 800,
|
||||
color: '#ffffff',
|
||||
textShadow: '0 2px 8px rgba(0,0,0,0.4)',
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.4)',
|
||||
mb: 0.5
|
||||
}}>
|
||||
{formatCurrency(current_usage.total_cost)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontSize: '0.75rem'
|
||||
}}>
|
||||
Total Cost
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
@@ -236,46 +331,71 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
p: 2.5,
|
||||
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(37, 99, 235, 0.08) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(59, 130, 246, 0.25)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(59, 130, 246, 0.2)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.4)'
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, #3b82f6, #2563eb)',
|
||||
zIndex: 1
|
||||
}
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 0 15px ${terminalColors.border}40`,
|
||||
borderColor: terminalColors.secondary
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: terminalColors.border,
|
||||
zIndex: 1
|
||||
}
|
||||
} : {
|
||||
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(37, 99, 235, 0.08) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(59, 130, 246, 0.25)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(59, 130, 246, 0.2)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.4)'
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, #3b82f6, #2563eb)',
|
||||
zIndex: 1
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<Typography variant="h5" sx={{
|
||||
<TypographyComponent variant="h5" sx={{
|
||||
fontWeight: 800,
|
||||
color: '#ffffff',
|
||||
textShadow: '0 2px 8px rgba(0,0,0,0.4)',
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.4)',
|
||||
mb: 0.5
|
||||
}}>
|
||||
{formatNumber(current_usage.total_calls)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontSize: '0.75rem'
|
||||
}}>
|
||||
API Calls
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
@@ -302,46 +422,71 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
p: 2.5,
|
||||
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.12) 0%, rgba(147, 51, 234, 0.08) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(168, 85, 247, 0.25)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(168, 85, 247, 0.2)',
|
||||
border: '1px solid rgba(168, 85, 247, 0.4)'
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, #a855f7, #9333ea)',
|
||||
zIndex: 1
|
||||
}
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 0 15px ${terminalColors.border}40`,
|
||||
borderColor: terminalColors.secondary
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: terminalColors.border,
|
||||
zIndex: 1
|
||||
}
|
||||
} : {
|
||||
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.12) 0%, rgba(147, 51, 234, 0.08) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(168, 85, 247, 0.25)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(168, 85, 247, 0.2)',
|
||||
border: '1px solid rgba(168, 85, 247, 0.4)'
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, #a855f7, #9333ea)',
|
||||
zIndex: 1
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<Typography variant="h5" sx={{
|
||||
<TypographyComponent variant="h5" sx={{
|
||||
fontWeight: 800,
|
||||
color: '#ffffff',
|
||||
textShadow: '0 2px 8px rgba(0,0,0,0.4)',
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.4)',
|
||||
mb: 0.5
|
||||
}}>
|
||||
{(current_usage.total_tokens / 1000).toFixed(1)}k
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="body2" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontSize: '0.75rem'
|
||||
}}>
|
||||
Tokens
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
@@ -371,59 +516,89 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
p: 2.5,
|
||||
background: systemHealth?.status === 'healthy'
|
||||
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(22, 163, 74, 0.08) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.12) 0%, rgba(220, 38, 38, 0.08) 100%)',
|
||||
borderRadius: 3,
|
||||
border: systemHealth?.status === 'healthy'
|
||||
? '1px solid rgba(34, 197, 94, 0.25)'
|
||||
: '1px solid rgba(239, 68, 68, 0.25)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: systemHealth?.status === 'healthy'
|
||||
? '0 8px 25px rgba(34, 197, 94, 0.2)'
|
||||
: '0 8px 25px rgba(239, 68, 68, 0.2)',
|
||||
border: systemHealth?.status === 'healthy'
|
||||
? '1px solid rgba(34, 197, 94, 0.4)'
|
||||
: '1px solid rgba(239, 68, 68, 0.4)'
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error}`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 0 15px ${systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error}40`,
|
||||
borderColor: systemHealth?.status === 'healthy' ? terminalColors.secondary : terminalColors.error
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error,
|
||||
zIndex: 1
|
||||
}
|
||||
} : {
|
||||
background: systemHealth?.status === 'healthy'
|
||||
? 'linear-gradient(90deg, #22c55e, #16a34a)'
|
||||
: 'linear-gradient(90deg, #ef4444, #dc2626)',
|
||||
zIndex: 1
|
||||
}
|
||||
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(22, 163, 74, 0.08) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.12) 0%, rgba(220, 38, 38, 0.08) 100%)',
|
||||
borderRadius: 3,
|
||||
border: systemHealth?.status === 'healthy'
|
||||
? '1px solid rgba(34, 197, 94, 0.25)'
|
||||
: '1px solid rgba(239, 68, 68, 0.25)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'help',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: systemHealth?.status === 'healthy'
|
||||
? '0 8px 25px rgba(34, 197, 94, 0.2)'
|
||||
: '0 8px 25px rgba(239, 68, 68, 0.2)',
|
||||
border: systemHealth?.status === 'healthy'
|
||||
? '1px solid rgba(34, 197, 94, 0.4)'
|
||||
: '1px solid rgba(239, 68, 68, 0.4)'
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: systemHealth?.status === 'healthy'
|
||||
? 'linear-gradient(90deg, #22c55e, #16a34a)'
|
||||
: 'linear-gradient(90deg, #ef4444, #dc2626)',
|
||||
zIndex: 1
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5, mb: 0.5 }}>
|
||||
<CheckCircle size={18} color={systemHealth?.status === 'healthy' ? '#4ade80' : '#ff6b6b'} />
|
||||
<Typography variant="body1" sx={{
|
||||
color: systemHealth?.status === 'healthy' ? '#4ade80' : '#ff6b6b',
|
||||
<CheckCircle size={18} color={terminalTheme
|
||||
? (systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error)
|
||||
: (systemHealth?.status === 'healthy' ? '#4ade80' : '#ff6b6b')
|
||||
} />
|
||||
<TypographyComponent variant="body1" sx={{
|
||||
color: terminalTheme
|
||||
? (systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error)
|
||||
: (systemHealth?.status === 'healthy' ? '#4ade80' : '#ff6b6b'),
|
||||
fontWeight: 700,
|
||||
textTransform: 'capitalize',
|
||||
textShadow: '0 2px 4px rgba(0,0,0,0.3)'
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)'
|
||||
}}>
|
||||
{systemHealth?.status || 'Unknown'}
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
<TypographyComponent variant="body2" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontSize: '0.75rem'
|
||||
}}>
|
||||
System Health
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
@@ -435,40 +610,46 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
|
||||
<Box sx={{
|
||||
mb: 3,
|
||||
p: 2.5,
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0.04) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${terminalColors.border}`
|
||||
} : {
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0.04) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
})
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{
|
||||
color: '#ffffff',
|
||||
<TypographyComponent variant="subtitle2" sx={{
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
fontWeight: 600,
|
||||
mb: 0.5
|
||||
}}>
|
||||
Monthly Budget Usage
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="caption" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
|
||||
display: 'block'
|
||||
}}>
|
||||
Track your AI spending against monthly limits
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Typography variant="h6" sx={{
|
||||
color: '#ffffff',
|
||||
<TypographyComponent variant="h6" sx={{
|
||||
color: terminalTheme ? terminalColors.text : '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
textShadow: '0 2px 4px rgba(0,0,0,0.3)'
|
||||
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)'
|
||||
}}>
|
||||
{formatCurrency(current_usage.total_cost)}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="caption" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
|
||||
display: 'block'
|
||||
}}>
|
||||
of {formatCurrency(limits.limits.monthly_cost)}
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
@@ -477,21 +658,29 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
backgroundColor: terminalTheme ? terminalColors.backgroundLight : 'rgba(255,255,255,0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
background: current_usage.total_cost / limits.limits.monthly_cost > 0.8
|
||||
? 'linear-gradient(90deg, #ff6b6b, #ff5252)'
|
||||
: current_usage.total_cost / limits.limits.monthly_cost > 0.6
|
||||
? 'linear-gradient(90deg, #ffa726, #ff9800)'
|
||||
: 'linear-gradient(90deg, #4ade80, #22c55e)',
|
||||
background: terminalTheme
|
||||
? (current_usage.total_cost / limits.limits.monthly_cost > 0.8
|
||||
? terminalColors.error
|
||||
: current_usage.total_cost / limits.limits.monthly_cost > 0.6
|
||||
? terminalColors.warning
|
||||
: terminalColors.success)
|
||||
: (current_usage.total_cost / limits.limits.monthly_cost > 0.8
|
||||
? 'linear-gradient(90deg, #ff6b6b, #ff5252)'
|
||||
: current_usage.total_cost / limits.limits.monthly_cost > 0.6
|
||||
? 'linear-gradient(90deg, #ffa726, #ff9800)'
|
||||
: 'linear-gradient(90deg, #4ade80, #22c55e)'),
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.2)'
|
||||
boxShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.2)'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}>
|
||||
<Typography variant="caption" sx={{
|
||||
color: current_usage.total_cost / limits.limits.monthly_cost > 0.8 ? '#ff6b6b' : 'rgba(255,255,255,0.7)',
|
||||
<TypographyComponent variant="caption" sx={{
|
||||
color: terminalTheme
|
||||
? (current_usage.total_cost / limits.limits.monthly_cost > 0.8 ? terminalColors.error : terminalColors.textSecondary)
|
||||
: (current_usage.total_cost / limits.limits.monthly_cost > 0.8 ? '#ff6b6b' : 'rgba(255,255,255,0.7)'),
|
||||
fontWeight: current_usage.total_cost / limits.limits.monthly_cost > 0.8 ? 600 : 400
|
||||
}}>
|
||||
{current_usage.total_cost / limits.limits.monthly_cost > 0.8
|
||||
@@ -500,13 +689,13 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
|
||||
? '⚡ Moderate usage'
|
||||
: '✅ Within budget'
|
||||
}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
</TypographyComponent>
|
||||
<TypographyComponent variant="caption" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{((current_usage.total_cost / limits.limits.monthly_cost) * 100).toFixed(1)}% used
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
@@ -516,38 +705,55 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
|
||||
<Box sx={{
|
||||
mb: 3,
|
||||
p: 2.5,
|
||||
background: 'linear-gradient(135deg, rgba(255, 107, 107, 0.1) 0%, rgba(239, 68, 68, 0.05) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255, 107, 107, 0.2)',
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, #ff6b6b, #ef4444)',
|
||||
borderRadius: '3px 3px 0 0'
|
||||
}
|
||||
...(terminalTheme ? {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${terminalColors.error}`,
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: terminalColors.error,
|
||||
borderRadius: '3px 3px 0 0'
|
||||
}
|
||||
} : {
|
||||
background: 'linear-gradient(135deg, rgba(255, 107, 107, 0.1) 0%, rgba(239, 68, 68, 0.05) 100%)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255, 107, 107, 0.2)',
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, #ff6b6b, #ef4444)',
|
||||
borderRadius: '3px 3px 0 0'
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<AlertTriangle size={18} color="#ff6b6b" />
|
||||
<Typography variant="subtitle2" sx={{
|
||||
<AlertTriangle size={18} color={terminalTheme ? terminalColors.error : "#ff6b6b"} />
|
||||
<TypographyComponent variant="subtitle2" sx={{
|
||||
fontWeight: 700,
|
||||
color: '#ff6b6b',
|
||||
textShadow: '0 1px 2px rgba(0,0,0,0.3)'
|
||||
color: terminalTheme ? terminalColors.error : '#ff6b6b',
|
||||
textShadow: terminalTheme ? 'none' : '0 1px 2px rgba(0,0,0,0.3)'
|
||||
}}>
|
||||
System Alerts ({alerts.length})
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
<TypographyComponent variant="caption" sx={{
|
||||
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.8)',
|
||||
display: 'block',
|
||||
mb: 2
|
||||
}}>
|
||||
Important notifications requiring your attention
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{alerts.slice(0, 3).map((alert) => (
|
||||
<Tooltip
|
||||
@@ -565,42 +771,65 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Chip
|
||||
label={alert.title}
|
||||
size="small"
|
||||
icon={<AlertTriangle size={14} />}
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255, 107, 107, 0.2)',
|
||||
color: '#ff6b6b',
|
||||
border: '1px solid rgba(255, 107, 107, 0.3)',
|
||||
fontWeight: 500,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 107, 107, 0.3)',
|
||||
transform: 'translateY(-1px)'
|
||||
},
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
/>
|
||||
{terminalTheme ? (
|
||||
<TerminalChipError
|
||||
label={alert.title}
|
||||
size="small"
|
||||
icon={<AlertTriangle size={14} />}
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
'&:hover': {
|
||||
transform: 'translateY(-1px)'
|
||||
},
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
label={alert.title}
|
||||
size="small"
|
||||
icon={<AlertTriangle size={14} />}
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255, 107, 107, 0.2)',
|
||||
color: '#ff6b6b',
|
||||
border: '1px solid rgba(255, 107, 107, 0.3)',
|
||||
fontWeight: 500,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 107, 107, 0.3)',
|
||||
transform: 'translateY(-1px)'
|
||||
},
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
))}
|
||||
{alerts.length > 3 && (
|
||||
<Chip
|
||||
label={`+${alerts.length - 3} more`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
fontWeight: 500
|
||||
}}
|
||||
/>
|
||||
terminalTheme ? (
|
||||
<TerminalChip
|
||||
label={`+${alerts.length - 3} more`}
|
||||
size="small"
|
||||
sx={{ fontWeight: 500 }}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
label={`+${alerts.length - 3} more`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
fontWeight: 500
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContentComponent>
|
||||
</CardComponent>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { billingService } from '../../services/billingService';
|
||||
import { monitoringService } from '../../services/monitoringService';
|
||||
import { onApiEvent } from '../../utils/apiEvents';
|
||||
import { showToastNotification } from '../../utils/toastNotifications';
|
||||
|
||||
// Types
|
||||
import { DashboardData } from '../../types/billing';
|
||||
@@ -38,20 +39,31 @@ import UsageTrends from './UsageTrends';
|
||||
import UsageAlerts from './UsageAlerts';
|
||||
import ComprehensiveAPIBreakdown from './ComprehensiveAPIBreakdown';
|
||||
|
||||
// Terminal Theme
|
||||
import {
|
||||
TerminalTypography,
|
||||
TerminalAlert,
|
||||
terminalColors
|
||||
} from '../SchedulerDashboard/terminalTheme';
|
||||
|
||||
interface EnhancedBillingDashboardProps {
|
||||
userId?: string;
|
||||
terminalTheme?: boolean;
|
||||
}
|
||||
|
||||
type ViewMode = 'compact' | 'detailed';
|
||||
|
||||
const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ userId }) => {
|
||||
const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ userId, terminalTheme = false }) => {
|
||||
// Conditional component selection based on terminal theme
|
||||
const TypographyComponent = terminalTheme ? TerminalTypography : Typography;
|
||||
const AlertComponent = terminalTheme ? TerminalAlert : Alert;
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
|
||||
const [systemHealth, setSystemHealth] = useState<SystemHealth | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('compact');
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
const fetchDashboardData = async (showSuccessToast: boolean = false) => {
|
||||
try {
|
||||
const [billingData, healthData] = await Promise.all([
|
||||
billingService.getDashboardData(),
|
||||
@@ -59,8 +71,21 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
|
||||
]);
|
||||
setDashboardData(billingData);
|
||||
setSystemHealth(healthData);
|
||||
|
||||
// Show success toast only if explicitly requested (user-initiated refresh)
|
||||
if (showSuccessToast && billingData && healthData) {
|
||||
showToastNotification(
|
||||
'Billing data refreshed successfully',
|
||||
'success',
|
||||
{ duration: 3000 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to fetch dashboard data');
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch dashboard data';
|
||||
setError(errorMessage);
|
||||
|
||||
// Always show error toast for failures
|
||||
showToastNotification(errorMessage, 'error', { duration: 5000 });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -95,6 +120,29 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
|
||||
return () => document.removeEventListener('visibilitychange', onVisible);
|
||||
}, []);
|
||||
|
||||
// Listen for billing refresh requests (e.g., when subscription limits are exceeded)
|
||||
useEffect(() => {
|
||||
const handleBillingRefresh = () => {
|
||||
console.log('EnhancedBillingDashboard: Billing refresh requested, refreshing data...');
|
||||
// Use a fresh call to fetchDashboardData to ensure we get latest data
|
||||
Promise.all([billingService.getDashboardData(), monitoringService.getSystemHealth()])
|
||||
.then(([billingData, healthData]) => {
|
||||
setDashboardData(billingData);
|
||||
setSystemHealth(healthData);
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to refresh billing data';
|
||||
setError(errorMessage);
|
||||
console.error('Error refreshing billing data:', error);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('billing-refresh-requested', handleBillingRefresh);
|
||||
return () => {
|
||||
window.removeEventListener('billing-refresh-requested', handleBillingRefresh);
|
||||
};
|
||||
}, []); // Empty deps - handler doesn't depend on component state
|
||||
|
||||
const handleViewModeChange = (
|
||||
event: React.MouseEvent<HTMLElement>,
|
||||
newViewMode: ViewMode | null,
|
||||
@@ -117,9 +165,9 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
|
||||
if (error) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
<AlertComponent severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
</AlertComponent>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -127,9 +175,9 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
|
||||
if (!dashboardData) {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Alert severity="warning">
|
||||
<AlertComponent severity="warning">
|
||||
No billing data available. Please check your subscription status.
|
||||
</Alert>
|
||||
</AlertComponent>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -146,17 +194,17 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography
|
||||
<TypographyComponent
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
mb: 1.5,
|
||||
fontSize: '1.1rem',
|
||||
color: 'rgba(255,255,255,0.95)',
|
||||
color: terminalTheme ? terminalColors.text : 'rgba(255,255,255,0.95)',
|
||||
}}
|
||||
>
|
||||
Billing & Usage Dashboard
|
||||
</Typography>
|
||||
</TypographyComponent>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
@@ -231,7 +279,7 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
|
||||
<Tooltip title="Refresh billing data">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={fetchDashboardData}
|
||||
onClick={() => fetchDashboardData(true)}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
@@ -305,7 +353,7 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<CompactBillingDashboard userId={userId} />
|
||||
<CompactBillingDashboard userId={userId} terminalTheme={terminalTheme} />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
@@ -321,6 +369,7 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
|
||||
<BillingOverview
|
||||
usageStats={dashboardData.current_usage}
|
||||
onRefresh={fetchDashboardData}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
|
||||
467
frontend/src/components/billing/SubscriptionRenewalHistory.tsx
Normal file
467
frontend/src/components/billing/SubscriptionRenewalHistory.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* Subscription Renewal History Component
|
||||
* Displays historical subscription renewals with details about plan changes, renewals, and usage.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Table,
|
||||
TableBody,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Refresh as RefreshIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
TrendingDown as TrendingDownIcon,
|
||||
Add as AddIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { RefreshCw } from 'lucide-react'; // Use lucide-react for header icon
|
||||
import { billingService } from '../../services/billingService';
|
||||
import { SubscriptionRenewal, RenewalHistoryResponse } from '../../types/billing';
|
||||
import {
|
||||
TerminalPaper,
|
||||
TerminalTypography,
|
||||
TerminalTableCell,
|
||||
TerminalTableRow,
|
||||
TerminalAlert,
|
||||
TerminalChip,
|
||||
TerminalChipSuccess,
|
||||
TerminalChipWarning,
|
||||
TerminalChipError,
|
||||
terminalColors
|
||||
} from '../SchedulerDashboard/terminalTheme';
|
||||
import { showToastNotification } from '../../utils/toastNotifications';
|
||||
|
||||
interface SubscriptionRenewalHistoryProps {
|
||||
userId?: string;
|
||||
terminalTheme?: boolean;
|
||||
initialLimit?: number;
|
||||
}
|
||||
|
||||
const SubscriptionRenewalHistory: React.FC<SubscriptionRenewalHistoryProps> = ({
|
||||
userId,
|
||||
terminalTheme = false,
|
||||
initialLimit = 20
|
||||
}) => {
|
||||
const [renewals, setRenewals] = useState<SubscriptionRenewal[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(initialLimit);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
const fetchRenewals = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response: RenewalHistoryResponse = await billingService.getRenewalHistory(
|
||||
userId,
|
||||
rowsPerPage,
|
||||
page * rowsPerPage
|
||||
);
|
||||
|
||||
setRenewals(response.renewals || []);
|
||||
setTotalCount(response.total_count || 0);
|
||||
|
||||
// Don't show success toast on automatic refresh - only show errors
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.message || 'Failed to fetch renewal history';
|
||||
setError(errorMessage);
|
||||
console.error('Error fetching renewal history:', err);
|
||||
|
||||
// Show error toast
|
||||
showToastNotification(errorMessage, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRenewals();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, rowsPerPage, userId]);
|
||||
|
||||
const handleChangePage = (_event: unknown, newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return 'Invalid Date';
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getRenewalTypeIcon = (type: string): React.ReactElement | undefined => {
|
||||
switch (type) {
|
||||
case 'upgrade':
|
||||
return <TrendingUpIcon fontSize="small" sx={{ color: terminalTheme ? terminalColors.success : '#4ade80' }} />;
|
||||
case 'downgrade':
|
||||
return <TrendingDownIcon fontSize="small" sx={{ color: terminalTheme ? terminalColors.warning : '#f59e0b' }} />;
|
||||
case 'renewal':
|
||||
return <RefreshIcon fontSize="small" sx={{ color: terminalTheme ? terminalColors.primary : '#3b82f6' }} />;
|
||||
case 'new':
|
||||
return <AddIcon fontSize="small" sx={{ color: terminalTheme ? terminalColors.primary : '#3b82f6' }} />;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getRenewalTypeChip = (type: string) => {
|
||||
const ChipComponent = terminalTheme ? TerminalChip : Chip;
|
||||
const chipStyles = {
|
||||
upgrade: { backgroundColor: 'rgba(34, 197, 94, 0.2)', color: '#22c55e' },
|
||||
downgrade: { backgroundColor: 'rgba(245, 158, 11, 0.2)', color: '#f59e0b' },
|
||||
renewal: { backgroundColor: 'rgba(59, 130, 246, 0.2)', color: '#3b82f6' },
|
||||
new: { backgroundColor: 'rgba(59, 130, 246, 0.2)', color: '#3b82f6' },
|
||||
};
|
||||
|
||||
const chipStyle = chipStyles[type as keyof typeof chipStyles] || chipStyles.renewal;
|
||||
const label = type.charAt(0).toUpperCase() + type.slice(1);
|
||||
const icon = getRenewalTypeIcon(type);
|
||||
|
||||
if (terminalTheme) {
|
||||
const TerminalChipComponent = type === 'upgrade' ? TerminalChipSuccess :
|
||||
type === 'downgrade' ? TerminalChipWarning :
|
||||
TerminalChip;
|
||||
return (
|
||||
<TerminalChipComponent
|
||||
label={label}
|
||||
size="small"
|
||||
icon={icon}
|
||||
sx={{ fontWeight: 500 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip
|
||||
label={label}
|
||||
size="small"
|
||||
icon={icon}
|
||||
sx={{
|
||||
...chipStyle,
|
||||
fontWeight: 500,
|
||||
border: `1px solid ${chipStyle.color}40`
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Conditional component selection based on terminal theme
|
||||
const PaperComponent = terminalTheme ? TerminalPaper : Box;
|
||||
|
||||
// Alert component wrapper for terminal theme
|
||||
const AlertWrapper: React.FC<{ children: React.ReactNode; severity?: 'error' | 'info' | 'warning'; sx?: any }> = ({ children, severity = 'info', sx }) => {
|
||||
if (terminalTheme) {
|
||||
return (
|
||||
<TerminalAlert severity={severity} sx={sx}>
|
||||
{children}
|
||||
</TerminalAlert>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
backgroundColor: severity === 'error' ? 'rgba(239, 68, 68, 0.1)' : 'rgba(59, 130, 246, 0.1)',
|
||||
borderRadius: 2,
|
||||
color: severity === 'error' ? '#ef4444' : '#3b82f6',
|
||||
...sx
|
||||
}}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PaperComponent sx={{ p: 3, mt: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{terminalTheme ? (
|
||||
<RefreshCw size={20} color={terminalColors.primary} />
|
||||
) : (
|
||||
<RefreshCw size={20} />
|
||||
)}
|
||||
{terminalTheme ? (
|
||||
<TerminalTypography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
Subscription Renewal History
|
||||
</TerminalTypography>
|
||||
) : (
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
Subscription Renewal History
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Tooltip title="Refresh Renewal History">
|
||||
<IconButton
|
||||
onClick={fetchRenewals}
|
||||
disabled={loading}
|
||||
sx={{ color: terminalTheme ? terminalColors.primary : 'inherit' }}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{loading && renewals.length === 0 ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 200 }}>
|
||||
<CircularProgress sx={{ color: terminalTheme ? terminalColors.primary : 'inherit' }} />
|
||||
</Box>
|
||||
) : error ? (
|
||||
<AlertWrapper severity="error">
|
||||
{terminalTheme ? (
|
||||
<TerminalTypography>{error}</TerminalTypography>
|
||||
) : (
|
||||
<Typography>{error}</Typography>
|
||||
)}
|
||||
</AlertWrapper>
|
||||
) : renewals.length === 0 ? (
|
||||
<AlertWrapper severity="info">
|
||||
{terminalTheme ? (
|
||||
<TerminalTypography>No renewal history found. Your subscription renewals will appear here.</TerminalTypography>
|
||||
) : (
|
||||
<Typography>No renewal history found. Your subscription renewals will appear here.</Typography>
|
||||
)}
|
||||
</AlertWrapper>
|
||||
) : (
|
||||
<>
|
||||
<TableContainer component={Box} sx={{ maxHeight: 600, overflow: 'auto' }}>
|
||||
<Table stickyHeader size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TerminalTableCell sx={{ width: '8%' }}>#</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ width: '12%' }}>Type</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ width: '15%' }}>Plan</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ width: '12%' }}>Previous Plan</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ width: '10%' }}>Billing Cycle</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ width: '12%' }}>Period Start</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ width: '12%' }}>Period End</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ width: '10%' }}>Amount</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ width: '9%' }}>Status</TerminalTableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{renewals.map((renewal) => (
|
||||
<TerminalTableRow key={renewal.id}>
|
||||
<TerminalTableCell>
|
||||
{terminalTheme ? (
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem' }}>
|
||||
#{renewal.renewal_count}
|
||||
</TerminalTypography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ fontSize: '0.75rem' }}>
|
||||
#{renewal.renewal_count}
|
||||
</Typography>
|
||||
)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
{getRenewalTypeChip(renewal.renewal_type)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
{terminalTheme ? (
|
||||
<>
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem', fontWeight: 600 }}>
|
||||
{renewal.plan_name}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ fontSize: '0.7rem', display: 'block', opacity: 0.7 }}>
|
||||
{renewal.plan_tier}
|
||||
</TerminalTypography>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.75rem', fontWeight: 600 }}>
|
||||
{renewal.plan_name}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', display: 'block', opacity: 0.7 }}>
|
||||
{renewal.plan_tier}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
{renewal.previous_plan_name ? (
|
||||
terminalTheme ? (
|
||||
<>
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem' }}>
|
||||
{renewal.previous_plan_name}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ fontSize: '0.7rem', display: 'block', opacity: 0.7 }}>
|
||||
{renewal.previous_plan_tier}
|
||||
</TerminalTypography>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.75rem' }}>
|
||||
{renewal.previous_plan_name}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', display: 'block', opacity: 0.7 }}>
|
||||
{renewal.previous_plan_tier}
|
||||
</Typography>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
terminalTheme ? (
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem', fontStyle: 'italic', opacity: 0.5 }}>
|
||||
N/A
|
||||
</TerminalTypography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ fontSize: '0.75rem', fontStyle: 'italic', opacity: 0.5 }}>
|
||||
N/A
|
||||
</Typography>
|
||||
)
|
||||
)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
{terminalTheme ? (
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem', textTransform: 'capitalize' }}>
|
||||
{renewal.billing_cycle}
|
||||
</TerminalTypography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ fontSize: '0.75rem', textTransform: 'capitalize' }}>
|
||||
{renewal.billing_cycle}
|
||||
</Typography>
|
||||
)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
{terminalTheme ? (
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem' }}>
|
||||
{formatDate(renewal.new_period_start)}
|
||||
</TerminalTypography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ fontSize: '0.75rem' }}>
|
||||
{formatDate(renewal.new_period_start)}
|
||||
</Typography>
|
||||
)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
{terminalTheme ? (
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem' }}>
|
||||
{formatDate(renewal.new_period_end)}
|
||||
</TerminalTypography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ fontSize: '0.75rem' }}>
|
||||
{formatDate(renewal.new_period_end)}
|
||||
</Typography>
|
||||
)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
{terminalTheme ? (
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem', fontWeight: 600 }}>
|
||||
{formatCurrency(renewal.payment_amount)}
|
||||
</TerminalTypography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ fontSize: '0.75rem', fontWeight: 600 }}>
|
||||
{formatCurrency(renewal.payment_amount)}
|
||||
</Typography>
|
||||
)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
{renewal.payment_status === 'paid' ? (
|
||||
terminalTheme ? (
|
||||
<TerminalChipSuccess label="Paid" size="small" />
|
||||
) : (
|
||||
<Chip
|
||||
label="Paid"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.2)',
|
||||
color: '#22c55e',
|
||||
fontWeight: 500
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : renewal.payment_status === 'pending' ? (
|
||||
terminalTheme ? (
|
||||
<TerminalChipWarning label="Pending" size="small" />
|
||||
) : (
|
||||
<Chip
|
||||
label="Pending"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.2)',
|
||||
color: '#f59e0b',
|
||||
fontWeight: 500
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
terminalTheme ? (
|
||||
<TerminalChipError label="Failed" size="small" />
|
||||
) : (
|
||||
<Chip
|
||||
label="Failed"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.2)',
|
||||
color: '#ef4444',
|
||||
fontWeight: 500
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</TerminalTableCell>
|
||||
</TerminalTableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[10, 20, 50, 100]}
|
||||
sx={{
|
||||
color: terminalTheme ? terminalColors.text : 'inherit',
|
||||
'& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': {
|
||||
color: terminalTheme ? terminalColors.text : 'inherit',
|
||||
fontFamily: terminalTheme ? 'monospace' : 'inherit'
|
||||
},
|
||||
'& .MuiIconButton-root': {
|
||||
color: terminalTheme ? terminalColors.primary : 'inherit'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PaperComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionRenewalHistory;
|
||||
|
||||
426
frontend/src/components/billing/UsageLogsTable.tsx
Normal file
426
frontend/src/components/billing/UsageLogsTable.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* Usage Logs Table Component
|
||||
* Displays API usage logs in a table with pagination and filtering.
|
||||
* Terminal-themed UI matching scheduler dashboard style.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Table,
|
||||
TableBody,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Receipt as ReceiptIcon
|
||||
} from '@mui/icons-material';
|
||||
import { billingService } from '../../services/billingService';
|
||||
import { UsageLog, UsageLogsResponse } from '../../types/billing';
|
||||
import {
|
||||
TerminalPaper,
|
||||
TerminalTypography,
|
||||
TerminalChipSuccess,
|
||||
TerminalChipError,
|
||||
TerminalTableCell,
|
||||
TerminalTableRow,
|
||||
TerminalAlert,
|
||||
terminalColors
|
||||
} from '../SchedulerDashboard/terminalTheme';
|
||||
import { formatCurrency } from '../../services/billingService';
|
||||
|
||||
interface UsageLogsTableProps {
|
||||
initialLimit?: number;
|
||||
}
|
||||
|
||||
const UsageLogsTable: React.FC<UsageLogsTableProps> = ({ initialLimit = 50 }) => {
|
||||
const [logs, setLogs] = useState<UsageLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(initialLimit);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [statusFilter, setStatusFilter] = useState<'success' | 'failed' | 'all'>('all');
|
||||
const [providerFilter, setProviderFilter] = useState<string>('all');
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const statusCode = statusFilter === 'all' ? undefined : (statusFilter === 'success' ? 200 : 400);
|
||||
const provider = providerFilter === 'all' ? undefined : providerFilter;
|
||||
|
||||
const response: UsageLogsResponse = await billingService.getUsageLogs(
|
||||
rowsPerPage,
|
||||
page * rowsPerPage,
|
||||
provider,
|
||||
statusCode
|
||||
);
|
||||
|
||||
setLogs(response.logs || []);
|
||||
setTotalCount(response.total_count || 0);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch usage logs');
|
||||
console.error('Error fetching usage logs:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, rowsPerPage, statusFilter, providerFilter]);
|
||||
|
||||
const handleChangePage = (_event: unknown, newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string): React.ReactElement | undefined => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <CheckCircleIcon fontSize="small" sx={{ color: terminalColors.success }} />;
|
||||
case 'failed':
|
||||
return <ErrorIcon fontSize="small" sx={{ color: terminalColors.error }} />;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const formatResponseTime = (seconds: number) => {
|
||||
if (!seconds) return 'N/A';
|
||||
const ms = seconds * 1000;
|
||||
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
||||
return `${seconds.toFixed(2)}s`;
|
||||
};
|
||||
|
||||
const formatTokens = (tokens: number) => {
|
||||
return new Intl.NumberFormat('en-US').format(tokens);
|
||||
};
|
||||
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 3 }}>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<ReceiptIcon sx={{ color: terminalColors.primary }} />
|
||||
<TerminalTypography variant="h6" component="h2" sx={{ fontSize: '1.25rem', fontWeight: 'bold' }}>
|
||||
API Usage Logs
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{
|
||||
minWidth: 120,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
color: terminalColors.primary,
|
||||
'& fieldset': {
|
||||
borderColor: terminalColors.primary,
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: terminalColors.secondary,
|
||||
},
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: terminalColors.textSecondary,
|
||||
},
|
||||
'& .MuiSelect-icon': {
|
||||
color: terminalColors.primary,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputLabel>Provider</InputLabel>
|
||||
<Select
|
||||
value={providerFilter}
|
||||
label="Provider"
|
||||
onChange={(e) => {
|
||||
setProviderFilter(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
sx: {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
border: `1px solid ${terminalColors.primary}`,
|
||||
'& .MuiMenuItem-root': {
|
||||
color: terminalColors.primary,
|
||||
fontFamily: 'monospace',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.15)',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="gemini">Gemini</MenuItem>
|
||||
<MenuItem value="huggingface">HuggingFace</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{
|
||||
minWidth: 120,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
color: terminalColors.primary,
|
||||
'& fieldset': {
|
||||
borderColor: terminalColors.primary,
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: terminalColors.secondary,
|
||||
},
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: terminalColors.textSecondary,
|
||||
},
|
||||
'& .MuiSelect-icon': {
|
||||
color: terminalColors.primary,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
label="Status"
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value as any);
|
||||
setPage(0);
|
||||
}}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
sx: {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
border: `1px solid ${terminalColors.primary}`,
|
||||
'& .MuiMenuItem-root': {
|
||||
color: terminalColors.primary,
|
||||
fontFamily: 'monospace',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.15)',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="success">Success</MenuItem>
|
||||
<MenuItem value="failed">Failed</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Tooltip title="Refresh logs">
|
||||
<IconButton
|
||||
onClick={fetchLogs}
|
||||
size="small"
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
border: `1px solid ${terminalColors.primary}`,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<TerminalAlert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</TerminalAlert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" p={3}>
|
||||
<CircularProgress sx={{ color: terminalColors.primary }} />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<TableContainer
|
||||
sx={{
|
||||
backgroundColor: terminalColors.background,
|
||||
maxHeight: '600px',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
<Table size="small" sx={{ minWidth: 800 }}>
|
||||
<TableHead>
|
||||
<TerminalTableRow>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Timestamp</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Provider</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Model</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Tokens</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Cost</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Status</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Response Time</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Endpoint</TerminalTableCell>
|
||||
</TerminalTableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{logs.length === 0 ? (
|
||||
<TerminalTableRow>
|
||||
<TerminalTableCell colSpan={8} align="center">
|
||||
<Box sx={{ py: 4, textAlign: 'center' }}>
|
||||
<ReceiptIcon sx={{ color: terminalColors.textSecondary, fontSize: 48, mb: 2, opacity: 0.5 }} />
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.primary, mb: 1, fontWeight: 'bold' }}>
|
||||
No Usage Logs Yet
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary }}>
|
||||
API usage logs will appear here once you start making API calls.
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
</TerminalTableCell>
|
||||
</TerminalTableRow>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<TerminalTableRow
|
||||
key={log.id}
|
||||
sx={{
|
||||
backgroundColor: terminalColors.background,
|
||||
color: terminalColors.primary,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TerminalTableCell>
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem' }}>
|
||||
{formatDate(log.timestamp)}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
<TerminalTypography variant="body2" sx={{ textTransform: 'capitalize' }}>
|
||||
{log.provider}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem' }}>
|
||||
{log.model_used || 'N/A'}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
<TerminalTypography variant="body2">
|
||||
{formatTokens(log.tokens_total)}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
<TerminalTypography variant="body2">
|
||||
{formatCurrency(log.cost_total)}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
{log.status === 'success' ? (
|
||||
<TerminalChipSuccess
|
||||
icon={getStatusIcon(log.status) || undefined}
|
||||
label={`${log.status_code}`}
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<TerminalChipError
|
||||
icon={getStatusIcon(log.status) || undefined}
|
||||
label={`${log.status_code}`}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem' }}>
|
||||
{formatResponseTime(log.response_time)}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
<Tooltip title={log.endpoint || ''}>
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{log.is_aggregated ? (
|
||||
<span style={{ color: terminalColors.warning, fontStyle: 'italic' }}>
|
||||
[AGGREGATED] {log.error_message || 'Historical data'}
|
||||
</span>
|
||||
) : (
|
||||
`${log.method} ${log.endpoint?.substring(0, 30)}...`
|
||||
)}
|
||||
</TerminalTypography>
|
||||
</Tooltip>
|
||||
</TerminalTableCell>
|
||||
</TerminalTableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
borderTop: `1px solid ${terminalColors.primary}`,
|
||||
'& .MuiTablePagination-toolbar': {
|
||||
color: terminalColors.primary,
|
||||
},
|
||||
'& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': {
|
||||
color: terminalColors.primary,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
'& .MuiIconButton-root': {
|
||||
color: terminalColors.primary,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
color: terminalColors.textSecondary,
|
||||
}
|
||||
},
|
||||
'& .MuiSelect-root': {
|
||||
color: terminalColors.primary,
|
||||
borderColor: terminalColors.primary,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TerminalPaper>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageLogsTable;
|
||||
|
||||
Reference in New Issue
Block a user