AI Story Writer Backend Migration Complete, Frontend UI Components Added

This commit is contained in:
ajaysi
2025-11-16 19:25:26 +05:30
parent 3b9356e2c8
commit 4901b7eb72
70 changed files with 4765 additions and 1439 deletions

12
.github/FUNDING.yml vendored
View File

@@ -1,12 +0,0 @@
# These are supported funding model platforms
github: [AJaySi]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective name
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: ['https://www.alwrity.com/donate', 'https://www.buymeacoffee.com/alwrity']

640
.github/README.md vendored
View File

@@ -1,559 +1,163 @@
# 🚀 ALwrity - AI-Powered Digital Marketing Platform
<div align="center">
![ALwrity Logo](https://github.com/AJaySi/AI-Writer/blob/main/lib/workspace/alwrity_logo.png)
# 🚀 ALwrity — AI-Powered Digital Marketing Platform
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-green.svg)](https://fastapi.tiangolo.com/)
[![React](https://img.shields.io/badge/React-18+-blue.svg)](https://reactjs.org/)
[![GitHub Stars](https://img.shields.io/badge/GitHub-Stars-yellow?style=social)](https://github.com/AJaySi/AI-Writer/stargazers)
[![GitHub Forks](https://img.shields.io/badge/GitHub-Forks-blue?style=social)](https://github.com/AJaySi/AI-Writer/network/members)
[![React](https://img.shields.io/badge/React-18+-blue.svg)](https://react.dev/)
[![Stars](https://img.shields.io/github/stars/AJaySi/AI-Writer?style=social)](https://github.com/AJaySi/AI-Writer/stargazers)
**🌟 The Ultimate AI-Powered Digital Marketing Platform for Solopreneurs & Content Creators**
**Create, optimize, and publish highquality content across platforms — in minutes, not months.**
[🚀 Live Demo](https://www.alwrity.com) • [📖 Documentation](https://github.com/AJaySi/AI-Writer/wiki) • [💬 Community](https://github.com/AJaySi/AI-Writer/discussions) • [🐛 Report Issues](https://github.com/AJaySi/AI-Writer/issues)
[🌐 Live Demo](https://www.alwrity.com) • [📚 Docs Site](https://ajaysi.github.io/ALwrity/) • [📖 Wiki](https://github.com/AJaySi/AI-Writer/wiki) • [💬 Discussions](https://github.com/AJaySi/AI-Writer/discussions) • [🐛 Issues](https://github.com/AJaySi/AI-Writer/issues)
</div>
---
## 🎯 What is ALwrity?
ALwrity is a **comprehensive AI-powered digital marketing platform** that revolutionizes how solopreneurs and small businesses create, optimize, and manage their entire digital presence. From **content strategy** and **SEO optimization** to **social media automation** and **performance analytics**, ALwrity democratizes enterprise-level marketing capabilities through cutting-edge AI technology.
### 🔥 Why Choose ALwrity?
- **🧠 AI-First Strategy Generation**: Professional content strategies with minimal user input
- **🌍 Multi-Modal Content Creation**: Text, images, audio, and video content generation
- **📊 Data-Driven Insights**: Web research, competitor analysis, and predictive analytics
- **🤖 AI Agent Teams**: Specialized AI agents for different marketing tasks
- **🔗 Platform Integration**: Direct publishing to WordPress, Wix, Google Search Console, and more
- **📈 Performance Optimization**: Continuous learning and strategy refinement
- **🎯 Solopreneur-Focused**: Designed specifically for independent entrepreneurs
- **🛡️ Enterprise Security**: JWT authentication, rate limiting, and comprehensive monitoring
- **✨ Intelligent Onboarding**: AI-powered setup process that analyzes your business and generates personalized strategies
### 🚀 **NEW: Complete Onboarding & Integration System**
**Transform Your Digital Presence in 5 Simple Steps:**
1. **📧 Email Setup & Business Analysis** - AI analyzes your business domain and industry
2. **🎭 AI Persona Generation** - Creates detailed buyer personas and audience insights
3. **🏢 Business Information Collection** - Gathers comprehensive business data for personalized strategies
4. **🔍 Competitor Analysis** - Real-time competitor research and market positioning
5. **🔗 Platform Integrations** - Connect WordPress, Wix, Google Search Console with OAuth security
**🎯 User Impact**: Go from zero to fully optimized digital presence in under 15 minutes!
<p align="center">
<a href="https://ajaysi.github.io/ALwrity/"><img src="../docs-site/docs/assets/hero-1.jpg" alt="ALwrity dashboard overview" width="30%"/></a>
<a href="https://ajaysi.github.io/ALwrity/features/blog-writer/overview/"><img src="../docs-site/docs/assets/hero-2.png" alt="Story Writer workflow" width="30%"/></a>
<a href="https://ajaysi.github.io/ALwrity/features/seo-dashboard/overview/"><img src="../docs-site/docs/assets/hero-3.png" alt="SEO dashboard insights" width="30%"/></a>
</p>
---
## 🚀 Getting Started (Live Now!)
### Why ALwrity
- **AI-first outcomes**: Strategy-to-publishing in one flow — strategy, research, creation, QA, and distribution.
- **Grounded & reliable**: Google grounding, Exa/Tavily research, citation management.
- **Secure & scalable**: JWT auth, OAuth2, rate limiting, monitoring, subscription/usage tracking.
- **Built for solopreneurs**: Enterprise-grade capabilities with a fast, friendly UI.
### **⚡ Quick Start - 3 Steps to Success**
---
### Why it matters for creators & marketers
- **Reduce complexity of AI tools**: Guided flows (research → outline → write → optimize → publish) remove prompt engineering and tool-juggling.
- **Save time, ship consistently**: Phase navigation and checklists keep you moving, ensuring on-time publishing across platforms.
- **Trust the content**: Google grounding, retrieval (web/semantic/neural), and citations mean fewer rewrites and safer publishing.
- **Stay on-brand and compliant**: Personas, tone controls, and rate limits help maintain voice and prevent platform penalties.
- **Catch issues early**: Scheduler “tasks needing intervention,” alerts, and logs highlight problems before your audience sees them.
---
### Whats functional now
- **AI Blog Writer (Phases)**: Research → Outline → Content → SEO → Publish, with guarded navigation and local persistence (`frontend/src/hooks/usePhaseNavigation.ts`).
- **SEO Dashboard**: Analysis, metadata, and Google Search Console insights (see docs under `docs-site/docs/features/seo-dashboard`).
- **Story Writer**: Setup (premise) → Outline → Writing → Export with phase navigation and reset (`frontend/src/hooks/useStoryWriterPhaseNavigation.ts`).
- **LinkedIn (Factual, GoogleGrounded)**: Real Google grounding + citations + quality metrics for posts/articles/carousels/scripts (see `frontend/docs/linkedin_factual_google_grounded_url_content.md`).
- **Persona System**: Core personas and platform adaptations via APIs (`backend/api/persona.py`).
- **Facebook Persona Service**: Gemini structured JSON for Facebookspecific persona optimization (`backend/services/persona/facebook/facebook_persona_service.py`).
- **Personalization & Brand Voice**: Validation and configuration of writing style, tone, structure (`backend/services/component_logic/personalization_logic.py`).
See details in the Wiki: [Docs Home](https://github.com/AJaySi/AI-Writer/wiki)
---
### Quick Start
1) Clone & install
**1. Clone & Setup (2 minutes)**
```bash
git clone https://github.com/AJaySi/AI-Writer.git
cd AI-Writer/backend && pip install -r requirements.txt
cd ../frontend && npm install
```
**2. Launch Platform (1 minute)**
```bash
# Terminal 1: Backend
cd backend && python start_alwrity_backend.py
2) Run locally
# Terminal 2: Frontend
```bash
# Backend
cd backend && python start_alwrity_backend.py
# Frontend
cd frontend && npm start
```
**3. Access & Create (Instant)**
- **Frontend**: http://localhost:3000
- **API Docs**: http://localhost:8000/api/docs
- **Complete onboarding****Generate content****Publish everywhere**
### **🎯 What You'll Get Immediately:**
-**AI-powered business analysis** and strategy generation
-**LinkedIn content creation** with fact-checking and Google grounding
-**Blog writing** with research, SEO optimization, and metadata
-**Facebook content generation** with platform-specific optimization
-**WordPress & Wix integration** with OAuth security
-**Google Search Console** analytics and insights
-**Competitor analysis** and market intelligence
3) Open and create
- Frontend: http://localhost:3000
- API docs (local): http://localhost:8000/api/docs
- Complete onboarding → generate content → publish
---
## 🚀 Current Status & Implementation Progress
### **✅ Backend Architecture - COMPLETE**
- **FastAPI Backend**: Fully implemented with modular service architecture
- **Database Integration**: SQLite with SQLAlchemy ORM (PostgreSQL ready)
- **Authentication System**: JWT-based multi-tenant system with Clerk integration
- **API Documentation**: Auto-generated OpenAPI/Swagger docs
- **Rate Limiting**: Intelligent rate limiting with streaming endpoint exemptions
- **Monitoring**: Comprehensive logging and performance monitoring
- **Subscription System**: Complete billing and usage tracking infrastructure
### **✅ Core AI Services - COMPLETE**
- **Content Strategy Generation**: 12-step automated strategy creation
- **LinkedIn Content Generation**: Posts, articles, carousels, video scripts, fact-checking
- **Facebook Content Generation**: Platform-specific content optimization
- **Blog Writer**: Complete AI-powered blog creation with research, outline, and content generation
- **SEO Analysis Tools**: Comprehensive website analysis and optimization
- **SEO Metadata Generation**: Automated title, description, and structured data creation
- **Image Generation**: AI-powered image creation with Gemini/Imagen APIs
- **Content Planning**: Advanced calendar generation and content scheduling
### **✅ Advanced Features - COMPLETE**
- **Multi-Provider AI Integration**: OpenAI, Anthropic Claude, Google Gemini, Mistral
- **Web Research Engine**: Real-time competitor and market analysis
- **Quality Assurance**: AI-powered content quality analysis and scoring
- **Citation Management**: Automated source tracking and verification
- **Content Gap Analysis**: Strategic content opportunity identification
- **Performance Analytics**: Real-time content performance tracking
- **Google Search Console Integration**: OAuth2 authentication and real-time analytics
- **Hallucination Detection**: AI-powered fact-checking and content verification
- **Persona System**: Advanced writing persona generation and management
- **Google Grounding**: Real-time fact verification using Google Search API
- **Exa AI Integration**: Advanced semantic search and content discovery
- **Assistive Writing**: Real-time writing suggestions and optimization
### **✅ Frontend Development - COMPLETE**
- **React Application**: Modern TypeScript-based frontend with Material-UI
- **CopilotKit Integration**: AI-powered chat interface with contextual suggestions
- **Responsive Design**: Mobile-optimized user experience
- **Real-time Updates**: Live progress tracking and notifications
- **Blog Writer Interface**: Complete WYSIWYG editor with research integration
- **SEO Dashboard**: Comprehensive SEO analysis and metadata generation tools
- **Complete Onboarding System**: 5-step AI-powered setup with business analysis
- **Platform Integrations**: WordPress, Wix, Google Search Console with OAuth
- **Coming Soon Section**: Interactive preview of upcoming features
### **📅 Launch Timeline**
- **Current**: Full platform operational with all core features
- **Q1 2025**: Advanced integrations and mobile application
- **Q2 2025**: Enterprise features and white-label solutions
### Integrations & Security
- **Integrations**: Google Search Console (SEO Dashboard), LinkedIn (factual/grounded content).
- **AI Models**: OpenAI, Google Gemini/Imagen, Hugging Face, Anthropic, Mistral.
- **Security**: JWT auth, OAuth2, rate limiting, monitoring/logging.
- **Reliability**: Grounding + retrieval and citation tracking for factual generation.
---
## ✨ Core Features (Currently Available)
### Tech Stack
### 🎯 **AI-Powered Content Strategy Generation**
| **Strategy Component** | **AI Capabilities** | **Status** |
|----------------------|-------------------|------------|
| **Goal Setting & KPIs** | SMART goal generation, measurable KPIs | ✅ Complete |
| **Audience Personas** | Detailed buyer personas, journey mapping | ✅ Complete |
| **Competitive Intelligence** | Real-time competitor analysis, gap identification | ✅ Complete |
| **Keyword Strategy** | Topic clusters, long-tail keywords, intent analysis | ✅ Complete |
| **Content Calendar** | AI-suggested content types, optimal timing | ✅ Complete |
### 🖋️ **Multi-Platform Content Creation**
| **Platform** | **Content Types** | **Status** |
|--------------|------------------|------------|
| **LinkedIn** | Posts, Articles, Carousels, Video Scripts, Comments, Fact-Checking, Google Grounding | ✅ Complete |
| **Facebook** | Posts, Stories, Ads, Community Content | ✅ Complete |
| **Blog Writer** | Research, Outline, Content Generation, SEO Analysis, Metadata, Exa AI Integration | ✅ Complete |
| **SEO Content** | Blog posts, landing pages, technical content | ✅ Complete |
| **General Content** | Long-form articles, social media posts | ✅ Complete |
| **Assistive Writing** | Real-time suggestions, grammar checking, tone optimization | ✅ Complete |
### 🔍 **Advanced Research & Fact-Checking**
| **Feature** | **AI Capabilities** | **Status** |
|-------------|-------------------|------------|
| **Google Grounding** | Real-time fact verification using Google Search | ✅ Complete |
| **Exa AI Integration** | Semantic search and content discovery | ✅ Complete |
| **Fact-Checking Engine** | AI-powered content verification and source validation | ✅ Complete |
| **Web Research** | Automated competitor analysis and market intelligence | ✅ Complete |
| **Citation Management** | Automatic source tracking and verification | ✅ Complete |
### 🚀 **Complete Onboarding System**
| **Step** | **AI Capabilities** | **User Impact** |
|----------|-------------------|-----------------|
| **📧 Email & Business Analysis** | AI analyzes your domain, industry, and business model | **Instant business insights** and personalized recommendations |
| **🎭 AI Persona Generation** | Creates detailed buyer personas with demographic and psychographic data | **Target the right audience** with precision marketing strategies |
| **🏢 Business Information** | Collects comprehensive business data for strategy personalization | **Customized content strategies** that align with your business goals |
| **🔍 Competitor Analysis** | Real-time competitor research using Exa AI and web scraping | **Stay ahead of competition** with data-driven market positioning |
| **🔗 Platform Integrations** | OAuth-secured connections to WordPress, Wix, Google Search Console | **Publish everywhere** with one-click integration and real-time analytics |
**🎯 User Benefits:**
- **15-minute setup** from zero to fully optimized digital presence
- **Professional marketing strategy** without hiring agencies
- **Automated competitor intelligence** for strategic advantage
- **One-click publishing** across all major platforms
- **Real-time performance tracking** with actionable insights
### 🔍 **Advanced SEO & Technical Optimization**
| **SEO Category** | **AI Capabilities** | **Status** |
|------------------|-------------------|------------|
| **Technical SEO** | Automated audits, schema generation, site optimization | ✅ Complete |
| **Content SEO** | Intent optimization, semantic analysis, featured snippet targeting | ✅ Complete |
| **Local SEO** | Local business optimization, GMB content generation | ✅ Complete |
| **AI Search Optimization** | Optimization for AI tools and voice search | ✅ Complete |
| **SEO Metadata** | Automated title, description, Open Graph, Twitter Cards | ✅ Complete |
| **Google Search Console** | OAuth2 integration, real-time analytics, sitemap analysis | ✅ Complete |
### 🔗 **Platform Integrations & Publishing**
| **Integration** | **Features** | **User Impact** |
|-----------------|-------------|-----------------|
| **WordPress OAuth** | Direct publishing, media management, category/tag sync | **One-click publishing** to WordPress sites with full content optimization |
| **Wix Integration** | Blog post creation, media upload, SEO optimization | **Seamless Wix publishing** with automatic SEO metadata generation |
| **Google Search Console** | Real-time analytics, search performance, keyword tracking | **Data-driven optimization** with actual search performance insights |
| **LinkedIn Publishing** | Direct post creation, article publishing, engagement tracking | **Professional content** published directly to LinkedIn with analytics |
| **Facebook Integration** | Post scheduling, media upload, audience targeting | **Social media automation** with platform-specific optimization |
**🎯 Integration Benefits:**
- **OAuth security** - No password sharing, enterprise-grade authentication
- **Real-time analytics** - Performance tracking across all platforms
- **Automated SEO** - Every published piece optimized for search engines
- **Content synchronization** - Consistent branding and messaging across platforms
- **Performance insights** - Data-driven content optimization recommendations
### 🖼️ **AI Image Generation**
| **Feature** | **Capabilities** | **Status** |
|-------------|------------------|------------|
| **Text-to-Image** | Gemini API integration with Imagen fallback | ✅ Complete |
| **Content-Aware Generation** | AI-powered prompt generation based on content | ✅ Complete |
| **Platform Optimization** | LinkedIn-specific image generation | ✅ Complete |
| **Quality Control** | AI-powered image quality assessment | ✅ Complete |
### 📊 **Performance Analytics & Optimization**
| **Analytics Feature** | **AI Capabilities** | **Status** |
|---------------------|-------------------|------------|
| **Real-time Analytics** | Content performance tracking and insights | ✅ Complete |
| **Quality Scoring** | AI-powered content quality assessment | ✅ Complete |
| **Performance Prediction** | Content success forecasting | ✅ Complete |
| **Automated Optimization** | Continuous strategy refinement | ✅ Complete |
| **Usage Tracking** | Comprehensive API usage and billing analytics | ✅ Complete |
### 🚀 **Coming Soon Features**
| **Feature** | **Status** | **Expected Impact** |
|-------------|------------|-------------------|
| **Social Media OAuth** | 🔄 Awaiting Platform Approval | **Automated LinkedIn & Facebook posting** with advanced scheduling |
| **Instagram Integration** | 📅 Planned | **Story creation, hashtag optimization, and visual content** |
| **Advanced WordPress Features** | 🔄 In Development | **Media library management, advanced SEO tools, auto-publishing** |
| **Mobile Application** | 📅 Q2 2025 | **Content creation and management on-the-go** |
| **AI Agent Marketplace** | 📅 Q3 2025 | **Specialized AI agents for specific marketing tasks** |
| **Enterprise White-Label** | 📅 Q3 2025 | **Customizable platform for agencies and enterprises** |
**🎯 Future Benefits:**
- **Complete social media automation** across all major platforms
- **Mobile-first content creation** for busy entrepreneurs
- **AI agent ecosystem** for specialized marketing tasks
- **Enterprise-grade customization** for agencies and large teams
### 🛡️ **Enterprise Features**
| **Feature** | **Capabilities** | **Status** |
|-------------|------------------|------------|
| **Authentication** | JWT-based multi-tenant system with Clerk integration | ✅ Complete |
| **Rate Limiting** | Intelligent rate limiting with streaming exemptions | ✅ Complete |
| **Monitoring** | Comprehensive logging and performance monitoring | ✅ Complete |
| **Subscription System** | Complete billing and usage tracking | ✅ Complete |
| **Hallucination Detection** | AI-powered fact-checking and verification | ✅ Complete |
| **Persona Management** | Advanced writing persona generation | ✅ Complete |
| Area | Technologies |
| --- | --- |
| Backend | FastAPI, Python 3.10+, SQLAlchemy |
| Frontend | React 18+, TypeScript, MaterialUI, CopilotKit |
| AI/Research | OpenAI, Gemini/Imagen, Hugging Face, Anthropic, Mistral; Exa, Tavily, Serper (auto provider selection: Gemini default, HF fallback) |
| Data | SQLite (PostgreSQLready) |
| Integrations | Google Search Console, LinkedIn |
| Ops | Loguru monitoring, rate limiting, JWT/OAuth2 |
---
## 🛠️ Technology Stack (Current Implementation)
### LLM Providers: Gemini & Hugging Face
- **Autoselection**: The backend autoselects the provider based on `GPT_PROVIDER` and available keys.
- Default: Gemini (if `GEMINI_API_KEY` present)
- Fallback: Hugging Face (if `HF_TOKEN` present)
- **Configure**:
- `GEMINI_API_KEY=...` (text + structured JSON; image via Imagen)
- `HF_TOKEN=...` (text via Inference API; image via supported HF models)
- Optional: `GPT_PROVIDER=gemini` or `GPT_PROVIDER=hf_response_api`
- **Text generation**:
- Gemini: optimized for structured outputs and fast general generation
- HF: broad model access via the Inference Providers
- **Image generation**:
- Gemini/Imagen and Hugging Face providers are supported with a unified interface
For module details, see `backend/services/llm_providers/README.md`.
---
### Documentation
- Docs Site (MkDocs): https://ajaysi.github.io/ALwrity/
- Blog Writer (phases and UI): `docs-site/docs/features/blog-writer/overview.md`
- SEO Dashboard overview: `docs-site/docs/features/seo-dashboard/overview.md`
- SEO Dashboard GSC integration: `docs-site/docs/features/seo-dashboard/gsc-integration.md`
- LinkedIn factual, Google-grounded content: `frontend/docs/linkedin_factual_google_grounded_url_content.md`
- Persona Development (docs-site): `docs-site/docs/features/content-strategy/personas.md`
For additional pages, browse the `docs-site/docs/` folder.
---
### Personas (Brief)
ALwrity generates a core writing persona from onboarding data, then adapts it per platform (e.g., Facebook, LinkedIn). Personas guide tone, structure, and content preferences across tools.
- Core Persona & API: `backend/api/persona.py`
- Facebook Persona Service (Gemini structured JSON): `backend/services/persona/facebook/facebook_persona_service.py`
- Personalization/Brand Voice logic: `backend/services/component_logic/personalization_logic.py`
- Docs (GitHub paths):
- Personas (docs-site): https://github.com/AJaySi/AI-Writer/blob/main/docs-site/docs/features/content-strategy/personas.md
- LinkedIn Grounded Content plan: https://github.com/AJaySi/AI-Writer/blob/main/frontend/docs/linkedin_factual_google_grounded_url_content.md
At a glance:
- Data → Persona: Onboarding + website analysis → core persona
- Platform adaptations: Platform-specific JSON with validations/optimizations
- Usage: Informs tone, content length, structure, and platform best practices
---
### Community
- **Docs & Wiki**: https://github.com/AJaySi/AI-Writer/wiki
- **Discussions**: https://github.com/AJaySi/AI-Writer/discussions
- **Issues**: https://github.com/AJaySi/AI-Writer/issues
- **Website**: https://www.alwrity.com
---
### License
MIT — see [LICENSE](../LICENSE).
<div align="center">
| **Category** | **Technologies** |
|--------------|------------------|
| **Backend Framework** | FastAPI, Python 3.10+ |
| **Frontend Framework** | React 18+, TypeScript |
| **Database** | SQLite (PostgreSQL ready), SQLAlchemy ORM |
| **AI Models** | OpenAI GPT-4, Google Gemini, Anthropic Claude, Mistral |
| **Web Research** | Tavily AI, Exa AI, Serper.dev |
| **Image Generation** | Google Gemini, Imagen API |
| **Authentication** | JWT, OAuth2, Clerk, Multi-tenant architecture |
| **UI Framework** | Material-UI, CopilotKit |
| **SEO Tools** | Google Search Console API, Custom SEO analyzers |
| **Monitoring** | Loguru, Custom performance tracking |
Made with ❤️ by the ALwrity team
</div>
---
## 🎯 Target Audience & Use Cases
### 🏢 **For Solopreneurs & Small Businesses**
- **Content Strategy**: Professional marketing strategies without hiring agencies
- **Time Savings**: Automate repetitive tasks and focus on core business
- **Cost Efficiency**: Access enterprise-level tools at affordable prices
- **Scalability**: Grow your digital presence as your business expands
- **Blog Creation**: Complete AI-powered blog writing from research to publication
### 📈 **For Digital Marketers**
- **Client Management**: Manage multiple client strategies efficiently
- **Data-Driven Decisions**: AI-powered insights for better campaign performance
- **Content Creation**: Generate high-quality content at scale
- **Performance Optimization**: Continuous improvement through AI analytics
- **SEO Optimization**: Comprehensive SEO analysis and metadata generation
### 🎨 **For Content Creators**
- **Multi-Platform Content**: Create content optimized for different platforms
- **Audience Growth**: AI-driven strategies for building engaged audiences
- **Monetization**: Optimize content for maximum revenue potential
- **Trend Analysis**: Stay ahead with AI-powered trend prediction
- **Fact-Checking**: AI-powered content verification and quality assurance
---
## 🎯 **Start Creating Content Now**
### **Complete Onboarding Process**
1. **📧 Email Setup** - Enter your business email for AI analysis
2. **🎭 Persona Generation** - AI creates detailed buyer personas
3. **🏢 Business Info** - Provide comprehensive business details
4. **🔍 Competitor Analysis** - AI researches your competition
5. **🔗 Platform Integration** - Connect WordPress, Wix, GSC
### **Immediate Content Creation**
-**LinkedIn Posts** with fact-checking and Google grounding
-**Blog Writing** with research and SEO optimization
-**Facebook Content** with platform-specific optimization
-**SEO Analysis** with metadata generation
-**Image Generation** with AI-powered visuals
### **Publishing & Analytics**
- **One-click publishing** to WordPress and Wix
- **Real-time analytics** from Google Search Console
- **Performance tracking** across all platforms
- **Data-driven optimization** recommendations
---
## 🌟 What Makes ALwrity Special?
### 🧠 **AI-First Design**
Unlike traditional tools, ALwrity uses AI to generate complete marketing strategies, not just individual pieces of content. This ensures every piece of content serves your overall business goals.
### 🎯 **Solopreneur-Focused**
Built specifically for independent entrepreneurs who need enterprise-level marketing capabilities without the enterprise price tag or complexity.
### 📊 **Data-Driven Intelligence**
Combines web research, competitor analysis, and predictive analytics to create strategies that actually work in the real world.
### 🔄 **Continuous Optimization**
ALwrity learns from your performance and continuously optimizes your strategy, ensuring long-term success and growth.
### 🌍 **Multi-Modal Capabilities**
Create text, images, audio, and video content from a single platform, maximizing your content's reach and impact.
### 🛡️ **Enterprise-Grade Security**
Built with enterprise-level security, monitoring, and scalability in mind, ensuring your data and content are always protected.
---
## 🗺️ Development Roadmap 2025
### **Q1 2025 (Current)**
- ✅ FastAPI backend architecture - COMPLETE
- ✅ AI content strategy generation - COMPLETE
- ✅ Multi-tenant authentication system - COMPLETE
- ✅ LinkedIn & Facebook content generation - COMPLETE
- ✅ Blog Writer with research and SEO - COMPLETE
- ✅ SEO analysis tools and metadata generation - COMPLETE
- ✅ AI image generation - COMPLETE
- ✅ Google Search Console integration - COMPLETE
- ✅ Hallucination detection and fact-checking - COMPLETE
- ✅ Subscription and billing system - COMPLETE
- ✅ React frontend development - COMPLETE
- ✅ End-to-end integration testing - COMPLETE
### **Q2 2025 (Launch)**
- 📅 Advanced integrations and API ecosystem
- 📅 Performance optimization and scaling
- 📅 User experience enhancements
- 📅 Mobile application development
- 📅 Advanced analytics and reporting
### **Q3 2025 (Expansion)**
- 📅 AI agent marketplace
- 📅 Advanced integrations ecosystem
- 📅 Enterprise features and white-label solutions
- 📅 Multi-language support
- 📅 Advanced workflow automation
---
## 🤝 Contributing
We welcome contributions from the community! Here's how you can help:
### 🐛 **Report Issues**
Found a bug? [Create an issue](https://github.com/AJaySi/AI-Writer/issues) with detailed information.
### 💡 **Suggest Features**
Have an idea? [Start a discussion](https://github.com/AJaySi/AI-Writer/discussions) to share your thoughts.
### 🔧 **Contribute Code**
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Submit a pull request
### 📖 **Improve Documentation**
Help us improve our documentation, tutorials, and guides.
**📚 Contributing Guide**: [CONTRIBUTING.md](CONTRIBUTING.md)
---
## 🏆 Community & Support
<div align="center">
[![GitHub Discussions](https://img.shields.io/badge/GitHub-Discussions-green?logo=github)](https://github.com/AJaySi/AI-Writer/discussions)
[![Discord](https://img.shields.io/badge/Discord-Community-blue?logo=discord)](https://discord.gg/alwrity)
[![Twitter](https://img.shields.io/badge/Twitter-Follow-blue?logo=twitter)](https://twitter.com/alwrity)
</div>
### 💬 **Get Help**
- 📖 [Documentation](https://github.com/AJaySi/AI-Writer/wiki)
- 💬 [Community Discussions](https://github.com/AJaySi/AI-Writer/discussions)
- 🐛 [Issue Tracker](https://github.com/AJaySi/AI-Writer/issues)
- 📧 [Email Support](mailto:support@alwrity.com)
### 🌟 **Stay Updated**
-**Star this repository** to show your support
- 👀 Watch for updates
- 🔔 Follow our [blog](https://www.alwrity.com/blog)
---
## 📄 License & Credits
### 📜 **License**
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
### 🙏 **Acknowledgments**
ALwrity stands on the shoulders of giants. Special thanks to:
**🤖 AI Providers**
- [OpenAI](https://openai.com/) - GPT models and DALL-E
- [Google](https://ai.google/) - Gemini AI and Imagen
- [Anthropic](https://anthropic.com/) - Claude AI models
- [Mistral AI](https://mistral.ai/) - Advanced language models
**🔍 Research & Data**
- [Tavily AI](https://tavily.com/) - AI-powered web search
- [Exa AI](https://exa.ai/) - Semantic search capabilities
- [Serper.dev](https://serper.dev/) - Search engine results
**🛠️ Development Tools**
- [FastAPI](https://fastapi.tiangolo.com/) - Modern web framework
- [React](https://reactjs.org/) - Frontend framework
- [Material-UI](https://mui.com/) - UI component library
- [CopilotKit](https://copilotkit.ai/) - AI-powered UI components
- [Clerk](https://clerk.com/) - Authentication and user management
---
## 📊 Project Stats
<div align="center">
![GitHub Stats](https://github-readme-stats.vercel.app/api?username=AJaySi&repo=AI-Writer&show_icons=true&theme=radical)
[![GitHub Activity Graph](https://github-readme-activity-graph.vercel.app/graph?username=AJaySi&repo=AI-Writer&theme=react-dark)](https://github.com/AJaySi/AI-Writer/graphs/contributors)
</div>
---
<div align="center">
## 🚀 Ready to Transform Your Digital Marketing?
**⭐ Star this repository to show your support!**
**🎯 Current Status: Full Platform Operational with All Core Features**
**[🌐 Visit Website](https://www.alwrity.com)** • **[📖 Read Documentation](https://github.com/AJaySi/AI-Writer/wiki)** • **[💬 Join Community](https://github.com/AJaySi/AI-Writer/discussions)**
---
**Made with ❤️ by the ALwrity Team**
[Website](https://www.alwrity.com) • [Blog](https://www.alwrity.com/blog) • [Twitter](https://twitter.com/alwrity) • [LinkedIn](https://linkedin.com/company/alwrity)
</div>
---
# ALwrity Community Health Files
This directory contains community health files that help maintain a welcoming and productive environment for contributors to ALwrity.
## 📁 Files Overview
### Core Community Files
- **`CONTRIBUTING.md`** - Guidelines for contributing to ALwrity
- **`CODE_OF_CONDUCT.md`** - Community standards and behavior expectations
- **`SECURITY.md`** - Security policy and vulnerability reporting
- **`SUPPORT.md`** - Help resources and troubleshooting guides
- **`FUNDING.yml`** - Funding and sponsorship information
### Issue Templates (`ISSUE_TEMPLATE/`)
- **`bug_report.md`** - Template for reporting bugs
- **`feature_request.md`** - Template for requesting new features
- **`question.yml`** - Form for asking questions
- **`config.yml`** - Issue template configuration
### Pull Request Template
- **`pull_request_template.md`** - Template for pull requests
## 🎯 Purpose
These files help:
- **Improve Project Visibility** - Better GitHub community profile score
- **Enhance Contributor Experience** - Clear guidelines and expectations
- **Streamline Issue Management** - Structured templates for better organization
- **Maintain Quality** - Consistent PR reviews and code standards
- **Build Community** - Welcoming environment for all contributors
## 📊 Community Profile Status
With these files, ALwrity should achieve:
-**README** - Comprehensive project documentation
-**Contributing** - Clear contribution guidelines
-**Code of Conduct** - Community standards
-**License** - MIT License (in root directory)
-**Issue Templates** - Structured issue reporting
-**Pull Request Template** - Consistent PR format
-**Security Policy** - Vulnerability reporting process
-**Support** - Help resources and documentation
## 🔄 Maintenance
These files should be updated as the project evolves:
- Review and update contribution guidelines quarterly
- Update security policy when new features are added
- Refresh issue templates based on common questions
- Update support resources as new features are released
## 📞 Questions?
If you have questions about these community health files:
- Open an [issue](https://github.com/AJaySi/ALwrity/issues)
- Start a [discussion](https://github.com/AJaySi/ALwrity/discussions)
- Check the [main README](../README.md) for project overview
---
**Thank you for contributing to ALwrity!** 🚀

View File

@@ -696,15 +696,15 @@ async def generate_scene_audio(
audio_filename = result.get("audio_filename") or ""
audio_models.append(
StoryAudioResult(
scene_number=result.get("scene_number", 0),
scene_title=result.get("scene_title", "Untitled"),
StoryAudioResult(
scene_number=result.get("scene_number", 0),
scene_title=result.get("scene_title", "Untitled"),
audio_filename=audio_filename,
audio_url=audio_url,
provider=result.get("provider", "unknown"),
file_size=result.get("file_size", 0),
error=result.get("error")
)
provider=result.get("provider", "unknown"),
file_size=result.get("file_size", 0),
error=result.get("error")
)
)
return StoryAudioGenerationResponse(

View File

@@ -438,6 +438,45 @@ def llm_text_gen(prompt: str, system_prompt: Optional[str] = None, json_struct:
current_tokens_before = 0
new_tokens = 0
# Determine tracked tokens (after any safety capping)
tracked_tokens_input = min(tokens_input, tokens_total)
tracked_tokens_output = max(tokens_total - tracked_tokens_input, 0)
# Calculate and persist cost for this call
try:
cost_info = pricing.calculate_api_cost(
provider=provider_enum,
model_name=model,
tokens_input=tracked_tokens_input,
tokens_output=tracked_tokens_output,
request_count=1
)
cost_total = cost_info.get('cost_total', 0.0) or 0.0
except Exception as cost_error:
cost_total = 0.0
logger.error(f"[llm_text_gen] ❌ Failed to calculate API cost: {cost_error}", exc_info=True)
if cost_total > 0:
logger.debug(f"[llm_text_gen] 💰 Calculated cost for {provider_name}: ${cost_total:.6f}")
update_costs_query = text(f"""
UPDATE usage_summaries
SET {provider_name}_cost = COALESCE({provider_name}_cost, 0) + :cost,
total_cost = COALESCE(total_cost, 0) + :cost
WHERE user_id = :user_id AND billing_period = :period
""")
db_track.execute(update_costs_query, {
'cost': cost_total,
'user_id': user_id,
'period': current_period
})
# Keep ORM object in sync for logging/debugging
current_provider_cost = getattr(summary, f"{provider_name}_cost", 0.0) or 0.0
setattr(summary, f"{provider_name}_cost", current_provider_cost + cost_total)
summary.total_cost = (summary.total_cost or 0.0) + cost_total
else:
logger.debug(f"[llm_text_gen] 💰 Cost calculation returned $0 for {provider_name} (tokens_input={tracked_tokens_input}, tokens_output={tracked_tokens_output})")
# Update totals using SQL UPDATE
old_total_calls = summary.total_calls or 0
old_total_tokens = summary.total_tokens or 0
@@ -717,6 +756,39 @@ def llm_text_gen(prompt: str, system_prompt: Optional[str] = None, json_struct:
current_tokens_before = 0
new_tokens = 0
# Determine tracked tokens after any safety capping
tracked_tokens_input = min(tokens_input, tokens_total)
tracked_tokens_output = max(tokens_total - tracked_tokens_input, 0)
# Calculate and persist cost for this fallback call
cost_total = 0.0
try:
cost_info = pricing.calculate_api_cost(
provider=provider_enum,
model_name=fallback_model,
tokens_input=tracked_tokens_input,
tokens_output=tracked_tokens_output,
request_count=1
)
cost_total = cost_info.get('cost_total', 0.0) or 0.0
except Exception as cost_error:
logger.error(f"[llm_text_gen] ❌ Failed to calculate fallback cost: {cost_error}", exc_info=True)
if cost_total > 0:
update_costs_query = text(f"""
UPDATE usage_summaries
SET {provider_name}_cost = COALESCE({provider_name}_cost, 0) + :cost,
total_cost = COALESCE(total_cost, 0) + :cost
WHERE user_id = :user_id AND billing_period = :period
""")
db_track.execute(update_costs_query, {
'cost': cost_total,
'user_id': user_id,
'period': current_period
})
setattr(summary, f"{provider_name}_cost", (getattr(summary, f"{provider_name}_cost", 0.0) or 0.0) + cost_total)
summary.total_cost = (summary.total_cost or 0.0) + cost_total
# Update totals (using potentially capped tokens_total from safety check)
summary.total_calls = (summary.total_calls or 0) + 1
summary.total_tokens = (summary.total_tokens or 0) + tokens_total

View File

@@ -72,11 +72,11 @@ class StoryAudioGenerationService:
logger.info(f"[StoryAudioGeneration] Generated audio using gTTS: {output_path}")
return True
except ImportError:
logger.error("[StoryAudioGeneration] gTTS not installed. Install with: pip install gtts")
except ImportError as e:
logger.error(f"[StoryAudioGeneration] gTTS not installed. ImportError: {e}. Install with: pip install gtts")
return False
except Exception as e:
logger.error(f"[StoryAudioGeneration] Error generating audio with gTTS: {e}")
logger.error(f"[StoryAudioGeneration] Error generating audio with gTTS: {type(e).__name__}: {e}")
return False
def _generate_audio_pyttsx3(

View File

@@ -72,8 +72,40 @@ class StoryVideoGenerationService:
# Import MoviePy
try:
from moviepy.editor import ImageClip, AudioFileClip, concatenate_videoclips, CompositeVideoClip
except ImportError:
# MoviePy v2.x exposes classes at top-level (moviepy.ImageClip, etc)
from moviepy import ImageClip, AudioFileClip, concatenate_videoclips
except Exception as _imp_err:
# Detailed diagnostics to help users fix environment issues
try:
import sys as _sys
import platform as _platform
import importlib
mv = None
imv = None
ff_path = "unresolved"
try:
mv = importlib.import_module("moviepy")
except Exception:
pass
try:
imv = importlib.import_module("imageio")
except Exception:
pass
try:
import imageio_ffmpeg as _iff
ff_path = _iff.get_ffmpeg_exe()
except Exception:
pass
logger.error(
"[StoryVideoGeneration] MoviePy import failed. "
f"py={_sys.executable} plat={_platform.platform()} "
f"moviepy_ver={getattr(mv,'__version__', 'NA')} "
f"imageio_ver={getattr(imv,'__version__', 'NA')} "
f"ffmpeg_path={ff_path} err={_imp_err}"
)
except Exception:
# best-effort diagnostics
pass
logger.error("[StoryVideoGeneration] MoviePy not installed. Install with: pip install moviepy imageio imageio-ffmpeg")
raise RuntimeError("MoviePy is not installed. Please install it to generate videos.")
@@ -182,8 +214,38 @@ class StoryVideoGenerationService:
# Import MoviePy
try:
from moviepy.editor import ImageClip, AudioFileClip, concatenate_videoclips, CompositeVideoClip
except ImportError:
from moviepy import ImageClip, AudioFileClip, concatenate_videoclips
except Exception as _imp_err:
# Detailed diagnostics to help users fix environment issues
try:
import sys as _sys
import platform as _platform
import importlib
mv = None
imv = None
ff_path = "unresolved"
try:
mv = importlib.import_module("moviepy")
except Exception:
pass
try:
imv = importlib.import_module("imageio")
except Exception:
pass
try:
import imageio_ffmpeg as _iff
ff_path = _iff.get_ffmpeg_exe()
except Exception:
pass
logger.error(
"[StoryVideoGeneration] MoviePy import failed. "
f"py={_sys.executable} plat={_platform.platform()} "
f"moviepy_ver={getattr(mv,'__version__', 'NA')} "
f"imageio_ver={getattr(imv,'__version__', 'NA')} "
f"ffmpeg_path={ff_path} err={_imp_err}"
)
except Exception:
pass
logger.error("[StoryVideoGeneration] MoviePy not installed. Install with: pip install moviepy imageio imageio-ffmpeg")
raise RuntimeError("MoviePy is not installed. Please install it to generate videos.")

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

View File

@@ -2,6 +2,13 @@
ALwrity's Assistive Writing feature revolutionizes content creation by providing AI-powered writing assistance that helps you create high-quality, engaging content with minimal effort. This intelligent writing companion understands context, maintains consistency, and adapts to your unique writing style.
## Visuals
<p align="center">
<img src="../../assets/assistive-1.png" alt="Assistive writing selection tools" width="45%">
<img src="../../assets/assistive-2.png" alt="Inline fact checking and quick edits" width="45%">
</p>
## What is Assistive Writing?
Assistive Writing is an AI-powered feature that provides real-time writing assistance, suggestions, and enhancements to help you create compelling content. It combines advanced natural language processing with contextual understanding to offer intelligent recommendations that improve your writing quality and efficiency.

View File

@@ -2,6 +2,12 @@
ALwrity's Grounding UI feature provides AI-powered content verification and fact-checking capabilities, ensuring your content is accurate, reliable, and trustworthy. This advanced feature helps maintain content credibility by grounding AI-generated content in verified information sources.
## Visuals
<p align="center">
<img src="../../assets/assistive-2.png" alt="Inline fact checking with citations and claim statuses" width="60%">
</p>
## What is Grounding UI?
Grounding UI is an intelligent content verification system that connects AI-generated content with real-world data sources, ensuring accuracy and reliability. It provides visual indicators, source citations, and verification status to help you create trustworthy, fact-checked content.

View File

@@ -63,6 +63,25 @@ export interface SchedulerDashboardData {
last_updated: string;
}
export interface TaskFailurePattern {
consecutive_failures: number;
recent_failures: number;
failure_reason: string;
last_failure_time: string | null;
error_patterns: string[];
}
export interface TaskNeedingIntervention {
task_id: number;
task_type: string;
user_id: string;
platform?: string;
website_url?: string;
failure_pattern: TaskFailurePattern;
failure_reason: string | null;
last_failure: string | null;
}
export interface TaskInfo {
id: number;
task_title: string;
@@ -258,3 +277,29 @@ export const getRecentSchedulerLogs = async (): Promise<ExecutionLogsResponse> =
}
};
/**
* Get tasks that require manual intervention for a user.
*/
export const getTasksNeedingIntervention = async (userId: string): Promise<TaskNeedingIntervention[]> => {
try {
const response = await apiClient.get<{
success: boolean;
tasks: TaskNeedingIntervention[];
count: number;
}>(`/api/scheduler/tasks-needing-intervention/${userId}`);
if (!response.data.success) {
throw new Error('Failed to fetch tasks needing intervention');
}
return response.data.tasks || [];
} catch (error: any) {
console.error('Error fetching tasks needing intervention:', error);
throw new Error(
error.response?.data?.detail ||
error.message ||
'Failed to fetch tasks needing intervention'
);
}
};

View File

@@ -13,7 +13,6 @@ import { SubscriptionGuard } from '../SubscriptionGuard';
// Shared components
import DashboardHeader from '../shared/DashboardHeader';
import SystemStatusIndicator from '../ContentPlanningDashboard/components/SystemStatusIndicator';
import LoadingSkeleton from '../shared/LoadingSkeleton';
import ErrorDisplay from '../shared/ErrorDisplay';
import ContentLifecyclePillars from './ContentLifecyclePillars';
@@ -285,7 +284,6 @@ const MainDashboard: React.FC = () => {
title="Alwrity Content Hub"
subtitle=""
statusChips={[]}
rightContent={<SystemStatusIndicator />}
customIcon={AskAlwrityIcon}
workflowControls={{
onStartWorkflow: handleStartWorkflow,

View File

@@ -27,6 +27,7 @@ import {
import { styled } from '@mui/material/styles';
import { apiClient } from '../../api/client';
import { TerminalTypography, terminalColors } from './terminalTheme';
import { getTasksNeedingIntervention, TaskNeedingIntervention } from '../../api/schedulerDashboard';
const InterventionContainer = styled(Box)({
backgroundColor: 'rgba(26, 26, 26, 0.8)',
@@ -76,23 +77,6 @@ const StatusChip = styled(Chip)(({ severity }: { severity: 'error' | 'warning' }
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;
}
@@ -106,15 +90,8 @@ const TasksNeedingIntervention: React.FC<TasksNeedingInterventionProps> = ({ use
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 || []);
}
const fetchedTasks = await getTasksNeedingIntervention(userId);
setTasks(fetchedTasks || []);
} catch (error) {
console.error('Error fetching tasks needing intervention:', error);
} finally {

View File

@@ -27,40 +27,64 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
};
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)',
}}
>
<Box sx={{ position: 'relative' }}>
{onReset && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Box sx={{ position: 'absolute', top: -8, right: -8, zIndex: 10 }}>
<Tooltip title="Restart Story (Clear all data and start from beginning)">
<IconButton
onClick={handleReset}
sx={{
color: '#5D4037',
color: 'rgba(255, 255, 255, 0.9)',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
'&:hover': {
backgroundColor: '#E8E5D3',
color: '#1A1611',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
},
}}
size="small"
>
<RefreshIcon />
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
<Stepper activeStep={activeStep} alternativeLabel>
<Stepper
activeStep={activeStep}
alternativeLabel
sx={{
backgroundColor: 'transparent',
'& .MuiStepLabel-label': {
color: 'rgba(255, 255, 255, 0.9)',
'&.Mui-active': {
color: 'white',
},
'&.Mui-completed': {
color: 'rgba(255, 255, 255, 0.7)',
},
'&.Mui-disabled': {
color: 'rgba(255, 255, 255, 0.4)',
},
},
'& .MuiStepLabel-iconContainer': {
'& .MuiSvgIcon-root': {
color: 'rgba(255, 255, 255, 0.3)',
'&.Mui-active': {
color: 'rgba(255, 255, 255, 0.6)',
},
'&.Mui-completed': {
color: 'rgba(255, 255, 255, 0.5)',
},
},
},
}}
>
{phases.map((phase) => (
<Step key={phase.id} completed={phase.completed} disabled={phase.disabled}>
<StepButton
onClick={() => !phase.disabled && onPhaseClick(phase.id)}
disabled={phase.disabled}
sx={{
padding: '8px 4px',
'& .MuiStepLabel-root': {
cursor: phase.disabled ? 'not-allowed' : 'pointer',
},
@@ -70,22 +94,33 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
StepIconComponent={() => (
<Box
sx={{
width: 40,
height: 40,
width: 32,
height: 32,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: phase.current
? 'primary.main'
? 'rgba(255, 255, 255, 0.9)'
: phase.completed
? 'success.main'
? 'rgba(76, 175, 80, 0.9)'
: phase.disabled
? 'grey.300'
: 'grey.200',
color: phase.current || phase.completed ? 'white' : 'text.secondary',
fontSize: '1.2rem',
? 'rgba(255, 255, 255, 0.2)'
: 'rgba(255, 255, 255, 0.3)',
color: phase.current
? '#667eea'
: phase.completed
? 'white'
: 'rgba(255, 255, 255, 0.7)',
fontSize: '1rem',
fontWeight: phase.current ? 600 : 400,
transition: 'all 0.2s ease',
'&:hover': !phase.disabled ? {
backgroundColor: phase.current
? 'rgba(255, 255, 255, 1)'
: 'rgba(255, 255, 255, 0.4)',
transform: 'scale(1.05)',
} : {},
}}
>
{phase.icon}
@@ -93,29 +128,26 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
)}
>
<Typography
variant="body2"
variant="caption"
sx={{
fontWeight: phase.current ? 600 : 400,
color: phase.disabled ? '#9E9E9E' : '#2C2416', // Dark brown text
fontSize: '0.75rem',
color: phase.disabled
? 'rgba(255, 255, 255, 0.4)'
: phase.current
? 'white'
: 'rgba(255, 255, 255, 0.8)',
mt: 0.5,
}}
>
{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>
</Box>
);
};

View File

@@ -45,6 +45,10 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
};
const handleGenerateVideo = async () => {
if (!state.enableVideoNarration) {
setError('Story video generation is disabled in Story Setup.');
return;
}
if (!state.outlineScenes || state.outlineScenes.length === 0) {
setError('Please generate a structured outline first');
return;
@@ -270,6 +274,7 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
{/* Video Generation */}
{state.isOutlineStructured && state.outlineScenes && (
state.enableVideoNarration ? (
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom sx={{ color: '#1A1611' }}>
Video Generation
@@ -338,6 +343,11 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
)}
</Box>
</Box>
) : (
<Alert severity="info" sx={{ mb: 4 }}>
Story video generation is disabled in Story Setup. Enable it to create narrated videos.
</Alert>
)
)}
<Divider sx={{ my: 3 }} />

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { Box, Button, CircularProgress, Typography } from '@mui/material';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
interface AudioControlsPanelProps {
enabled: boolean;
regenerating: boolean;
onRegenerate: () => void;
}
const AudioControlsPanel: React.FC<AudioControlsPanelProps> = ({
enabled,
regenerating,
onRegenerate,
}) => {
return (
<Box sx={{ mt: 1.5, p: 2, border: '1px dashed rgba(120,90,60,0.35)', borderRadius: 1.5, backgroundColor: 'rgba(255,255,255,0.6)' }}>
<Typography variant="caption" sx={{ color: '#7a5335', display: 'block', mb: 1 }}>
Audio controls (uses Setup settings)
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Button
size="small"
variant="outlined"
startIcon={regenerating ? <CircularProgress size={16} /> : <VolumeUpIcon />}
onClick={onRegenerate}
disabled={regenerating || !enabled}
>
{regenerating ? 'Regenerating...' : 'Regenerate Audio (Scene)'}
</Button>
</Box>
{!enabled && (
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: '#a37b55' }}>
Enable Narration in Story Setup to generate audio.
</Typography>
)}
</Box>
);
};
export default AudioControlsPanel;

View File

@@ -0,0 +1,122 @@
import React from 'react';
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material';
interface AudioScriptModalProps {
open: boolean;
sceneNumber: number;
value: string;
onChange: (v: string) => void;
onClose: () => void;
onSave: () => void;
// audio settings
audioProvider: string;
audioLang: string;
audioSlow: boolean;
audioRate: number;
onChangeProvider: (v: string) => void;
onChangeLang: (v: string) => void;
onChangeSlow: (v: boolean) => void;
onChangeRate: (v: number) => void;
audioUrl?: string | null;
}
const AudioScriptModal: React.FC<AudioScriptModalProps> = ({
open, sceneNumber, value, onChange, onClose, onSave,
audioProvider, audioLang, audioSlow, audioRate,
onChangeProvider, onChangeLang, onChangeSlow, onChangeRate,
audioUrl,
}) => {
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
backgroundColor: '#fff',
borderRadius: 2,
boxShadow: '0 24px 64px rgba(0,0,0,0.18)',
border: '1px solid rgba(0,0,0,0.06)',
},
}}
>
<DialogTitle>Edit Audio Narration Script (Scene {sceneNumber})</DialogTitle>
<DialogContent dividers sx={{ color: '#2C2416' }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
'& .MuiFormLabel-root': { color: '#6b5846' },
'& .MuiInputBase-root': { color: '#2C2416' },
}}
>
{audioUrl ? (
<Box
sx={{
p: 1,
backgroundColor: 'rgba(0,0,0,0.03)',
borderRadius: 1,
border: '1px solid rgba(0,0,0,0.06)',
}}
>
<audio controls src={audioUrl || undefined} style={{ width: '100%' }}>
Your browser does not support the audio element.
</audio>
</Box>
) : null}
<TextField
label="Audio Narration"
value={value}
onChange={(e) => onChange(e.target.value)}
multiline
minRows={6}
fullWidth
/>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
<TextField
select
label="Audio Provider"
value={audioProvider}
onChange={(e) => onChangeProvider(e.target.value)}
SelectProps={{ native: true }}
>
<option value="gtts">gTTS</option>
<option value="pyttsx3">pyttsx3</option>
</TextField>
<TextField
label="Language (e.g., en, hi)"
value={audioLang}
onChange={(e) => onChangeLang(e.target.value)}
/>
<TextField
select
label="Slow (gTTS)"
value={audioSlow ? 'true' : 'false'}
onChange={(e) => onChangeSlow(e.target.value === 'true')}
SelectProps={{ native: true }}
>
<option value="false">Normal</option>
<option value="true">Slow</option>
</TextField>
<TextField
type="number"
label="Rate (pyttsx3)"
value={audioRate}
onChange={(e) => onChangeRate(Number(e.target.value))}
inputProps={{ min: 50, max: 300, step: 10 }}
/>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={onSave}>Save</Button>
</DialogActions>
</Dialog>
);
};
export default AudioScriptModal;

View File

@@ -0,0 +1,472 @@
import React from 'react';
import { Box, Typography, Tooltip, Chip } from '@mui/material';
import { motion, AnimatePresence } from 'framer-motion';
import OutlineHoverActions from './OutlineHoverActions';
import EditNoteIcon from '@mui/icons-material/EditNote';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import TipsAndUpdatesIcon from '@mui/icons-material/TipsAndUpdates';
import { leftPageVariants, rightPageVariants } from './pageVariants';
import { storyWriterApi, StoryScene } from '../../../../services/storyWriterApi';
const MotionBox = motion(Box);
interface ImageSettings {
provider?: string | null;
width: number;
height: number;
model?: string | null;
enabled: boolean;
}
interface BookPagesProps {
currentScene: StoryScene | null;
currentSceneIndex: number;
scenesLength: number;
canGoPrev: boolean;
canGoNext: boolean;
pageDirection: number;
onPrev: () => void;
onNext: () => void;
imageUrl: string | null;
onImageError: () => void;
narrationEnabled: boolean;
audioUrl: string | null;
onOpenImageModal: () => void;
onOpenAudioModal: () => void;
onOpenCharactersModal: () => void;
onOpenKeyEventsModal: () => void;
onOpenTitleModal: () => void;
onOpenEditModal: () => void;
}
const BookPages: React.FC<BookPagesProps> = ({
currentScene,
currentSceneIndex,
scenesLength,
canGoPrev,
canGoNext,
pageDirection,
onPrev,
onNext,
imageUrl,
onImageError,
narrationEnabled,
onOpenImageModal,
onOpenAudioModal,
audioUrl,
onOpenCharactersModal,
onOpenKeyEventsModal,
onOpenTitleModal,
onOpenEditModal,
}) => {
const currentSceneNumber = currentScene?.scene_number || currentSceneIndex + 1;
return (
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'center' }}>
<Box
className="tw-shadow-book tw-rounded-book"
sx={{
position: 'relative',
width: { xs: '100%', lg: '90vw' },
maxWidth: '1800px',
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}>
<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={onPrev}
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 {currentSceneNumber} of {scenesLength}
</Typography>
<Box sx={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 1, '&:hover .title-edit': { opacity: 1, pointerEvents: 'auto' } }}>
<Typography
variant="h4"
sx={{
mt: 1,
color: '#2C2416',
fontFamily: `'Playfair Display', serif`,
fontWeight: 600,
lineHeight: 1.2,
pr: 2,
}}
>
{currentScene?.title}
</Typography>
<Box
className="title-edit"
role="button"
aria-label="Edit title"
onClick={(e) => { e.stopPropagation(); onOpenTitleModal(); }}
sx={{
width: 32,
height: 32,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #7F5AF0 0%, #2CB67D 100%)',
color: '#fff',
boxShadow: '0 6px 12px rgba(127,90,240,0.25)',
cursor: 'pointer',
opacity: 0,
pointerEvents: 'none',
}}
>
<EditNoteIcon fontSize="small" />
</Box>
</Box>
</Box>
<Box
sx={{
flex: '1 1 auto',
overflowY: 'auto',
mt: 3,
display: 'grid',
gridTemplateRows: imageUrl ? 'auto 1fr auto auto' : 'auto auto auto 1fr',
alignContent: 'start',
gap: 3,
}}
>
<Box sx={{ position: 'relative', '&:hover .left-image-actions': { opacity: 1, pointerEvents: 'auto' } }}>
{imageUrl ? (
<>
{/* Removed 'Scene Illustration' heading for cleaner look */}
<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={imageUrl}
alt={currentScene?.title || `Scene ${currentSceneNumber} illustration`}
sx={{
width: '100%',
height: 'auto',
display: 'block',
objectFit: 'contain',
minHeight: '300px',
maxHeight: '500px',
}}
onError={onImageError}
/>
<Box
className="left-image-actions"
sx={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
gap: 1,
opacity: 0,
pointerEvents: 'none',
transition: 'opacity 0.2s ease',
zIndex: 5,
}}
>
<Tooltip title="Edit scene image prompt">
<Box
role="button"
aria-label="Edit scene image"
onClick={(e) => { e.stopPropagation(); onOpenImageModal(); }}
sx={{
width: 40,
height: 40,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #7F5AF0 0%, #2CB67D 100%)',
boxShadow: '0 8px 16px rgba(127,90,240,0.3)',
color: 'white',
cursor: 'pointer',
}}
>
<EditNoteIcon />
</Box>
</Tooltip>
</Box>
</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 sx={{ mt: 1 }}>
<Tooltip title="Edit scene image prompt">
<Box
role="button"
aria-label="Edit scene image"
onClick={(e) => { e.stopPropagation(); onOpenImageModal(); }}
sx={{
width: 40,
height: 40,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #7F5AF0 0%, #2CB67D 100%)',
boxShadow: '0 8px 16px rgba(127,90,240,0.3)',
color: 'white',
cursor: 'pointer',
}}
>
<EditNoteIcon />
</Box>
</Tooltip>
</Box>
</>
)}
</Box>
{/* Audio chip moved to right page */}
{/* Characters */}
{currentScene?.character_descriptions && currentScene.character_descriptions.length > 0 && (
<></>
)}
{/* Key Events */}
{currentScene?.key_events && currentScene.key_events.length > 0 && (<></>)}
</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
role="button"
aria-label="Next scene"
onClick={onNext}
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',
position: 'relative',
'&:hover .outline-actions': { opacity: 1, pointerEvents: 'auto' },
'&:hover .chip-actions': { opacity: 1, pointerEvents: 'auto' },
}}
>
<OutlineHoverActions onEdit={onOpenEditModal} onImprove={onOpenEditModal} />
<Box sx={{ flex: 1, overflowY: 'auto', pt: { xs: 1, md: 2 } }}>
<Box className="chip-actions" sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 1.5, opacity: 0, pointerEvents: 'none', transition: 'opacity 0.2s ease' }}>
<Chip
size="medium"
variant="filled"
icon={<TipsAndUpdatesIcon sx={{ color: '#2CB67D !important' }} />}
label={audioUrl ? 'Audio' : 'Audio'}
onClick={(e) => { e.stopPropagation(); onOpenAudioModal(); }}
sx={{
px: 1.6,
py: 0.6,
fontWeight: 700,
letterSpacing: 0.2,
color: '#2C2416',
background: 'linear-gradient(135deg, #fffefc 0%, #f7efe1 100%)',
boxShadow: '0 8px 20px rgba(93,59,36,0.15)',
border: '1px solid rgba(120,90,60,0.25)',
cursor: 'pointer',
}}
/>
{currentScene?.character_descriptions && currentScene.character_descriptions.length > 0 && (
<Chip
size="medium"
variant="filled"
label={`Characters (${currentScene.character_descriptions.length})`}
onClick={(e) => { e.stopPropagation(); onOpenCharactersModal(); }}
sx={{
px: 1.6,
py: 0.6,
fontWeight: 700,
color: '#2C2416',
background: 'linear-gradient(135deg, #fffefc 0%, #f5ecd8 100%)',
boxShadow: '0 8px 20px rgba(93,59,36,0.15)',
border: '1px solid rgba(120,90,60,0.25)',
cursor: 'pointer',
}}
/>
)}
{currentScene?.key_events && currentScene.key_events.length > 0 && (
<Chip
size="medium"
variant="filled"
label={`Key events (${currentScene.key_events.length})`}
onClick={(e) => { e.stopPropagation(); onOpenKeyEventsModal(); }}
sx={{
px: 1.6,
py: 0.6,
fontWeight: 700,
color: '#2C2416',
background: 'linear-gradient(135deg, #fffefc 0%, #f5ecd8 100%)',
boxShadow: '0 8px 20px rgba(93,59,36,0.15)',
border: '1px solid rgba(120,90,60,0.25)',
cursor: 'pointer',
}}
/>
)}
</Box>
<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>
);
};
export default BookPages;

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { Box, Dialog, DialogActions, DialogContent, DialogTitle, Button, Chip, Typography } from '@mui/material';
interface CharactersModalProps {
open: boolean;
sceneNumber: number;
characters: string[];
onClose: () => void;
}
const CharactersModal: React.FC<CharactersModalProps> = ({ open, sceneNumber, characters, onClose }) => {
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
backgroundColor: '#fff',
borderRadius: 2,
boxShadow: '0 24px 64px rgba(0,0,0,0.18)',
border: '1px solid rgba(0,0,0,0.06)',
},
}}
>
<DialogTitle>Characters (Scene {sceneNumber})</DialogTitle>
<DialogContent dividers sx={{ color: '#2C2416' }}>
{characters && characters.length > 0 ? (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.25 }}>
{characters.map((c, idx) => (
<Chip
key={idx}
label={c}
variant="outlined"
sx={{ bgcolor: '#fff', color: '#2C2416', borderColor: 'rgba(0,0,0,0.15)' }}
/>
))}
</Box>
) : (
<Typography variant="body2">No characters provided for this scene.</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
};
export default CharactersModal;

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, Paper, TextField, Typography } from '@mui/material';
interface EditSectionModalProps {
open: boolean;
sceneNumber: number;
editText: string;
onChangeEditText: (val: string) => void;
aiFeedback: string;
onChangeAiFeedback: (val: string) => void;
aiLoading: boolean;
onGenerateSuggestions: () => void;
suggestions: string[];
onPickSuggestion: (index: number) => void;
onClose: () => void;
onSave: () => void;
}
const EditSectionModal: React.FC<EditSectionModalProps> = ({
open,
sceneNumber,
editText,
onChangeEditText,
aiFeedback,
onChangeAiFeedback,
aiLoading,
onGenerateSuggestions,
suggestions,
onPickSuggestion,
onClose,
onSave,
}) => {
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
backgroundColor: '#fff',
borderRadius: 2,
boxShadow: '0 24px 64px rgba(0,0,0,0.18)',
border: '1px solid rgba(0,0,0,0.06)',
},
}}
>
<DialogTitle>Edit Section (Scene {sceneNumber})</DialogTitle>
<DialogContent dividers>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Section Text"
value={editText}
onChange={(e) => onChangeEditText(e.target.value)}
multiline
minRows={6}
fullWidth
/>
<TextField
label="Tell Alwrity what to improve (optional)"
value={aiFeedback}
onChange={(e) => onChangeAiFeedback(e.target.value)}
multiline
minRows={3}
fullWidth
helperText="Describe desired changes (tone, pacing, details). Generate to get 2 suggestions."
/>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Button variant="outlined" onClick={onGenerateSuggestions} disabled={aiLoading}>
{aiLoading ? 'Generating...' : 'Generate AI Suggestions'}
</Button>
</Box>
{suggestions.length > 0 && (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
{suggestions.map((s, i) => (
<Paper key={i} sx={{ p: 2, border: '1px solid rgba(120,90,60,0.2)' }}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
Suggestion {i + 1}
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{s}
</Typography>
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'flex-end' }}>
<Button size="small" variant="text" onClick={() => onPickSuggestion(i)}>
Use this
</Button>
</Box>
</Paper>
))}
</Box>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={onSave}>
Save & Update
</Button>
</DialogActions>
</Dialog>
);
};
export default EditSectionModal;

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material';
interface ImageEditModalProps {
open: boolean;
sceneNumber: number;
value: string;
onChange: (v: string) => void;
onClose: () => void;
onSave: () => void;
}
const ImageEditModal: React.FC<ImageEditModalProps> = ({ open, sceneNumber, value, onChange, onClose, onSave }) => {
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
backgroundColor: '#fff',
borderRadius: 2,
boxShadow: '0 24px 64px rgba(0,0,0,0.18)',
border: '1px solid rgba(0,0,0,0.06)',
},
}}
>
<DialogTitle>Edit Scene Illustration Prompt (Scene {sceneNumber})</DialogTitle>
<DialogContent dividers sx={{ color: '#2C2416' }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
'& .MuiFormLabel-root': { color: '#6b5846' },
'& .MuiInputBase-root': { color: '#2C2416' },
}}
>
<TextField
label="Image Prompt"
value={value}
onChange={(e) => onChange(e.target.value)}
multiline
minRows={5}
fullWidth
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={onSave}>Save</Button>
</DialogActions>
</Dialog>
);
};
export default ImageEditModal;

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { Box, Dialog, DialogActions, DialogContent, DialogTitle, Button, Typography } from '@mui/material';
interface KeyEventsModalProps {
open: boolean;
sceneNumber: number;
events: string[];
onClose: () => void;
}
const KeyEventsModal: React.FC<KeyEventsModalProps> = ({ open, sceneNumber, events, onClose }) => {
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
backgroundColor: '#fff',
borderRadius: 2,
boxShadow: '0 24px 64px rgba(0,0,0,0.18)',
border: '1px solid rgba(0,0,0,0.06)',
},
}}
>
<DialogTitle>Key Events (Scene {sceneNumber})</DialogTitle>
<DialogContent dividers sx={{ color: '#2C2416' }}>
{events && events.length > 0 ? (
<Box component="ul" sx={{ pl: 2, mb: 0 }}>
{events.map((e, idx) => (
<li key={idx}>
<Typography variant="body2">{e}</Typography>
</li>
))}
</Box>
) : (
<Typography variant="body2">No key events provided for this scene.</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
};
export default KeyEventsModal;

View File

@@ -0,0 +1,110 @@
import React from 'react';
import { Box, Button, CircularProgress } from '@mui/material';
import ImageIcon from '@mui/icons-material/Image';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import { outlineActionButtonSx, primaryButtonSx } from './buttonStyles';
interface OutlineActionsBarProps {
isGenerating: boolean;
canRegenerateOutline: boolean;
onRegenerateOutline: () => void;
showMediaActions: boolean;
isGeneratingImages: boolean;
isGeneratingAudio: boolean;
illustrationEnabled: boolean;
narrationEnabled: boolean;
onGenerateImages: () => void;
onGenerateAudio: () => void;
canContinue: boolean;
onContinue: () => void;
}
const OutlineActionsBar: React.FC<OutlineActionsBarProps> = ({
isGenerating,
canRegenerateOutline,
onRegenerateOutline,
showMediaActions,
isGeneratingImages,
isGeneratingAudio,
illustrationEnabled,
narrationEnabled,
onGenerateImages,
onGenerateAudio,
canContinue,
onContinue,
}) => {
return (
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
<Button
variant="outlined"
onClick={onRegenerateOutline}
disabled={isGenerating || !canRegenerateOutline}
sx={outlineActionButtonSx}
>
{isGenerating ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
Regenerating...
</>
) : (
'Regenerate Outline'
)}
</Button>
{showMediaActions && (
<>
<span>
<Button
variant="outlined"
startIcon={<ImageIcon />}
onClick={onGenerateImages}
disabled={isGeneratingImages || !illustrationEnabled}
sx={outlineActionButtonSx}
title={!illustrationEnabled ? 'Enable Illustration in Story Setup to generate images.' : undefined}
>
{isGeneratingImages ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
Generating Images...
</>
) : (
'Generate Images'
)}
</Button>
</span>
<span>
<Button
variant="outlined"
startIcon={<VolumeUpIcon />}
onClick={onGenerateAudio}
disabled={isGeneratingAudio || !narrationEnabled}
sx={outlineActionButtonSx}
title={!narrationEnabled ? 'Enable Narration in Story Setup to generate audio.' : undefined}
>
{isGeneratingAudio ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
Generating Audio...
</>
) : (
'Generate Audio'
)}
</Button>
</span>
</>
)}
<Button
variant="contained"
onClick={onContinue}
disabled={!canContinue}
sx={primaryButtonSx}
>
Continue to Writing
</Button>
</Box>
);
};
export default OutlineActionsBar;

View File

@@ -0,0 +1,79 @@
import React from 'react';
import { Box, Tooltip } from '@mui/material';
import EditNoteIcon from '@mui/icons-material/EditNote';
import TipsAndUpdatesIcon from '@mui/icons-material/TipsAndUpdates';
interface OutlineHoverActionsProps {
onEdit: () => void;
onImprove: () => void;
}
const OutlineHoverActions: React.FC<OutlineHoverActionsProps> = ({ onEdit, onImprove }) => {
return (
<Box
className="outline-actions"
sx={{
position: 'absolute',
top: 16,
right: 16,
display: 'flex',
gap: 1,
opacity: 0,
pointerEvents: 'none',
transition: 'opacity 0.2s ease',
}}
>
<Tooltip title="Edit this section">
<Box
role="button"
aria-label="Edit section"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
sx={{
width: 40,
height: 40,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #7F5AF0 0%, #2CB67D 100%)',
boxShadow: '0 8px 16px rgba(127,90,240,0.3)',
color: 'white',
cursor: 'pointer',
}}
>
<EditNoteIcon />
</Box>
</Tooltip>
<Tooltip title="Improve with AI (2 suggestions)">
<Box
role="button"
aria-label="AI improve section"
onClick={(e) => {
e.stopPropagation();
onImprove();
}}
sx={{
width: 40,
height: 40,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #5d3b24 0%, #a36c3b 45%, #f1c27d 100%)',
boxShadow: '0 8px 16px rgba(93,59,36,0.35)',
color: 'white',
cursor: 'pointer',
}}
>
<TipsAndUpdatesIcon />
</Box>
</Tooltip>
</Box>
);
};
export default OutlineHoverActions;

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { Box, Button, CircularProgress, Typography } from '@mui/material';
import ImageIcon from '@mui/icons-material/Image';
interface SceneGenerationPanelProps {
provider?: string | null;
width: number;
height: number;
model?: string | null;
enabled: boolean;
regenerating: boolean;
onRegenerate: () => void;
}
const SceneGenerationPanel: React.FC<SceneGenerationPanelProps> = ({
provider,
width,
height,
model,
enabled,
regenerating,
onRegenerate,
}) => {
return (
<Box sx={{ mt: 1.5, p: 2, border: '1px dashed rgba(120,90,60,0.35)', borderRadius: 1.5, backgroundColor: 'rgba(255,255,255,0.6)' }}>
<Typography variant="caption" sx={{ color: '#7a5335', display: 'block', mb: 1 }}>
Scene generation controls (uses Setup settings)
</Typography>
<Typography variant="caption" sx={{ color: '#5D4037', display: 'block', mb: 1 }}>
Provider: {provider || 'Auto'} · Size: {width}x{height}{model ? ` · Model: ${model}` : ''}
</Typography>
<Button
size="small"
variant="outlined"
startIcon={regenerating ? <CircularProgress size={16} /> : <ImageIcon />}
onClick={onRegenerate}
disabled={regenerating || !enabled}
>
{regenerating ? 'Regenerating...' : 'Regenerate Image (Scene)'}
</Button>
{!enabled && (
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: '#a37b55' }}>
Enable Illustration in Story Setup to generate images.
</Typography>
)}
</Box>
);
};
export default SceneGenerationPanel;

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material';
interface TitleEditModalProps {
open: boolean;
sceneNumber: number;
value: string;
onChange: (v: string) => void;
onClose: () => void;
onSave: () => void;
}
const TitleEditModal: React.FC<TitleEditModalProps> = ({ open, sceneNumber, value, onChange, onClose, onSave }) => {
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
backgroundColor: '#fff',
borderRadius: 2,
boxShadow: '0 24px 64px rgba(0,0,0,0.18)',
border: '1px solid rgba(0,0,0,0.06)',
},
}}
>
<DialogTitle>Edit Scene Title (Scene {sceneNumber})</DialogTitle>
<DialogContent dividers sx={{ color: '#2C2416' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Title"
value={value}
onChange={(e) => onChange(e.target.value)}
fullWidth
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={onSave}>Save</Button>
</DialogActions>
</Dialog>
);
};
export default TitleEditModal;

View File

@@ -0,0 +1,44 @@
export const outlineActionButtonSx = {
textTransform: 'none',
borderRadius: '999px',
fontWeight: 600,
px: 3,
py: 1.2,
borderWidth: 2,
borderColor: 'rgba(59, 34, 18, 0.35)',
color: '#3B2618',
backgroundColor: 'rgba(255, 249, 239, 0.95)',
boxShadow: '0 14px 26px rgba(26, 22, 17, 0.12)',
transition: 'all 0.25s ease',
'&:hover': {
borderColor: '#3B2618',
boxShadow: '0 18px 32px rgba(26, 22, 17, 0.18)',
transform: 'translateY(-2px)',
backgroundColor: 'rgba(255, 245, 228, 0.98)',
},
'&:disabled': {
opacity: 0.4,
boxShadow: 'none',
transform: 'none',
backgroundColor: 'rgba(255, 255, 255, 0.6)',
},
} as const;
export const primaryButtonSx = {
...outlineActionButtonSx,
background: 'linear-gradient(125deg, #5d3b24 0%, #a36c3b 45%, #f1c27d 100%)',
color: '#fff',
border: 'none',
boxShadow: '0 20px 40px rgba(93, 59, 36, 0.35)',
'&:hover': {
...outlineActionButtonSx['&:hover'],
background: 'linear-gradient(125deg, #4c2f1c 0%, #8b552d 45%, #f7d9a0 100%)',
boxShadow: '0 24px 46px rgba(93, 59, 36, 0.45)',
},
'&:disabled': {
opacity: 0.35,
boxShadow: 'none',
cursor: 'not-allowed',
},
} as const;

View File

@@ -0,0 +1,51 @@
import { Variants } from 'framer-motion';
export const easeInOut = [0.22, 0.61, 0.36, 1] as const;
export const easeOut = [0.4, 0, 1, 1] as const;
export const leftPageVariants: Variants = {
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 },
}),
};
export const rightPageVariants: Variants = {
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 },
}),
};

View File

@@ -2,41 +2,76 @@ import React from 'react';
import { Grid, Typography, Box, FormControlLabel, Checkbox } from '@mui/material';
import { SectionProps } from './types';
export const FeatureCheckboxesSection: React.FC<SectionProps> = ({ state }) => {
interface FeatureCheckboxesProps {
state: SectionProps['state'];
layout?: 'stack' | 'inline';
}
export const FeatureCheckboxesSection: React.FC<FeatureCheckboxesProps> = ({ state, layout = 'stack' }) => {
const options = [
{
label: 'Explainer',
checked: state.enableExplainer,
onChange: (checked: boolean) => state.setEnableExplainer(checked),
},
{
label: 'Illustration',
checked: state.enableIllustration,
onChange: (checked: boolean) => state.setEnableIllustration(checked),
},
{
label: 'Narration',
checked: state.enableNarration,
onChange: (checked: boolean) => state.setEnableNarration(checked),
},
{
label: 'Story Video',
checked: state.enableVideoNarration,
onChange: (checked: boolean) => state.setEnableVideoNarration(checked),
},
];
const renderCheckboxes = (direction: 'row' | 'column') => (
<Box
sx={{
display: 'flex',
flexWrap: direction === 'row' ? 'wrap' : 'nowrap',
flexDirection: direction === 'row' ? 'row' : 'column',
gap: 1.5,
}}
>
{options.map((option) => (
<FormControlLabel
key={option.label}
control={
<Checkbox
checked={option.checked}
onChange={(e) => option.onChange(e.target.checked)}
size="small"
/>
}
label={option.label}
sx={{
m: 0,
'& .MuiFormControlLabel-label': {
fontWeight: 600,
},
}}
/>
))}
</Box>
);
if (layout === 'inline') {
return renderCheckboxes('row');
}
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>
{renderCheckboxes('column')}
</Grid>
);
};

View File

@@ -11,6 +11,7 @@ import {
FormControlLabel,
Checkbox,
Slider,
Chip,
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { SectionProps } from './types';
@@ -18,6 +19,27 @@ import { textFieldStyles, accordionStyles } from './styles';
import { IMAGE_PROVIDERS, AUDIO_PROVIDERS, COMMON_IMAGE_SIZES } from './constants';
export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) => {
const imageDisabled = !state.enableIllustration;
const audioDisabled = !state.enableNarration;
const videoDisabled = !state.enableVideoNarration;
const disabledStyles = (disabled: boolean) =>
disabled
? {
opacity: 0.4,
pointerEvents: 'none',
}
: undefined;
const renderHeading = (title: string, disabled: boolean) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
{title}
</Typography>
{disabled && <Chip label="Disabled in Story Setup" size="small" />}
</Box>
);
return (
<Box sx={{ mt: 4 }}>
<Typography variant="h6" gutterBottom sx={{ mb: 2, fontWeight: 600 }}>
@@ -28,13 +50,11 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
</Typography>
{/* Image Generation Settings */}
<Accordion sx={accordionStyles}>
<Accordion sx={accordionStyles} disabled={imageDisabled}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
Image Generation Settings
</Typography>
{renderHeading('Image Generation Settings', imageDisabled)}
</AccordionSummary>
<AccordionDetails>
<AccordionDetails sx={disabledStyles(imageDisabled)}>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<TextField
@@ -45,6 +65,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
onChange={(e) => state.setImageProvider(e.target.value || null)}
helperText="Select the image generation provider. Leave as 'Auto' to use the default."
sx={textFieldStyles}
disabled={imageDisabled}
>
{IMAGE_PROVIDERS.map((provider) => (
<MenuItem key={provider.value} value={provider.value}>
@@ -66,6 +87,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
}}
helperText="Select a common image size or set custom dimensions below."
sx={textFieldStyles}
disabled={imageDisabled}
>
{COMMON_IMAGE_SIZES.map((size) => (
<MenuItem key={`${size.width}x${size.height}`} value={`${size.width}x${size.height}`}>
@@ -84,6 +106,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
inputProps={{ min: 256, max: 2048, step: 64 }}
helperText="Image width in pixels (256-2048)"
sx={textFieldStyles}
disabled={imageDisabled}
/>
</Grid>
<Grid item xs={12} md={6}>
@@ -96,6 +119,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
inputProps={{ min: 256, max: 2048, step: 64 }}
helperText="Image height in pixels (256-2048)"
sx={textFieldStyles}
disabled={imageDisabled}
/>
</Grid>
<Grid item xs={12}>
@@ -107,6 +131,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
placeholder="Leave empty to use default model"
helperText="Specific model to use for image generation (optional)"
sx={textFieldStyles}
disabled={imageDisabled}
/>
</Grid>
</Grid>
@@ -114,13 +139,11 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
</Accordion>
{/* Video Generation Settings */}
<Accordion sx={accordionStyles}>
<Accordion sx={accordionStyles} disabled={videoDisabled}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
Video Generation Settings
</Typography>
{renderHeading('Video Generation Settings', videoDisabled)}
</AccordionSummary>
<AccordionDetails>
<AccordionDetails sx={disabledStyles(videoDisabled)}>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<TextField
@@ -132,6 +155,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
inputProps={{ min: 15, max: 60, step: 1 }}
helperText="Video frame rate (15-60 fps). Higher values create smoother video but larger files."
sx={textFieldStyles}
disabled={videoDisabled}
/>
</Grid>
<Grid item xs={12} md={6}>
@@ -151,6 +175,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
{ value: 2, label: '2s' },
]}
valueLabelDisplay="auto"
disabled={videoDisabled}
/>
<Typography variant="caption" sx={{ color: '#5D4037' }}>
Duration of transitions between scenes in seconds
@@ -162,13 +187,11 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
</Accordion>
{/* Audio Generation Settings */}
<Accordion sx={accordionStyles}>
<Accordion sx={accordionStyles} disabled={audioDisabled}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
Audio Generation Settings
</Typography>
{renderHeading('Audio Generation Settings', audioDisabled)}
</AccordionSummary>
<AccordionDetails>
<AccordionDetails sx={disabledStyles(audioDisabled)}>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<TextField
@@ -179,6 +202,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
onChange={(e) => state.setAudioProvider(e.target.value)}
helperText="Text-to-speech provider for narration"
sx={textFieldStyles}
disabled={audioDisabled}
>
{AUDIO_PROVIDERS.map((provider) => (
<MenuItem key={provider.value} value={provider.value}>
@@ -196,6 +220,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
placeholder="en"
helperText="Language code for text-to-speech (e.g., 'en' for English, 'es' for Spanish)"
sx={textFieldStyles}
disabled={audioDisabled}
/>
</Grid>
{state.audioProvider === 'gtts' && (
@@ -205,6 +230,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
<Checkbox
checked={state.audioSlow}
onChange={(e) => state.setAudioSlow(e.target.checked)}
disabled={audioDisabled}
/>
}
label="Slow Speech (gTTS only)"
@@ -229,6 +255,7 @@ export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) =>
{ value: 300, label: '300' },
]}
valueLabelDisplay="auto"
disabled={audioDisabled}
/>
<Typography variant="caption" sx={{ color: '#5D4037' }}>
Speech rate in words per minute (pyttsx3 only)

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Paper, Typography, Box, Button, Alert, Grid, CircularProgress } from '@mui/material';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import { useStoryWriterState } from '../../../../hooks/useStoryWriterState';
import { storyWriterApi, StoryScene } from '../../../../services/storyWriterApi';
import { triggerSubscriptionError } from '../../../../api/client';
@@ -169,13 +170,28 @@ const StorySetup: React.FC<StorySetupProps> = ({ state, onNext }) => {
};
return (
<>
<Paper sx={paperStyles}>
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600, color: '#1A1611' }}>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
justifyContent: 'space-between',
gap: 3,
mb: 4,
}}
>
<Box>
<Typography variant="h5" gutterBottom sx={{ 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 variant="body2" sx={{ color: '#5D4037' }}>
Configure your story parameters and premise. Fill in the required fields and click "Generate Outline" to
continue.
</Typography>
</Box>
<FeatureCheckboxesSection state={state} layout="inline" />
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
@@ -183,15 +199,37 @@ const StorySetup: React.FC<StorySetupProps> = ({ state, onNext }) => {
</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
variant="contained"
size="large"
startIcon={<AutoAwesomeIcon />}
onClick={() => setIsModalOpen(true)}
sx={{
mb: 1,
px: 4,
py: 1.5,
borderRadius: '999px',
textTransform: 'none',
fontWeight: 600,
background: 'linear-gradient(135deg, #7F5AF0 0%, #2CB67D 100%)',
boxShadow: '0 12px 24px rgba(127, 90, 240, 0.3)',
'&:hover': {
background: 'linear-gradient(135deg, #6c4cd4 0%, #24a26f 100%)',
boxShadow: '0 14px 30px rgba(127, 90, 240, 0.35)',
},
}}
>
Generate Story Setup with Alwrity AI
</Button>
<Typography variant="caption" sx={{ color: '#5D4037' }}>
Let Alwrity AI craft a cohesive persona, setting, and premise instantly.
</Typography>
</Box>
<Grid container spacing={4}>
<Grid item xs={12} md={8}>
<Grid container spacing={3}>
{/* Story Parameters Section */}
<StoryParametersSection
state={state}
customValues={customValues}
@@ -200,56 +238,61 @@ const StorySetup: React.FC<StorySetupProps> = ({ state, onNext }) => {
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>
</Grid>
<Grid item xs={12} md={4}>
<Box
sx={{
position: { md: 'sticky' },
top: { md: 16 },
}}
>
<GenerationSettingsSection state={state} customValues={customValues} textFieldStyles={textFieldStyles} />
</Box>
</Grid>
</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
}
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'
)}
{isGeneratingOutline ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
Generating Outline...
</>
) : (
'Generate Outline'
)}
</Button>
</Box>
</Paper>
{/* AI Story Setup Modal */}
<AIStorySetupModal
open={isModalOpen}
onClose={() => setIsModalOpen(false)}
state={state}
customValuesSetters={customValuesSetters}
/>
</Paper>
</>
);
};

View File

@@ -1,16 +1,71 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
Paper,
Typography,
Button,
TextField,
Alert,
CircularProgress,
} from '@mui/material';
import GlobalStyles from '@mui/material/GlobalStyles';
import { motion, AnimatePresence } from 'framer-motion';
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
import { storyWriterApi } from '../../../services/storyWriterApi';
import { triggerSubscriptionError } from '../../../api/client';
import { aiApiClient } from '../../../api/client';
import { MultimediaSection } from '../components/MultimediaSection';
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 StoryWritingProps {
state: ReturnType<typeof useStoryWriterState>;
@@ -24,10 +79,158 @@ const isShortStory = (storyLength: string | null | undefined): boolean => {
return storyLengthLower.includes('short') || storyLengthLower.includes('1000');
};
// Split story content into sections based on the number of scenes
const splitStoryContent = (content: string, numSections: number): string[] => {
if (!content || numSections <= 1) {
return [content || ''];
}
// Split by paragraphs (double newlines)
const paragraphs = content.split(/\n\s*\n/).filter(p => p.trim().length > 0);
if (paragraphs.length === 0) {
return [content];
}
// If we have fewer paragraphs than sections, use paragraphs as sections
if (paragraphs.length <= numSections) {
// Pad with empty sections if needed
const sections = [...paragraphs];
while (sections.length < numSections) {
sections.push('');
}
return sections;
}
// Divide paragraphs into roughly equal sections
const sections: string[] = [];
const paragraphsPerSection = Math.ceil(paragraphs.length / numSections);
for (let i = 0; i < numSections; i++) {
const start = i * paragraphsPerSection;
const end = Math.min(start + paragraphsPerSection, paragraphs.length);
sections.push(paragraphs.slice(start, end).join('\n\n'));
}
return sections;
};
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 [currentPageIndex, setCurrentPageIndex] = useState(0);
const [pageDirection, setPageDirection] = useState(0);
const [imageLoadError, setImageLoadError] = useState<Set<number>>(new Set());
const [imageBlobUrls, setImageBlobUrls] = useState<Map<number, string>>(new Map());
// Get scenes and images from state
const scenes = state.outlineScenes || [];
const sceneImages = state.sceneImages || new Map<number, string>();
const hasScenes = state.isOutlineStructured && scenes.length > 0;
// Split story content into sections mapped to scenes
const storySections = useMemo(() => {
if (!state.storyContent) {
return [];
}
if (hasScenes && scenes.length > 0) {
// Split story content into sections based on number of scenes
return splitStoryContent(state.storyContent, scenes.length);
}
// If no scenes, treat entire story as one section
return [state.storyContent];
}, [state.storyContent, hasScenes, scenes.length]);
const numPages = Math.max(storySections.length, hasScenes ? scenes.length : 1);
const currentPage = currentPageIndex < storySections.length ? storySections[currentPageIndex] : '';
const currentSceneIndex = hasScenes ? Math.min(currentPageIndex, scenes.length - 1) : 0;
const currentScene = hasScenes ? scenes[currentSceneIndex] : null;
const canGoPrev = currentPageIndex > 0;
const canGoNext = currentPageIndex < numPages - 1;
// 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
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 page changes
useEffect(() => {
setImageLoadError((prev) => {
const next = new Set(prev);
next.delete(currentSceneNumber);
return next;
});
}, [currentSceneNumber]);
useEffect(() => {
if (storySections.length > 0) {
setCurrentPageIndex(0);
setPageDirection(0);
}
}, [storySections.length]);
const handlePrevPage = () => {
if (canGoPrev) {
setPageDirection(-1);
setCurrentPageIndex((prev) => prev - 1);
}
};
const handleNextPage = () => {
if (canGoNext) {
setPageDirection(1);
setCurrentPageIndex((prev) => prev + 1);
}
};
const handleGenerateStart = async () => {
if (!state.premise || (!state.outline && !state.outlineScenes)) {
@@ -178,19 +381,26 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
sx={{
p: 4,
mt: 2,
backgroundColor: '#F7F3E9', // Warm cream/parchment color
color: '#2C2416', // Dark brown text for readability
backgroundColor: '#F7F3E9',
color: '#2C2416',
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>
<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)',
},
}}
/>
{state.storyContent && (
<Typography variant="body2" sx={{ mb: 4, color: '#5D4037', fontStyle: 'italic' }}>
<Typography variant="body2" sx={{ mb: 3, 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)</>
@@ -212,15 +422,248 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
{state.storyContent ? (
<>
<TextField
fullWidth
multiline
rows={20}
value={state.storyContent}
onChange={(e) => state.setStoryContent(e.target.value)}
label="Story Content"
sx={{ mb: 3 }}
/>
{hasScenes && numPages > 1 ? (
// Book-like UI with images
<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%)',
}}
>
<AnimatePresence mode="wait" custom={pageDirection}>
<MotionBox
key={`book-pages-${currentPageIndex}`}
custom={pageDirection}
variants={{}}
initial="enter"
animate="center"
exit="exit"
sx={{
width: '100%',
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
position: 'relative',
height: '100%',
}}
>
{/* Left page - Image */}
<MotionBox
key={`image-${currentPageIndex}`}
role="button"
aria-label="Previous page"
onClick={handlePrevPage}
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',
justifyContent: 'center',
alignItems: 'center',
'&: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,
},
}}
>
{currentSceneImageFullUrl ? (
<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={() => {
setImageLoadError((prev) => new Set(prev).add(currentSceneNumber));
}}
/>
</Box>
) : (
<Box
sx={{
width: '100%',
minHeight: '300px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#7a5335',
}}
>
<Typography variant="body2" sx={{ textAlign: 'center' }}>
{currentScene?.image_prompt || 'No image available for this scene'}
</Typography>
</Box>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 2, width: '100%' }}>
<Typography variant="caption" sx={{ color: '#7a5335' }}>
Click to turn back
</Typography>
<Typography variant="caption" sx={{ color: '#a37b55' }}>
{canGoPrev ? '← Previous page' : 'Start of story'}
</Typography>
</Box>
</MotionBox>
{/* Right page - Story text */}
<MotionBox
key={`story-${currentPageIndex}`}
role="button"
aria-label="Next page"
onClick={handleNextPage}
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',
lineHeight: 1.8,
fontFamily: `'Georgia', 'Times New Roman', serif`,
fontSize: '1.1rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{currentPage || 'Loading...'}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 2 }}>
<Typography variant="caption" sx={{ color: '#a37b55' }}>
{canGoNext ? 'Next page →' : 'End of story'}
</Typography>
<Typography variant="caption" sx={{ color: '#7a5335' }}>
Page {currentPageIndex + 1} of {numPages}
</Typography>
</Box>
</MotionBox>
</MotionBox>
</AnimatePresence>
</Box>
</Box>
) : (
// Simple text display if no scenes
<Box sx={{ mb: 3 }}>
<Paper
sx={{
p: 3,
backgroundColor: '#FAF9F6',
minHeight: '400px',
}}
>
<Typography
variant="body1"
sx={{
color: '#2C2416',
lineHeight: 1.8,
fontFamily: `'Georgia', 'Times New Roman', serif`,
fontSize: '1.1rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{state.storyContent}
</Typography>
</Paper>
</Box>
)}
{/* Multimedia Generation Section */}
{state.isOutlineStructured && state.outlineScenes && state.outlineScenes.length > 0 && (
<MultimediaSection state={state} />
)}
<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) && (

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Box, Container, Typography, useTheme } from '@mui/material';
import React, { useState } from 'react';
import { Box, Container, Typography, useTheme, Dialog, DialogTitle, DialogContent, IconButton } from '@mui/material';
import { useStoryWriterState } from '../../hooks/useStoryWriterState';
import { useStoryWriterPhaseNavigation } from '../../hooks/useStoryWriterPhaseNavigation';
import StorySetup from './Phases/StorySetup';
@@ -7,6 +7,12 @@ import StoryOutline from './Phases/StoryOutline';
import StoryWriting from './Phases/StoryWriting';
import StoryExport from './Phases/StoryExport';
import PhaseNavigation from './PhaseNavigation';
import { MultimediaToolbar } from './components/MultimediaToolbar';
import { storyWriterApi } from '../../services/storyWriterApi';
import { triggerSubscriptionError } from '../../api/client';
import CloseIcon from '@mui/icons-material/Close';
import { MultimediaSection } from './components/MultimediaSection';
import StoryWriterLanding from './StoryWriterLanding';
export const StoryWriter: React.FC = () => {
const theme = useTheme();
@@ -14,6 +20,15 @@ export const StoryWriter: React.FC = () => {
// State management
const state = useStoryWriterState();
// Multimedia generation state
const [isGeneratingAudio, setIsGeneratingAudio] = useState(false);
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
const [isMultimediaDialogOpen, setIsMultimediaDialogOpen] = useState(false);
const [landingDismissed, setLandingDismissed] = useState(() => {
if (typeof window === 'undefined') return false;
return window.localStorage.getItem('storywriter:landingDismissed') === 'true';
});
// Phase navigation
const {
phases,
@@ -30,12 +45,138 @@ export const StoryWriter: React.FC = () => {
const handleReset = () => {
// Reset story state (this also clears localStorage)
state.resetState();
if (typeof window !== 'undefined') {
window.localStorage.removeItem('storywriter:landingDismissed');
}
// Simplest approach: reload the page to ensure a clean slate
if (typeof window !== 'undefined') {
window.location.reload();
}
};
const handleOpenMultimediaDialog = () => {
setIsMultimediaDialogOpen(true);
};
const handleCloseMultimediaDialog = () => {
setIsMultimediaDialogOpen(false);
};
// Audio generation handler
const handleGenerateAudio = async () => {
if (!state.enableNarration) {
return;
}
if (!state.outlineScenes || state.outlineScenes.length === 0) {
return;
}
setIsGeneratingAudio(true);
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) {
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);
}
} catch (err: any) {
const status = err?.response?.status;
if (status === 429 || status === 402) {
await triggerSubscriptionError(err);
}
console.error('Audio generation failed:', err);
} finally {
setIsGeneratingAudio(false);
}
};
// Video generation handler
const handleGenerateVideo = async () => {
if (!state.enableVideoNarration) {
return;
}
if (!state.outlineScenes || state.outlineScenes.length === 0) {
return;
}
if (!state.sceneImages || state.sceneImages.size === 0) {
return;
}
if (!state.sceneAudio || state.sceneAudio.size === 0) {
return;
}
setIsGeneratingVideo(true);
try {
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);
}
}
if (imageUrls.length !== scenes.length || audioUrls.length !== scenes.length) {
throw new Error('Number of images and audio files must match number of scenes');
}
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);
}
} catch (err: any) {
const status = err?.response?.status;
if (status === 429 || status === 402) {
await triggerSubscriptionError(err);
}
console.error('Video generation failed:', err);
} finally {
setIsGeneratingVideo(false);
}
};
const hasStoryProgress = Boolean(state.premise || state.outline || state.storyContent);
const showLanding = !landingDismissed && !hasStoryProgress;
const handleLandingStart = () => {
setLandingDismissed(true);
if (typeof window !== 'undefined') {
window.localStorage.setItem('storywriter:landingDismissed', 'true');
}
navigateToPhase('setup');
};
// Render phase content
const renderPhaseContent = () => {
switch (currentPhase) {
@@ -52,6 +193,22 @@ export const StoryWriter: React.FC = () => {
}
};
if (showLanding) {
return (
<Box
sx={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)',
padding: theme.spacing(4),
}}
>
<Container maxWidth="xl">
<StoryWriterLanding onStart={handleLandingStart} />
</Container>
</Box>
);
}
return (
<Box
sx={{
@@ -90,29 +247,62 @@ export const StoryWriter: React.FC = () => {
zIndex: 1,
}}
>
{/* Header */}
{/* Header with Phase Navigation and Multimedia Toolbar */}
<Box sx={{ mb: 4 }}>
<Typography variant="h3" component="h1" gutterBottom>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
<Box>
<Typography variant="h3" component="h1" gutterBottom sx={{ color: 'white' }}>
Story Writer
</Typography>
<Typography variant="body1" color="text.secondary">
<Typography variant="body1" sx={{ color: 'rgba(255, 255, 255, 0.8)' }}>
Create compelling stories with AI assistance
</Typography>
</Box>
{/* Phase Navigation */}
{/* Compact Phase Navigation */}
<Box sx={{ flex: '1 1 auto', minWidth: { xs: '100%', md: '600px' }, display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ flex: 1 }}>
<PhaseNavigation
phases={phases}
currentPhase={currentPhase}
onPhaseClick={navigateToPhase}
onReset={handleReset}
/>
</Box>
{/* Multimedia Toolbar */}
<MultimediaToolbar
state={state}
onGenerateAudio={handleGenerateAudio}
onGenerateVideo={handleGenerateVideo}
isGeneratingAudio={isGeneratingAudio}
isGeneratingVideo={isGeneratingVideo}
onOpenPanel={(_section) => handleOpenMultimediaDialog()}
/>
</Box>
</Box>
</Box>
{/* Phase Content */}
<Box sx={{ mt: 4 }}>
{renderPhaseContent()}
</Box>
</Container>
<Dialog
open={isMultimediaDialogOpen}
onClose={handleCloseMultimediaDialog}
maxWidth="md"
fullWidth
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
Multimedia Controls
<IconButton size="small" onClick={handleCloseMultimediaDialog}>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<MultimediaSection state={state} />
</DialogContent>
</Dialog>
</Box>
);
};

View File

@@ -0,0 +1,198 @@
import React from 'react';
import { Box, Button, Grid, Paper, Typography } from '@mui/material';
import GlobalStyles from '@mui/material/GlobalStyles';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import MenuBookIcon from '@mui/icons-material/MenuBook';
import ImageIcon from '@mui/icons-material/Image';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import VideoLibraryIcon from '@mui/icons-material/VideoLibrary';
interface StoryWriterLandingProps {
onStart: () => void;
}
const featureHighlights = [
{
title: 'AI Story Blueprint',
description: 'Persona, setting, tone, and premise woven together automatically.',
detail: 'Start with cohesive outlines tailored to your audience and genre.',
icon: <MenuBookIcon sx={{ fontSize: 32, color: '#8D5524' }} />,
},
{
title: 'Cinematic Illustrations',
description: 'Scene-by-scene image prompts and gallery-ready renders.',
detail: 'Control aspect ratios, providers, and models for every chapter.',
icon: <ImageIcon sx={{ fontSize: 32, color: '#B25D3E' }} />,
},
{
title: 'Voice-Ready Narration',
description: 'Generate lifelike audio in multiple languages and speeds.',
detail: 'Perfect for bedtime stories, podcasts, or accessibility-ready scripts.',
icon: <VolumeUpIcon sx={{ fontSize: 32, color: '#7A4C9F' }} />,
},
{
title: 'Story Video Composer',
description: 'Blend scenes, audio, and transitions into immersive videos.',
detail: 'Fine-tune FPS, transitions, and pacing for a studio polish.',
icon: <VideoLibraryIcon sx={{ fontSize: 32, color: '#2E7D83' }} />,
},
];
export const StoryWriterLanding: React.FC<StoryWriterLandingProps> = ({ onStart }) => {
return (
<Box sx={{ py: 6 }}>
<GlobalStyles
styles={{
'.storywriter-landing-shadow': {
boxShadow: '0 36px 80px rgba(45, 30, 15, 0.25)',
},
}}
/>
<Box sx={{ mb: 5, display: 'flex', justifyContent: 'center' }}>
<Box
className="storywriter-landing-shadow"
sx={{
position: 'relative',
width: { xs: '100%', lg: '90vw' },
maxWidth: 1400,
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
borderRadius: '24px',
overflow: 'hidden',
background: 'linear-gradient(120deg, #fff9ef 0%, #f5e1c7 45%, #fff9ef 100%)',
border: '1px solid rgba(120, 90, 60, 0.28)',
}}
>
<Box
sx={{
flex: 1,
p: { xs: 4, md: 6 },
borderRight: { md: '1px solid rgba(120, 90, 60, 0.18)' },
background: 'linear-gradient(100deg, rgba(255,255,255,0.85) 0%, rgba(242,226,204,0.95) 100%)',
}}
>
<Typography variant="overline" sx={{ letterSpacing: 6, color: '#7a5335', fontWeight: 600 }}>
Story Text & Blueprint
</Typography>
<Typography variant="h4" sx={{ fontFamily: `'Playfair Display', serif`, color: '#2C2416', mb: 2 }}>
Watch Alwrity AI open your storybook
</Typography>
<Typography variant="body1" sx={{ color: '#3f3224', lineHeight: 1.8, mb: 3 }}>
Begin with a book-inspired canvas. Alwrity assembles personas, settings, tones, and story beats so you can
focus on imagination, not forms.
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{[
'AI-curated personas, stakes, and endings',
'Guided tone, POV, rating, and length controls',
'Scene-by-scene descriptions ready for writing',
].map((item) => (
<Typography key={item} variant="body2" sx={{ color: '#5D4037' }}>
{item}
</Typography>
))}
</Box>
</Box>
<Box
sx={{
flex: 1,
p: { xs: 4, md: 6 },
background: 'linear-gradient(260deg, rgba(255,255,255,0.9) 0%, rgba(243,226,206,0.95) 100%)',
}}
>
<Typography variant="overline" sx={{ letterSpacing: 6, color: '#7a5335', fontWeight: 600 }}>
Multimedia Magic
</Typography>
<Typography variant="h4" sx={{ fontFamily: `'Playfair Display', serif`, color: '#2C2416', mb: 2 }}>
Illustrations, narration, and video on tap
</Typography>
<Typography variant="body1" sx={{ color: '#3f3224', lineHeight: 1.8, mb: 3 }}>
Every scene can bloom into art, audio, and cinematic video. Toggle features that matter and let AI stitch
them together.
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{[
'High-fidelity prompts for image generators',
'Narration in multiple languages and speeds',
'Video assembly with scene transitions and audio sync',
].map((item) => (
<Typography key={item} variant="body2" sx={{ color: '#5D4037' }}>
{item}
</Typography>
))}
</Box>
</Box>
</Box>
</Box>
<Box sx={{ textAlign: 'center', mb: 5 }}>
<Button
variant="contained"
size="large"
startIcon={<AutoAwesomeIcon />}
onClick={onStart}
sx={{
mb: 1,
px: 5,
py: 1.8,
borderRadius: '999px',
textTransform: 'none',
fontWeight: 600,
background: 'linear-gradient(135deg, #7F5AF0 0%, #2CB67D 100%)',
boxShadow: '0 16px 32px rgba(127, 90, 240, 0.35)',
'&:hover': {
background: 'linear-gradient(135deg, #6c4cd4 0%, #24a26f 100%)',
boxShadow: '0 18px 36px rgba(127, 90, 240, 0.4)',
},
}}
>
Lets ALwrity Your Story Journey
</Button>
<Typography variant="body2" sx={{ color: '#5D4037' }}>
Tap once to open the book. Inputs appear after AI drafts your foundation.
</Typography>
</Box>
<Typography variant="h5" sx={{ fontWeight: 600, color: '#1A1611', mb: 2 }}>
Everything Story Writer helps you create
</Typography>
<Grid container spacing={2}>
{featureHighlights.map((feature) => (
<Grid item xs={12} sm={6} md={3} key={feature.title}>
<Paper
elevation={0}
sx={{
height: '100%',
p: 3,
background: 'linear-gradient(180deg, #fff8ef 0%, #f8efe2 100%)',
borderRadius: 3,
border: '1px solid rgba(138, 85, 36, 0.18)',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
{feature.icon}
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#2C2416' }}>
{feature.title}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: '#5D4037' }}>
{feature.description}
</Typography>
<Typography variant="caption" sx={{ color: '#7A5A3C' }}>
{feature.detail}
</Typography>
</Paper>
</Grid>
))}
</Grid>
</Box>
);
};
export default StoryWriterLanding;

View File

@@ -0,0 +1,578 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Paper,
Typography,
Button,
Alert,
Divider,
LinearProgress,
CircularProgress,
Chip,
FormGroup,
FormControlLabel,
Checkbox,
Collapse,
IconButton,
} from '@mui/material';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import VideoLibraryIcon from '@mui/icons-material/VideoLibrary';
import DownloadIcon from '@mui/icons-material/Download';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
import { storyWriterApi } from '../../../services/storyWriterApi';
import { triggerSubscriptionError, aiApiClient } from '../../../api/client';
interface MultimediaSectionProps {
state: ReturnType<typeof useStoryWriterState>;
}
export const MultimediaSection: React.FC<MultimediaSectionProps> = ({ state }) => {
const [isGeneratingAudio, setIsGeneratingAudio] = useState(false);
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
const [audioProgress, setAudioProgress] = useState(0);
const [videoProgress, setVideoProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [selectedScenes, setSelectedScenes] = useState<Set<number>>(new Set());
const [showSceneSelection, setShowSceneSelection] = useState(false);
const [audioBlobUrls, setAudioBlobUrls] = useState<Map<number, string>>(new Map());
const hasScenes = state.isOutlineStructured && state.outlineScenes && state.outlineScenes.length > 0;
const narrationEnabled = state.enableNarration;
const videoEnabled = state.enableVideoNarration;
const hasAudio = narrationEnabled && state.sceneAudio && state.sceneAudio.size > 0;
const hasVideo = videoEnabled && !!state.storyVideo;
const hasImages = state.sceneImages && state.sceneImages.size > 0;
// Initialize selected scenes to all scenes by default
useEffect(() => {
if (!narrationEnabled || !state.outlineScenes) {
setSelectedScenes(new Set());
return;
}
setSelectedScenes((prev) => {
if (prev.size > 0) return prev;
const scenes = state.outlineScenes ?? [];
const allSceneNumbers = new Set(
scenes.map((scene: any, index: number) => scene.scene_number || index + 1),
);
return allSceneNumbers;
});
}, [narrationEnabled, state.outlineScenes]);
const canGenerateAudio = hasScenes && selectedScenes.size > 0 && !isGeneratingAudio;
const canGenerateVideo = hasScenes && hasImages && hasAudio && !isGeneratingVideo;
const handleSceneSelectionToggle = (sceneNumber: number) => {
setSelectedScenes((prev) => {
const next = new Set(prev);
if (next.has(sceneNumber)) {
next.delete(sceneNumber);
} else {
next.add(sceneNumber);
}
return next;
});
};
const handleSelectAllScenes = () => {
if (hasScenes && state.outlineScenes) {
const allSceneNumbers = new Set(
state.outlineScenes.map((scene: any, index: number) =>
scene.scene_number || index + 1
)
);
setSelectedScenes(allSceneNumbers);
}
};
const handleDeselectAllScenes = () => {
setSelectedScenes(new Set());
};
// Fetch authenticated audio blobs for playback
useEffect(() => {
const sceneAudioMap = state.sceneAudio;
if (!narrationEnabled || !sceneAudioMap || sceneAudioMap.size === 0) {
setAudioBlobUrls((prev) => {
prev.forEach((url) => URL.revokeObjectURL(url));
return new Map();
});
return;
}
let isMounted = true;
const loadAudioBlobs = async () => {
const entries = Array.from(sceneAudioMap.entries());
const blobEntries: Array<[number, string]> = [];
for (const [sceneNumber, audioPath] of entries) {
if (!audioPath) continue;
try {
const normalizedPath = audioPath.startsWith('/') ? audioPath : `/${audioPath}`;
const response = await aiApiClient.get(normalizedPath, {
responseType: 'blob',
});
const blobUrl = URL.createObjectURL(response.data);
blobEntries.push([sceneNumber, blobUrl]);
} catch (err) {
console.error('Failed to load audio blob:', err);
}
}
if (!isMounted) {
blobEntries.forEach(([, url]) => URL.revokeObjectURL(url));
return;
}
setAudioBlobUrls((prev) => {
prev.forEach((url) => URL.revokeObjectURL(url));
return new Map(blobEntries);
});
};
loadAudioBlobs();
return () => {
isMounted = false;
setAudioBlobUrls((prev) => {
prev.forEach((url) => URL.revokeObjectURL(url));
return new Map();
});
};
}, [state.sceneAudio, narrationEnabled]);
const handleGenerateAudio = async () => {
if (!state.outlineScenes || state.outlineScenes.length === 0) {
setError('Please generate a structured outline first');
return;
}
if (!narrationEnabled) {
setError('Narration feature is disabled in Story Setup.');
return;
}
if (selectedScenes.size === 0) {
setError('Please select at least one scene to generate audio for');
return;
}
setIsGeneratingAudio(true);
setError(null);
setAudioProgress(0);
try {
// Filter scenes to only selected ones
const scenesToGenerate = state.outlineScenes.filter((scene: any, index: number) => {
const sceneNumber = scene.scene_number || index + 1;
return selectedScenes.has(sceneNumber);
});
const response = await storyWriterApi.generateSceneAudio({
scenes: scenesToGenerate,
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);
setAudioProgress(100);
} else {
throw new Error('Failed to generate audio');
}
} catch (err: any) {
console.error('Audio generation failed:', err);
// Check if this is a subscription error (429/402)
const status = err?.response?.status;
if (status === 429 || status === 402) {
const handled = await triggerSubscriptionError(err);
if (handled) {
setIsGeneratingAudio(false);
return;
}
}
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate audio';
setError(errorMessage);
state.setError(errorMessage);
} finally {
setIsGeneratingAudio(false);
}
};
const handleGenerateVideo = async () => {
if (!state.outlineScenes || state.outlineScenes.length === 0) {
setError('Please generate a structured outline first');
return;
}
if (!videoEnabled) {
setError('Story video feature is disabled in Story Setup.');
return;
}
if (!hasImages) {
setError('Please generate images for scenes first');
return;
}
if (!hasAudio) {
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');
}
setVideoProgress(30);
// 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) {
console.error('Video generation failed:', err);
// Check if this is a subscription error (429/402)
const status = err?.response?.status;
if (status === 429 || status === 402) {
const handled = await triggerSubscriptionError(err);
if (handled) {
setIsGeneratingVideo(false);
return;
}
}
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);
}
};
if (!hasScenes) {
return null; // Don't show if no scenes available
}
return (
<Paper
sx={{
p: 3,
mb: 3,
backgroundColor: '#FAF9F6',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
}}
>
<Typography variant="h6" gutterBottom sx={{ mb: 2, fontWeight: 600, color: '#1A1611' }}>
Multimedia Generation
</Typography>
<Typography variant="body2" sx={{ mb: 3, color: '#5D4037' }}>
Generate audio narration and video for your story scenes.
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Audio Section */}
{narrationEnabled ? (
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<VolumeUpIcon sx={{ color: hasAudio ? '#4caf50' : '#5D4037' }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
Audio Narration
</Typography>
{hasAudio && (
<Chip
icon={<CheckCircleIcon />}
label="Generated"
size="small"
color="success"
sx={{ ml: 1 }}
/>
)}
</Box>
<Button
variant={hasAudio ? 'outlined' : 'contained'}
startIcon={isGeneratingAudio ? <CircularProgress size={16} /> : <VolumeUpIcon />}
onClick={handleGenerateAudio}
disabled={!canGenerateAudio || isGeneratingAudio}
>
{hasAudio
? 'Regenerate Selected'
: `Generate Audio (${selectedScenes.size} scene${selectedScenes.size !== 1 ? 's' : ''})`}
</Button>
</Box>
{hasScenes && state.outlineScenes && (
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" sx={{ color: '#5D4037', fontWeight: 500 }}>
Select scenes to generate audio for:
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="text"
onClick={handleSelectAllScenes}
sx={{ minWidth: 'auto', px: 1, fontSize: '0.75rem' }}
>
Select All
</Button>
<Button
size="small"
variant="text"
onClick={handleDeselectAllScenes}
sx={{ minWidth: 'auto', px: 1, fontSize: '0.75rem' }}
>
Deselect All
</Button>
<IconButton
size="small"
onClick={() => setShowSceneSelection(!showSceneSelection)}
sx={{ p: 0.5 }}
>
{showSceneSelection ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Box>
</Box>
<Collapse in={showSceneSelection}>
<FormGroup sx={{ pl: 1 }}>
{state.outlineScenes.map((scene: any, index: number) => {
const sceneNumber = scene.scene_number || index + 1;
const hasAudioForScene = state.sceneAudio?.has(sceneNumber);
return (
<FormControlLabel
key={sceneNumber}
control={
<Checkbox
checked={selectedScenes.has(sceneNumber)}
onChange={() => handleSceneSelectionToggle(sceneNumber)}
size="small"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2">
Scene {sceneNumber}: {scene.title || `Scene ${sceneNumber}`}
</Typography>
{hasAudioForScene && (
<CheckCircleIcon sx={{ fontSize: 16, color: '#4caf50' }} />
)}
</Box>
}
/>
);
})}
</FormGroup>
</Collapse>
</Box>
)}
{isGeneratingAudio && (
<Box sx={{ mt: 1 }}>
<LinearProgress variant="indeterminate" />
<Typography variant="caption" sx={{ mt: 0.5, color: '#5D4037', display: 'block' }}>
Generating audio for {selectedScenes.size} selected scene
{selectedScenes.size !== 1 ? 's' : ''}...
</Typography>
</Box>
)}
{hasAudio && state.sceneAudio && state.outlineScenes && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" sx={{ color: '#5D4037', fontSize: '0.875rem', mb: 2 }}>
Audio narration generated for {state.sceneAudio.size} scene(s). Listen to audio for each scene:
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{state.outlineScenes.map((scene: any, index: number) => {
const sceneNumber = scene.scene_number || index + 1;
const audioUrl = state.sceneAudio?.get(sceneNumber);
if (!audioUrl) return null;
const blobUrl = audioBlobUrls.get(sceneNumber);
return (
<Box
key={sceneNumber}
sx={{
p: 2,
backgroundColor: '#FFFFFF',
borderRadius: '8px',
border: '1px solid rgba(120, 90, 60, 0.2)',
}}
>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#1A1611' }}>
Scene {sceneNumber}: {scene.title || `Scene ${sceneNumber}`}
</Typography>
<audio
controls
src={blobUrl ? blobUrl : storyWriterApi.getAudioUrl(audioUrl)}
style={{ width: '100%' }}
>
Your browser does not support the audio element.
</audio>
</Box>
);
})}
</Box>
</Box>
)}
</Box>
) : (
<Alert severity="info" sx={{ mb: 3 }}>
Narration is disabled in Story Setup. Enable it to generate or listen to audio narration.
</Alert>
)}
<Divider sx={{ my: 3 }} />
{/* Video Section */}
{videoEnabled ? (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<VideoLibraryIcon sx={{ color: hasVideo ? '#4caf50' : '#5D4037' }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
Story Video
</Typography>
{hasVideo && (
<Chip
icon={<CheckCircleIcon />}
label="Generated"
size="small"
color="success"
sx={{ ml: 1 }}
/>
)}
{!hasVideo && !hasImages && (
<Chip label="Images required" size="small" color="warning" sx={{ ml: 1 }} />
)}
{!hasVideo && hasImages && !hasAudio && (
<Chip label="Audio required" size="small" color="warning" sx={{ ml: 1 }} />
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{hasVideo && (
<Button variant="outlined" startIcon={<DownloadIcon />} onClick={handleDownloadVideo}>
Download
</Button>
)}
<Button
variant={hasVideo ? 'outlined' : 'contained'}
startIcon={isGeneratingVideo ? <CircularProgress size={16} /> : <VideoLibraryIcon />}
onClick={handleGenerateVideo}
disabled={!canGenerateVideo || isGeneratingVideo}
>
{hasVideo ? 'Regenerate Video' : 'Generate Video'}
</Button>
</Box>
</Box>
{isGeneratingVideo && (
<Box sx={{ mt: 1 }}>
<LinearProgress
variant={videoProgress > 0 ? 'determinate' : 'indeterminate'}
value={videoProgress}
/>
<Typography variant="caption" sx={{ mt: 0.5, color: '#5D4037', display: 'block' }}>
Generating video... This may take a few minutes.
</Typography>
</Box>
)}
{hasVideo && state.storyVideo && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" sx={{ color: '#5D4037', mb: 1, fontSize: '0.875rem' }}>
Video ready! Preview and download below.
</Typography>
<Box
component="video"
controls
src={storyWriterApi.getVideoUrl(state.storyVideo)}
sx={{
width: '100%',
maxWidth: '600px',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}}
>
Your browser does not support the video tag.
</Box>
</Box>
)}
</Box>
) : (
<Alert severity="info">
Story video generation is disabled in Story Setup. Enable it to create narrative videos.
</Alert>
)}
</Paper>
);
};

View File

@@ -0,0 +1,246 @@
import React, { useState } from 'react';
import {
Box,
IconButton,
Tooltip,
Badge,
CircularProgress,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Divider,
} from '@mui/material';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import VideoLibraryIcon from '@mui/icons-material/VideoLibrary';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RefreshIcon from '@mui/icons-material/Refresh';
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
interface MultimediaToolbarProps {
state: ReturnType<typeof useStoryWriterState>;
onGenerateAudio?: () => void;
onGenerateVideo?: () => void;
isGeneratingAudio?: boolean;
isGeneratingVideo?: boolean;
onOpenPanel?: (section: 'audio' | 'video') => void;
}
export const MultimediaToolbar: React.FC<MultimediaToolbarProps> = ({
state,
onGenerateAudio,
onGenerateVideo,
isGeneratingAudio = false,
isGeneratingVideo = false,
onOpenPanel,
}) => {
const hasScenes = state.isOutlineStructured && state.outlineScenes && state.outlineScenes.length > 0;
const hasAudio = state.enableNarration && state.sceneAudio && state.sceneAudio.size > 0;
const hasVideo = state.enableVideoNarration && !!state.storyVideo;
const hasImages = state.sceneImages && state.sceneImages.size > 0;
// Determine if audio generation is available
const audioFeatureEnabled = state.enableNarration;
const videoFeatureEnabled = state.enableVideoNarration;
const canGenerateAudio = hasScenes && audioFeatureEnabled && !isGeneratingAudio;
const canGenerateVideo = hasScenes && videoFeatureEnabled && hasImages && hasAudio && !isGeneratingVideo;
// Determine status for each
const audioStatus = hasAudio ? 'success' : isGeneratingAudio ? 'loading' : 'idle';
const videoStatus = hasVideo ? 'success' : isGeneratingVideo ? 'loading' : canGenerateVideo ? 'ready' : 'disabled';
const [audioMenuAnchor, setAudioMenuAnchor] = useState<null | HTMLElement>(null);
const [videoMenuAnchor, setVideoMenuAnchor] = useState<null | HTMLElement>(null);
const handleAudioMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAudioMenuAnchor(event.currentTarget);
};
const handleAudioMenuClose = () => {
setAudioMenuAnchor(null);
};
const handleVideoMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setVideoMenuAnchor(event.currentTarget);
};
const handleVideoMenuClose = () => {
setVideoMenuAnchor(null);
};
const handleOpenPanel = (section: 'audio' | 'video') => {
handleAudioMenuClose();
handleVideoMenuClose();
onOpenPanel?.(section);
};
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Audio Generation Button */}
<Tooltip
title={
!audioFeatureEnabled
? 'Enable Narration in Story Setup'
: !hasScenes
? 'Generate outline first'
: hasAudio
? 'Audio generated ✓'
: isGeneratingAudio
? 'Generating audio...'
: 'Generate audio narration'
}
>
<span>
<IconButton
onClick={handleAudioMenuOpen}
disabled={!hasScenes || !audioFeatureEnabled}
sx={{
color: 'rgba(255, 255, 255, 0.9)',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
},
'&:disabled': {
color: 'rgba(255, 255, 255, 0.4)',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
}}
size="small"
>
{isGeneratingAudio ? (
<CircularProgress size={20} sx={{ color: 'rgba(255, 255, 255, 0.9)' }} />
) : hasAudio ? (
<Badge
overlap="circular"
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
badgeContent={
<CheckCircleIcon sx={{ fontSize: 12, color: '#4caf50' }} />
}
>
<VolumeUpIcon fontSize="small" />
</Badge>
) : (
<VolumeUpIcon fontSize="small" />
)}
</IconButton>
</span>
</Tooltip>
<Menu
anchorEl={audioMenuAnchor}
open={Boolean(audioMenuAnchor)}
onClose={handleAudioMenuClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<MenuItem onClick={() => handleOpenPanel('audio')} disabled={!hasScenes || !audioFeatureEnabled}>
<ListItemIcon>
<PlayArrowIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Listen & manage audio" />
</MenuItem>
<Divider />
<MenuItem
onClick={() => {
handleAudioMenuClose();
onGenerateAudio?.();
}}
disabled={!canGenerateAudio || !onGenerateAudio}
>
<ListItemIcon>
<RefreshIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primary={hasAudio ? 'Regenerate audio' : 'Generate audio for all scenes'}
/>
</MenuItem>
</Menu>
{/* Video Generation Button */}
<Tooltip
title={
!videoFeatureEnabled
? 'Enable Story Video in Story Setup'
: !hasScenes
? 'Generate outline first'
: !hasImages
? 'Generate images first'
: !hasAudio
? 'Generate audio first'
: hasVideo
? 'Video generated ✓'
: isGeneratingVideo
? 'Generating video...'
: 'Generate video'
}
>
<span>
<IconButton
onClick={handleVideoMenuOpen}
disabled={!hasScenes || !videoFeatureEnabled}
sx={{
color: 'rgba(255, 255, 255, 0.9)',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
},
'&:disabled': {
color: 'rgba(255, 255, 255, 0.4)',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
}}
size="small"
>
{isGeneratingVideo ? (
<CircularProgress size={20} sx={{ color: 'rgba(255, 255, 255, 0.9)' }} />
) : hasVideo ? (
<Badge
overlap="circular"
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
badgeContent={
<CheckCircleIcon sx={{ fontSize: 12, color: '#4caf50' }} />
}
>
<VideoLibraryIcon fontSize="small" />
</Badge>
) : (
<VideoLibraryIcon fontSize="small" />
)}
</IconButton>
</span>
</Tooltip>
<Menu
anchorEl={videoMenuAnchor}
open={Boolean(videoMenuAnchor)}
onClose={handleVideoMenuClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<MenuItem onClick={() => handleOpenPanel('video')} disabled={!hasScenes || !videoFeatureEnabled}>
<ListItemIcon>
<PlayArrowIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="View video options" />
</MenuItem>
<Divider />
<MenuItem
onClick={() => {
handleVideoMenuClose();
onGenerateVideo?.();
}}
disabled={!canGenerateVideo || !onGenerateVideo}
>
<ListItemIcon>
<RefreshIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primary={hasVideo ? 'Regenerate video' : 'Generate video'}
/>
</MenuItem>
</Menu>
</Box>
);
};

View File

@@ -0,0 +1,624 @@
import React, { useState, useEffect, useRef } from 'react';
import { Badge, IconButton, Menu, MenuItem, Typography, Box, Divider, Chip, Tooltip, List, ListItem, ListItemText, ListItemIcon, Button } from '@mui/material';
import { Notifications as NotificationsIcon, NotificationsActive as NotificationsActiveIcon } from '@mui/icons-material';
import { Warning as WarningIcon, Error as ErrorIcon, Info as InfoIcon, CheckCircle as CheckCircleIcon } from '@mui/icons-material';
import { billingService } from '../../services/billingService';
import { useAuth } from '@clerk/clerk-react';
import { getTasksNeedingIntervention, TaskNeedingIntervention } from '../../api/schedulerDashboard';
interface Alert {
id: string;
type: string;
title: string;
message: string;
severity: 'error' | 'warning' | 'info';
priority: 'high' | 'medium' | 'low';
is_read: boolean;
created_at: string;
source: 'billing' | 'scheduler' | 'task';
metadata?: Record<string, any>;
groupKey?: string;
}
interface AlertGroup {
id: string;
title: string;
source: Alert['source'];
severity: Alert['severity'];
priority: 'high' | 'medium' | 'low';
summary: string;
count: number;
latestTimestamp: string;
alerts: Alert[];
metadata?: Record<string, any>;
actionLabel?: string;
actionHref?: string;
}
interface AlertsBadgeProps {
colorMode?: 'light' | 'dark';
}
const AlertsBadge: React.FC<AlertsBadgeProps> = ({ colorMode = 'light' }) => {
const { userId } = useAuth();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [alerts, setAlerts] = useState<Alert[]>([]);
const [alertGroups, setAlertGroups] = useState<AlertGroup[]>([]);
const [loading, setLoading] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const open = Boolean(anchorEl);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const isPollingRef = useRef(false);
const schedulerDismissedRef = useRef<Set<string>>(new Set());
const getSchedulerStorageKey = (uid: string) => `scheduler_alerts_dismissed_${uid}`;
const loadSchedulerDismissed = (uid: string) => {
if (!uid) return new Set<string>();
try {
const stored = localStorage.getItem(getSchedulerStorageKey(uid));
if (!stored) return new Set<string>();
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
return new Set(parsed);
}
return new Set<string>();
} catch {
return new Set<string>();
}
};
const persistSchedulerDismissed = (uid: string, dismissed: Set<string>) => {
if (!uid) return;
try {
localStorage.setItem(getSchedulerStorageKey(uid), JSON.stringify(Array.from(dismissed)));
} catch {
// ignore storage errors
}
};
const dismissSchedulerAlert = (alertId: string) => {
if (!userId) return;
const updated = new Set(schedulerDismissedRef.current);
updated.add(alertId);
schedulerDismissedRef.current = updated;
persistSchedulerDismissed(userId, updated);
};
useEffect(() => {
if (!userId) return;
schedulerDismissedRef.current = loadSchedulerDismissed(userId);
}, [userId]);
// Fetch all alerts
const rebuildGroups = (alertList: Alert[]) => {
const groups = buildAlertGroups(alertList);
setAlertGroups(groups);
const unreadGroups = groups.filter(group => group.alerts.some(alert => !alert.is_read)).length;
setUnreadCount(unreadGroups);
};
const fetchAlerts = async () => {
if (!userId || isPollingRef.current) return;
try {
isPollingRef.current = true;
setLoading(true);
const allAlerts: Alert[] = [];
// Phase 1: Fetch billing alerts
try {
const billingAlerts = await billingService.getUsageAlerts(userId, true);
const formattedBillingAlerts: Alert[] = billingAlerts.map((alert: any) => ({
id: `billing-${alert.id}`,
type: alert.type,
title: alert.title || 'Billing Alert',
message: alert.message,
severity: alert.severity || 'warning',
priority: mapSeverityToPriority(alert.severity || 'warning'),
is_read: alert.is_read || false,
created_at: alert.created_at,
source: 'billing' as const,
groupKey: `billing-${alert.type}-${alert.title || 'alert'}`
}));
allAlerts.push(...formattedBillingAlerts);
} catch (error) {
console.error('Error fetching billing alerts:', error);
}
// Phase 2: Fetch scheduler/task alerts
try {
const taskAlerts = await getTasksNeedingIntervention(userId);
const formattedSchedulerAlerts: Alert[] = taskAlerts.map((task: TaskNeedingIntervention) => {
const alertId = `scheduler-${task.task_type}-${task.task_id}`;
const failureReason = task.failure_pattern?.failure_reason || 'unknown';
const reasonInfo = failureReasonDetails[failureReason] || failureReasonDetails.unknown;
const taskLabel = formatTaskDisplayName(task);
const message = buildSchedulerAlertMessage(task);
const timestamp = task.failure_pattern?.last_failure_time || task.last_failure || new Date().toISOString();
return {
id: alertId,
type: 'scheduler_task_failure',
title: `Task needs attention: ${taskLabel}`,
message,
severity: reasonInfo.severity,
priority: mapSchedulerReasonToPriority(failureReason),
is_read: schedulerDismissedRef.current.has(alertId),
created_at: timestamp,
source: 'scheduler' as const,
metadata: {
taskId: task.task_id,
taskType: task.task_type,
failureReason,
occurrences: task.failure_pattern?.consecutive_failures ?? 0,
lastFailure: timestamp,
},
groupKey: `scheduler-${task.task_type}-${task.task_id}`
};
});
allAlerts.push(...formattedSchedulerAlerts);
} catch (error) {
console.error('Error fetching scheduler alerts:', error);
}
// Sort alerts by created_at (newest first)
allAlerts.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
setAlerts(allAlerts);
rebuildGroups(allAlerts);
} catch (error) {
console.error('Error fetching alerts:', error);
} finally {
setLoading(false);
isPollingRef.current = false;
}
};
// Poll for alerts
useEffect(() => {
if (!userId) return;
fetchAlerts();
// Poll every 60 seconds
intervalRef.current = setInterval(() => {
fetchAlerts();
}, 60000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
const handleOpen = (e: React.MouseEvent<HTMLElement>) => {
setAnchorEl(e.currentTarget);
// Refresh alerts when menu opens
fetchAlerts();
};
const handleClose = () => {
setAnchorEl(null);
};
const handleMarkAsRead = async (alert: Alert) => {
try {
if (alert.source === 'billing') {
const numericId = Number(alert.id.replace('billing-', ''));
if (!Number.isNaN(numericId)) {
await billingService.markAlertRead(numericId);
}
} else if (alert.source === 'scheduler') {
dismissSchedulerAlert(alert.id);
}
// Update local state
const updated = alerts.map(a => (a.id === alert.id ? { ...a, is_read: true } : a));
setAlerts(updated);
rebuildGroups(updated);
} catch (error) {
console.error('Error marking alert as read:', error);
}
};
const handleGroupClick = async (group: AlertGroup) => {
for (const alert of group.alerts.filter(a => !a.is_read)) {
await handleMarkAsRead(alert);
}
};
const handleMarkAllAsRead = async () => {
try {
for (const alert of alerts.filter(a => !a.is_read)) {
await handleMarkAsRead(alert);
}
} catch (error) {
console.error('Error marking all alerts as read:', error);
}
};
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'error':
return <ErrorIcon sx={{ color: '#f44336', fontSize: 20 }} />;
case 'warning':
return <WarningIcon sx={{ color: '#ff9800', fontSize: 20 }} />;
case 'info':
return <InfoIcon sx={{ color: '#2196f3', fontSize: 20 }} />;
default:
return <InfoIcon sx={{ color: '#757575', fontSize: 20 }} />;
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'error':
return '#f44336';
case 'warning':
return '#ff9800';
case 'info':
return '#2196f3';
default:
return '#757575';
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
if (!userId) return null;
return (
<>
<Tooltip title={unreadCount > 0 ? `${unreadCount} unread alert${unreadCount > 1 ? 's' : ''}` : 'No alerts'}>
<IconButton
onClick={handleOpen}
sx={{
color: colorMode === 'dark' ? 'white' : 'inherit',
position: 'relative',
}}
>
<Badge badgeContent={unreadCount} color="error" max={99}>
{unreadCount > 0 ? (
<NotificationsActiveIcon sx={{ color: '#ff9800' }} />
) : (
<NotificationsIcon />
)}
</Badge>
</IconButton>
</Tooltip>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
PaperProps={{
sx: {
minWidth: 360,
maxWidth: 450,
maxHeight: '80vh',
overflow: 'auto',
}
}}
>
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
Alerts
</Typography>
{unreadCount > 0 && (
<Button
size="small"
onClick={handleMarkAllAsRead}
sx={{ fontSize: '0.75rem', textTransform: 'none' }}
>
Mark all as read
</Button>
)}
</Box>
</Box>
{loading && alertGroups.length === 0 ? (
<Box sx={{ px: 2, py: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Loading alerts...
</Typography>
</Box>
) : alertGroups.length === 0 ? (
<Box sx={{ px: 2, py: 4, textAlign: 'center' }}>
<CheckCircleIcon sx={{ fontSize: 48, color: '#4caf50', mb: 1 }} />
<Typography variant="body2" color="text.secondary">
No alerts
</Typography>
</Box>
) : (
<List sx={{ p: 0 }}>
{alertGroups.map((group, index) => (
<React.Fragment key={group.id}>
<ListItem
sx={{
bgcolor: group.alerts.every(a => a.is_read) ? 'transparent' : 'rgba(255, 152, 0, 0.05)',
'&:hover': {
bgcolor: 'rgba(0,0,0,0.04)',
},
cursor: 'pointer',
}}
onClick={() => handleGroupClick(group)}
>
<ListItemIcon>
{getSeverityIcon(group.severity)}
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: group.alerts.every(a => a.is_read) ? 400 : 700 }}>
{group.title}
</Typography>
<Chip
label={group.source}
size="small"
sx={{
height: 18,
fontSize: '0.65rem',
bgcolor: `${getSeverityColor(group.severity)}20`,
color: getSeverityColor(group.severity),
fontWeight: 600,
}}
/>
<Chip
label={`${group.count} occurrence${group.count > 1 ? 's' : ''}`}
size="small"
sx={{
height: 18,
fontSize: '0.65rem',
bgcolor: 'rgba(0,0,0,0.08)',
color: colorMode === 'dark' ? 'white' : 'inherit',
}}
/>
<Chip
label={`${group.priority.toUpperCase()} priority`}
size="small"
sx={{
height: 18,
fontSize: '0.65rem',
bgcolor: priorityStyles[group.priority].bg,
color: priorityStyles[group.priority].color,
fontWeight: 600,
}}
/>
</Box>
}
secondary={
<>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{group.summary}
</Typography>
{group.alerts.slice(0, 2).map((alert, idx) => (
<Typography key={alert.id} variant="caption" color="text.secondary" sx={{ display: 'block', mt: idx === 0 ? 0.5 : 0 }}>
{alert.message}
</Typography>
))}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
Last alert: {formatDate(group.latestTimestamp)}
</Typography>
{group.actionHref && (
<Button
size="small"
sx={{ mt: 1, textTransform: 'none', fontSize: '0.75rem' }}
onClick={(event) => {
event.stopPropagation();
if (group.actionHref?.startsWith('http')) {
window.open(group.actionHref, '_blank');
} else {
window.location.href = group.actionHref!;
}
}}
>
{group.actionLabel || 'View Details'}
</Button>
)}
</>
}
/>
</ListItem>
{index < alertGroups.length - 1 && <Divider />}
</React.Fragment>
))}
</List>
)}
{alertGroups.length > 0 && (
<>
<Divider />
<Box sx={{ px: 2, py: 1, textAlign: 'center' }}>
<Button
size="small"
onClick={() => {
handleClose();
window.location.href = '/billing';
}}
sx={{ fontSize: '0.75rem', textTransform: 'none' }}
>
View All Alerts
</Button>
</Box>
</>
)}
</Menu>
</>
);
};
const failureReasonDetails: Record<string, { label: string; severity: 'error' | 'warning' | 'info'; guidance: string }> = {
api_limit: {
label: 'API limit exceeded',
severity: 'error',
guidance: 'Usage quota exceeded. Consider upgrading or waiting for quota reset.',
},
auth_error: {
label: 'Authentication error',
severity: 'error',
guidance: 'Refresh your platform credentials and retry the task.',
},
network_error: {
label: 'Network error',
severity: 'warning',
guidance: 'Network instability detected. Retry once connectivity is restored.',
},
config_error: {
label: 'Configuration issue',
severity: 'warning',
guidance: 'Review task configuration and ensure required inputs are set.',
},
unknown: {
label: 'Unknown failure',
severity: 'info',
guidance: 'Check task logs for more details.',
},
};
const formatTaskDisplayName = (task: TaskNeedingIntervention): string => {
if (task.task_type === 'oauth_token_monitoring') {
return `OAuth ${task.platform?.toUpperCase() || 'Token'}`;
}
if (task.task_type === 'website_analysis') {
if (task.website_url) {
return `Website Analysis (${task.website_url})`;
}
return 'Website Analysis';
}
if (task.task_type.includes('_insights')) {
return `${task.platform?.toUpperCase() || 'Platform'} Insights`;
}
return task.task_type.replace(/_/g, ' ');
};
const buildSchedulerAlertMessage = (task: TaskNeedingIntervention): string => {
const reasonKey = task.failure_pattern?.failure_reason || 'unknown';
const reasonInfo = failureReasonDetails[reasonKey] || failureReasonDetails.unknown;
const consecutive = task.failure_pattern?.consecutive_failures ?? 0;
const recent = task.failure_pattern?.recent_failures ?? 0;
return `${reasonInfo.label}. ${consecutive} consecutive failures, ${recent} in the last 7 days. ${reasonInfo.guidance}`;
};
const getAlertAction = (alert: Alert): { label?: string; href?: string } => {
if (alert.source === 'billing') {
return {
label: 'Open Billing',
href: '/billing',
};
}
if (alert.source === 'scheduler') {
const taskId = alert.metadata?.taskId;
if (taskId) {
return {
label: `Review Task #${taskId}`,
href: `/scheduler?taskId=${taskId}`,
};
}
return {
label: 'View Scheduler',
href: '/scheduler#tasks',
};
}
if (alert.source === 'task') {
return {
label: 'View Tasks',
href: '/tasks',
};
}
return {};
};
const mapSeverityToPriority = (severity: string): 'high' | 'medium' | 'low' => {
if (severity === 'error') return 'high';
if (severity === 'warning') return 'medium';
return 'low';
};
const mapSchedulerReasonToPriority = (reason: string): 'high' | 'medium' | 'low' => {
switch (reason) {
case 'api_limit':
case 'auth_error':
return 'high';
case 'network_error':
return 'medium';
default:
return 'low';
}
};
const priorityRank: Record<'high' | 'medium' | 'low', number> = {
high: 0,
medium: 1,
low: 2,
};
const priorityStyles: Record<'high' | 'medium' | 'low', { bg: string; color: string }> = {
high: { bg: 'rgba(244,67,54,0.15)', color: '#f44336' },
medium: { bg: 'rgba(255,152,0,0.2)', color: '#ff9800' },
low: { bg: 'rgba(33,150,243,0.15)', color: '#2196f3' },
};
const buildAlertGroups = (alertList: Alert[]): AlertGroup[] => {
const map = new Map<string, AlertGroup>();
for (const alert of alertList) {
const key = alert.groupKey || `${alert.source}-${alert.type}-${alert.title}`;
const existing = map.get(key);
const timestamp = alert.created_at;
if (existing) {
existing.count += 1;
existing.alerts.push(alert);
if (new Date(timestamp).getTime() > new Date(existing.latestTimestamp).getTime()) {
existing.latestTimestamp = timestamp;
existing.summary = alert.message;
}
if (priorityRank[alert.priority] < priorityRank[existing.priority]) {
existing.priority = alert.priority;
existing.severity = alert.severity;
}
} else {
const action = getAlertAction(alert);
map.set(key, {
id: key,
title: alert.title,
source: alert.source,
severity: alert.severity,
priority: alert.priority,
summary: alert.message,
count: 1,
latestTimestamp: timestamp,
alerts: [alert],
metadata: alert.metadata,
actionLabel: action.label,
actionHref: action.href,
});
}
}
return Array.from(map.values()).sort((a, b) => {
const priorityCompare = priorityRank[a.priority] - priorityRank[b.priority];
if (priorityCompare !== 0) return priorityCompare;
return new Date(b.latestTimestamp).getTime() - new Date(a.latestTimestamp).getTime();
});
};
export default AlertsBadge;

View File

@@ -2,8 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Box, Typography, Chip, Button, Tooltip } from '@mui/material';
import { PlayArrow } from '@mui/icons-material';
import { ShimmerHeader } from './styled';
import UserBadge from './UserBadge';
import UsageDashboard from './UsageDashboard';
import HeaderControls from './HeaderControls';
import { DashboardHeaderProps } from './types';
const DashboardHeader: React.FC<DashboardHeaderProps> = ({
@@ -407,10 +406,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
)}
{rightContent}
{/* Usage Dashboard - Show API usage statistics */}
<UsageDashboard compact={true} />
<UserBadge colorMode="dark" />
<HeaderControls colorMode="dark" />
</Box>
</Box>
</ShimmerHeader>

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { Box } from '@mui/material';
import AlertsBadge from './AlertsBadge';
import UserBadge from './UserBadge';
interface HeaderControlsProps {
colorMode?: 'light' | 'dark';
showAlerts?: boolean;
showUser?: boolean;
gap?: number;
}
const HeaderControls: React.FC<HeaderControlsProps> = ({
colorMode = 'light',
showAlerts = true,
showUser = true,
gap = 1.5,
}) => {
if (!showAlerts && !showUser) {
return null;
}
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap }}>
{showAlerts && <AlertsBadge colorMode={colorMode} />}
{showUser && <UserBadge colorMode={colorMode} />}
</Box>
);
};
export default HeaderControls;

View File

@@ -1,7 +1,10 @@
import React from 'react';
import { Avatar, Box, Menu, MenuItem, Typography, Tooltip, Chip } from '@mui/material';
import React, { useState, useEffect } from 'react';
import { Avatar, Box, Menu, MenuItem, Typography, Tooltip, Chip, Divider } from '@mui/material';
import { useUser, useClerk } from '@clerk/clerk-react';
import { useSubscription } from '../../contexts/SubscriptionContext';
import SystemStatusIndicator from '../ContentPlanningDashboard/components/SystemStatusIndicator';
import UsageDashboard from './UsageDashboard';
import { apiClient } from '../../api/client';
interface UserBadgeProps {
colorMode?: 'light' | 'dark';
@@ -12,6 +15,7 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
const { signOut } = useClerk();
const { subscription } = useSubscription();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const [systemStatus, setSystemStatus] = useState<'healthy' | 'warning' | 'critical' | 'unknown'>('unknown');
const open = Boolean(anchorEl);
const initials = React.useMemo(() => {
@@ -20,8 +24,43 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
return (first + last || user?.username?.[0] || user?.primaryEmailAddress?.emailAddress?.[0] || '?').toUpperCase();
}, [user]);
// Fetch system status for status bulb
useEffect(() => {
const fetchSystemStatus = async () => {
try {
const response = await apiClient.get('/api/content-planning/monitoring/lightweight-stats');
const result = response.data;
if (result.status === 'success' && result.data) {
setSystemStatus(result.data.status || 'unknown');
}
} catch (err) {
console.error('Error fetching system status:', err);
setSystemStatus('unknown');
}
};
fetchSystemStatus();
// Refresh every 30 seconds
const interval = setInterval(fetchSystemStatus, 30000);
return () => clearInterval(interval);
}, []);
if (!isSignedIn) return null;
// Get status bulb color
const getStatusBulbColor = () => {
switch (systemStatus) {
case 'healthy':
return '#4caf50'; // Green
case 'warning':
return '#ff9800'; // Orange
case 'critical':
return '#f44336'; // Red
default:
return '#757575'; // Gray for unknown
}
};
// Get plan display info
const getPlanColor = () => {
switch (subscription?.plan) {
@@ -65,24 +104,65 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
}}
/>
<Tooltip title={`${user?.fullName || user?.username || user?.primaryEmailAddress?.emailAddress || 'User'}`}>
<Avatar
onClick={handleOpen}
sx={{
width: 36,
height: 36,
cursor: 'pointer',
bgcolor: colorMode === 'dark' ? 'rgba(255,255,255,0.2)' : 'primary.main',
color: colorMode === 'dark' ? 'white' : 'white',
fontWeight: 700,
}}
src={user?.imageUrl || undefined}
>
{initials}
</Avatar>
<Tooltip title={`${user?.fullName || user?.username || user?.primaryEmailAddress?.emailAddress || 'User'} - System: ${systemStatus.toUpperCase()}`}>
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
<Avatar
onClick={handleOpen}
sx={{
width: 36,
height: 36,
cursor: 'pointer',
bgcolor: colorMode === 'dark' ? 'rgba(255,255,255,0.2)' : 'primary.main',
color: colorMode === 'dark' ? 'white' : 'white',
fontWeight: 700,
}}
src={user?.imageUrl || undefined}
>
{initials}
</Avatar>
{/* Status Bulb */}
<Box
sx={{
position: 'absolute',
bottom: 0,
right: 0,
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: getStatusBulbColor(),
border: `2px solid ${colorMode === 'dark' ? '#1a1a1a' : 'white'}`,
boxShadow: `0 0 8px ${getStatusBulbColor()}80`,
animation: systemStatus === 'healthy' ? 'pulse 2s ease-in-out infinite' : 'none',
'@keyframes pulse': {
'0%, 100%': {
opacity: 1,
transform: 'scale(1)',
},
'50%': {
opacity: 0.8,
transform: 'scale(1.1)',
},
},
}}
/>
</Box>
</Tooltip>
<Menu anchorEl={anchorEl} open={open} onClose={handleClose} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} transformOrigin={{ vertical: 'top', horizontal: 'right' }}>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
PaperProps={{
sx: {
minWidth: 320,
maxWidth: 400,
maxHeight: '80vh',
overflow: 'auto'
}
}}
>
<Box sx={{ px: 2, py: 1, borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{user?.fullName || user?.username || 'User'}
@@ -110,6 +190,50 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
/>
</Box>
<Divider sx={{ my: 1 }} />
{/* System Status Indicator */}
<Box
sx={{
px: 2,
py: 1.5,
bgcolor: 'rgba(0,0,0,0.02)',
maxWidth: '100%',
overflow: 'hidden'
}}
onClick={(e) => e.stopPropagation()}
>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1, fontWeight: 600 }}>
System Health
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'center', '& > *': { transform: 'scale(0.85)' } }}>
<SystemStatusIndicator />
</Box>
</Box>
<Divider sx={{ my: 1 }} />
{/* Usage Dashboard */}
<Box
sx={{
px: 2,
py: 1.5,
bgcolor: 'rgba(0,0,0,0.02)',
maxWidth: '100%',
overflow: 'auto'
}}
onClick={(e) => e.stopPropagation()}
>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1, fontWeight: 600 }}>
Usage Statistics
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'center', flexWrap: 'wrap', gap: 0.5 }}>
<UsageDashboard compact={true} />
</Box>
</Box>
<Divider sx={{ my: 1 }} />
<MenuItem onClick={() => { handleClose(); window.location.href = '/pricing'; }}>
Manage Subscription
</MenuItem>

