AI Story Writer Backend Migration Complete, Frontend UI Components Added
This commit is contained in:
12
.github/FUNDING.yml
vendored
12
.github/FUNDING.yml
vendored
@@ -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
640
.github/README.md
vendored
@@ -1,559 +1,163 @@
|
||||
# 🚀 ALwrity - AI-Powered Digital Marketing Platform
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
# 🚀 ALwrity — AI-Powered Digital Marketing Platform
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://fastapi.tiangolo.com/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://github.com/AJaySi/AI-Writer/stargazers)
|
||||
[](https://github.com/AJaySi/AI-Writer/network/members)
|
||||
[](https://react.dev/)
|
||||
[](https://github.com/AJaySi/AI-Writer/stargazers)
|
||||
|
||||
**🌟 The Ultimate AI-Powered Digital Marketing Platform for Solopreneurs & Content Creators**
|
||||
**Create, optimize, and publish high‑quality 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.
|
||||
|
||||
---
|
||||
|
||||
### What’s 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, Google‑Grounded)**: 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 Facebook‑specific 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, Material‑UI, CopilotKit |
|
||||
| AI/Research | OpenAI, Gemini/Imagen, Hugging Face, Anthropic, Mistral; Exa, Tavily, Serper (auto provider selection: Gemini default, HF fallback) |
|
||||
| Data | SQLite (PostgreSQL‑ready) |
|
||||
| Integrations | Google Search Console, LinkedIn |
|
||||
| Ops | Loguru monitoring, rate limiting, JWT/OAuth2 |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Technology Stack (Current Implementation)
|
||||
### LLM Providers: Gemini & Hugging Face
|
||||
- **Auto‑selection**: The backend auto‑selects 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">
|
||||
|
||||
[](https://github.com/AJaySi/AI-Writer/discussions)
|
||||
[](https://discord.gg/alwrity)
|
||||
[](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">
|
||||
|
||||

|
||||
|
||||
[](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!** 🚀
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/story_audio/scene_4_Gravity_s_Gentle_Pull_496a7494.mp3
Normal file
BIN
backend/story_audio/scene_4_Gravity_s_Gentle_Pull_496a7494.mp3
Normal file
Binary file not shown.
BIN
backend/story_audio/scene_4_Gravity_s_Gentle_Pull_a1f7e80d.mp3
Normal file
BIN
backend/story_audio/scene_4_Gravity_s_Gentle_Pull_a1f7e80d.mp3
Normal file
Binary file not shown.
BIN
backend/story_audio/scene_4_Gravity_s_Gentle_Pull_fdd8a3cb.mp3
Normal file
BIN
backend/story_audio/scene_4_Gravity_s_Gentle_Pull_fdd8a3cb.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/story_audio/scene_6_The_Birth_of_a_New_Star_0ac0e570.mp3
Normal file
BIN
backend/story_audio/scene_6_The_Birth_of_a_New_Star_0ac0e570.mp3
Normal file
Binary file not shown.
BIN
backend/story_audio/scene_6_The_Birth_of_a_New_Star_245475df.mp3
Normal file
BIN
backend/story_audio/scene_6_The_Birth_of_a_New_Star_245475df.mp3
Normal file
Binary file not shown.
BIN
backend/story_audio/scene_6_The_Birth_of_a_New_Star_80b3f63a.mp3
Normal file
BIN
backend/story_audio/scene_6_The_Birth_of_a_New_Star_80b3f63a.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
docs-site/docs/assests/assistive-1.png
Normal file
BIN
docs-site/docs/assests/assistive-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 195 KiB |
BIN
docs-site/docs/assests/assistive-2.png
Normal file
BIN
docs-site/docs/assests/assistive-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
BIN
docs-site/docs/assests/hero-1.jpg
Normal file
BIN
docs-site/docs/assests/hero-1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 300 KiB |
BIN
docs-site/docs/assests/hero-2.png
Normal file
BIN
docs-site/docs/assests/hero-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
BIN
docs-site/docs/assests/hero-3.png
Normal file
BIN
docs-site/docs/assests/hero-3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 737 KiB |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
198
frontend/src/components/StoryWriter/StoryWriterLanding.tsx
Normal file
198
frontend/src/components/StoryWriter/StoryWriterLanding.tsx
Normal 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)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Let’s 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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
624
frontend/src/components/shared/AlertsBadge.tsx
Normal file
624
frontend/src/components/shared/AlertsBadge.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
32
frontend/src/components/shared/HeaderControls.tsx
Normal file
32
frontend/src/components/shared/HeaderControls.tsx
Normal 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user