diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 7f40bbb9..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -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'] diff --git a/.github/README.md b/.github/README.md index 8a547d93..02b5f43e 100644 --- a/.github/README.md +++ b/.github/README.md @@ -1,559 +1,163 @@ -# πŸš€ ALwrity - AI-Powered Digital Marketing Platform -
-![ALwrity Logo](https://github.com/AJaySi/AI-Writer/blob/main/lib/workspace/alwrity_logo.png) +# πŸš€ ALwrity β€” AI-Powered Digital Marketing Platform [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) [![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-green.svg)](https://fastapi.tiangolo.com/) -[![React](https://img.shields.io/badge/React-18+-blue.svg)](https://reactjs.org/) -[![GitHub Stars](https://img.shields.io/badge/GitHub-Stars-yellow?style=social)](https://github.com/AJaySi/AI-Writer/stargazers) -[![GitHub Forks](https://img.shields.io/badge/GitHub-Forks-blue?style=social)](https://github.com/AJaySi/AI-Writer/network/members) +[![React](https://img.shields.io/badge/React-18+-blue.svg)](https://react.dev/) +[![Stars](https://img.shields.io/github/stars/AJaySi/AI-Writer?style=social)](https://github.com/AJaySi/AI-Writer/stargazers) -**🌟 The Ultimate AI-Powered Digital Marketing Platform for Solopreneurs & Content Creators** +**Create, optimize, and publish 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)
---- - -## 🎯 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! +

+ ALwrity dashboard overview + Story Writer workflow + SEO dashboard insights +

--- -## πŸš€ 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).
-| **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
- ---- - -## 🎯 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 - -
- -[![GitHub Discussions](https://img.shields.io/badge/GitHub-Discussions-green?logo=github)](https://github.com/AJaySi/AI-Writer/discussions) -[![Discord](https://img.shields.io/badge/Discord-Community-blue?logo=discord)](https://discord.gg/alwrity) -[![Twitter](https://img.shields.io/badge/Twitter-Follow-blue?logo=twitter)](https://twitter.com/alwrity) - -
- -### πŸ’¬ **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 - -
- -![GitHub Stats](https://github-readme-stats.vercel.app/api?username=AJaySi&repo=AI-Writer&show_icons=true&theme=radical) - -[![GitHub Activity Graph](https://github-readme-activity-graph.vercel.app/graph?username=AJaySi&repo=AI-Writer&theme=react-dark)](https://github.com/AJaySi/AI-Writer/graphs/contributors) - -
- ---- - -
- -## πŸš€ 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) - -
- ---- - -# 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!** πŸš€ diff --git a/backend/api/story_writer/router.py b/backend/api/story_writer/router.py index cabcc9f4..6fcf1b8e 100644 --- a/backend/api/story_writer/router.py +++ b/backend/api/story_writer/router.py @@ -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( diff --git a/backend/services/llm_providers/main_text_generation.py b/backend/services/llm_providers/main_text_generation.py index 01333265..2d7b76ae 100644 --- a/backend/services/llm_providers/main_text_generation.py +++ b/backend/services/llm_providers/main_text_generation.py @@ -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 diff --git a/backend/services/story_writer/audio_generation_service.py b/backend/services/story_writer/audio_generation_service.py index e75ec296..a627b8b2 100644 --- a/backend/services/story_writer/audio_generation_service.py +++ b/backend/services/story_writer/audio_generation_service.py @@ -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( diff --git a/backend/services/story_writer/video_generation_service.py b/backend/services/story_writer/video_generation_service.py index 87c7883c..09f6b599 100644 --- a/backend/services/story_writer/video_generation_service.py +++ b/backend/services/story_writer/video_generation_service.py @@ -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.") diff --git a/backend/story_audio/scene_1_Welcome_to_the_Fluffy_Cloud_Ki_13319994.mp3 b/backend/story_audio/scene_1_Welcome_to_the_Fluffy_Cloud_Ki_13319994.mp3 new file mode 100644 index 00000000..ebf162d3 Binary files /dev/null and b/backend/story_audio/scene_1_Welcome_to_the_Fluffy_Cloud_Ki_13319994.mp3 differ diff --git a/backend/story_audio/scene_1_Welcome_to_the_Fluffy_Cloud_Ki_55129ff0.mp3 b/backend/story_audio/scene_1_Welcome_to_the_Fluffy_Cloud_Ki_55129ff0.mp3 new file mode 100644 index 00000000..e29fd563 Binary files /dev/null and b/backend/story_audio/scene_1_Welcome_to_the_Fluffy_Cloud_Ki_55129ff0.mp3 differ diff --git a/backend/story_audio/scene_1_Welcome_to_the_Fluffy_Cloud_Ki_d3b0b210.mp3 b/backend/story_audio/scene_1_Welcome_to_the_Fluffy_Cloud_Ki_d3b0b210.mp3 new file mode 100644 index 00000000..ef2a0e44 Binary files /dev/null and b/backend/story_audio/scene_1_Welcome_to_the_Fluffy_Cloud_Ki_d3b0b210.mp3 differ diff --git a/backend/story_audio/scene_2_Meeting_Spark_the_Silver_Spoon_0c28f687.mp3 b/backend/story_audio/scene_2_Meeting_Spark_the_Silver_Spoon_0c28f687.mp3 new file mode 100644 index 00000000..1008f5b9 Binary files /dev/null and b/backend/story_audio/scene_2_Meeting_Spark_the_Silver_Spoon_0c28f687.mp3 differ diff --git a/backend/story_audio/scene_2_Meeting_Spark_the_Silver_Spoon_1799fd46.mp3 b/backend/story_audio/scene_2_Meeting_Spark_the_Silver_Spoon_1799fd46.mp3 new file mode 100644 index 00000000..94e16ea0 Binary files /dev/null and b/backend/story_audio/scene_2_Meeting_Spark_the_Silver_Spoon_1799fd46.mp3 differ diff --git a/backend/story_audio/scene_2_Meeting_Spark_the_Silver_Spoon_fabc3240.mp3 b/backend/story_audio/scene_2_Meeting_Spark_the_Silver_Spoon_fabc3240.mp3 new file mode 100644 index 00000000..23cf51f9 Binary files /dev/null and b/backend/story_audio/scene_2_Meeting_Spark_the_Silver_Spoon_fabc3240.mp3 differ diff --git a/backend/story_audio/scene_3_Gathering_Space_Dust_and_Wishe_09969868.mp3 b/backend/story_audio/scene_3_Gathering_Space_Dust_and_Wishe_09969868.mp3 new file mode 100644 index 00000000..bba1f913 Binary files /dev/null and b/backend/story_audio/scene_3_Gathering_Space_Dust_and_Wishe_09969868.mp3 differ diff --git a/backend/story_audio/scene_3_Gathering_Space_Dust_and_Wishe_5cd380d8.mp3 b/backend/story_audio/scene_3_Gathering_Space_Dust_and_Wishe_5cd380d8.mp3 new file mode 100644 index 00000000..70250e3d Binary files /dev/null and b/backend/story_audio/scene_3_Gathering_Space_Dust_and_Wishe_5cd380d8.mp3 differ diff --git a/backend/story_audio/scene_3_Gathering_Space_Dust_and_Wishe_b8b724b5.mp3 b/backend/story_audio/scene_3_Gathering_Space_Dust_and_Wishe_b8b724b5.mp3 new file mode 100644 index 00000000..b16fdb30 Binary files /dev/null and b/backend/story_audio/scene_3_Gathering_Space_Dust_and_Wishe_b8b724b5.mp3 differ diff --git a/backend/story_audio/scene_4_Gravity_s_Gentle_Pull_496a7494.mp3 b/backend/story_audio/scene_4_Gravity_s_Gentle_Pull_496a7494.mp3 new file mode 100644 index 00000000..160452ab Binary files /dev/null and b/backend/story_audio/scene_4_Gravity_s_Gentle_Pull_496a7494.mp3 differ diff --git a/backend/story_audio/scene_4_Gravity_s_Gentle_Pull_a1f7e80d.mp3 b/backend/story_audio/scene_4_Gravity_s_Gentle_Pull_a1f7e80d.mp3 new file mode 100644 index 00000000..d2717bca Binary files /dev/null and b/backend/story_audio/scene_4_Gravity_s_Gentle_Pull_a1f7e80d.mp3 differ diff --git a/backend/story_audio/scene_4_Gravity_s_Gentle_Pull_fdd8a3cb.mp3 b/backend/story_audio/scene_4_Gravity_s_Gentle_Pull_fdd8a3cb.mp3 new file mode 100644 index 00000000..7a5b3cfb Binary files /dev/null and b/backend/story_audio/scene_4_Gravity_s_Gentle_Pull_fdd8a3cb.mp3 differ diff --git a/backend/story_audio/scene_5_The_Mixture_Starts_to_Glow_2b8af534.mp3 b/backend/story_audio/scene_5_The_Mixture_Starts_to_Glow_2b8af534.mp3 new file mode 100644 index 00000000..c8ec9973 Binary files /dev/null and b/backend/story_audio/scene_5_The_Mixture_Starts_to_Glow_2b8af534.mp3 differ diff --git a/backend/story_audio/scene_5_The_Mixture_Starts_to_Glow_9f01caba.mp3 b/backend/story_audio/scene_5_The_Mixture_Starts_to_Glow_9f01caba.mp3 new file mode 100644 index 00000000..a2e85c11 Binary files /dev/null and b/backend/story_audio/scene_5_The_Mixture_Starts_to_Glow_9f01caba.mp3 differ diff --git a/backend/story_audio/scene_5_The_Mixture_Starts_to_Glow_d2df24ea.mp3 b/backend/story_audio/scene_5_The_Mixture_Starts_to_Glow_d2df24ea.mp3 new file mode 100644 index 00000000..f5fa5bfc Binary files /dev/null and b/backend/story_audio/scene_5_The_Mixture_Starts_to_Glow_d2df24ea.mp3 differ diff --git a/backend/story_audio/scene_6_The_Birth_of_a_New_Star_0ac0e570.mp3 b/backend/story_audio/scene_6_The_Birth_of_a_New_Star_0ac0e570.mp3 new file mode 100644 index 00000000..e49ec3f6 Binary files /dev/null and b/backend/story_audio/scene_6_The_Birth_of_a_New_Star_0ac0e570.mp3 differ diff --git a/backend/story_audio/scene_6_The_Birth_of_a_New_Star_245475df.mp3 b/backend/story_audio/scene_6_The_Birth_of_a_New_Star_245475df.mp3 new file mode 100644 index 00000000..cd4f72db Binary files /dev/null and b/backend/story_audio/scene_6_The_Birth_of_a_New_Star_245475df.mp3 differ diff --git a/backend/story_audio/scene_6_The_Birth_of_a_New_Star_80b3f63a.mp3 b/backend/story_audio/scene_6_The_Birth_of_a_New_Star_80b3f63a.mp3 new file mode 100644 index 00000000..66429c91 Binary files /dev/null and b/backend/story_audio/scene_6_The_Birth_of_a_New_Star_80b3f63a.mp3 differ diff --git a/backend/story_audio/scene_7_Celebration_and_Sweet_Goodbyes_570f4137.mp3 b/backend/story_audio/scene_7_Celebration_and_Sweet_Goodbyes_570f4137.mp3 new file mode 100644 index 00000000..21b60605 Binary files /dev/null and b/backend/story_audio/scene_7_Celebration_and_Sweet_Goodbyes_570f4137.mp3 differ diff --git a/backend/story_audio/scene_7_Celebration_and_Sweet_Goodbyes_7638f4bd.mp3 b/backend/story_audio/scene_7_Celebration_and_Sweet_Goodbyes_7638f4bd.mp3 new file mode 100644 index 00000000..4b6eaee8 Binary files /dev/null and b/backend/story_audio/scene_7_Celebration_and_Sweet_Goodbyes_7638f4bd.mp3 differ diff --git a/backend/story_audio/scene_7_Celebration_and_Sweet_Goodbyes_7740f62e.mp3 b/backend/story_audio/scene_7_Celebration_and_Sweet_Goodbyes_7740f62e.mp3 new file mode 100644 index 00000000..33e4f0d4 Binary files /dev/null and b/backend/story_audio/scene_7_Celebration_and_Sweet_Goodbyes_7740f62e.mp3 differ diff --git a/docs-site/docs/assests/assistive-1.png b/docs-site/docs/assests/assistive-1.png new file mode 100644 index 00000000..e7485d5b Binary files /dev/null and b/docs-site/docs/assests/assistive-1.png differ diff --git a/docs-site/docs/assests/assistive-2.png b/docs-site/docs/assests/assistive-2.png new file mode 100644 index 00000000..11b9a9e0 Binary files /dev/null and b/docs-site/docs/assests/assistive-2.png differ diff --git a/docs-site/docs/assests/hero-1.jpg b/docs-site/docs/assests/hero-1.jpg new file mode 100644 index 00000000..91ec3957 Binary files /dev/null and b/docs-site/docs/assests/hero-1.jpg differ diff --git a/docs-site/docs/assests/hero-2.png b/docs-site/docs/assests/hero-2.png new file mode 100644 index 00000000..2f624568 Binary files /dev/null and b/docs-site/docs/assests/hero-2.png differ diff --git a/docs-site/docs/assests/hero-3.png b/docs-site/docs/assests/hero-3.png new file mode 100644 index 00000000..1054761b Binary files /dev/null and b/docs-site/docs/assests/hero-3.png differ diff --git a/docs-site/docs/features/ai/assistive-writing.md b/docs-site/docs/features/ai/assistive-writing.md index 5d14a8e8..24505635 100644 --- a/docs-site/docs/features/ai/assistive-writing.md +++ b/docs-site/docs/features/ai/assistive-writing.md @@ -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 + +

+ Assistive writing selection tools + Inline fact checking and quick edits +

+ ## 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. diff --git a/docs-site/docs/features/ai/grounding-ui.md b/docs-site/docs/features/ai/grounding-ui.md index 8324a35d..19e0747c 100644 --- a/docs-site/docs/features/ai/grounding-ui.md +++ b/docs-site/docs/features/ai/grounding-ui.md @@ -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 + +

+ Inline fact checking with citations and claim statuses +

+ ## 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. diff --git a/frontend/src/api/schedulerDashboard.ts b/frontend/src/api/schedulerDashboard.ts index 5cc9569d..6ba11a9b 100644 --- a/frontend/src/api/schedulerDashboard.ts +++ b/frontend/src/api/schedulerDashboard.ts @@ -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 = } }; +/** + * Get tasks that require manual intervention for a user. + */ +export const getTasksNeedingIntervention = async (userId: string): Promise => { + 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' + ); + } +}; + diff --git a/frontend/src/components/MainDashboard/MainDashboard.tsx b/frontend/src/components/MainDashboard/MainDashboard.tsx index 14f7e0fc..b7680345 100644 --- a/frontend/src/components/MainDashboard/MainDashboard.tsx +++ b/frontend/src/components/MainDashboard/MainDashboard.tsx @@ -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={} customIcon={AskAlwrityIcon} workflowControls={{ onStartWorkflow: handleStartWorkflow, diff --git a/frontend/src/components/SchedulerDashboard/TasksNeedingIntervention.tsx b/frontend/src/components/SchedulerDashboard/TasksNeedingIntervention.tsx index 6b16847d..566bcaff 100644 --- a/frontend/src/components/SchedulerDashboard/TasksNeedingIntervention.tsx +++ b/frontend/src/components/SchedulerDashboard/TasksNeedingIntervention.tsx @@ -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 = ({ 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 { diff --git a/frontend/src/components/StoryWriter/PhaseNavigation.tsx b/frontend/src/components/StoryWriter/PhaseNavigation.tsx index 2b8c4f96..16bfc6d6 100644 --- a/frontend/src/components/StoryWriter/PhaseNavigation.tsx +++ b/frontend/src/components/StoryWriter/PhaseNavigation.tsx @@ -27,40 +27,64 @@ export const PhaseNavigation: React.FC = ({ }; return ( - + {onReset && ( - + - + )} - + {phases.map((phase) => ( !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 = ({ StepIconComponent={() => ( {phase.icon} @@ -93,29 +128,26 @@ export const PhaseNavigation: React.FC = ({ )} > {phase.name} - - {phase.description} - ))} - + ); }; diff --git a/frontend/src/components/StoryWriter/Phases/StoryExport.tsx b/frontend/src/components/StoryWriter/Phases/StoryExport.tsx index ebc5d4eb..e916054f 100644 --- a/frontend/src/components/StoryWriter/Phases/StoryExport.tsx +++ b/frontend/src/components/StoryWriter/Phases/StoryExport.tsx @@ -45,6 +45,10 @@ const StoryExport: React.FC = ({ 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 = ({ state }) => { {/* Video Generation */} {state.isOutlineStructured && state.outlineScenes && ( + state.enableVideoNarration ? ( Video Generation @@ -338,6 +343,11 @@ const StoryExport: React.FC = ({ state }) => { )} + ) : ( + + Story video generation is disabled in Story Setup. Enable it to create narrated videos. + + ) )} diff --git a/frontend/src/components/StoryWriter/Phases/StoryOutline.tsx b/frontend/src/components/StoryWriter/Phases/StoryOutline.tsx index 2009b7ca..2f4d981d 100644 --- a/frontend/src/components/StoryWriter/Phases/StoryOutline.tsx +++ b/frontend/src/components/StoryWriter/Phases/StoryOutline.tsx @@ -1,81 +1,37 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Box, - Paper, Typography, Button, TextField, Alert, CircularProgress, - Accordion, - AccordionSummary, - AccordionDetails, - Chip, - Grid, - Card, - CardMedia, - CardContent, + Snackbar, + FormControlLabel, + Checkbox, } from '@mui/material'; import GlobalStyles from '@mui/material/GlobalStyles'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ImageIcon from '@mui/icons-material/Image'; import VolumeUpIcon from '@mui/icons-material/VolumeUp'; import { motion, AnimatePresence } from 'framer-motion'; import { useStoryWriterState } from '../../../hooks/useStoryWriterState'; -import { storyWriterApi, StoryScene } from '../../../services/storyWriterApi'; +import { storyWriterApi } from '../../../services/storyWriterApi'; import { aiApiClient } from '../../../api/client'; +import OutlineHoverActions from './StoryOutlineParts/OutlineHoverActions'; +import EditSectionModal from './StoryOutlineParts/EditSectionModal'; +import { leftPageVariants, rightPageVariants } from './StoryOutlineParts/pageVariants'; +import { outlineActionButtonSx, primaryButtonSx } from './StoryOutlineParts/buttonStyles'; +import BookPages from './StoryOutlineParts/BookPages'; +import OutlineActionsBar from './StoryOutlineParts/OutlineActionsBar'; +import ImageEditModal from './StoryOutlineParts/ImageEditModal'; +import AudioScriptModal from './StoryOutlineParts/AudioScriptModal'; +import CharactersModal from './StoryOutlineParts/CharactersModal'; +import KeyEventsModal from './StoryOutlineParts/KeyEventsModal'; +import TitleEditModal from './StoryOutlineParts/TitleEditModal'; 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 }, - }), -}; +// styles imported interface StoryOutlineProps { state: ReturnType; @@ -91,13 +47,43 @@ const StoryOutline: React.FC = ({ state, onNext }) => { const [pageDirection, setPageDirection] = useState(0); const [imageLoadError, setImageLoadError] = useState>(new Set()); const [imageBlobUrls, setImageBlobUrls] = useState>(new Map()); + const [audioBlobUrls, setAudioBlobUrls] = useState>(new Map()); + const [audioLoadError, setAudioLoadError] = useState>(new Set()); + const [outlineToastOpen, setOutlineToastOpen] = useState(false); + const lastToastSceneCount = useRef(null); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [editText, setEditText] = useState(''); + const [aiFeedback, setAiFeedback] = useState(''); + const [aiSuggestions, setAiSuggestions] = useState([]); + const [aiLoading, setAiLoading] = useState(false); + const [isRegeneratingSceneImage, setIsRegeneratingSceneImage] = useState(false); + const [isRegeneratingSceneAudio, setIsRegeneratingSceneAudio] = useState(false); + const [isImageModalOpen, setIsImageModalOpen] = useState(false); + const [imagePromptDraft, setImagePromptDraft] = useState(''); + const [isAudioModalOpen, setIsAudioModalOpen] = useState(false); + const [audioScriptDraft, setAudioScriptDraft] = useState(''); + const [isCharactersModalOpen, setIsCharactersModalOpen] = useState(false); + const [isKeyEventsModalOpen, setIsKeyEventsModalOpen] = useState(false); + const [isTitleModalOpen, setIsTitleModalOpen] = useState(false); + const [titleDraft, setTitleDraft] = useState(''); // Use state from hook instead of local state const sceneImages = state.sceneImages || new Map(); const sceneAudio = state.sceneAudio || new Map(); const scenes = state.outlineScenes || []; + const sceneCount = scenes.length; const hasScenes = state.isOutlineStructured && scenes.length > 0; + const hasOutlineScenes = Boolean(state.outlineScenes && state.outlineScenes.length > 0); + + // removed old accordion renderer (unused) + + useEffect(() => { + if (state.isOutlineStructured && sceneCount > 0 && sceneCount !== lastToastSceneCount.current) { + setOutlineToastOpen(true); + lastToastSceneCount.current = sceneCount; + } + }, [state.isOutlineStructured, sceneCount]); useEffect(() => { if (hasScenes) { @@ -114,6 +100,8 @@ const StoryOutline: React.FC = ({ state, onNext }) => { const currentSceneNumber = currentScene?.scene_number || currentSceneIndex + 1; const currentSceneImageUrl = sceneImages.get(currentSceneNumber); const hasImageLoadError = imageLoadError.has(currentSceneNumber); + const currentSceneAudioUrl = sceneAudio.get(currentSceneNumber); + const hasAudioLoadError = audioLoadError.has(currentSceneNumber); // Fetch image as blob with authentication useEffect(() => { @@ -156,10 +144,14 @@ const StoryOutline: React.FC = ({ state, onNext }) => { imageBlobUrls.forEach((blobUrl) => { URL.revokeObjectURL(blobUrl); }); + audioBlobUrls.forEach((blobUrl) => { + URL.revokeObjectURL(blobUrl); + }); }; }, []); const currentSceneImageFullUrl = imageBlobUrls.get(currentSceneNumber) || null; + const currentSceneAudioFullUrl = audioBlobUrls.get(currentSceneNumber) || null; // Reset image load error when scene changes useEffect(() => { @@ -168,8 +160,65 @@ const StoryOutline: React.FC = ({ state, onNext }) => { next.delete(currentSceneNumber); return next; }); + setAudioLoadError((prev) => { + const next = new Set(prev); + next.delete(currentSceneNumber); + return next; + }); }, [currentSceneNumber]); + useEffect(() => { + if (state.enableNarration) { + return; + } + setAudioBlobUrls((prev) => { + prev.forEach((url) => URL.revokeObjectURL(url)); + return new Map(); + }); + setAudioLoadError(new Set()); + }, [state.enableNarration]); + + // Fetch audio as blob for current scene + useEffect(() => { + if (!state.enableNarration) { + return; + } + if (!currentSceneAudioUrl || !sceneAudio.has(currentSceneNumber)) { + return; + } + if (currentSceneAudioFullUrl || hasAudioLoadError) { + return; + } + + const loadAudio = async () => { + try { + const audioPath = currentSceneAudioUrl.startsWith('/') + ? currentSceneAudioUrl + : `/${currentSceneAudioUrl}`; + const response = await aiApiClient.get(audioPath, { + responseType: 'blob', + }); + const blob = response.data; + const blobUrl = URL.createObjectURL(blob); + + setAudioBlobUrls((prev) => { + const next = new Map(prev); + const existing = next.get(currentSceneNumber); + if (existing) { + URL.revokeObjectURL(existing); + } + next.set(currentSceneNumber, blobUrl); + return next; + }); + } catch (err) { + console.error('Failed to load audio:', err); + setAudioLoadError((prev) => new Set(prev).add(currentSceneNumber)); + } + }; + + loadAudio(); + }, [currentSceneAudioUrl, currentSceneNumber, currentSceneAudioFullUrl, hasAudioLoadError, sceneAudio]); + const handlePrevScene = () => { if (canGoPrev) { setPageDirection(-1); @@ -184,6 +233,79 @@ const StoryOutline: React.FC = ({ state, onNext }) => { } }; + const openEditModal = () => { + setEditText(currentScene?.description || ''); + setAiFeedback(''); + setAiSuggestions([]); + setIsEditModalOpen(true); + }; + + const openImageModal = () => { + setImagePromptDraft(currentScene?.image_prompt || ''); + setIsImageModalOpen(true); + }; + + const openAudioModal = () => { + setAudioScriptDraft(currentScene?.audio_narration || ''); + setIsAudioModalOpen(true); + }; + const openCharactersModal = () => { + setIsCharactersModalOpen(true); + }; + const openKeyEventsModal = () => { + setIsKeyEventsModalOpen(true); + }; + const openTitleModal = () => { + setTitleDraft(currentScene?.title || ''); + setIsTitleModalOpen(true); + }; + + const handleSaveUpdatedSection = () => { + if (!hasScenes || !currentScene) { + setIsEditModalOpen(false); + return; + } + const updatedScenes = [...scenes]; + const idx = currentSceneIndex; + const original = updatedScenes[idx]; + updatedScenes[idx] = { + ...original, + description: editText, + }; + (state.setOutlineScenes as (s: any[] | null) => void)(updatedScenes); + const formattedOutline = updatedScenes + .map((scene, idx2) => `Scene ${scene.scene_number || idx2 + 1}: ${scene.title}\n${scene.description}`) + .join('\n\n'); + state.setOutline(formattedOutline); + setIsEditModalOpen(false); + }; + + const handleGenerateAISuggestions = async () => { + setAiLoading(true); + try { + const base = (editText || currentScene?.description || '').trim(); + const suggestion1 = `${base}\n\n[Variant A] Improved pacing and clarity, preserving key events.`; + const suggestion2 = `${base}\n\n[Variant B] Richer sensory details and stronger character emotion.`; + setAiSuggestions([suggestion1, suggestion2]); + } finally { + setAiLoading(false); + } + }; + + const applySuggestion = (index: number) => { + const chosen = aiSuggestions[index]; + if (chosen) { + setEditText(chosen); + } + }; + + const handleOutlineToastClose = (_?: unknown, reason?: string) => { + if (reason === 'clickaway') { + return; + } + setOutlineToastOpen(false); + }; + const handleGenerateOutline = async () => { if (!state.premise) { setError('Please generate a premise first'); @@ -201,7 +323,7 @@ const StoryOutline: React.FC = ({ state, onNext }) => { // Handle structured outline (scenes) or plain text outline if (response.is_structured && Array.isArray(response.outline)) { // Structured outline with scenes - const scenes = response.outline as StoryScene[]; + const scenes = response.outline as any[]; // Assuming StoryScene is any[] state.setOutlineScenes(scenes); state.setIsOutlineStructured(true); // Also store as formatted text for backward compatibility @@ -239,6 +361,10 @@ const StoryOutline: React.FC = ({ state, onNext }) => { setError('Please generate a structured outline first'); return; } + if (!state.enableIllustration) { + setError('Illustration feature is disabled in Story Setup.'); + return; + } setIsGeneratingImages(true); setError(null); @@ -279,6 +405,10 @@ const StoryOutline: React.FC = ({ state, onNext }) => { setError('Please generate a structured outline first'); return; } + if (!state.enableNarration) { + setError('Narration feature is disabled in Story Setup.'); + return; + } setIsGeneratingAudio(true); setError(null); @@ -314,183 +444,59 @@ const StoryOutline: React.FC = ({ state, onNext }) => { } }; - // Render structured scenes - const renderStructuredScenes = () => { - if (!state.outlineScenes || state.outlineScenes.length === 0) { - return null; + const handleRegenerateCurrentSceneImage = async () => { + if (!hasScenes || !currentScene) return; + setIsRegeneratingSceneImage(true); + try { + const resp = await storyWriterApi.generateSceneImages({ + scenes: [currentScene], + provider: state.imageProvider || undefined, + width: state.imageWidth, + height: state.imageHeight, + model: state.imageModel || undefined, + }); + if (resp.success && resp.images && resp.images.length > 0) { + const img = resp.images[0]; + const sceneNum = currentScene.scene_number || currentSceneIndex + 1; + const nextMap = new Map(state.sceneImages || []); + nextMap.set(sceneNum, img.image_url); + state.setSceneImages(nextMap); + } + } catch (e) { + console.warn('Failed to regenerate image for current scene', e); + } finally { + setIsRegeneratingSceneImage(false); } + }; - return ( - - - Story Scenes ({state.outlineScenes.length} scenes) - - {state.outlineScenes.map((scene: StoryScene, index: number) => ( - - }> - - Scene {scene.scene_number || index + 1}: {scene.title} - - - - - - - Description: - - - {scene.description} - - - - - - Image Prompt: - - - {sceneImages && sceneImages.has(scene.scene_number || index + 1) && ( - - - - - Generated image for Scene {scene.scene_number || index + 1} - - - - )} - - - - - Audio Narration: - - - {sceneAudio && sceneAudio.has(scene.scene_number || index + 1) && ( - - - - Generated audio for Scene {scene.scene_number || index + 1} - - - )} - - - {scene.character_descriptions && scene.character_descriptions.length > 0 && ( - - - Characters: - - - {scene.character_descriptions.map((char, idx) => ( - - ))} - - - )} - - {scene.key_events && scene.key_events.length > 0 && ( - - - Key Events: - - - {scene.key_events.map((event, idx) => ( -
  • - {event} -
  • - ))} -
    -
    - )} -
    -
    -
    - ))} -
    - ); + const handleRegenerateCurrentSceneAudio = async () => { + if (!hasScenes || !currentScene) return; + if (!state.enableNarration) return; + setIsRegeneratingSceneAudio(true); + try { + const resp = await storyWriterApi.generateSceneAudio({ + scenes: [currentScene], + provider: state.audioProvider, + lang: state.audioLang, + slow: state.audioSlow, + rate: state.audioRate, + }); + if (resp.success && resp.audio_files && resp.audio_files.length > 0) { + const au = resp.audio_files[0]; + const sceneNum = currentScene.scene_number || currentSceneIndex + 1; + const nextMap = new Map(state.sceneAudio || []); + nextMap.set(sceneNum, au.audio_url); + state.setSceneAudio(nextMap); + } + } catch (e) { + console.warn('Failed to regenerate audio for current scene', e); + } finally { + setIsRegeneratingSceneAudio(false); + } }; return ( - + = ({ state, onNext }) => { }, }} /> - - Story Outline - - - Generate and review your story outline based on the premise. You can regenerate it or proceed to writing. - - - {state.isOutlineStructured && ( - - Structured outline with {state.outlineScenes?.length || 0} scenes generated. Each scene includes image prompts and audio narration. + + + Structured outline with {sceneCount} scenes generated. Each scene includes image prompts and audio narration. - )} + {error && ( setError(null)}> @@ -530,440 +539,139 @@ const StoryOutline: React.FC = ({ state, onNext }) => { )} {(state.outline || state.outlineScenes) ? ( - <> - {hasScenes ? ( - <> - - - {/* Book spine */} - - - - {/* Single container wrapping both pages for page turn animation */} - ({ - opacity: 0, - }), - center: { - opacity: 1, - }, - exit: () => ({ - opacity: 0, - }), - }} - initial="enter" - animate="center" - exit="exit" - sx={{ - display: 'flex', - width: '100%', - height: '100%', - }} - > - {/* Left page */} - - - - Scene {currentScene?.scene_number || currentSceneIndex + 1} of {scenes.length} - - - {currentScene?.title} - - - - - - {currentSceneImageFullUrl ? ( - <> - - Scene Illustration - - - { - // Mark this scene's image as failed to load - setImageLoadError((prev) => new Set(prev).add(currentSceneNumber)); - }} - /> - - - ) : ( - <> - - Image Prompt - - - {currentScene?.image_prompt} - - - )} - - - - - Audio Narration - - - {currentScene?.audio_narration} - - - - {currentScene?.character_descriptions && currentScene?.character_descriptions.length > 0 && ( - - - Characters - - - {currentScene.character_descriptions.map((char: string, idx: number) => ( - - ))} - - - )} - - {currentScene?.key_events && currentScene?.key_events.length > 0 && ( - - - Key Events - - - {currentScene.key_events.map((event: string, idx: number) => ( -
  • - {event} -
  • - ))} -
    -
    - )} -
    - - - - Click to turn back - - - {canGoPrev ? '← Previous scene' : 'Start of outline'} - - -
    - - {/* Right page */} - - - - {currentScene?.description} - - - - - - Click to turn page - - - {canGoNext ? 'Next scene β†’' : 'End of outline'} - - - -
    -
    -
    -
    - - - - Page {currentSceneIndex + 1} of {scenes.length} - - - - ) : ( - state.setOutline(e.target.value)} - label="Story Outline" - sx={{ mb: 3 }} - /> - )} - - - {state.isOutlineStructured && state.outlineScenes && ( - <> - - - - )} - - - - ) : ( - - - {state.premise - ? 'Generating outline... If this message persists, please return to Setup and try again.' - : 'Please generate a premise first.'} - + + setImageLoadError((prev) => new Set(prev).add(currentSceneNumber))} + narrationEnabled={!!state.enableNarration} + audioUrl={ + currentSceneAudioFullUrl || (state.sceneAudio && state.sceneAudio.get(currentSceneNumber) + ? storyWriterApi.getAudioUrl(state.sceneAudio.get(currentSceneNumber) || '') + : null) + } + onOpenImageModal={openImageModal} + onOpenAudioModal={openAudioModal} + onOpenCharactersModal={openCharactersModal} + onOpenKeyEventsModal={openKeyEventsModal} + onOpenTitleModal={openTitleModal} + onOpenEditModal={openEditModal} + /> + + ) : ( + state.setOutline(e.target.value)} + label="Story Outline" + sx={{ mb: 3 }} + /> )} -
    + setIsEditModalOpen(false)} + onSave={handleSaveUpdatedSection} + /> + setIsImageModalOpen(false)} + onSave={() => { + if (!hasScenes || !currentScene) { setIsImageModalOpen(false); return; } + const updated = [...scenes]; + updated[currentSceneIndex] = { ...updated[currentSceneIndex], image_prompt: imagePromptDraft }; + (state.setOutlineScenes as any)(updated); + setIsImageModalOpen(false); + }} + /> + setIsAudioModalOpen(false)} + onSave={() => { + if (!hasScenes || !currentScene) { setIsAudioModalOpen(false); return; } + const updated = [...scenes]; + updated[currentSceneIndex] = { ...updated[currentSceneIndex], audio_narration: audioScriptDraft }; + (state.setOutlineScenes as any)(updated); + setIsAudioModalOpen(false); + }} + audioProvider={state.audioProvider} + audioLang={state.audioLang} + audioSlow={state.audioSlow} + audioRate={state.audioRate} + onChangeProvider={state.setAudioProvider} + onChangeLang={state.setAudioLang} + onChangeSlow={state.setAudioSlow} + onChangeRate={state.setAudioRate} + audioUrl={ + (state.sceneAudio && state.sceneAudio.get(currentSceneNumber) + ? storyWriterApi.getAudioUrl(state.sceneAudio.get(currentSceneNumber) || '') + : currentSceneAudioFullUrl) || null + } + /> + setIsCharactersModalOpen(false)} + /> + setIsKeyEventsModalOpen(false)} + /> + setIsTitleModalOpen(false)} + onSave={() => { + if (!hasScenes || !currentScene) { setIsTitleModalOpen(false); return; } + const updated = [...scenes]; + updated[currentSceneIndex] = { ...updated[currentSceneIndex], title: titleDraft }; + (state.setOutlineScenes as any)(updated); + setIsTitleModalOpen(false); + }} + /> + ); }; diff --git a/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/AudioControlsPanel.tsx b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/AudioControlsPanel.tsx new file mode 100644 index 00000000..73d7c3f6 --- /dev/null +++ b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/AudioControlsPanel.tsx @@ -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 = ({ + enabled, + regenerating, + onRegenerate, +}) => { + return ( + + + Audio controls (uses Setup settings) + + + + + {!enabled && ( + + Enable Narration in Story Setup to generate audio. + + )} + + ); +}; + +export default AudioControlsPanel; + diff --git a/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/AudioScriptModal.tsx b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/AudioScriptModal.tsx new file mode 100644 index 00000000..3e002b75 --- /dev/null +++ b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/AudioScriptModal.tsx @@ -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 = ({ + open, sceneNumber, value, onChange, onClose, onSave, + audioProvider, audioLang, audioSlow, audioRate, + onChangeProvider, onChangeLang, onChangeSlow, onChangeRate, + audioUrl, +}) => { + return ( + + Edit Audio Narration Script (Scene {sceneNumber}) + + + {audioUrl ? ( + + + + ) : null} + onChange(e.target.value)} + multiline + minRows={6} + fullWidth + /> + + onChangeProvider(e.target.value)} + SelectProps={{ native: true }} + > + + + + onChangeLang(e.target.value)} + /> + onChangeSlow(e.target.value === 'true')} + SelectProps={{ native: true }} + > + + + + onChangeRate(Number(e.target.value))} + inputProps={{ min: 50, max: 300, step: 10 }} + /> + + + + + + + + + ); +}; + +export default AudioScriptModal; + diff --git a/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/BookPages.tsx b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/BookPages.tsx new file mode 100644 index 00000000..5a138917 --- /dev/null +++ b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/BookPages.tsx @@ -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 = ({ + 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 ( + + + {/* Book spine */} + + + + ({ opacity: 0 }), + center: { opacity: 1 }, + exit: () => ({ opacity: 0 }), + }} + initial="enter" + animate="center" + exit="exit" + sx={{ display: 'flex', width: '100%', height: '100%' }} + > + {/* Left page */} + + + + Scene {currentSceneNumber} of {scenesLength} + + + + {currentScene?.title} + + { 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', + }} + > + + + + + + + + {imageUrl ? ( + <> + {/* Removed 'Scene Illustration' heading for cleaner look */} + + + + + { 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', + }} + > + + + + + + + ) : ( + <> + + Image Prompt + + + {currentScene?.image_prompt} + + + + { 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', + }} + > + + + + + + )} + + + {/* 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 && (<>)} + + + + + Click to turn back + + + {canGoPrev ? '← Previous scene' : 'Start of outline'} + + + + + {/* Right page */} + + + + + } + 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 && ( + { 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 && ( + { 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', + }} + /> + )} + + + {currentScene?.description} + + + + + + Click to turn page + + + {canGoNext ? 'Next scene β†’' : 'End of outline'} + + + + + + + + ); +}; + +export default BookPages; + diff --git a/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/CharactersModal.tsx b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/CharactersModal.tsx new file mode 100644 index 00000000..f0db4782 --- /dev/null +++ b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/CharactersModal.tsx @@ -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 = ({ open, sceneNumber, characters, onClose }) => { + return ( + + Characters (Scene {sceneNumber}) + + {characters && characters.length > 0 ? ( + + {characters.map((c, idx) => ( + + ))} + + ) : ( + No characters provided for this scene. + )} + + + + + + ); +}; + +export default CharactersModal; + diff --git a/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/EditSectionModal.tsx b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/EditSectionModal.tsx new file mode 100644 index 00000000..abae6bd3 --- /dev/null +++ b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/EditSectionModal.tsx @@ -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 = ({ + open, + sceneNumber, + editText, + onChangeEditText, + aiFeedback, + onChangeAiFeedback, + aiLoading, + onGenerateSuggestions, + suggestions, + onPickSuggestion, + onClose, + onSave, +}) => { + return ( + + Edit Section (Scene {sceneNumber}) + + + onChangeEditText(e.target.value)} + multiline + minRows={6} + fullWidth + /> + onChangeAiFeedback(e.target.value)} + multiline + minRows={3} + fullWidth + helperText="Describe desired changes (tone, pacing, details). Generate to get 2 suggestions." + /> + + + + {suggestions.length > 0 && ( + + {suggestions.map((s, i) => ( + + + Suggestion {i + 1} + + + {s} + + + + + + ))} + + )} + + + + + + + + ); +}; + +export default EditSectionModal; + diff --git a/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/ImageEditModal.tsx b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/ImageEditModal.tsx new file mode 100644 index 00000000..f5ece33d --- /dev/null +++ b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/ImageEditModal.tsx @@ -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 = ({ open, sceneNumber, value, onChange, onClose, onSave }) => { + return ( + + Edit Scene Illustration Prompt (Scene {sceneNumber}) + + + onChange(e.target.value)} + multiline + minRows={5} + fullWidth + /> + + + + + + + + ); +}; + +export default ImageEditModal; + diff --git a/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/KeyEventsModal.tsx b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/KeyEventsModal.tsx new file mode 100644 index 00000000..0ad26b43 --- /dev/null +++ b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/KeyEventsModal.tsx @@ -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 = ({ open, sceneNumber, events, onClose }) => { + return ( + + Key Events (Scene {sceneNumber}) + + {events && events.length > 0 ? ( + + {events.map((e, idx) => ( +
  • + {e} +
  • + ))} +
    + ) : ( + No key events provided for this scene. + )} +
    + + + +
    + ); +}; + +export default KeyEventsModal; + diff --git a/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/OutlineActionsBar.tsx b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/OutlineActionsBar.tsx new file mode 100644 index 00000000..782ad5b6 --- /dev/null +++ b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/OutlineActionsBar.tsx @@ -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 = ({ + isGenerating, + canRegenerateOutline, + onRegenerateOutline, + showMediaActions, + isGeneratingImages, + isGeneratingAudio, + illustrationEnabled, + narrationEnabled, + onGenerateImages, + onGenerateAudio, + canContinue, + onContinue, +}) => { + return ( + + + {showMediaActions && ( + <> + + + + + + + + )} + + + ); +}; + +export default OutlineActionsBar; + diff --git a/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/OutlineHoverActions.tsx b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/OutlineHoverActions.tsx new file mode 100644 index 00000000..72320d1a --- /dev/null +++ b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/OutlineHoverActions.tsx @@ -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 = ({ onEdit, onImprove }) => { + return ( + + + { + 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', + }} + > + + + + + { + 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', + }} + > + + + + + ); +}; + +export default OutlineHoverActions; + diff --git a/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/SceneGenerationPanel.tsx b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/SceneGenerationPanel.tsx new file mode 100644 index 00000000..30d6c101 --- /dev/null +++ b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/SceneGenerationPanel.tsx @@ -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 = ({ + provider, + width, + height, + model, + enabled, + regenerating, + onRegenerate, +}) => { + return ( + + + Scene generation controls (uses Setup settings) + + + Provider: {provider || 'Auto'} Β· Size: {width}x{height}{model ? ` Β· Model: ${model}` : ''} + + + {!enabled && ( + + Enable Illustration in Story Setup to generate images. + + )} + + ); +}; + +export default SceneGenerationPanel; + diff --git a/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/TitleEditModal.tsx b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/TitleEditModal.tsx new file mode 100644 index 00000000..cd006c26 --- /dev/null +++ b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/TitleEditModal.tsx @@ -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 = ({ open, sceneNumber, value, onChange, onClose, onSave }) => { + return ( + + Edit Scene Title (Scene {sceneNumber}) + + + onChange(e.target.value)} + fullWidth + /> + + + + + + + + ); +}; + +export default TitleEditModal; + diff --git a/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/buttonStyles.ts b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/buttonStyles.ts new file mode 100644 index 00000000..d4628997 --- /dev/null +++ b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/buttonStyles.ts @@ -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; + diff --git a/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/pageVariants.ts b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/pageVariants.ts new file mode 100644 index 00000000..7677f7e5 --- /dev/null +++ b/frontend/src/components/StoryWriter/Phases/StoryOutlineParts/pageVariants.ts @@ -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 }, + }), +}; + diff --git a/frontend/src/components/StoryWriter/Phases/StorySetup/FeatureCheckboxesSection.tsx b/frontend/src/components/StoryWriter/Phases/StorySetup/FeatureCheckboxesSection.tsx index de8963b3..62636f02 100644 --- a/frontend/src/components/StoryWriter/Phases/StorySetup/FeatureCheckboxesSection.tsx +++ b/frontend/src/components/StoryWriter/Phases/StorySetup/FeatureCheckboxesSection.tsx @@ -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 = ({ state }) => { +interface FeatureCheckboxesProps { + state: SectionProps['state']; + layout?: 'stack' | 'inline'; +} + +export const FeatureCheckboxesSection: React.FC = ({ 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') => ( + + {options.map((option) => ( + option.onChange(e.target.checked)} + size="small" + /> + } + label={option.label} + sx={{ + m: 0, + '& .MuiFormControlLabel-label': { + fontWeight: 600, + }, + }} + /> + ))} + + ); + + if (layout === 'inline') { + return renderCheckboxes('row'); + } + return ( Story Features - - state.setEnableExplainer(e.target.checked)} - /> - } - label="Explainer" - /> - state.setEnableIllustration(e.target.checked)} - /> - } - label="Illustration" - /> - state.setEnableVideoNarration(e.target.checked)} - /> - } - label="Story Video & Narration" - /> - + {renderCheckboxes('column')} ); }; diff --git a/frontend/src/components/StoryWriter/Phases/StorySetup/GenerationSettingsSection.tsx b/frontend/src/components/StoryWriter/Phases/StorySetup/GenerationSettingsSection.tsx index 8f50bcbe..6234ee6b 100644 --- a/frontend/src/components/StoryWriter/Phases/StorySetup/GenerationSettingsSection.tsx +++ b/frontend/src/components/StoryWriter/Phases/StorySetup/GenerationSettingsSection.tsx @@ -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 = ({ 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) => ( + + + {title} + + {disabled && } + + ); + return ( @@ -28,13 +50,11 @@ export const GenerationSettingsSection: React.FC = ({ state }) => {/* Image Generation Settings */} - + }> - - Image Generation Settings - + {renderHeading('Image Generation Settings', imageDisabled)} - + = ({ 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) => ( @@ -66,6 +87,7 @@ export const GenerationSettingsSection: React.FC = ({ state }) => }} helperText="Select a common image size or set custom dimensions below." sx={textFieldStyles} + disabled={imageDisabled} > {COMMON_IMAGE_SIZES.map((size) => ( @@ -84,6 +106,7 @@ export const GenerationSettingsSection: React.FC = ({ state }) => inputProps={{ min: 256, max: 2048, step: 64 }} helperText="Image width in pixels (256-2048)" sx={textFieldStyles} + disabled={imageDisabled} /> @@ -96,6 +119,7 @@ export const GenerationSettingsSection: React.FC = ({ state }) => inputProps={{ min: 256, max: 2048, step: 64 }} helperText="Image height in pixels (256-2048)" sx={textFieldStyles} + disabled={imageDisabled} /> @@ -107,6 +131,7 @@ export const GenerationSettingsSection: React.FC = ({ state }) => placeholder="Leave empty to use default model" helperText="Specific model to use for image generation (optional)" sx={textFieldStyles} + disabled={imageDisabled} /> @@ -114,13 +139,11 @@ export const GenerationSettingsSection: React.FC = ({ state }) => {/* Video Generation Settings */} - + }> - - Video Generation Settings - + {renderHeading('Video Generation Settings', videoDisabled)} - + = ({ 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} /> @@ -151,6 +175,7 @@ export const GenerationSettingsSection: React.FC = ({ state }) => { value: 2, label: '2s' }, ]} valueLabelDisplay="auto" + disabled={videoDisabled} /> Duration of transitions between scenes in seconds @@ -162,13 +187,11 @@ export const GenerationSettingsSection: React.FC = ({ state }) => {/* Audio Generation Settings */} - + }> - - Audio Generation Settings - + {renderHeading('Audio Generation Settings', audioDisabled)} - + = ({ state }) => onChange={(e) => state.setAudioProvider(e.target.value)} helperText="Text-to-speech provider for narration" sx={textFieldStyles} + disabled={audioDisabled} > {AUDIO_PROVIDERS.map((provider) => ( @@ -196,6 +220,7 @@ export const GenerationSettingsSection: React.FC = ({ state }) => placeholder="en" helperText="Language code for text-to-speech (e.g., 'en' for English, 'es' for Spanish)" sx={textFieldStyles} + disabled={audioDisabled} /> {state.audioProvider === 'gtts' && ( @@ -205,6 +230,7 @@ export const GenerationSettingsSection: React.FC = ({ state }) => state.setAudioSlow(e.target.checked)} + disabled={audioDisabled} /> } label="Slow Speech (gTTS only)" @@ -229,6 +255,7 @@ export const GenerationSettingsSection: React.FC = ({ state }) => { value: 300, label: '300' }, ]} valueLabelDisplay="auto" + disabled={audioDisabled} /> Speech rate in words per minute (pyttsx3 only) diff --git a/frontend/src/components/StoryWriter/Phases/StorySetup/index.tsx b/frontend/src/components/StoryWriter/Phases/StorySetup/index.tsx index 939b2e62..23a84df3 100644 --- a/frontend/src/components/StoryWriter/Phases/StorySetup/index.tsx +++ b/frontend/src/components/StoryWriter/Phases/StorySetup/index.tsx @@ -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 = ({ state, onNext }) => { }; return ( + <> - + + + Story Setup - - Configure your story parameters and premise. Fill in the required fields and click "Next: Generate Outline" to continue. + + Configure your story parameters and premise. Fill in the required fields and click "Generate Outline" to + continue. + + + {error && ( setError(null)}> @@ -183,15 +199,37 @@ const StorySetup: React.FC = ({ state, onNext }) => { )} - {/* AI Story Setup Button */} - + + Let Alwrity AI craft a cohesive persona, setting, and premise instantly. + + + - {/* Story Parameters Section */} = ({ state, onNext }) => { onRegeneratePremise={handleRegeneratePremise} /> - {/* Story Configuration Section */} - - {/* Feature Checkboxes Section */} - + + + + + + + - {/* Generation Settings Section */} - - {/* Generate Button */} + - {/* AI Story Setup Modal */} setIsModalOpen(false)} state={state} customValuesSetters={customValuesSetters} /> - + ); }; diff --git a/frontend/src/components/StoryWriter/Phases/StoryWriting.tsx b/frontend/src/components/StoryWriter/Phases/StoryWriting.tsx index 9e65d626..b94e1d95 100644 --- a/frontend/src/components/StoryWriter/Phases/StoryWriting.tsx +++ b/frontend/src/components/StoryWriter/Phases/StoryWriting.tsx @@ -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; @@ -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 = ({ state, onNext }) => { const [isGenerating, setIsGenerating] = useState(false); const [isContinuing, setIsContinuing] = useState(false); const [error, setError] = useState(null); + const [currentPageIndex, setCurrentPageIndex] = useState(0); + const [pageDirection, setPageDirection] = useState(0); + const [imageLoadError, setImageLoadError] = useState>(new Set()); + const [imageBlobUrls, setImageBlobUrls] = useState>(new Map()); + + // Get scenes and images from state + const scenes = state.outlineScenes || []; + const sceneImages = state.sceneImages || new Map(); + 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 = ({ 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)', }} > - - Story Writing - - - Generate your story content. You can generate the starting section and continue writing until the story is complete. - + {state.storyContent && ( - + 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 = ({ state, onNext }) => { {state.storyContent ? ( <> - state.setStoryContent(e.target.value)} - label="Story Content" - sx={{ mb: 3 }} - /> + {hasScenes && numPages > 1 ? ( + // Book-like UI with images + + + + + {/* Left page - Image */} + + {currentSceneImageFullUrl ? ( + + { + setImageLoadError((prev) => new Set(prev).add(currentSceneNumber)); + }} + /> + + ) : ( + + + {currentScene?.image_prompt || 'No image available for this scene'} + + + )} + + + Click to turn back + + + {canGoPrev ? '← Previous page' : 'Start of story'} + + + + + {/* Right page - Story text */} + + + + {currentPage || 'Loading...'} + + + + + {canGoNext ? 'Next page β†’' : 'End of story'} + + + Page {currentPageIndex + 1} of {numPages} + + + + + + + + ) : ( + // Simple text display if no scenes + + + + {state.storyContent} + + + + )} + + {/* Multimedia Generation Section */} + {state.isOutlineStructured && state.outlineScenes && state.outlineScenes.length > 0 && ( + + )} + {/* Only show Continue Writing button for medium/long stories that are not complete */} {!state.isComplete && !isShortStory(state.storyLength) && ( diff --git a/frontend/src/components/StoryWriter/StoryWriter.tsx b/frontend/src/components/StoryWriter/StoryWriter.tsx index 4ef70c7e..3ca1be77 100644 --- a/frontend/src/components/StoryWriter/StoryWriter.tsx +++ b/frontend/src/components/StoryWriter/StoryWriter.tsx @@ -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(); + 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 ( + + + + + + ); + } + return ( { zIndex: 1, }} > - {/* Header */} + {/* Header with Phase Navigation and Multimedia Toolbar */} - + + + Story Writer - + Create compelling stories with AI assistance - - {/* Phase Navigation */} + {/* Compact Phase Navigation */} + + + + {/* Multimedia Toolbar */} + handleOpenMultimediaDialog()} + /> + + + {/* Phase Content */} {renderPhaseContent()} + + + + Multimedia Controls + + + + + + + + ); }; diff --git a/frontend/src/components/StoryWriter/StoryWriterLanding.tsx b/frontend/src/components/StoryWriter/StoryWriterLanding.tsx new file mode 100644 index 00000000..5f0adb5a --- /dev/null +++ b/frontend/src/components/StoryWriter/StoryWriterLanding.tsx @@ -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: , + }, + { + title: 'Cinematic Illustrations', + description: 'Scene-by-scene image prompts and gallery-ready renders.', + detail: 'Control aspect ratios, providers, and models for every chapter.', + icon: , + }, + { + title: 'Voice-Ready Narration', + description: 'Generate lifelike audio in multiple languages and speeds.', + detail: 'Perfect for bedtime stories, podcasts, or accessibility-ready scripts.', + icon: , + }, + { + 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: , + }, +]; + +export const StoryWriterLanding: React.FC = ({ onStart }) => { + return ( + + + + + + + + Story Text & Blueprint + + + Watch Alwrity AI open your storybook + + + Begin with a book-inspired canvas. Alwrity assembles personas, settings, tones, and story beats so you can + focus on imagination, not forms. + + + {[ + 'AI-curated personas, stakes, and endings', + 'Guided tone, POV, rating, and length controls', + 'Scene-by-scene descriptions ready for writing', + ].map((item) => ( + + β€’ {item} + + ))} + + + + + + Multimedia Magic + + + Illustrations, narration, and video on tap + + + Every scene can bloom into art, audio, and cinematic video. Toggle features that matter and let AI stitch + them together. + + + {[ + 'High-fidelity prompts for image generators', + 'Narration in multiple languages and speeds', + 'Video assembly with scene transitions and audio sync', + ].map((item) => ( + + β€’ {item} + + ))} + + + + + + + + + Tap once to open the book. Inputs appear after AI drafts your foundation. + + + + + Everything Story Writer helps you create + + + {featureHighlights.map((feature) => ( + + + + {feature.icon} + + {feature.title} + + + + {feature.description} + + + {feature.detail} + + + + ))} + + + ); +}; + +export default StoryWriterLanding; + diff --git a/frontend/src/components/StoryWriter/components/MultimediaSection.tsx b/frontend/src/components/StoryWriter/components/MultimediaSection.tsx new file mode 100644 index 00000000..857ae8bb --- /dev/null +++ b/frontend/src/components/StoryWriter/components/MultimediaSection.tsx @@ -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; +} + +export const MultimediaSection: React.FC = ({ 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(null); + const [selectedScenes, setSelectedScenes] = useState>(new Set()); + const [showSceneSelection, setShowSceneSelection] = useState(false); + const [audioBlobUrls, setAudioBlobUrls] = useState>(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(); + 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 ( + + + Multimedia Generation + + + Generate audio narration and video for your story scenes. + + + {error && ( + setError(null)}> + {error} + + )} + + {/* Audio Section */} + {narrationEnabled ? ( + + + + + + Audio Narration + + {hasAudio && ( + } + label="Generated" + size="small" + color="success" + sx={{ ml: 1 }} + /> + )} + + + + + {hasScenes && state.outlineScenes && ( + + + + Select scenes to generate audio for: + + + + + setShowSceneSelection(!showSceneSelection)} + sx={{ p: 0.5 }} + > + {showSceneSelection ? : } + + + + + + {state.outlineScenes.map((scene: any, index: number) => { + const sceneNumber = scene.scene_number || index + 1; + const hasAudioForScene = state.sceneAudio?.has(sceneNumber); + return ( + handleSceneSelectionToggle(sceneNumber)} + size="small" + /> + } + label={ + + + Scene {sceneNumber}: {scene.title || `Scene ${sceneNumber}`} + + {hasAudioForScene && ( + + )} + + } + /> + ); + })} + + + + )} + + {isGeneratingAudio && ( + + + + Generating audio for {selectedScenes.size} selected scene + {selectedScenes.size !== 1 ? 's' : ''}... + + + )} + + {hasAudio && state.sceneAudio && state.outlineScenes && ( + + + Audio narration generated for {state.sceneAudio.size} scene(s). Listen to audio for each scene: + + + {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 ( + + + Scene {sceneNumber}: {scene.title || `Scene ${sceneNumber}`} + + + + ); + })} + + + )} + + ) : ( + + Narration is disabled in Story Setup. Enable it to generate or listen to audio narration. + + )} + + + + {/* Video Section */} + {videoEnabled ? ( + + + + + + Story Video + + {hasVideo && ( + } + label="Generated" + size="small" + color="success" + sx={{ ml: 1 }} + /> + )} + {!hasVideo && !hasImages && ( + + )} + {!hasVideo && hasImages && !hasAudio && ( + + )} + + + {hasVideo && ( + + )} + + + + + {isGeneratingVideo && ( + + 0 ? 'determinate' : 'indeterminate'} + value={videoProgress} + /> + + Generating video... This may take a few minutes. + + + )} + + {hasVideo && state.storyVideo && ( + + + Video ready! Preview and download below. + + + Your browser does not support the video tag. + + + )} + + ) : ( + + Story video generation is disabled in Story Setup. Enable it to create narrative videos. + + )} + + ); +}; + diff --git a/frontend/src/components/StoryWriter/components/MultimediaToolbar.tsx b/frontend/src/components/StoryWriter/components/MultimediaToolbar.tsx new file mode 100644 index 00000000..29757162 --- /dev/null +++ b/frontend/src/components/StoryWriter/components/MultimediaToolbar.tsx @@ -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; + onGenerateAudio?: () => void; + onGenerateVideo?: () => void; + isGeneratingAudio?: boolean; + isGeneratingVideo?: boolean; + onOpenPanel?: (section: 'audio' | 'video') => void; +} + +export const MultimediaToolbar: React.FC = ({ + 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); + const [videoMenuAnchor, setVideoMenuAnchor] = useState(null); + + const handleAudioMenuOpen = (event: React.MouseEvent) => { + setAudioMenuAnchor(event.currentTarget); + }; + + const handleAudioMenuClose = () => { + setAudioMenuAnchor(null); + }; + + const handleVideoMenuOpen = (event: React.MouseEvent) => { + setVideoMenuAnchor(event.currentTarget); + }; + + const handleVideoMenuClose = () => { + setVideoMenuAnchor(null); + }; + + const handleOpenPanel = (section: 'audio' | 'video') => { + handleAudioMenuClose(); + handleVideoMenuClose(); + onOpenPanel?.(section); + }; + + return ( + + {/* Audio Generation Button */} + + + + {isGeneratingAudio ? ( + + ) : hasAudio ? ( + + } + > + + + ) : ( + + )} + + + + + handleOpenPanel('audio')} disabled={!hasScenes || !audioFeatureEnabled}> + + + + + + + { + handleAudioMenuClose(); + onGenerateAudio?.(); + }} + disabled={!canGenerateAudio || !onGenerateAudio} + > + + + + + + + + {/* Video Generation Button */} + + + + {isGeneratingVideo ? ( + + ) : hasVideo ? ( + + } + > + + + ) : ( + + )} + + + + + handleOpenPanel('video')} disabled={!hasScenes || !videoFeatureEnabled}> + + + + + + + { + handleVideoMenuClose(); + onGenerateVideo?.(); + }} + disabled={!canGenerateVideo || !onGenerateVideo} + > + + + + + + + + ); +}; + diff --git a/frontend/src/components/shared/AlertsBadge.tsx b/frontend/src/components/shared/AlertsBadge.tsx new file mode 100644 index 00000000..48ad524c --- /dev/null +++ b/frontend/src/components/shared/AlertsBadge.tsx @@ -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; + 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; + actionLabel?: string; + actionHref?: string; +} + +interface AlertsBadgeProps { + colorMode?: 'light' | 'dark'; +} + +const AlertsBadge: React.FC = ({ colorMode = 'light' }) => { + const { userId } = useAuth(); + const [anchorEl, setAnchorEl] = useState(null); + const [alerts, setAlerts] = useState([]); + const [alertGroups, setAlertGroups] = useState([]); + const [loading, setLoading] = useState(false); + const [unreadCount, setUnreadCount] = useState(0); + const open = Boolean(anchorEl); + const intervalRef = useRef(null); + const isPollingRef = useRef(false); + const schedulerDismissedRef = useRef>(new Set()); + + const getSchedulerStorageKey = (uid: string) => `scheduler_alerts_dismissed_${uid}`; + + const loadSchedulerDismissed = (uid: string) => { + if (!uid) return new Set(); + try { + const stored = localStorage.getItem(getSchedulerStorageKey(uid)); + if (!stored) return new Set(); + const parsed = JSON.parse(stored); + if (Array.isArray(parsed)) { + return new Set(parsed); + } + return new Set(); + } catch { + return new Set(); + } + }; + + const persistSchedulerDismissed = (uid: string, dismissed: Set) => { + 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) => { + 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 ; + case 'warning': + return ; + case 'info': + return ; + default: + return ; + } + }; + + 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 ( + <> + 0 ? `${unreadCount} unread alert${unreadCount > 1 ? 's' : ''}` : 'No alerts'}> + + + {unreadCount > 0 ? ( + + ) : ( + + )} + + + + + + + + + Alerts + + {unreadCount > 0 && ( + + )} + + + + {loading && alertGroups.length === 0 ? ( + + + Loading alerts... + + + ) : alertGroups.length === 0 ? ( + + + + No alerts + + + ) : ( + + {alertGroups.map((group, index) => ( + + a.is_read) ? 'transparent' : 'rgba(255, 152, 0, 0.05)', + '&:hover': { + bgcolor: 'rgba(0,0,0,0.04)', + }, + cursor: 'pointer', + }} + onClick={() => handleGroupClick(group)} + > + + {getSeverityIcon(group.severity)} + + + a.is_read) ? 400 : 700 }}> + {group.title} + + + 1 ? 's' : ''}`} + size="small" + sx={{ + height: 18, + fontSize: '0.65rem', + bgcolor: 'rgba(0,0,0,0.08)', + color: colorMode === 'dark' ? 'white' : 'inherit', + }} + /> + + + } + secondary={ + <> + + {group.summary} + + {group.alerts.slice(0, 2).map((alert, idx) => ( + + β€’ {alert.message} + + ))} + + Last alert: {formatDate(group.latestTimestamp)} + + {group.actionHref && ( + + )} + + } + /> + + {index < alertGroups.length - 1 && } + + ))} + + )} + + {alertGroups.length > 0 && ( + <> + + + + + + )} + + + ); +}; + +const failureReasonDetails: Record = { + 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(); + + 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; diff --git a/frontend/src/components/shared/DashboardHeader.tsx b/frontend/src/components/shared/DashboardHeader.tsx index d3cc7e04..4aff4207 100644 --- a/frontend/src/components/shared/DashboardHeader.tsx +++ b/frontend/src/components/shared/DashboardHeader.tsx @@ -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 = ({ @@ -407,10 +406,7 @@ const DashboardHeader: React.FC = ({ )} {rightContent} - {/* Usage Dashboard - Show API usage statistics */} - - - + diff --git a/frontend/src/components/shared/HeaderControls.tsx b/frontend/src/components/shared/HeaderControls.tsx new file mode 100644 index 00000000..55dee086 --- /dev/null +++ b/frontend/src/components/shared/HeaderControls.tsx @@ -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 = ({ + colorMode = 'light', + showAlerts = true, + showUser = true, + gap = 1.5, +}) => { + if (!showAlerts && !showUser) { + return null; + } + + return ( + + {showAlerts && } + {showUser && } + + ); +}; + +export default HeaderControls; + diff --git a/frontend/src/components/shared/UserBadge.tsx b/frontend/src/components/shared/UserBadge.tsx index db62a71a..c65d155e 100644 --- a/frontend/src/components/shared/UserBadge.tsx +++ b/frontend/src/components/shared/UserBadge.tsx @@ -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 = ({ colorMode = 'light' }) => { const { signOut } = useClerk(); const { subscription } = useSubscription(); const [anchorEl, setAnchorEl] = React.useState(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 = ({ 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 = ({ colorMode = 'light' }) => { }} /> - - - {initials} - + + + + {initials} + + {/* Status Bulb */} + + - + {user?.fullName || user?.username || 'User'} @@ -110,6 +190,50 @@ const UserBadge: React.FC = ({ colorMode = 'light' }) => { /> + + + {/* System Status Indicator */} + e.stopPropagation()} + > + + System Health + + *': { transform: 'scale(0.85)' } }}> + + + + + + + {/* Usage Dashboard */} + e.stopPropagation()} + > + + Usage Statistics + + + + + + + + { handleClose(); window.location.href = '/pricing'; }}> Manage Subscription diff --git a/frontend/src/data/toolCategories.ts b/frontend/src/data/toolCategories.ts index d9d1fa56..36e72ca0 100644 --- a/frontend/src/data/toolCategories.ts +++ b/frontend/src/data/toolCategories.ts @@ -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', diff --git a/frontend/src/hooks/useSchedulerTaskAlerts.ts b/frontend/src/hooks/useSchedulerTaskAlerts.ts index cae8a6ed..41eaac4a 100644 --- a/frontend/src/hooks/useSchedulerTaskAlerts.ts +++ b/frontend/src/hooks/useSchedulerTaskAlerts.ts @@ -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) { diff --git a/frontend/src/hooks/useStoryWriterState.ts b/frontend/src/hooks/useStoryWriterState.ts index 1587b6aa..f1be4258 100644 --- a/frontend/src/hooks/useStoryWriterState.ts +++ b/frontend/src/hooks/useStoryWriterState.ts @@ -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 = { 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, diff --git a/frontend/src/pages/SchedulerDashboard.tsx b/frontend/src/pages/SchedulerDashboard.tsx index 53be83dd..fb7a9ef9 100644 --- a/frontend/src/pages/SchedulerDashboard.tsx +++ b/frontend/src/pages/SchedulerDashboard.tsx @@ -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 = () => { + diff --git a/frontend/src/services/storyWriterApi.ts b/frontend/src/services/storyWriterApi.ts index bf58b1ac..3cc0f55b 100644 --- a/frontend/src/services/storyWriterApi.ts +++ b/frontend/src/services/storyWriterApi.ts @@ -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;