View File

@@ -20,6 +20,7 @@ import {
Audiotrack as AudioIcon,
VideoLibrary as VideoIcon
} from '@mui/icons-material';
import MenuBookIcon from '@mui/icons-material/MenuBook';
import { ToolCategories } from '../components/shared/types';
export const toolCategories: ToolCategories = {
@@ -37,6 +38,15 @@ export const toolCategories: ToolCategories = {
features: ['SEO Optimized', 'Multiple Formats', 'Custom Tone', 'Research Integration', 'Plagiarism Free'],
isHighlighted: true
},
{
name: 'Story Writer',
description: 'Create stories with AI: outline, images, narration, and video',
icon: React.createElement(MenuBookIcon),
status: 'beta',
path: '/story-writer',
features: ['Structured Outline', 'Image Generation', 'Audio Narration', 'Story Video'],
isHighlighted: true
},
{
name: 'Image Generator',
description: 'AI image creation and visual content generation',

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
import { useAuth } from '@clerk/clerk-react';
import { apiClient } from '../api/client';
import { showToastNotification } from '../utils/toastNotifications';
import { getTasksNeedingIntervention, TaskNeedingIntervention } from '../api/schedulerDashboard';
/**
* Hook to poll for tasks needing intervention and show toast notifications
@@ -31,32 +31,7 @@ export function useSchedulerTaskAlerts(options: {
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 || [];
const tasks: TaskNeedingIntervention[] = await getTasksNeedingIntervention(userId);
// Show toast only for critical failures (API limits) - other failures are shown in dedicated section
for (const task of tasks) {

View File

@@ -22,6 +22,7 @@ export interface StoryWriterState {
storyLength: string;
enableExplainer: boolean;
enableIllustration: boolean;
enableNarration: boolean;
enableVideoNarration: boolean;
// Image generation settings
@@ -75,6 +76,7 @@ const DEFAULT_STATE: Partial<StoryWriterState> = {
storyLength: 'Medium',
enableExplainer: true,
enableIllustration: true,
enableNarration: true,
enableVideoNarration: true,
// Image generation settings
imageProvider: null,
@@ -252,6 +254,10 @@ export const useStoryWriterState = () => {
setState((prev) => ({ ...prev, enableIllustration: enabled }));
}, []);
const setEnableNarration = useCallback((enabled: boolean) => {
setState((prev) => ({ ...prev, enableNarration: enabled }));
}, []);
const setEnableVideoNarration = useCallback((enabled: boolean) => {
setState((prev) => ({ ...prev, enableVideoNarration: enabled }));
}, []);
@@ -371,6 +377,7 @@ export const useStoryWriterState = () => {
story_length: state.storyLength,
enable_explainer: state.enableExplainer,
enable_illustration: state.enableIllustration,
enable_narration: state.enableNarration,
enable_video_narration: state.enableVideoNarration,
// Image generation settings
image_provider: state.imageProvider || undefined,
@@ -422,6 +429,7 @@ export const useStoryWriterState = () => {
setStoryLength,
setEnableExplainer,
setEnableIllustration,
setEnableNarration,
setEnableVideoNarration,
setImageProvider,
setImageWidth,

View File

@@ -38,6 +38,7 @@ import TaskMonitoringTabs from '../components/SchedulerDashboard/TaskMonitoringT
import { TerminalTypography, terminalColors } from '../components/SchedulerDashboard/terminalTheme';
import { useSchedulerTaskAlerts } from '../hooks/useSchedulerTaskAlerts';
import TasksNeedingIntervention from '../components/SchedulerDashboard/TasksNeedingIntervention';
import HeaderControls from '../components/shared/HeaderControls';
// Terminal-themed styled components
const TerminalContainer = styled(Container)(({ theme }) => ({
@@ -451,6 +452,7 @@ const SchedulerDashboard: React.FC = () => {
</TerminalIconButton>
</span>
</Tooltip>
<HeaderControls colorMode="dark" />
</Box>
</Box>

View File

@@ -20,6 +20,7 @@ export interface StoryGenerationRequest {
story_length?: string;
enable_explainer?: boolean;
enable_illustration?: boolean;
enable_narration?: boolean;
enable_video_narration?: boolean;
// Image generation settings
image_provider?: string;