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:
ajaysi
2025-11-13 16:14:26 +05:30
parent 7191c7e7f0
commit 3b9356e2c8
124 changed files with 20055 additions and 1208 deletions

View File

@@ -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) => {

View File

@@ -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

View 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;
}

View 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;

View 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,
};
};