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:
@@ -2,6 +2,11 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { BlogOutlineSection, BlogResearchResponse, BlogSEOMetadataResponse, BlogSEOAnalyzeResponse, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../services/blogWriterApi';
|
||||
import { researchCache } from '../services/researchCache';
|
||||
|
||||
const MINOR_TITLE_WORDS = new Set([
|
||||
'a', 'an', 'and', 'or', 'but', 'the', 'for', 'nor', 'on', 'at', 'to', 'from', 'by',
|
||||
'of', 'in', 'with', 'as', 'vs', 'vs.', 'into', 'over', 'under'
|
||||
]);
|
||||
|
||||
export const useBlogWriterState = () => {
|
||||
// Core state
|
||||
const [research, setResearch] = useState<BlogResearchResponse | null>(null);
|
||||
@@ -36,6 +41,57 @@ export const useBlogWriterState = () => {
|
||||
// Section images state - persists images generated in outline phase to content phase
|
||||
const [sectionImages, setSectionImages] = useState<Record<string, string>>({});
|
||||
|
||||
const formatContentAngleToTitle = useCallback((angle: string): string => {
|
||||
if (!angle || typeof angle !== 'string') {
|
||||
return '';
|
||||
}
|
||||
const cleaned = angle.replace(/\s+/g, ' ').trim();
|
||||
if (!cleaned) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const words = cleaned.split(' ');
|
||||
const formattedWords = words.map((word, index) => {
|
||||
const lower = word.toLowerCase();
|
||||
if (index !== 0 && MINOR_TITLE_WORDS.has(lower)) {
|
||||
return lower;
|
||||
}
|
||||
if (!lower) {
|
||||
return '';
|
||||
}
|
||||
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
||||
}).filter(Boolean);
|
||||
|
||||
let formatted = formattedWords.join(' ');
|
||||
if (formatted.length > 120) {
|
||||
formatted = formatted.slice(0, 117).trimEnd() + '...';
|
||||
}
|
||||
return formatted;
|
||||
}, []);
|
||||
|
||||
const dedupeTitles = useCallback((titles: string[]): string[] => {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
||||
titles.forEach((title) => {
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
const normalized = title.replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const key = normalized.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push(normalized);
|
||||
});
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
// Cache recovery - restore most recent research on page load
|
||||
useEffect(() => {
|
||||
const cachedEntries = researchCache.getAllCachedEntries();
|
||||
@@ -71,14 +127,46 @@ export const useBlogWriterState = () => {
|
||||
// Handle research completion
|
||||
const handleResearchComplete = useCallback((researchData: BlogResearchResponse) => {
|
||||
setResearch(researchData);
|
||||
}, []);
|
||||
const formattedAngles = dedupeTitles(
|
||||
(researchData?.suggested_angles || []).map(formatContentAngleToTitle)
|
||||
);
|
||||
setResearchTitles(formattedAngles);
|
||||
|
||||
// Prefill title from research if no title is currently selected
|
||||
if (!selectedTitle && formattedAngles.length > 0) {
|
||||
const firstTitle = formattedAngles[0];
|
||||
setSelectedTitle(firstTitle);
|
||||
localStorage.setItem('blog_selected_title', firstTitle);
|
||||
}
|
||||
}, [dedupeTitles, formatContentAngleToTitle, selectedTitle]);
|
||||
|
||||
// Handle outline completion with enhanced metadata
|
||||
const handleOutlineComplete = useCallback((result: any) => {
|
||||
if (result?.outline) {
|
||||
setOutline(result.outline);
|
||||
setTitleOptions(result.title_options || []);
|
||||
|
||||
|
||||
const aiTitleOptions: string[] = result.title_options || [];
|
||||
const formattedAngles = dedupeTitles(
|
||||
(research?.suggested_angles || []).map(formatContentAngleToTitle)
|
||||
);
|
||||
const combinedTitleOptions = dedupeTitles([
|
||||
...formattedAngles,
|
||||
...aiTitleOptions
|
||||
]);
|
||||
|
||||
setTitleOptions(combinedTitleOptions);
|
||||
setResearchTitles(formattedAngles);
|
||||
|
||||
const aiTitlesList = dedupeTitles(
|
||||
aiTitleOptions.filter((title: string) => !formattedAngles.some(angle => angle.toLowerCase() === (title || '').toLowerCase().trim()))
|
||||
);
|
||||
setAiGeneratedTitles(aiTitlesList);
|
||||
|
||||
const nextSelectedTitle = aiTitlesList[0] || formattedAngles[0] || combinedTitleOptions[0] || '';
|
||||
if (nextSelectedTitle) {
|
||||
setSelectedTitle(nextSelectedTitle);
|
||||
}
|
||||
|
||||
// Store enhanced metadata
|
||||
if (result.source_mapping_stats) {
|
||||
setSourceMappingStats(result.source_mapping_stats);
|
||||
@@ -92,35 +180,13 @@ export const useBlogWriterState = () => {
|
||||
if (result.research_coverage) {
|
||||
setResearchCoverage(result.research_coverage);
|
||||
}
|
||||
|
||||
// Separate research titles from AI-generated titles
|
||||
if (result.title_options && research) {
|
||||
const researchAngles = research.suggested_angles || [];
|
||||
const researchTitlesList = result.title_options.filter((title: string) =>
|
||||
researchAngles.some((angle: string) => title.toLowerCase().includes(angle.toLowerCase().substring(0, 20)))
|
||||
);
|
||||
const aiTitlesList = result.title_options.filter((title: string) =>
|
||||
!researchTitlesList.includes(title)
|
||||
);
|
||||
|
||||
setResearchTitles(researchTitlesList);
|
||||
setAiGeneratedTitles(aiTitlesList);
|
||||
|
||||
// Auto-select first AI-generated title if available, otherwise first research title
|
||||
if (aiTitlesList.length > 0) {
|
||||
setSelectedTitle(aiTitlesList[0]);
|
||||
} else if (researchTitlesList.length > 0) {
|
||||
setSelectedTitle(researchTitlesList[0]);
|
||||
} else if (result.title_options.length > 0) {
|
||||
setSelectedTitle(result.title_options[0]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Save to localStorage for persistence (using shared cache utility)
|
||||
try {
|
||||
const { blogWriterCache } = require('../services/blogWriterCache');
|
||||
blogWriterCache.cacheOutline(result.outline, result.title_options);
|
||||
localStorage.setItem('blog_selected_title', result.title_options?.[0] || '');
|
||||
blogWriterCache.cacheOutline(result.outline, combinedTitleOptions);
|
||||
localStorage.setItem('blog_title_options', JSON.stringify(combinedTitleOptions));
|
||||
localStorage.setItem('blog_selected_title', nextSelectedTitle || '');
|
||||
console.log('Saved outline data to localStorage');
|
||||
} catch (error) {
|
||||
console.error('Error saving outline data:', error);
|
||||
@@ -129,7 +195,7 @@ export const useBlogWriterState = () => {
|
||||
setOutlineTaskId(null);
|
||||
// Reset outline confirmation when new outline is generated
|
||||
setOutlineConfirmed(false);
|
||||
}, [research]);
|
||||
}, [research, dedupeTitles, formatContentAngleToTitle]);
|
||||
|
||||
// Handle outline error
|
||||
const handleOutlineError = useCallback((error: any) => {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { billingService } from '../services/billingService';
|
||||
import { UsageAlert } from '../types/billing';
|
||||
import { showToastNotification } from '../utils/toastNotifications';
|
||||
|
||||
interface UseOAuthTokenAlertsOptions {
|
||||
/**
|
||||
@@ -135,58 +136,8 @@ export function useOAuthTokenAlerts(options: UseOAuthTokenAlertsOptions = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast notification using DOM-based approach
|
||||
* Works globally across the app, regardless of which component is mounted
|
||||
*/
|
||||
function showToastNotification(message: string, type: 'error' | 'warning' | 'info' = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
|
||||
// Determine background color based on type
|
||||
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);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// Remove after 5 seconds (longer for important alerts)
|
||||
const duration = type === 'error' ? 7000 : 5000;
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(toast)) {
|
||||
document.body.removeChild(toast);
|
||||
}
|
||||
}, 300);
|
||||
}, duration);
|
||||
}
|
||||
// Note: showToastNotification is now imported from utils/toastNotifications.ts
|
||||
// This ensures consistent toast notifications across the app
|
||||
|
||||
/**
|
||||
* Extract platform name from alert title
|
||||
|
||||
156
frontend/src/hooks/useSchedulerTaskAlerts.ts
Normal file
156
frontend/src/hooks/useSchedulerTaskAlerts.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import { apiClient } from '../api/client';
|
||||
import { showToastNotification } from '../utils/toastNotifications';
|
||||
|
||||
/**
|
||||
* Hook to poll for tasks needing intervention and show toast notifications
|
||||
*/
|
||||
export function useSchedulerTaskAlerts(options: {
|
||||
enabled?: boolean;
|
||||
interval?: number;
|
||||
} = {}) {
|
||||
const { enabled = true, interval = 60000 } = options;
|
||||
const { userId, getToken } = useAuth();
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastAlertTimeRef = useRef<number>(0);
|
||||
const isPollingRef = useRef(false);
|
||||
const shownTaskIdsRef = useRef<Set<number>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pollAlerts = async () => {
|
||||
if (isPollingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isPollingRef.current = true;
|
||||
|
||||
// Fetch tasks needing intervention
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
tasks: Array<{
|
||||
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;
|
||||
}>;
|
||||
count: number;
|
||||
}>(`/api/scheduler/tasks-needing-intervention/${userId}`);
|
||||
|
||||
if (!response.data.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tasks = response.data.tasks || [];
|
||||
|
||||
// Show toast only for critical failures (API limits) - other failures are shown in dedicated section
|
||||
for (const task of tasks) {
|
||||
// Only show alert once per task
|
||||
if (shownTaskIdsRef.current.has(task.task_id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only show toast for critical failures (API limits) and if failure is very recent (within last 10 minutes)
|
||||
const failureReason = task.failure_pattern?.failure_reason || 'unknown';
|
||||
if (task.last_failure && failureReason === 'api_limit') {
|
||||
const failureTime = new Date(task.last_failure).getTime();
|
||||
const tenMinutesAgo = Date.now() - 10 * 60 * 1000;
|
||||
|
||||
if (failureTime > tenMinutesAgo) {
|
||||
showTaskAlert(task);
|
||||
shownTaskIdsRef.current.add(task.task_id);
|
||||
lastAlertTimeRef.current = Math.max(
|
||||
lastAlertTimeRef.current,
|
||||
failureTime
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling scheduler task alerts:', error);
|
||||
// Don't show error to user - this is background polling
|
||||
} finally {
|
||||
isPollingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Poll immediately
|
||||
pollAlerts();
|
||||
|
||||
// Set up periodic polling
|
||||
intervalRef.current = setInterval(pollAlerts, interval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [enabled, userId, interval]);
|
||||
|
||||
return {
|
||||
isPolling: isPollingRef.current
|
||||
};
|
||||
}
|
||||
|
||||
function showTaskAlert(task: {
|
||||
task_id: number;
|
||||
task_type: string;
|
||||
platform?: string;
|
||||
website_url?: string;
|
||||
failure_pattern: {
|
||||
consecutive_failures: number;
|
||||
failure_reason: string;
|
||||
};
|
||||
}) {
|
||||
const failureReason = task.failure_pattern?.failure_reason || 'unknown';
|
||||
const consecutiveFailures = task.failure_pattern?.consecutive_failures || 0;
|
||||
|
||||
let message = '';
|
||||
let type: 'error' | 'warning' = 'error';
|
||||
|
||||
if (failureReason === 'api_limit') {
|
||||
message = `Task "${getTaskDisplayName(task)}" has failed ${consecutiveFailures} times due to API limits. Manual intervention required.`;
|
||||
type = 'error';
|
||||
} else if (failureReason === 'auth_error') {
|
||||
message = `Task "${getTaskDisplayName(task)}" has failed ${consecutiveFailures} times due to authentication issues. Please check your credentials.`;
|
||||
type = 'warning';
|
||||
} else {
|
||||
message = `Task "${getTaskDisplayName(task)}" has failed ${consecutiveFailures} times and needs manual intervention.`;
|
||||
type = 'error';
|
||||
}
|
||||
|
||||
// Use existing toast function
|
||||
showToastNotification(message, type);
|
||||
}
|
||||
|
||||
function getTaskDisplayName(task: {
|
||||
task_type: string;
|
||||
platform?: string;
|
||||
website_url?: string;
|
||||
}): 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 > 30 ? url.substring(0, 30) + '...' : url})`;
|
||||
} else if (task.task_type.includes('_insights')) {
|
||||
return `${task.platform?.toUpperCase() || 'Unknown'} Insights`;
|
||||
}
|
||||
return task.task_type;
|
||||
}
|
||||
|
||||
184
frontend/src/hooks/useStoryWriterPhaseNavigation.ts
Normal file
184
frontend/src/hooks/useStoryWriterPhaseNavigation.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
|
||||
export interface StoryPhase {
|
||||
id: 'setup' | 'outline' | 'writing' | 'export';
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
completed: boolean;
|
||||
current: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
interface UseStoryWriterPhaseNavigationParams {
|
||||
hasPremise: boolean;
|
||||
hasOutline: boolean;
|
||||
hasStoryContent: boolean;
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
export const useStoryWriterPhaseNavigation = ({
|
||||
hasPremise,
|
||||
hasOutline,
|
||||
hasStoryContent,
|
||||
isComplete,
|
||||
}: UseStoryWriterPhaseNavigationParams) => {
|
||||
// Initialize from localStorage if available
|
||||
const getInitialPhase = (): string => {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = window.localStorage.getItem('storywriter_current_phase');
|
||||
if (stored) return stored;
|
||||
}
|
||||
} catch {}
|
||||
return 'setup';
|
||||
};
|
||||
|
||||
const [currentPhase, setCurrentPhase] = useState<string>(getInitialPhase());
|
||||
const [userSelectedPhase, setUserSelectedPhase] = useState<boolean>(() => {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = window.localStorage.getItem('storywriter_user_selected_phase');
|
||||
return stored === 'true';
|
||||
}
|
||||
} catch {}
|
||||
return false;
|
||||
});
|
||||
const lastClickAtRef = useRef<number>(0);
|
||||
|
||||
// Determine phase states based on current data
|
||||
const phases = useMemo((): StoryPhase[] => {
|
||||
const setupCompleted = hasPremise; // Setup is complete when premise exists
|
||||
const outlineCompleted = hasOutline;
|
||||
const writingCompleted = hasStoryContent && isComplete;
|
||||
const exportCompleted = isComplete;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'setup',
|
||||
name: 'Setup',
|
||||
icon: '⚙️',
|
||||
description: 'Configure your story parameters and premise',
|
||||
completed: setupCompleted,
|
||||
current: currentPhase === 'setup',
|
||||
disabled: false, // Always accessible
|
||||
},
|
||||
{
|
||||
id: 'outline',
|
||||
name: 'Outline',
|
||||
icon: '📝',
|
||||
description: 'Generate and refine story outline',
|
||||
completed: outlineCompleted,
|
||||
current: currentPhase === 'outline',
|
||||
disabled: !hasPremise, // Need premise first
|
||||
},
|
||||
{
|
||||
id: 'writing',
|
||||
name: 'Writing',
|
||||
icon: '✍️',
|
||||
description: 'Generate and edit your story',
|
||||
completed: writingCompleted,
|
||||
current: currentPhase === 'writing',
|
||||
disabled: !hasOutline, // Need outline first
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
name: 'Export',
|
||||
icon: '📤',
|
||||
description: 'Export your completed story',
|
||||
completed: exportCompleted,
|
||||
current: currentPhase === 'export',
|
||||
disabled: !hasStoryContent, // Need story content first
|
||||
},
|
||||
];
|
||||
}, [hasPremise, hasOutline, hasStoryContent, isComplete, currentPhase]);
|
||||
|
||||
// Persist current phase and user selection
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem('storywriter_current_phase', currentPhase);
|
||||
window.localStorage.setItem('storywriter_user_selected_phase', String(userSelectedPhase));
|
||||
}
|
||||
} catch {}
|
||||
}, [currentPhase, userSelectedPhase]);
|
||||
|
||||
// Validate stored phase against current availability (quiet)
|
||||
// Also migrate old 'premise' phase to 'outline' if needed
|
||||
useEffect(() => {
|
||||
// Migrate old 'premise' phase to 'outline' if stored
|
||||
if (currentPhase === 'premise') {
|
||||
if (hasPremise) {
|
||||
setCurrentPhase('outline');
|
||||
} else {
|
||||
setCurrentPhase('setup');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const current = phases.find((p) => p.id === currentPhase);
|
||||
if (!current) {
|
||||
setCurrentPhase('setup');
|
||||
return;
|
||||
}
|
||||
if (current.disabled) {
|
||||
// Find the first non-disabled phase in order of progression
|
||||
const fallback = phases.find((p) => !p.disabled) || ({ id: 'setup' } as StoryPhase);
|
||||
if (fallback.id !== currentPhase) {
|
||||
setCurrentPhase(fallback.id);
|
||||
}
|
||||
}
|
||||
}, [phases, currentPhase, hasPremise]);
|
||||
|
||||
// Auto-update current phase based on completion status (only if user hasn't manually selected)
|
||||
useEffect(() => {
|
||||
if (userSelectedPhase) {
|
||||
return; // Don't auto-update if user has manually selected a phase
|
||||
}
|
||||
|
||||
// Auto-progress to the next available phase when conditions are met
|
||||
if (!hasPremise && currentPhase !== 'setup') {
|
||||
setCurrentPhase('setup');
|
||||
} else if (hasPremise && !hasOutline && currentPhase !== 'outline') {
|
||||
setCurrentPhase('outline');
|
||||
} else if (hasOutline && !hasStoryContent && currentPhase !== 'writing') {
|
||||
setCurrentPhase('writing');
|
||||
} else if (hasStoryContent && !isComplete && currentPhase !== 'export') {
|
||||
setCurrentPhase('export');
|
||||
}
|
||||
}, [hasPremise, hasOutline, hasStoryContent, isComplete, currentPhase, userSelectedPhase]);
|
||||
|
||||
const navigateToPhase = useCallback(
|
||||
(phaseId: string) => {
|
||||
// Minimal debounce (200ms) to avoid race conditions on rapid clicks
|
||||
const now = Date.now();
|
||||
if (now - lastClickAtRef.current < 200) {
|
||||
return;
|
||||
}
|
||||
lastClickAtRef.current = now;
|
||||
|
||||
const phase = phases.find((p) => p.id === phaseId);
|
||||
|
||||
if (phase && !phase.disabled) {
|
||||
setCurrentPhase(phaseId);
|
||||
setUserSelectedPhase(true); // Mark that user has manually selected a phase
|
||||
}
|
||||
},
|
||||
[phases]
|
||||
);
|
||||
|
||||
// Reset user selection when a new phase is completed (to allow auto-progression)
|
||||
const resetUserSelection = useCallback(() => {
|
||||
setUserSelectedPhase(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
phases,
|
||||
currentPhase,
|
||||
navigateToPhase,
|
||||
setCurrentPhase,
|
||||
resetUserSelection,
|
||||
};
|
||||
};
|
||||
|
||||
export default useStoryWriterPhaseNavigation;
|
||||
455
frontend/src/hooks/useStoryWriterState.ts
Normal file
455
frontend/src/hooks/useStoryWriterState.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
StoryGenerationRequest,
|
||||
StoryPremiseResponse,
|
||||
StoryOutlineResponse,
|
||||
StoryContentResponse,
|
||||
StoryFullGenerationResponse,
|
||||
} from '../services/storyWriterApi';
|
||||
|
||||
export interface StoryWriterState {
|
||||
// Story parameters (Setup phase)
|
||||
persona: string;
|
||||
storySetting: string;
|
||||
characters: string;
|
||||
plotElements: string;
|
||||
writingStyle: string;
|
||||
storyTone: string;
|
||||
narrativePOV: string;
|
||||
audienceAgeGroup: string;
|
||||
contentRating: string;
|
||||
endingPreference: string;
|
||||
storyLength: string;
|
||||
enableExplainer: boolean;
|
||||
enableIllustration: boolean;
|
||||
enableVideoNarration: boolean;
|
||||
|
||||
// Image generation settings
|
||||
imageProvider: string | null;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
imageModel: string | null;
|
||||
|
||||
// Video generation settings
|
||||
videoFps: number;
|
||||
videoTransitionDuration: number;
|
||||
|
||||
// Audio generation settings
|
||||
audioProvider: string;
|
||||
audioLang: string;
|
||||
audioSlow: boolean;
|
||||
audioRate: number;
|
||||
|
||||
// Generated content
|
||||
premise: string | null;
|
||||
outline: string | null;
|
||||
outlineScenes: any[] | null; // Structured scenes from outline
|
||||
isOutlineStructured: boolean;
|
||||
storyContent: string | null;
|
||||
isComplete: boolean;
|
||||
sceneImages: Map<number, string> | null; // Generated image URLs by scene number
|
||||
sceneAudio: Map<number, string> | null; // Generated audio URLs by scene number
|
||||
storyVideo: string | null; // Generated video URL
|
||||
|
||||
// Task management
|
||||
currentTaskId: string | null;
|
||||
generationProgress: number;
|
||||
generationMessage: string | null;
|
||||
|
||||
// UI state
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: Partial<StoryWriterState> = {
|
||||
persona: '',
|
||||
storySetting: '',
|
||||
characters: '',
|
||||
plotElements: '',
|
||||
writingStyle: 'Formal',
|
||||
storyTone: 'Suspenseful',
|
||||
narrativePOV: 'Third Person Limited',
|
||||
audienceAgeGroup: 'Adults (18+)',
|
||||
contentRating: 'PG-13',
|
||||
endingPreference: 'Happy',
|
||||
storyLength: 'Medium',
|
||||
enableExplainer: true,
|
||||
enableIllustration: true,
|
||||
enableVideoNarration: true,
|
||||
// Image generation settings
|
||||
imageProvider: null,
|
||||
imageWidth: 1024,
|
||||
imageHeight: 1024,
|
||||
imageModel: null,
|
||||
// Video generation settings
|
||||
videoFps: 24,
|
||||
videoTransitionDuration: 0.5,
|
||||
// Audio generation settings
|
||||
audioProvider: 'gtts',
|
||||
audioLang: 'en',
|
||||
audioSlow: false,
|
||||
audioRate: 150,
|
||||
premise: null,
|
||||
outline: null,
|
||||
outlineScenes: null,
|
||||
isOutlineStructured: false,
|
||||
storyContent: null,
|
||||
isComplete: false,
|
||||
sceneImages: null,
|
||||
sceneAudio: null,
|
||||
storyVideo: null,
|
||||
currentTaskId: null,
|
||||
generationProgress: 0,
|
||||
generationMessage: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Mapping for old values to new values (for migration)
|
||||
const AUDIENCE_AGE_GROUP_MIGRATION: Record<string, string> = {
|
||||
'Adults': 'Adults (18+)',
|
||||
'Children': 'Children (5-12)',
|
||||
'Young Adults': 'Young Adults (13-17)',
|
||||
};
|
||||
|
||||
// Valid audience age groups
|
||||
const VALID_AUDIENCE_AGE_GROUPS = ['Children (5-12)', 'Young Adults (13-17)', 'Adults (18+)', 'All Ages'];
|
||||
|
||||
export const useStoryWriterState = () => {
|
||||
const [state, setState] = useState<StoryWriterState>(() => {
|
||||
// Initialize from localStorage if available
|
||||
try {
|
||||
const saved = localStorage.getItem('story_writer_state');
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
|
||||
// Migrate old audienceAgeGroup values to new format
|
||||
if (parsed.audienceAgeGroup && AUDIENCE_AGE_GROUP_MIGRATION[parsed.audienceAgeGroup]) {
|
||||
parsed.audienceAgeGroup = AUDIENCE_AGE_GROUP_MIGRATION[parsed.audienceAgeGroup];
|
||||
}
|
||||
// Validate audienceAgeGroup is in valid list, if not, use default
|
||||
if (parsed.audienceAgeGroup && !VALID_AUDIENCE_AGE_GROUPS.includes(parsed.audienceAgeGroup)) {
|
||||
console.warn(`Invalid audienceAgeGroup value: ${parsed.audienceAgeGroup}, using default`);
|
||||
parsed.audienceAgeGroup = DEFAULT_STATE.audienceAgeGroup;
|
||||
}
|
||||
|
||||
// Convert arrays back to Maps
|
||||
const restoredState = {
|
||||
...DEFAULT_STATE,
|
||||
...parsed,
|
||||
sceneImages: parsed.sceneImages ? new Map(parsed.sceneImages) : null,
|
||||
sceneAudio: parsed.sceneAudio ? new Map(parsed.sceneAudio) : null,
|
||||
};
|
||||
|
||||
return restoredState as StoryWriterState;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading story writer state from localStorage:', error);
|
||||
}
|
||||
return DEFAULT_STATE as StoryWriterState;
|
||||
});
|
||||
|
||||
// Fix invalid audienceAgeGroup values whenever state changes
|
||||
useEffect(() => {
|
||||
if (state.audienceAgeGroup && !VALID_AUDIENCE_AGE_GROUPS.includes(state.audienceAgeGroup)) {
|
||||
// Migrate old values to new format
|
||||
const migratedValue = AUDIENCE_AGE_GROUP_MIGRATION[state.audienceAgeGroup] || (DEFAULT_STATE.audienceAgeGroup as string);
|
||||
if (migratedValue !== state.audienceAgeGroup) {
|
||||
console.log(`Migrating audienceAgeGroup from '${state.audienceAgeGroup}' to '${migratedValue}'`);
|
||||
setState((prev) => ({ ...prev, audienceAgeGroup: migratedValue }));
|
||||
}
|
||||
}
|
||||
}, [state.audienceAgeGroup]);
|
||||
|
||||
// Persist state to localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
// Don't persist loading/error states
|
||||
const { isLoading, error, ...persistableState } = state;
|
||||
|
||||
// Ensure audienceAgeGroup is valid before persisting
|
||||
let validAudienceAgeGroup = persistableState.audienceAgeGroup;
|
||||
if (!VALID_AUDIENCE_AGE_GROUPS.includes(validAudienceAgeGroup)) {
|
||||
validAudienceAgeGroup = AUDIENCE_AGE_GROUP_MIGRATION[validAudienceAgeGroup] || (DEFAULT_STATE.audienceAgeGroup as string);
|
||||
// Update state if corrected
|
||||
if (validAudienceAgeGroup !== persistableState.audienceAgeGroup) {
|
||||
setState((prev) => ({ ...prev, audienceAgeGroup: validAudienceAgeGroup }));
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Maps to arrays for JSON serialization
|
||||
const serializableState = {
|
||||
...persistableState,
|
||||
audienceAgeGroup: validAudienceAgeGroup,
|
||||
sceneImages: persistableState.sceneImages ? Array.from(persistableState.sceneImages.entries()) : null,
|
||||
sceneAudio: persistableState.sceneAudio ? Array.from(persistableState.sceneAudio.entries()) : null,
|
||||
};
|
||||
|
||||
localStorage.setItem('story_writer_state', JSON.stringify(serializableState));
|
||||
} catch (error) {
|
||||
console.error('Error saving story writer state to localStorage:', error);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
// Setters
|
||||
const setPersona = useCallback((persona: string) => {
|
||||
setState((prev) => ({ ...prev, persona }));
|
||||
}, []);
|
||||
|
||||
const setStorySetting = useCallback((setting: string) => {
|
||||
setState((prev) => ({ ...prev, storySetting: setting }));
|
||||
}, []);
|
||||
|
||||
const setCharacters = useCallback((characters: string) => {
|
||||
setState((prev) => ({ ...prev, characters }));
|
||||
}, []);
|
||||
|
||||
const setPlotElements = useCallback((plotElements: string) => {
|
||||
setState((prev) => ({ ...prev, plotElements }));
|
||||
}, []);
|
||||
|
||||
const setWritingStyle = useCallback((style: string) => {
|
||||
setState((prev) => ({ ...prev, writingStyle: style }));
|
||||
}, []);
|
||||
|
||||
const setStoryTone = useCallback((tone: string) => {
|
||||
setState((prev) => ({ ...prev, storyTone: tone }));
|
||||
}, []);
|
||||
|
||||
const setNarrativePOV = useCallback((pov: string) => {
|
||||
setState((prev) => ({ ...prev, narrativePOV: pov }));
|
||||
}, []);
|
||||
|
||||
const setAudienceAgeGroup = useCallback((ageGroup: string) => {
|
||||
// Migrate old values to new format
|
||||
const migratedAgeGroup = AUDIENCE_AGE_GROUP_MIGRATION[ageGroup] || ageGroup;
|
||||
// Validate the value is in the valid list
|
||||
if (VALID_AUDIENCE_AGE_GROUPS.includes(migratedAgeGroup)) {
|
||||
setState((prev) => ({ ...prev, audienceAgeGroup: migratedAgeGroup }));
|
||||
} else {
|
||||
console.warn(`Invalid audienceAgeGroup value: ${ageGroup}, using default`);
|
||||
setState((prev) => ({ ...prev, audienceAgeGroup: DEFAULT_STATE.audienceAgeGroup as string }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setContentRating = useCallback((rating: string) => {
|
||||
setState((prev) => ({ ...prev, contentRating: rating }));
|
||||
}, []);
|
||||
|
||||
const setEndingPreference = useCallback((ending: string) => {
|
||||
setState((prev) => ({ ...prev, endingPreference: ending }));
|
||||
}, []);
|
||||
|
||||
const setStoryLength = useCallback((length: string) => {
|
||||
setState((prev) => ({ ...prev, storyLength: length }));
|
||||
}, []);
|
||||
|
||||
const setEnableExplainer = useCallback((enabled: boolean) => {
|
||||
setState((prev) => ({ ...prev, enableExplainer: enabled }));
|
||||
}, []);
|
||||
|
||||
const setEnableIllustration = useCallback((enabled: boolean) => {
|
||||
setState((prev) => ({ ...prev, enableIllustration: enabled }));
|
||||
}, []);
|
||||
|
||||
const setEnableVideoNarration = useCallback((enabled: boolean) => {
|
||||
setState((prev) => ({ ...prev, enableVideoNarration: enabled }));
|
||||
}, []);
|
||||
|
||||
// Image generation setters
|
||||
const setImageProvider = useCallback((provider: string | null) => {
|
||||
setState((prev) => ({ ...prev, imageProvider: provider }));
|
||||
}, []);
|
||||
|
||||
const setImageWidth = useCallback((width: number) => {
|
||||
setState((prev) => ({ ...prev, imageWidth: width }));
|
||||
}, []);
|
||||
|
||||
const setImageHeight = useCallback((height: number) => {
|
||||
setState((prev) => ({ ...prev, imageHeight: height }));
|
||||
}, []);
|
||||
|
||||
const setImageModel = useCallback((model: string | null) => {
|
||||
setState((prev) => ({ ...prev, imageModel: model }));
|
||||
}, []);
|
||||
|
||||
// Video generation setters
|
||||
const setVideoFps = useCallback((fps: number) => {
|
||||
setState((prev) => ({ ...prev, videoFps: fps }));
|
||||
}, []);
|
||||
|
||||
const setVideoTransitionDuration = useCallback((duration: number) => {
|
||||
setState((prev) => ({ ...prev, videoTransitionDuration: duration }));
|
||||
}, []);
|
||||
|
||||
// Audio generation setters
|
||||
const setAudioProvider = useCallback((provider: string) => {
|
||||
setState((prev) => ({ ...prev, audioProvider: provider }));
|
||||
}, []);
|
||||
|
||||
const setAudioLang = useCallback((lang: string) => {
|
||||
setState((prev) => ({ ...prev, audioLang: lang }));
|
||||
}, []);
|
||||
|
||||
const setAudioSlow = useCallback((slow: boolean) => {
|
||||
setState((prev) => ({ ...prev, audioSlow: slow }));
|
||||
}, []);
|
||||
|
||||
const setAudioRate = useCallback((rate: number) => {
|
||||
setState((prev) => ({ ...prev, audioRate: rate }));
|
||||
}, []);
|
||||
|
||||
const setPremise = useCallback((premise: string | null) => {
|
||||
setState((prev) => ({ ...prev, premise }));
|
||||
}, []);
|
||||
|
||||
const setOutline = useCallback((outline: string | null) => {
|
||||
setState((prev) => ({ ...prev, outline }));
|
||||
}, []);
|
||||
|
||||
const setOutlineScenes = useCallback((scenes: any[] | null) => {
|
||||
setState((prev) => ({ ...prev, outlineScenes: scenes, isOutlineStructured: scenes !== null && scenes.length > 0 }));
|
||||
}, []);
|
||||
|
||||
const setIsOutlineStructured = useCallback((isStructured: boolean) => {
|
||||
setState((prev) => ({ ...prev, isOutlineStructured: isStructured }));
|
||||
}, []);
|
||||
|
||||
const setStoryContent = useCallback((content: string | null) => {
|
||||
setState((prev) => ({ ...prev, storyContent: content }));
|
||||
}, []);
|
||||
|
||||
const setSceneImages = useCallback((images: Map<number, string> | null) => {
|
||||
setState((prev) => ({ ...prev, sceneImages: images }));
|
||||
}, []);
|
||||
|
||||
const setSceneAudio = useCallback((audio: Map<number, string> | null) => {
|
||||
setState((prev) => ({ ...prev, sceneAudio: audio }));
|
||||
}, []);
|
||||
|
||||
const setStoryVideo = useCallback((video: string | null) => {
|
||||
setState((prev) => ({ ...prev, storyVideo: video }));
|
||||
}, []);
|
||||
|
||||
const setIsComplete = useCallback((complete: boolean) => {
|
||||
setState((prev) => ({ ...prev, isComplete: complete }));
|
||||
}, []);
|
||||
|
||||
const setCurrentTaskId = useCallback((taskId: string | null) => {
|
||||
setState((prev) => ({ ...prev, currentTaskId: taskId }));
|
||||
}, []);
|
||||
|
||||
const setGenerationProgress = useCallback((progress: number) => {
|
||||
setState((prev) => ({ ...prev, generationProgress: progress }));
|
||||
}, []);
|
||||
|
||||
const setGenerationMessage = useCallback((message: string | null) => {
|
||||
setState((prev) => ({ ...prev, generationMessage: message }));
|
||||
}, []);
|
||||
|
||||
const setIsLoading = useCallback((loading: boolean) => {
|
||||
setState((prev) => ({ ...prev, isLoading: loading }));
|
||||
}, []);
|
||||
|
||||
const setError = useCallback((error: string | null) => {
|
||||
setState((prev) => ({ ...prev, error }));
|
||||
}, []);
|
||||
|
||||
// Helper to get request object
|
||||
const getRequest = useCallback((): StoryGenerationRequest => {
|
||||
return {
|
||||
persona: state.persona,
|
||||
story_setting: state.storySetting,
|
||||
character_input: state.characters,
|
||||
plot_elements: state.plotElements,
|
||||
writing_style: state.writingStyle,
|
||||
story_tone: state.storyTone,
|
||||
narrative_pov: state.narrativePOV,
|
||||
audience_age_group: state.audienceAgeGroup,
|
||||
content_rating: state.contentRating,
|
||||
ending_preference: state.endingPreference,
|
||||
story_length: state.storyLength,
|
||||
enable_explainer: state.enableExplainer,
|
||||
enable_illustration: state.enableIllustration,
|
||||
enable_video_narration: state.enableVideoNarration,
|
||||
// Image generation settings
|
||||
image_provider: state.imageProvider || undefined,
|
||||
image_width: state.imageWidth,
|
||||
image_height: state.imageHeight,
|
||||
image_model: state.imageModel || undefined,
|
||||
// Video generation settings
|
||||
video_fps: state.videoFps,
|
||||
video_transition_duration: state.videoTransitionDuration,
|
||||
// Audio generation settings
|
||||
audio_provider: state.audioProvider,
|
||||
audio_lang: state.audioLang,
|
||||
audio_slow: state.audioSlow,
|
||||
audio_rate: state.audioRate,
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
// Reset state
|
||||
const resetState = useCallback(() => {
|
||||
setState(DEFAULT_STATE as StoryWriterState);
|
||||
// Clear story writer state from localStorage
|
||||
localStorage.removeItem('story_writer_state');
|
||||
// Clear phase navigation from localStorage
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('storywriter_current_phase');
|
||||
localStorage.removeItem('storywriter_user_selected_phase');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing phase navigation from localStorage:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
...state,
|
||||
|
||||
// Setters
|
||||
setPersona,
|
||||
setStorySetting,
|
||||
setCharacters,
|
||||
setPlotElements,
|
||||
setWritingStyle,
|
||||
setStoryTone,
|
||||
setNarrativePOV,
|
||||
setAudienceAgeGroup,
|
||||
setContentRating,
|
||||
setEndingPreference,
|
||||
setStoryLength,
|
||||
setEnableExplainer,
|
||||
setEnableIllustration,
|
||||
setEnableVideoNarration,
|
||||
setImageProvider,
|
||||
setImageWidth,
|
||||
setImageHeight,
|
||||
setImageModel,
|
||||
setVideoFps,
|
||||
setVideoTransitionDuration,
|
||||
setAudioProvider,
|
||||
setAudioLang,
|
||||
setAudioSlow,
|
||||
setAudioRate,
|
||||
setPremise,
|
||||
setOutline,
|
||||
setOutlineScenes,
|
||||
setIsOutlineStructured,
|
||||
setStoryContent,
|
||||
setIsComplete,
|
||||
setSceneImages,
|
||||
setSceneAudio,
|
||||
setStoryVideo,
|
||||
setCurrentTaskId,
|
||||
setGenerationProgress,
|
||||
setGenerationMessage,
|
||||
setIsLoading,
|
||||
setError,
|
||||
|
||||
// Helpers
|
||||
getRequest,
|
||||
resetState,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user