AlgoVision Deployer commited on
Commit
1a25b7f
·
0 Parent(s):

deploy: minimal bootloader for public Space

Browse files
Files changed (48) hide show
  1. .gitattributes +13 -0
  2. Dockerfile +41 -0
  3. README.md +27 -0
  4. bootloader.sh +30 -0
  5. frontend/.editorconfig +13 -0
  6. frontend/.github/workflows/ci.yml +34 -0
  7. frontend/.gitignore +24 -0
  8. frontend/LICENSE +21 -0
  9. frontend/README.md +60 -0
  10. frontend/app/app.config.ts +8 -0
  11. frontend/app/app.vue +51 -0
  12. frontend/app/assets/css/main.css +287 -0
  13. frontend/app/components/AiAssistDialog.vue +236 -0
  14. frontend/app/components/AppLogo.vue +66 -0
  15. frontend/app/components/ChatInterface.vue +341 -0
  16. frontend/app/components/GenerationWidget.vue +700 -0
  17. frontend/app/components/GlassCard.vue +18 -0
  18. frontend/app/components/InteractivePanel.vue +112 -0
  19. frontend/app/components/OnboardingTour.vue +417 -0
  20. frontend/app/components/PlanReviewPanel.vue +191 -0
  21. frontend/app/components/PlyrVideoPlayer.vue +250 -0
  22. frontend/app/components/ProjectCard.vue +403 -0
  23. frontend/app/components/ProjectGroupCard.vue +197 -0
  24. frontend/app/components/QuizBuilder.vue +612 -0
  25. frontend/app/components/TemplateMenu.vue +49 -0
  26. frontend/app/components/TopNavbar.vue +198 -0
  27. frontend/app/layouts/default.vue +96 -0
  28. frontend/app/layouts/workspace.vue +88 -0
  29. frontend/app/pages/ai-providers.vue +595 -0
  30. frontend/app/pages/index.vue +612 -0
  31. frontend/app/pages/project/[id].vue +382 -0
  32. frontend/app/pages/projects.vue +620 -0
  33. frontend/app/stores/chat.ts +83 -0
  34. frontend/app/stores/groups.ts +142 -0
  35. frontend/app/stores/projects.ts +1596 -0
  36. frontend/app/stores/quiz.ts +208 -0
  37. frontend/app/utils/api.ts +170 -0
  38. frontend/eslint.config.mjs +6 -0
  39. frontend/nuxt.config.ts +43 -0
  40. frontend/package-lock.json +0 -0
  41. frontend/package.json +37 -0
  42. frontend/plugins/suppress-router-warnings.client.ts +14 -0
  43. frontend/pnpm-lock.yaml +0 -0
  44. frontend/pnpm-workspace.yaml +6 -0
  45. frontend/public/favicon.ico +3 -0
  46. frontend/public/favicon.svg +1 -0
  47. frontend/renovate.json +13 -0
  48. frontend/tsconfig.json +10 -0
.gitattributes ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ models/*.onnx filter=lfs diff=lfs merge=lfs -text
2
+ models/*.bin filter=lfs diff=lfs merge=lfs -text
3
+ *.png filter=lfs diff=lfs merge=lfs -text
4
+ *.jpg filter=lfs diff=lfs merge=lfs -text
5
+ *.mp3 filter=lfs diff=lfs merge=lfs -text
6
+ *.mp4 filter=lfs diff=lfs merge=lfs -text
7
+ *.gif filter=lfs diff=lfs merge=lfs -text
8
+ *.bin filter=lfs diff=lfs merge=lfs -text
9
+ *.onnx filter=lfs diff=lfs merge=lfs -text
10
+ *.ico filter=lfs diff=lfs merge=lfs -text
11
+ *.woff filter=lfs diff=lfs merge=lfs -text
12
+ *.woff2 filter=lfs diff=lfs merge=lfs -text
13
+ *.ttf filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 1: Build the Nuxt frontend natively inside the container
2
+ FROM node:20-slim AS builder
3
+
4
+ WORKDIR /build
5
+
6
+ # Copy only frontend requirements to leverage Docker cache
7
+ COPY frontend/package*.json ./
8
+ RUN npm install
9
+
10
+ # Copy frontend source files and compile
11
+ COPY frontend/ ./
12
+ RUN npm run build
13
+
14
+ # Stage 2: Final runtime image
15
+ FROM ldsprgrm/algovision-deps:latest
16
+
17
+ # Install Node.js and Git
18
+ RUN apt-get update && apt-get install -y curl git && \
19
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
20
+ apt-get install -y nodejs && \
21
+ rm -rf /var/lib/apt/lists/*
22
+
23
+ WORKDIR /app
24
+
25
+ # Copy the natively built frontend output to the runtime stage
26
+ COPY --from=builder /build/.output /app/frontend-server
27
+
28
+ # Copy the bootloader script
29
+ COPY bootloader.sh .
30
+ RUN chmod +x bootloader.sh
31
+
32
+ # Set environment variables for ports and execution
33
+ ENV PYTHONPATH=/app
34
+ ENV PORT=7860
35
+ ENV NUXT_PORT=7860
36
+ ENV NUXT_HOST=0.0.0.0
37
+
38
+ EXPOSE 7860
39
+
40
+ # Run bootloader
41
+ ENTRYPOINT ["./bootloader.sh"]
README.md ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: AlgoVision
3
+ emoji: 📽️
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # 📽️ AlgoVision Public Space
12
+
13
+ This is the public-facing interface for **AlgoVision**, an AI-powered video generation platform that automatically creates educational Manim animation videos explaining algorithms, theorems, and scientific concepts.
14
+
15
+ This Space runs as a secure public frontend that dynamically boots the core application from the private backend Space via the Two-Space Git Loader pattern.
16
+
17
+ ---
18
+ ### 🛠️ Architecture Overview
19
+ ```
20
+ [ Public User ] <---> [ Public Space (Frontend Interface) ]
21
+ ^
22
+ | (Secure API Git Clone with HF_TOKEN)
23
+ v
24
+ [ Private Space (Core logic / backend) ]
25
+ ```
26
+
27
+ All source code, assets, and intellectual property are kept fully private and secure.
bootloader.sh ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ echo "=== AlgoVision Two-Space Bootloader ==="
5
+
6
+ # 1. Validate Token
7
+ if [ -z "$HF_TOKEN" ]; then
8
+ echo "ERROR: The 'HF_TOKEN' environment variable is empty!"
9
+ echo "Please add a Write/Read access token under Space Settings -> Variables and secrets."
10
+ exit 1
11
+ fi
12
+
13
+ # 2. Clone Private Repository
14
+ echo "Cloning private source code from AlgoVision-Server..."
15
+ rm -rf /tmp/algo_repo
16
+ git clone --depth 1 https://oauth2:$HF_TOKEN@huggingface.co/spaces/ldsprgrm/AlgoVision-Server /tmp/algo_repo
17
+
18
+ # 3. Deploy to /app
19
+ echo "Moving private assets to active container space..."
20
+ # Copy everything (including hidden files) from /tmp/algo_repo to /app
21
+ cp -rp /tmp/algo_repo/. /app/
22
+ rm -rf /tmp/algo_repo
23
+
24
+ # 4. Set Permissions
25
+ chmod +x /app/entrypoint.sh
26
+ chmod -R 777 /app
27
+
28
+ # 5. Hand over control to native entrypoint
29
+ echo "Launching AlgoVision Application..."
30
+ exec /app/entrypoint.sh
frontend/.editorconfig ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # editorconfig.org
2
+ root = true
3
+
4
+ [*]
5
+ indent_size = 2
6
+ indent_style = space
7
+ end_of_line = lf
8
+ charset = utf-8
9
+ trim_trailing_whitespace = true
10
+ insert_final_newline = true
11
+
12
+ [*.md]
13
+ trim_trailing_whitespace = false
frontend/.github/workflows/ci.yml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: ci
2
+
3
+ on: push
4
+
5
+ jobs:
6
+ ci:
7
+ runs-on: ${{ matrix.os }}
8
+
9
+ strategy:
10
+ matrix:
11
+ os: [ubuntu-latest]
12
+ node: [22]
13
+
14
+ steps:
15
+ - name: Checkout
16
+ uses: actions/checkout@v6
17
+
18
+ - name: Install pnpm
19
+ uses: pnpm/action-setup@v4
20
+
21
+ - name: Install node
22
+ uses: actions/setup-node@v6
23
+ with:
24
+ node-version: ${{ matrix.node }}
25
+ cache: pnpm
26
+
27
+ - name: Install dependencies
28
+ run: pnpm install
29
+
30
+ - name: Lint
31
+ run: pnpm run lint
32
+
33
+ - name: Typecheck
34
+ run: pnpm run typecheck
frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Nuxt dev/build outputs
2
+ .output
3
+ .data
4
+ .nuxt
5
+ .nitro
6
+ .cache
7
+ dist
8
+
9
+ # Node dependencies
10
+ node_modules
11
+
12
+ # Logs
13
+ logs
14
+ *.log
15
+
16
+ # Misc
17
+ .DS_Store
18
+ .fleet
19
+ .idea
20
+
21
+ # Local env files
22
+ .env
23
+ .env.*
24
+ !.env.example
frontend/LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Nuxt UI Templates
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
frontend/README.md ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Nuxt Starter Template
2
+
3
+ [![Nuxt UI](https://img.shields.io/badge/Made%20with-Nuxt%20UI-00DC82?logo=nuxt&labelColor=020420)](https://ui.nuxt.com)
4
+
5
+ Use this template to get started with [Nuxt UI](https://ui.nuxt.com) quickly.
6
+
7
+ - [Live demo](https://starter-template.nuxt.dev/)
8
+ - [Documentation](https://ui.nuxt.com/docs/getting-started/installation/nuxt)
9
+
10
+ <a href="https://starter-template.nuxt.dev/" target="_blank">
11
+ <picture>
12
+ <source media="(prefers-color-scheme: dark)" srcset="https://ui.nuxt.com/assets/templates/nuxt/starter-dark.png">
13
+ <source media="(prefers-color-scheme: light)" srcset="https://ui.nuxt.com/assets/templates/nuxt/starter-light.png">
14
+ <img alt="Nuxt Starter Template" src="https://ui.nuxt.com/assets/templates/nuxt/starter-light.png" width="830" height="466">
15
+ </picture>
16
+ </a>
17
+
18
+ > The starter template for Vue is on https://github.com/nuxt-ui-templates/starter-vue.
19
+
20
+ ## Quick Start
21
+
22
+ ```bash [Terminal]
23
+ npm create nuxt@latest -- -t github:nuxt-ui-templates/starter
24
+ ```
25
+
26
+ ## Deploy your own
27
+
28
+ [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-name=starter&repository-url=https%3A%2F%2Fgithub.com%2Fnuxt-ui-templates%2Fstarter&demo-image=https%3A%2F%2Fui.nuxt.com%2Fassets%2Ftemplates%2Fnuxt%2Fstarter-dark.png&demo-url=https%3A%2F%2Fstarter-template.nuxt.dev%2F&demo-title=Nuxt%20Starter%20Template&demo-description=A%20minimal%20template%20to%20get%20started%20with%20Nuxt%20UI.)
29
+
30
+ ## Setup
31
+
32
+ Make sure to install the dependencies:
33
+
34
+ ```bash
35
+ pnpm install
36
+ ```
37
+
38
+ ## Development Server
39
+
40
+ Start the development server on `http://localhost:3000`:
41
+
42
+ ```bash
43
+ pnpm dev
44
+ ```
45
+
46
+ ## Production
47
+
48
+ Build the application for production:
49
+
50
+ ```bash
51
+ pnpm build
52
+ ```
53
+
54
+ Locally preview production build:
55
+
56
+ ```bash
57
+ pnpm preview
58
+ ```
59
+
60
+ Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
frontend/app/app.config.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ export default defineAppConfig({
2
+ ui: {
3
+ colors: {
4
+ primary: 'green',
5
+ neutral: 'slate'
6
+ }
7
+ }
8
+ })
frontend/app/app.vue ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup>
2
+ import { useProjectStore } from "~/stores/projects";
3
+
4
+ const store = useProjectStore();
5
+
6
+ if (process.client) {
7
+ store.initKeys();
8
+ }
9
+
10
+ onMounted(() => {
11
+ console.log("[AlgoVision] Global initialization started...");
12
+ store.fetchProjects();
13
+ store.fetchModels();
14
+ store.fetchVoices();
15
+ });
16
+
17
+ useHead({
18
+ meta: [{ name: "viewport", content: "width=device-width, initial-scale=1" }],
19
+ link: [{ rel: "icon", type: "image/svg+xml", href: "/favicon.svg" }],
20
+ htmlAttrs: {
21
+ lang: "en",
22
+ },
23
+ });
24
+
25
+ const title = "AlgoVision";
26
+ const description =
27
+ "A production-ready starter template powered by Nuxt UI. Build beautiful, accessible, and performant applications in minutes, not hours.";
28
+
29
+ useSeoMeta({
30
+ title,
31
+ description,
32
+ ogTitle: title,
33
+ ogDescription: description,
34
+ ogImage: "https://ui.nuxt.com/assets/templates/nuxt/starter-light.png",
35
+ twitterImage: "https://ui.nuxt.com/assets/templates/nuxt/starter-light.png",
36
+ twitterCard: "summary_large_image",
37
+ });
38
+ </script>
39
+
40
+ <template>
41
+ <UApp>
42
+ <UMain>
43
+ <NuxtLayout>
44
+ <NuxtPage />
45
+ </NuxtLayout>
46
+ </UMain>
47
+
48
+ <GenerationWidget />
49
+ <OnboardingTour />
50
+ </UApp>
51
+ </template>
frontend/app/assets/css/main.css ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+ @import "@nuxt/ui";
3
+
4
+ @theme static {
5
+ --font-sans: "Inter", "Public Sans", sans-serif;
6
+
7
+ /* Refined Nuxt Green scale */
8
+ --color-green-50: #f0fdf6;
9
+ --color-green-100: #dcfce9;
10
+ --color-green-200: #bbf7d4;
11
+ --color-green-300: #86efba;
12
+ --color-green-400: #4ade8d;
13
+ --color-green-500: #00dc82;
14
+ --color-green-600: #16a34a;
15
+ --color-green-700: #15803d;
16
+ --color-green-800: #166534;
17
+ --color-green-900: #14532d;
18
+
19
+ --color-glass-border: rgba(255, 255, 255, 0.6);
20
+ --color-glass-bg: rgba(255, 255, 255, 0.4);
21
+ }
22
+
23
+ /* ─── Base ─────────────────────────────────────────────────────── */
24
+ body {
25
+ background-color: #f8fafc;
26
+ color: #0f172a;
27
+ -webkit-font-smoothing: antialiased;
28
+ }
29
+
30
+ /* ─── Noise texture ─────────────────────────────────────────────── */
31
+ .bg-noise {
32
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
33
+ opacity: 0.04;
34
+ pointer-events: none;
35
+ }
36
+
37
+ /* ─── Glass primitives ──────────────────────────────────────────── */
38
+ .premium-glass {
39
+ background: linear-gradient(
40
+ 135deg,
41
+ rgba(255, 255, 255, 0.72) 0%,
42
+ rgba(255, 255, 255, 0.32) 100%
43
+ );
44
+ backdrop-filter: blur(24px) saturate(1.6);
45
+ -webkit-backdrop-filter: blur(24px) saturate(1.6);
46
+ border: 1px solid rgba(255, 255, 255, 0.65);
47
+ box-shadow:
48
+ 0 4px 6px -1px rgba(0, 0, 0, 0.025),
49
+ 0 12px 28px -6px rgba(0, 0, 0, 0.06),
50
+ inset 0 1px 0 rgba(255, 255, 255, 0.95);
51
+ }
52
+
53
+ .glass-dark {
54
+ background: rgba(10, 14, 22, 0.85);
55
+ backdrop-filter: blur(32px) saturate(1.4);
56
+ -webkit-backdrop-filter: blur(32px) saturate(1.4);
57
+ border: 1px solid rgba(255, 255, 255, 0.07);
58
+ }
59
+
60
+ /* ─── Inputs ────────────────────────────────────────────────────── */
61
+ .premium-input {
62
+ background: rgba(255, 255, 255, 0.55);
63
+ backdrop-filter: blur(12px);
64
+ border: 1px solid rgba(255, 255, 255, 0.85);
65
+ box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.025);
66
+ transition: all 0.2s ease;
67
+ }
68
+ .premium-input:focus {
69
+ background: rgba(255, 255, 255, 0.88);
70
+ border-color: var(--color-green-400);
71
+ box-shadow:
72
+ inset 0 2px 4px rgba(0, 0, 0, 0.01),
73
+ 0 0 0 3px rgba(0, 220, 130, 0.15);
74
+ outline: none;
75
+ }
76
+
77
+ /* ─── Buttons ───────────────────────────────────────────────────── */
78
+ .btn-premium {
79
+ position: relative;
80
+ background: linear-gradient(160deg, #00dc82 0%, #00b869 100%);
81
+ border: 1px solid rgba(255, 255, 255, 0.35);
82
+ box-shadow:
83
+ 0 4px 14px rgba(0, 220, 130, 0.35),
84
+ inset 0 1px 0 rgba(255, 255, 255, 0.4);
85
+ text-shadow: 0 1px 1px rgba(0, 0, 0, 0.12);
86
+ transition: all 0.22s cubic-bezier(0.16, 1, 0.3, 1);
87
+ }
88
+ .btn-premium:hover:not(:disabled) {
89
+ transform: translateY(-1.5px);
90
+ box-shadow:
91
+ 0 8px 22px rgba(0, 220, 130, 0.45),
92
+ inset 0 1px 0 rgba(255, 255, 255, 0.5);
93
+ }
94
+ .btn-premium:active:not(:disabled) {
95
+ transform: translateY(0.5px);
96
+ box-shadow: 0 2px 8px rgba(0, 220, 130, 0.3);
97
+ }
98
+
99
+ .btn-glass {
100
+ background: rgba(255, 255, 255, 0.5);
101
+ backdrop-filter: blur(12px);
102
+ border: 1px solid rgba(255, 255, 255, 0.75);
103
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
104
+ transition: all 0.2s ease;
105
+ }
106
+ .btn-glass:hover:not(:disabled) {
107
+ background: rgba(255, 255, 255, 0.75);
108
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
109
+ }
110
+
111
+ /* ─── Status badge colors ───────────────────────────────────────── */
112
+ .badge-completed {
113
+ background: rgba(22, 163, 74, 0.12);
114
+ color: #15803d;
115
+ border: 1px solid rgba(22, 163, 74, 0.2);
116
+ }
117
+ .badge-in-progress {
118
+ background: rgba(245, 158, 11, 0.12);
119
+ color: #b45309;
120
+ border: 1px solid rgba(245, 158, 11, 0.22);
121
+ }
122
+ .badge-pending {
123
+ background: rgba(100, 116, 139, 0.1);
124
+ color: #475569;
125
+ border: 1px solid rgba(100, 116, 139, 0.18);
126
+ }
127
+
128
+ /* ─── Animations ────────────────────────────────────────────────── */
129
+ @keyframes blob {
130
+ 0%,
131
+ 100% {
132
+ transform: translate(0, 0) scale(1);
133
+ }
134
+ 33% {
135
+ transform: translate(30px, -50px) scale(1.1);
136
+ }
137
+ 66% {
138
+ transform: translate(-20px, 20px) scale(0.9);
139
+ }
140
+ }
141
+ .animate-blob {
142
+ animation: blob 7s infinite alternate;
143
+ }
144
+ .animation-delay-2000 {
145
+ animation-delay: 2s;
146
+ }
147
+ .animation-delay-4000 {
148
+ animation-delay: 4s;
149
+ }
150
+
151
+ @keyframes fadeInUp {
152
+ from {
153
+ opacity: 0;
154
+ transform: translateY(18px);
155
+ }
156
+ to {
157
+ opacity: 1;
158
+ transform: translateY(0);
159
+ }
160
+ }
161
+ .animate-fade-in-up {
162
+ animation: fadeInUp 0.55s cubic-bezier(0.16, 1, 0.3, 1) forwards;
163
+ }
164
+
165
+ @keyframes fadeIn {
166
+ from {
167
+ opacity: 0;
168
+ transform: translateY(12px);
169
+ }
170
+ to {
171
+ opacity: 1;
172
+ transform: translateY(0);
173
+ }
174
+ }
175
+ .animate-fade-in {
176
+ animation: fadeIn 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards;
177
+ }
178
+
179
+ @keyframes scaleIn {
180
+ from {
181
+ opacity: 0;
182
+ transform: scale(0.96);
183
+ }
184
+ to {
185
+ opacity: 1;
186
+ transform: scale(1);
187
+ }
188
+ }
189
+ .animate-scale-in {
190
+ animation: scaleIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
191
+ }
192
+
193
+ @keyframes shimmer {
194
+ from {
195
+ background-position: -200% center;
196
+ }
197
+ to {
198
+ background-position: 200% center;
199
+ }
200
+ }
201
+ .animate-shimmer {
202
+ background: linear-gradient(90deg, #e2e8f0 25%, #f8fafc 50%, #e2e8f0 75%);
203
+ background-size: 200% auto;
204
+ animation: shimmer 1.5s linear infinite;
205
+ }
206
+
207
+ @keyframes spin-slow {
208
+ to {
209
+ transform: rotate(360deg);
210
+ }
211
+ }
212
+ .animate-spin-slow {
213
+ animation: spin-slow 3s linear infinite;
214
+ }
215
+
216
+ /* ─── Studio mode ring ──────────────────────────────────────────── */
217
+ @keyframes progress-ring {
218
+ from {
219
+ stroke-dashoffset: 220;
220
+ }
221
+ }
222
+
223
+ /* ─── Scrollbar ─────────────────────────────────────────────────── */
224
+ .scrollbar-thin::-webkit-scrollbar {
225
+ width: 4px;
226
+ height: 4px;
227
+ }
228
+ .scrollbar-thin::-webkit-scrollbar-track {
229
+ background: transparent;
230
+ }
231
+ .scrollbar-thin::-webkit-scrollbar-thumb {
232
+ background: rgba(100, 116, 139, 0.3);
233
+ border-radius: 2px;
234
+ }
235
+ .scrollbar-thin::-webkit-scrollbar-thumb:hover {
236
+ background: rgba(100, 116, 139, 0.5);
237
+ }
238
+
239
+ /* ─── Plyr overrides ────────────────────────────────────────────── */
240
+ .plyr--video {
241
+ border-radius: 0;
242
+ }
243
+ .plyr--video .plyr__controls {
244
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
245
+ }
246
+ .plyr__control--overlaid {
247
+ background: rgba(0, 220, 130, 0.9) !important;
248
+ box-shadow: 0 0 0 4px rgba(0, 220, 130, 0.25) !important;
249
+ }
250
+ .plyr__control:hover {
251
+ background: rgba(0, 220, 130, 0.8) !important;
252
+ }
253
+ .plyr--full-ui input[type="range"] {
254
+ color: #00dc82;
255
+ }
256
+
257
+ /* ─── NavLink active state ──────────────────────────────────────── */
258
+ .nav-link-active {
259
+ color: #00dc82 !important;
260
+ }
261
+
262
+ /* ─── Project Groups: drag-and-drop ─────────────────────────────── */
263
+ .drop-zone-active {
264
+ outline: 2px dashed #00dc82;
265
+ outline-offset: -4px;
266
+ background: rgba(0, 220, 130, 0.04);
267
+ }
268
+
269
+ .dragging-card {
270
+ opacity: 0.45;
271
+ pointer-events: none;
272
+ transform: scale(0.97);
273
+ }
274
+
275
+ @keyframes groupSlide {
276
+ from {
277
+ opacity: 0;
278
+ transform: translateY(-6px);
279
+ }
280
+ to {
281
+ opacity: 1;
282
+ transform: translateY(0);
283
+ }
284
+ }
285
+ .group-slide-in {
286
+ animation: groupSlide 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards;
287
+ }
frontend/app/components/AiAssistDialog.vue ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <UModal
3
+ v-model:open="isOpen"
4
+ prevent-close
5
+ title="AI Creative Director"
6
+ description="Draft a production plan in seconds"
7
+ :ui="{ content: 'p-0 w-full sm:w-[500px]' }"
8
+ >
9
+ <template #content>
10
+ <div
11
+ class="p-6 bg-white/95 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/60 relative w-full sm:w-[500px]"
12
+ @click.stop
13
+ >
14
+ <button
15
+ @click="isOpen = false"
16
+ class="absolute top-4 right-4 p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors z-10 cursor-pointer"
17
+ >
18
+ <UIcon name="i-lucide-x" class="w-5 h-5" />
19
+ </button>
20
+
21
+ <!-- Header -->
22
+ <div class="flex items-center gap-4 mb-8">
23
+ <div
24
+ class="w-12 h-12 rounded-xl bg-green-50 flex items-center justify-center border border-green-100"
25
+ >
26
+ <UIcon name="i-lucide-wand-2" class="w-6 h-6 text-green-500" />
27
+ </div>
28
+ <div>
29
+ <h2 class="text-xl font-bold text-slate-900">
30
+ AI Creative Director
31
+ </h2>
32
+ <p class="text-sm font-medium text-slate-500">
33
+ Draft a production plan in seconds
34
+ </p>
35
+ </div>
36
+ </div>
37
+
38
+ <!-- Content -->
39
+ <div v-if="!generating" class="space-y-6">
40
+ <div class="text-center relative">
41
+ <div class="absolute inset-0 flex items-center" aria-hidden="true">
42
+ <div class="w-full border-t border-slate-200"></div>
43
+ </div>
44
+ <div class="relative flex justify-center">
45
+ <span
46
+ class="bg-white px-3 text-xs font-bold tracking-widest text-slate-400 uppercase"
47
+ >
48
+ WHO IS THIS VIDEO FOR?
49
+ </span>
50
+ </div>
51
+ </div>
52
+
53
+ <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
54
+ <button
55
+ v-for="p in personas"
56
+ :key="p.value"
57
+ @click="persona = p.value"
58
+ class="flex flex-col items-center p-4 rounded-xl border-2 transition-all duration-200 text-center cursor-pointer"
59
+ :class="
60
+ persona === p.value
61
+ ? 'border-green-500 bg-green-50/50 shadow-md shadow-green-500/10'
62
+ : 'border-slate-100 bg-white hover:border-slate-300 hover:bg-slate-50'
63
+ "
64
+ >
65
+ <div
66
+ class="w-12 h-12 rounded-full flex items-center justify-center mb-3 transition-colors duration-200"
67
+ :class="
68
+ persona === p.value ? 'bg-white shadow-sm' : 'bg-slate-50'
69
+ "
70
+ >
71
+ <UIcon
72
+ :name="p.icon"
73
+ class="w-6 h-6"
74
+ :class="
75
+ persona === p.value ? 'text-green-500' : 'text-slate-400'
76
+ "
77
+ />
78
+ </div>
79
+ <div class="font-semibold text-slate-800 mb-1">
80
+ {{ p.label }}
81
+ </div>
82
+ <div class="text-xs text-slate-500 leading-tight">
83
+ {{ p.desc }}
84
+ </div>
85
+ </button>
86
+ </div>
87
+
88
+ <!-- Error Message -->
89
+ <div
90
+ v-if="errorMessage"
91
+ class="p-4 bg-orange-50 border border-orange-100 rounded-xl flex items-start gap-3"
92
+ >
93
+ <UIcon
94
+ name="i-lucide-alert-circle"
95
+ class="w-5 h-5 text-orange-500 shrink-0 mt-0.5"
96
+ />
97
+ <div class="text-sm text-orange-800 font-medium leading-relaxed">
98
+ {{ errorMessage }}
99
+ </div>
100
+ </div>
101
+
102
+ <!-- Generate Action -->
103
+ <button
104
+ @click="generate"
105
+ :disabled="generating || store.loadingModels"
106
+ class="w-full btn-premium bg-gradient-to-r from-green-600 to-emerald-500 hover:from-green-500 hover:to-emerald-400 text-white rounded-xl py-3.5 font-bold text-base shadow-lg shadow-green-500/30 hover:shadow-green-500/40 transition-all flex items-center justify-center gap-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
107
+ >
108
+ <UIcon
109
+ :name="
110
+ store.loadingModels ? 'i-lucide-loader-2' : 'i-lucide-wand-2'
111
+ "
112
+ class="w-5 h-5"
113
+ :class="{ 'animate-spin': store.loadingModels }"
114
+ />
115
+ {{
116
+ store.loadingModels ? "Loading Models..." : "Draft Magic Script"
117
+ }}
118
+ </button>
119
+ </div>
120
+
121
+ <!-- Loading State -->
122
+ <div
123
+ v-else
124
+ class="py-12 flex flex-col items-center justify-center text-center"
125
+ >
126
+ <div class="relative w-16 h-16 mb-6">
127
+ <div
128
+ class="absolute inset-0 bg-green-100 rounded-full animate-ping opacity-75"
129
+ ></div>
130
+ <div
131
+ class="relative w-full h-full bg-green-50 rounded-full flex items-center justify-center border-2 border-green-200"
132
+ >
133
+ <UIcon
134
+ name="i-lucide-loader-2"
135
+ class="w-8 h-8 text-green-500 animate-spin"
136
+ />
137
+ </div>
138
+ </div>
139
+ <h3 class="text-lg font-bold text-slate-900 animate-pulse mb-1">
140
+ Designing Lesson Plan...
141
+ </h3>
142
+ <p class="text-sm text-slate-500 font-medium">
143
+ Consulting pedagogical engine
144
+ </p>
145
+ </div>
146
+ </div>
147
+ </template>
148
+ </UModal>
149
+ </template>
150
+
151
+ <script setup>
152
+ import { ref, watch, computed, nextTick } from "vue";
153
+ import { useProjectStore } from "~/stores/projects";
154
+
155
+ const props = defineProps({
156
+ modelValue: {
157
+ type: Boolean,
158
+ default: false,
159
+ },
160
+ initialTopic: {
161
+ type: String,
162
+ default: "",
163
+ },
164
+ });
165
+ const emit = defineEmits(["update:modelValue"]);
166
+
167
+ const store = useProjectStore();
168
+ const isOpen = computed({
169
+ get: () => props.modelValue,
170
+ set: (val) => emit("update:modelValue", val),
171
+ });
172
+
173
+ const persona = ref("student");
174
+ const generating = ref(false);
175
+ const errorMessage = ref("");
176
+
177
+ const personas = [
178
+ {
179
+ value: "kid",
180
+ label: "Beginner",
181
+ icon: "i-lucide-sprout",
182
+ desc: "Simplifies concepts using intuitive visual analogies.",
183
+ },
184
+ {
185
+ value: "student",
186
+ label: "Student",
187
+ icon: "i-lucide-graduation-cap",
188
+ desc: "Structured, step-by-step guidance for academic depth.",
189
+ },
190
+ {
191
+ value: "expert",
192
+ label: "Expert",
193
+ icon: "i-lucide-brain",
194
+ desc: "Rigorous technical analysis for advanced reasoning.",
195
+ },
196
+ ];
197
+
198
+ watch(isOpen, (val) => {
199
+ if (val) {
200
+ errorMessage.value = "";
201
+ }
202
+ if (!val) {
203
+ setTimeout(() => {
204
+ generating.value = false;
205
+ }, 300);
206
+ }
207
+ });
208
+
209
+ watch(
210
+ () => store.draftTopic,
211
+ (val) => {
212
+ if (val && val.length > 0 && generating.value) {
213
+ isOpen.value = false;
214
+ }
215
+ },
216
+ );
217
+
218
+ async function generate() {
219
+ if (!store.draftTopic || generating.value) return;
220
+ generating.value = true;
221
+ errorMessage.value = "";
222
+ try {
223
+ await store.draftPlan(
224
+ store.draftTopic,
225
+ persona.value,
226
+ store.targetDuration,
227
+ );
228
+ isOpen.value = false;
229
+ } catch (error) {
230
+ console.error("Drafting failed:", error);
231
+ errorMessage.value =
232
+ error.message || "Something went wrong. Please try again.";
233
+ generating.value = false;
234
+ }
235
+ }
236
+ </script>
frontend/app/components/AppLogo.vue ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <svg
3
+ width="1020"
4
+ height="200"
5
+ viewBox="0 0 1020 200"
6
+ fill="none"
7
+ xmlns="http://www.w3.org/2000/svg"
8
+ >
9
+ <path
10
+ d="M377 200C379.16 200 381 198.209 381 196V103C381 103 386 112 395 127L434 194C435.785 197.74 439.744 200 443 200H470V50H443C441.202 50 439 51.4941 439 54V148L421 116L385 55C383.248 51.8912 379.479 50 376 50H350V200H377Z"
11
+ fill="currentColor"
12
+ />
13
+ <path
14
+ d="M726 92H739C742.314 92 745 89.3137 745 86V60H773V92H800V116H773V159C773 169.5 778.057 174 787 174H800V200H783C759.948 200 745 185.071 745 160V116H726V92Z"
15
+ fill="currentColor"
16
+ />
17
+ <path
18
+ d="M591 92V154C591 168.004 585.742 179.809 578 188C570.258 196.191 559.566 200 545 200C530.434 200 518.742 196.191 511 188C503.389 179.809 498 168.004 498 154V92H514C517.412 92 520.769 92.622 523 95C525.231 97.2459 526 98.5652 526 102V154C526 162.059 526.457 167.037 530 171C533.543 174.831 537.914 176 545 176C552.217 176 555.457 174.831 559 171C562.543 167.037 563 162.059 563 154V102C563 98.5652 563.769 96.378 566 94C567.96 91.9107 570.028 91.9599 573 92C573.411 92.0055 574.586 92 575 92H591Z"
19
+ fill="currentColor"
20
+ />
21
+ <path
22
+ d="M676 144L710 92H684C680.723 92 677.812 93.1758 676 96L660 120L645 97C643.188 94.1758 639.277 92 636 92H611L645 143L608 200H634C637.25 200 640.182 196.787 642 194L660 167L679 195C680.818 197.787 683.75 200 687 200H713L676 144Z"
23
+ fill="currentColor"
24
+ />
25
+ <!-- Replaced primary logo path with new boxes icon -->
26
+ <g
27
+ transform="translate(0, 10) scale(7.5)"
28
+ stroke="#00dc82"
29
+ stroke-width="1.5"
30
+ stroke-linecap="round"
31
+ stroke-linejoin="round"
32
+ >
33
+ <path
34
+ d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z"
35
+ fill="none"
36
+ />
37
+ <path d="m7 16.5-4.74-2.85" />
38
+ <path d="m7 16.5 5-3" />
39
+ <path d="M7 16.5v5.17" />
40
+ <path
41
+ d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z"
42
+ fill="none"
43
+ />
44
+ <path d="m17 16.5-5-3" />
45
+ <path d="m17 16.5 4.74-2.85" />
46
+ <path d="M17 16.5v5.17" />
47
+ <path
48
+ d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z"
49
+ fill="none"
50
+ />
51
+ <path d="M12 8 7.26 5.15" />
52
+ <path d="m12 8 4.74-2.85" />
53
+ <path d="M12 13.5V8" />
54
+ </g>
55
+ <path
56
+ d="M958 60.0001H938C933.524 60.0001 929.926 59.9395 927 63C924.074 65.8905 925 67.5792 925 72V141C925 151.372 923.648 156.899 919 162C914.352 166.931 908.468 169 899 169C889.705 169 882.648 166.931 878 162C873.352 156.899 873 151.372 873 141V72.0001C873 67.5793 872.926 65.8906 870 63.0001C867.074 59.9396 863.476 60.0001 859 60.0001H840V141C840 159.023 845.016 173.458 855 184C865.156 194.542 879.893 200 899 200C918.107 200 932.844 194.542 943 184C953.156 173.458 958 159.023 958 141V60.0001Z"
57
+ fill="var(--ui-primary)"
58
+ />
59
+ <path
60
+ fill-rule="evenodd"
61
+ clip-rule="evenodd"
62
+ d="M1000 60.0233L1020 60V77L1020 128V156.007L1020 181L1020 189.004C1020 192.938 1019.98 194.429 1017 197.001C1014.02 199.725 1009.56 200 1005 200H986.001V181.006L986 130.012V70.0215C986 66.1576 986.016 64.5494 989 62.023C991.819 59.6358 995.437 60.0233 1000 60.0233Z"
63
+ fill="var(--ui-primary)"
64
+ />
65
+ </svg>
66
+ </template>
frontend/app/components/ChatInterface.vue ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="flex flex-col h-full bg-slate-50/30">
3
+ <!-- Header Controls -->
4
+ <div
5
+ v-if="messages.length > 0"
6
+ class="px-4 py-2 border-b border-slate-100 flex justify-end shrink-0"
7
+ >
8
+ <UButton
9
+ icon="i-lucide-trash-2"
10
+ color="gray"
11
+ variant="ghost"
12
+ size="xs"
13
+ label="Clear Chat"
14
+ @click="chatStore.clearHistory(project.id)"
15
+ class="text-slate-400 hover:text-red-500 transition-colors"
16
+ />
17
+ </div>
18
+
19
+ <div
20
+ class="flex-1 p-4 overflow-y-auto custom-scrollbar space-y-4"
21
+ ref="chatContainer"
22
+ >
23
+ <!-- Welcome message -->
24
+ <div
25
+ v-if="messages.length === 0"
26
+ class="h-full flex flex-col items-center justify-center text-center p-6 animate-fade-in-up"
27
+ >
28
+ <div
29
+ class="w-16 h-16 rounded-2xl bg-gradient-to-br from-green-400 to-green-600 flex items-center justify-center mb-6 shadow-lg shadow-green-500/20"
30
+ >
31
+ <UIcon name="i-lucide-bot" class="w-8 h-8 text-white" />
32
+ </div>
33
+ <h3 class="font-bold text-slate-900 mb-2">AI Tutor Ready</h3>
34
+ <p class="text-sm text-slate-500 mb-8 max-w-[240px]">
35
+ Ask me any questions about the video or educational concepts.
36
+ </p>
37
+
38
+ <!-- Vertical Suggestion Chips -->
39
+ <div
40
+ v-if="suggestions.length > 0"
41
+ class="flex flex-col gap-2.5 w-full max-w-[300px]"
42
+ >
43
+ <p
44
+ class="text-[10px] uppercase tracking-[0.1em] font-bold text-slate-400 mb-1 text-left px-1"
45
+ >
46
+ Suggested for you
47
+ </p>
48
+ <button
49
+ v-for="suggestion in suggestions"
50
+ :key="suggestion"
51
+ class="group flex items-center gap-3 px-4 py-3 rounded-xl bg-white border border-slate-100 text-left text-sm text-slate-700 hover:border-green-500 hover:bg-green-50/30 transition-all duration-200 shadow-sm hover:shadow-md"
52
+ @click="useSuggestion(suggestion)"
53
+ >
54
+ <UIcon
55
+ name="i-lucide-sparkles"
56
+ class="w-4 h-4 text-green-500 opacity-40 group-hover:opacity-100 transition-opacity shrink-0"
57
+ />
58
+ <span class="whitespace-normal leading-relaxed text-slate-700">{{
59
+ suggestion
60
+ }}</span>
61
+ </button>
62
+ </div>
63
+ </div>
64
+
65
+ <!-- Messages -->
66
+ <div
67
+ v-for="(msg, i) in messages"
68
+ :key="i"
69
+ class="flex animate-fade-in-up"
70
+ :class="msg.role === 'user' ? 'justify-end' : 'justify-start'"
71
+ >
72
+ <!-- User Bubble -->
73
+ <div
74
+ v-if="msg.role === 'user'"
75
+ class="max-w-[85%] p-4 rounded-2xl shadow-sm text-sm leading-relaxed border transition-all bg-green-50/50 border-green-100 text-slate-800 rounded-tr-sm"
76
+ >
77
+ <div
78
+ class="prose prose-sm max-w-none text-slate-800 font-medium prose-p:mb-0 last:prose-p:mb-0 prose-code:bg-slate-200/60 prose-code:text-slate-800 prose-code:px-1 prose-code:py-0.5 prose-code:rounded"
79
+ v-html="renderMarkdown(msg.content)"
80
+ ></div>
81
+ </div>
82
+
83
+ <!-- AI Message - Clean text, no avatar, no bubble -->
84
+ <div v-else class="w-full transition-all duration-200">
85
+ <div
86
+ class="prose prose-sm max-w-none prose-p:mb-4 last:prose-p:mb-0 prose-p:text-slate-700 prose-p:leading-7 prose-headings:text-slate-900 prose-headings:font-bold prose-headings:mb-3 prose-headings:mt-6 first:prose-headings:mt-0 prose-h2:text-base prose-h2:border-b prose-h2:border-slate-200 prose-h2:pb-2 prose-h3:text-sm prose-h3:text-slate-900 prose-h3:font-medium prose-h4:text-sm prose-h4:text-slate-800 prose-h4:font-medium prose-strong:text-slate-900 prose-strong:font-semibold prose-ul:list-disc prose-ul:pl-5 prose-ul:mb-4 prose-ul:space-y-1.5 prose-li:text-slate-700 prose-ol:list-decimal prose-ol:pl-5 prose-ol:mb-4 prose-ol:space-y-1.5 prose-li:text-slate-700 prose-code:bg-slate-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-emerald-700 prose-code:font-mono prose-code:text-[0.85em] prose-code:before:content-none prose-code:after:content-none prose-pre:bg-[#0f172a] prose-pre:text-green-400 prose-pre:border prose-pre:border-slate-800 prose-pre:rounded-lg prose-pre:p-4 prose-pre:mt-4 prose-pre:mb-4 prose-pre:overflow-x-auto prose-blockquote:border-l-2 prose-blockquote:border-emerald-300 prose-blockquote:bg-emerald-50/30 prose-blockquote:pl-4 prose-blockquote:pr-4 prose-blockquote:py-3 prose-blockquote:rounded-r-xl prose-blockquote:italic prose-blockquote:text-slate-600 prose-blockquote:my-4 prose-table:w-full prose-table:border-collapse prose-table:my-4 prose-th:bg-slate-50 prose-th:border prose-th:border-slate-200 prose-th:px-3 prose-th:py-2 prose-th:text-left prose-th:text-xs prose-th:font-bold prose-th:text-slate-700 prose-td:border prose-td:border-slate-200 prose-td:px-3 prose-td:py-2 prose-td:text-sm prose-td:text-slate-700 prose-hr:border-slate-200 prose-hr:my-6 prose-a:text-emerald-600 prose-a:underline prose-a:decoration-emerald-600/30 prose-a:underline-offset-2 hover:prose-a:decoration-emerald-600/60 space-y-2"
87
+ v-html="renderMarkdown(msg.content)"
88
+ ></div>
89
+ </div>
90
+ </div>
91
+
92
+ <!-- Loading Indicator -->
93
+ <div v-if="isTyping" class="flex justify-start animate-fade-in">
94
+ <div
95
+ class="bg-green-50 border border-green-100 rounded-2xl rounded-tl-sm px-4 py-3 flex items-center gap-1.5 shadow-sm"
96
+ >
97
+ <span
98
+ class="w-1.5 h-1.5 bg-green-400 rounded-full animate-bounce"
99
+ ></span>
100
+ <span
101
+ class="w-1.5 h-1.5 bg-green-400 rounded-full animate-bounce"
102
+ style="animation-delay: 0.15s"
103
+ ></span>
104
+ <span
105
+ class="w-1.5 h-1.5 bg-green-400 rounded-full animate-bounce"
106
+ style="animation-delay: 0.3s"
107
+ ></span>
108
+ </div>
109
+ </div>
110
+ </div>
111
+
112
+ <!-- Input Area -->
113
+ <div class="bg-white/80 border-t border-slate-100 shrink-0">
114
+ <div class="p-4">
115
+ <form @submit.prevent="sendMessage" class="relative group">
116
+ <input
117
+ v-model="newMessage"
118
+ type="text"
119
+ @keydown.enter.prevent="sendMessage"
120
+ :placeholder="
121
+ projectStore.loadingModels
122
+ ? 'Loading AI models...'
123
+ : 'Ask a question...'
124
+ "
125
+ class="w-full premium-input rounded-xl pl-4 pr-12 py-3 text-sm outline-none disabled:opacity-50 disabled:cursor-not-allowed"
126
+ :disabled="isTyping || projectStore.loadingModels"
127
+ />
128
+ <button
129
+ type="submit"
130
+ :disabled="
131
+ !newMessage.trim() || isTyping || projectStore.loadingModels
132
+ "
133
+ class="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded-lg bg-green-500 text-white hover:bg-green-600 disabled:opacity-50 disabled:bg-slate-300 transition-colors"
134
+ :title="
135
+ projectStore.loadingModels ? 'Models loading' : 'Send message'
136
+ "
137
+ >
138
+ <UIcon
139
+ :name="
140
+ projectStore.loadingModels
141
+ ? 'i-lucide-loader-2'
142
+ : 'i-lucide-send'
143
+ "
144
+ class="w-4 h-4"
145
+ :class="
146
+ projectStore.loadingModels
147
+ ? 'animate-spin'
148
+ : '-translate-x-[1px] translate-y-[1px]'
149
+ "
150
+ />
151
+ </button>
152
+ </form>
153
+ <div class="mt-2 text-[10px] text-center text-slate-400">
154
+ AlgoVision can make mistakes. Check important information.
155
+ </div>
156
+ </div>
157
+ </div>
158
+ </div>
159
+ </template>
160
+
161
+ <script setup>
162
+ import { ref, onMounted, watch, nextTick, computed } from "vue";
163
+ import { marked } from "marked";
164
+ import markedKatex from "marked-katex-extension";
165
+ import "katex/dist/katex.min.css";
166
+ import api from "~/utils/api";
167
+ import { useProjectStore } from "~/stores/projects";
168
+ import { useChatStore } from "~/stores/chat";
169
+
170
+ marked.use(
171
+ markedKatex({
172
+ throwOnError: false,
173
+ output: "html",
174
+ }),
175
+ );
176
+
177
+ const props = defineProps({
178
+ project: { type: Object, required: true },
179
+ });
180
+
181
+ const projectStore = useProjectStore();
182
+ const chatStore = useChatStore();
183
+
184
+ const messages = computed(() => chatStore.getHistory(props.project.id));
185
+ const suggestions = computed(() => chatStore.getSuggestions(props.project.id));
186
+ const isTyping = ref(false);
187
+ const newMessage = ref("");
188
+ const chatContainer = ref(null);
189
+
190
+ const fetchSuggestions = async () => {
191
+ try {
192
+ await chatStore.fetchSuggestions(props.project.id, projectStore.apiKeys);
193
+ } catch (err) {
194
+ console.error("Error fetching suggestions:", err);
195
+ }
196
+ };
197
+
198
+ const useSuggestion = (text) => {
199
+ newMessage.value = text;
200
+ sendMessage();
201
+ };
202
+
203
+ const scrollToBottom = async () => {
204
+ await nextTick();
205
+ if (chatContainer.value) {
206
+ chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
207
+ }
208
+ };
209
+
210
+ onMounted(() => {
211
+ scrollToBottom();
212
+ if (!projectStore.loadingModels) {
213
+ fetchSuggestions();
214
+ }
215
+ });
216
+
217
+ watch(
218
+ () => projectStore.loadingModels,
219
+ (loading) => {
220
+ if (!loading && suggestions.value.length === 0) {
221
+ fetchSuggestions();
222
+ }
223
+ },
224
+ );
225
+
226
+ watch(messages, scrollToBottom, { deep: true });
227
+ watch(isTyping, scrollToBottom);
228
+
229
+ const renderMarkdown = (text) => {
230
+ if (!text) return "";
231
+ try {
232
+ return marked.parse(text);
233
+ } catch (e) {
234
+ return text;
235
+ }
236
+ };
237
+
238
+ const sendMessage = async () => {
239
+ if (!newMessage.value.trim() || isTyping.value) return;
240
+
241
+ const userText = newMessage.value;
242
+ chatStore.addMessage(props.project.id, { role: "user", content: userText });
243
+ newMessage.value = "";
244
+ isTyping.value = true;
245
+
246
+ try {
247
+ const res = await api.chat(props.project.id, {
248
+ message: userText,
249
+ history: messages.value.slice(0, -1),
250
+ model:
251
+ projectStore.planningModel ||
252
+ projectStore.selectedModel ||
253
+ "gemini/gemini-3.1-flash-latest",
254
+ openai_api_key: projectStore.apiKeys.openai,
255
+ anthropic_api_key: projectStore.apiKeys.anthropic,
256
+ gemini_api_key: projectStore.apiKeys.gemini,
257
+ });
258
+
259
+ chatStore.addMessage(props.project.id, {
260
+ role: "assistant",
261
+ content: res.response,
262
+ });
263
+ } catch (error) {
264
+ console.error("Chat error:", error);
265
+ chatStore.addMessage(props.project.id, {
266
+ role: "assistant",
267
+ content: `**Error:** Failed to connect to the AI engine. ${error.response?.data?.detail || error.message}`,
268
+ });
269
+ } finally {
270
+ isTyping.value = false;
271
+ }
272
+ };
273
+ </script>
274
+
275
+ <style scoped>
276
+ .custom-scrollbar::-webkit-scrollbar {
277
+ width: 4px;
278
+ }
279
+ .custom-scrollbar::-webkit-scrollbar-track {
280
+ background: transparent;
281
+ }
282
+ .custom-scrollbar::-webkit-scrollbar-thumb {
283
+ background: rgba(203, 213, 225, 0.5);
284
+ border-radius: 2px;
285
+ }
286
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover {
287
+ background: rgba(148, 163, 184, 0.8);
288
+ }
289
+
290
+ /* Additional prose polish for AI responses */
291
+ :deep(.prose a) {
292
+ color: #059669;
293
+ text-decoration: underline;
294
+ text-decoration-color: rgba(5, 150, 105, 0.3);
295
+ text-underline-offset: 2px;
296
+ transition: all 0.15s ease;
297
+ }
298
+ :deep(.prose a:hover) {
299
+ color: #047857;
300
+ text-decoration-color: rgba(5, 150, 105, 0.6);
301
+ }
302
+
303
+ /* Inline math/latex */
304
+ :deep(.prose .katex) {
305
+ font-size: 0.95em;
306
+ }
307
+
308
+ /* Display math blocks */
309
+ :deep(.prose .katex-display) {
310
+ margin: 0.75rem 0;
311
+ padding: 0.75rem;
312
+ overflow-x: auto;
313
+ }
314
+
315
+ /* Smooth text rendering */
316
+ :deep(.prose *) {
317
+ -webkit-font-smoothing: antialiased;
318
+ -moz-osx-font-smoothing: grayscale;
319
+ }
320
+
321
+ /* Code block line numbers area (if any) */
322
+ :deep(.prose pre code) {
323
+ font-family:
324
+ ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono",
325
+ monospace;
326
+ }
327
+
328
+ /* Prevent code wrap */
329
+ :deep(.prose pre) {
330
+ white-space: pre;
331
+ word-break: normal;
332
+ overflow-wrap: normal;
333
+ }
334
+
335
+ /* Table responsiveness */
336
+ :deep(.prose table) {
337
+ display: table;
338
+ width: 100%;
339
+ overflow-x: auto;
340
+ }
341
+ </style>
frontend/app/components/GenerationWidget.vue ADDED
@@ -0,0 +1,700 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <Transition name="widget-slide">
3
+ <div
4
+ v-if="
5
+ store.isGenerating ||
6
+ store.generationState.completed ||
7
+ store.generationState.errorMsg
8
+ "
9
+ class="fixed bottom-6 right-6 z-50 transition-all duration-500"
10
+ :class="isMinimized ? 'w-16 h-16' : 'w-88'"
11
+ >
12
+ <!-- Minimized State (Bubble) -->
13
+ <button
14
+ v-if="isMinimized"
15
+ @click="isMinimized = false"
16
+ class="w-16 h-16 rounded-full shadow-2xl flex items-center justify-center relative group overflow-hidden cursor-pointer border-2"
17
+ :class="
18
+ store.isGenerating
19
+ ? 'bg-gradient-to-br from-green-400 to-green-600 border-green-300'
20
+ : store.generationState.completed
21
+ ? 'bg-gradient-to-br from-emerald-400 to-emerald-600 border-emerald-300'
22
+ : 'bg-gradient-to-br from-slate-400 to-slate-600 border-slate-300'
23
+ "
24
+ >
25
+ <!-- Rhythmic Pulse Rings -->
26
+ <template v-if="store.isGenerating">
27
+ <div
28
+ class="absolute inset-0 bg-white/20 animate-ping-slow rounded-full"
29
+ ></div>
30
+ <div
31
+ class="absolute inset-0 bg-white/10 animate-ping-slower rounded-full"
32
+ ></div>
33
+ </template>
34
+
35
+ <!-- Icon + Progress -->
36
+ <div
37
+ class="relative z-10 flex flex-col items-center justify-center w-full h-full gap-0.5"
38
+ >
39
+ <UIcon
40
+ :name="currentIcon"
41
+ class="w-6 h-6 text-white transition-all duration-300 drop-shadow-sm"
42
+ :class="{
43
+ 'animate-float': store.isGenerating,
44
+ 'animate-spin-slow': store.generationState.isReconnecting,
45
+ }"
46
+ />
47
+ <!-- Always-visible progress -->
48
+ <span
49
+ v-if="store.isGenerating"
50
+ class="text-[9px] font-black text-white/90 tabular-nums leading-none"
51
+ >
52
+ {{ store.progress }}%
53
+ </span>
54
+ </div>
55
+
56
+ <!-- Progress Ring -->
57
+ <svg
58
+ class="absolute inset-0 -rotate-90 w-full h-full pointer-events-none"
59
+ >
60
+ <circle
61
+ cx="50%"
62
+ cy="50%"
63
+ r="45%"
64
+ fill="transparent"
65
+ stroke="currentColor"
66
+ stroke-width="2.5"
67
+ class="text-white/20"
68
+ />
69
+ <circle
70
+ cx="50%"
71
+ cy="50%"
72
+ r="45%"
73
+ fill="transparent"
74
+ stroke="currentColor"
75
+ stroke-width="3"
76
+ :stroke-dasharray="`${(store.progress / 100) * 282} 282`"
77
+ class="text-white/80 transition-all duration-700 ease-in-out"
78
+ stroke-linecap="round"
79
+ />
80
+ </svg>
81
+ </button>
82
+
83
+ <!-- Expanded State (Card) -->
84
+ <div
85
+ v-else
86
+ class="bg-white/95 backdrop-blur-xl rounded-2xl shadow-[0_20px_50px_rgba(0,0,0,0.15)] border border-slate-200 overflow-hidden overflow-y-auto max-h-[80vh]"
87
+ >
88
+ <!-- Completion Notification View -->
89
+ <div
90
+ v-if="store.generationState.completed"
91
+ @click="handleViewProject"
92
+ class="p-5 bg-gradient-to-br from-[#00dc82] to-[#15803d] text-white cursor-pointer hover:brightness-110 transition-all flex items-center gap-4 group active:scale-[0.98] shadow-[inset_0_1px_rgba(255,255,255,0.4),0_10px_25px_-10px_rgba(0,220,130,0.5)] border-b border-white/10"
93
+ >
94
+ <div
95
+ class="p-3 bg-white/20 backdrop-blur-md rounded-2xl group-hover:rotate-12 group-hover:scale-110 transition-all duration-300 border border-white/30 shadow-lg"
96
+ >
97
+ <UIcon name="i-lucide-party-popper" class="w-7 h-7" />
98
+ </div>
99
+ <div class="flex-1 min-w-0">
100
+ <p
101
+ class="font-black text-[10px] uppercase tracking-[0.25em] mb-1 opacity-90 drop-shadow-sm"
102
+ >
103
+ Success!
104
+ </p>
105
+ <p class="font-black text-sm leading-tight truncate drop-shadow-sm">
106
+ {{
107
+ store.currentProject?.title ||
108
+ store.generationState.completedProjectTitle ||
109
+ "Your project"
110
+ }}
111
+ is ready
112
+ </p>
113
+ </div>
114
+ <div class="flex items-center gap-2">
115
+ <div
116
+ class="w-9 h-9 rounded-full bg-white/10 flex items-center justify-center group-hover:bg-white/20 transition-all border border-white/10"
117
+ >
118
+ <UIcon
119
+ name="i-lucide-arrow-right"
120
+ class="w-5 h-5 animate-bounce-x"
121
+ />
122
+ </div>
123
+ <button
124
+ @click.stop="store.resetGenerationState()"
125
+ class="p-2 hover:bg-white/20 rounded-xl transition-colors cursor-pointer group/close flex items-center justify-center border border-transparent hover:border-white/20 shadow-none hover:shadow-lg"
126
+ title="Dismiss"
127
+ >
128
+ <UIcon
129
+ name="i-lucide-x"
130
+ class="w-4 h-4 opacity-70 group-hover/close:opacity-100"
131
+ />
132
+ </button>
133
+ </div>
134
+ </div>
135
+
136
+ <!-- Active Generation View -->
137
+ <div v-else>
138
+ <!-- Header -->
139
+ <div
140
+ class="px-4 py-3 bg-slate-50/80 border-b border-slate-100 flex items-center justify-between"
141
+ >
142
+ <div class="flex items-center gap-2 min-w-0">
143
+ <UIcon
144
+ :name="currentIcon"
145
+ class="w-4 h-4 flex-shrink-0"
146
+ :class="[
147
+ store.isGenerating || store.generationState.isReconnecting
148
+ ? 'text-green-600'
149
+ : 'text-slate-400',
150
+ {
151
+ 'animate-spin':
152
+ (store.isGenerating ||
153
+ store.generationState.isReconnecting) &&
154
+ currentIcon === 'i-lucide-loader-2',
155
+ },
156
+ ]"
157
+ />
158
+ <span
159
+ class="font-black text-slate-800 text-[10px] uppercase tracking-[0.15em] truncate"
160
+ >
161
+ {{ store.currentProject?.title || "Production Studio" }}
162
+ </span>
163
+ </div>
164
+ <button
165
+ @click="isMinimized = true"
166
+ class="p-1 hover:bg-slate-200 rounded text-slate-400 transition-colors cursor-pointer"
167
+ >
168
+ <UIcon name="i-lucide-minus" class="w-4 h-4" />
169
+ </button>
170
+ </div>
171
+
172
+ <!-- Content -->
173
+ <div class="p-5 space-y-4">
174
+ <div>
175
+ <div class="flex justify-between items-end mb-2">
176
+ <div class="flex flex-col gap-1 min-w-0">
177
+ <span
178
+ class="text-[9px] font-black uppercase tracking-[0.2em] text-slate-400 leading-none truncate"
179
+ >
180
+ {{
181
+ store.generationState.isReconnecting
182
+ ? "Reconnecting..."
183
+ : currentStatusLabel
184
+ }}
185
+ </span>
186
+ <!-- Core Stages Progress -->
187
+ <!-- <div
188
+ class="grid grid-cols-1 gap-2 mt-3 p-2 bg-slate-50/50 rounded-xl border border-slate-100"
189
+ >
190
+ <div
191
+ v-for="(progress, stage) in store.generationState
192
+ .stageProgress"
193
+ :key="stage"
194
+ class="space-y-1"
195
+ >
196
+ <div
197
+ class="flex justify-between items-center text-[9px] font-black uppercase tracking-tighter"
198
+ >
199
+ <span
200
+ :class="
201
+ progress > 0 ? 'text-slate-700' : 'text-slate-400'
202
+ "
203
+ >{{ stage }}</span
204
+ >
205
+ <span
206
+ :class="
207
+ progress === 100
208
+ ? 'text-green-600'
209
+ : 'text-slate-500'
210
+ "
211
+ >{{ progress }}%</span
212
+ >
213
+ </div>
214
+ <div
215
+ class="h-1 w-full bg-slate-200/50 rounded-full overflow-hidden"
216
+ >
217
+ <div
218
+ class="h-full transition-all duration-700 ease-in-out"
219
+ :class="
220
+ progress === 100
221
+ ? 'bg-green-500'
222
+ : progress > 0
223
+ ? 'bg-green-400'
224
+ : 'bg-slate-300'
225
+ "
226
+ :style="{ width: `${progress}%` }"
227
+ ></div>
228
+ </div>
229
+ </div>
230
+ </div> -->
231
+ <span
232
+ v-if="store.generationState.accumulatedCost > 0"
233
+ class="text-[9px] font-bold text-emerald-600 tabular-nums flex items-center gap-1"
234
+ >
235
+ <UIcon name="i-lucide-receipt-text" class="w-3 h-3" />
236
+ {{ store.accumulatedCostPHP }}
237
+ </span>
238
+ </div>
239
+ <span
240
+ class="text-2xl font-black text-slate-900 leading-none tabular-nums"
241
+ >{{ store.progress }}%</span
242
+ >
243
+ </div>
244
+ <!-- Progress Bar -->
245
+ <div
246
+ class="h-3 w-full bg-slate-200/40 rounded-full overflow-hidden relative border border-slate-100/50 backdrop-blur-sm shadow-[inset_0_1px_4px_rgba(0,0,0,0.05)]"
247
+ >
248
+ <!-- Progress Fill -->
249
+ <div
250
+ class="h-full bg-gradient-to-r from-green-400 via-emerald-500 to-emerald-600 transition-all duration-1000 ease-out relative"
251
+ :style="{ width: `${store.progress}%` }"
252
+ >
253
+ <!-- Inner Gloss/Sheen -->
254
+ <div
255
+ class="absolute inset-x-0 top-0 h-[35%] bg-white/20 blur-[1px]"
256
+ ></div>
257
+
258
+ <!-- Main Shimmer (Fast/Sharp) -->
259
+ <div
260
+ v-if="store.isGenerating"
261
+ class="absolute top-0 bottom-0 left-0 w-[30%] bg-gradient-to-r from-transparent via-white/40 to-transparent animate-shimmer-fast"
262
+ ></div>
263
+
264
+ <!-- Secondary Shimmer (Slow/Broad) -->
265
+ <div
266
+ v-if="store.isGenerating"
267
+ class="absolute top-0 bottom-0 left-0 w-[100%] bg-gradient-to-r from-transparent via-white/10 to-transparent animate-shimmer-slow"
268
+ ></div>
269
+
270
+ <!-- Leading Edge Glow -->
271
+ <!-- <div
272
+ class="absolute right-0 top-1/2 -translate-y-1/2 w-4 h-full bg-white/40 blur-md"
273
+ ></div>
274
+ <div
275
+ class="absolute right-0 top-1/2 -translate-y-1/2 w-1 h-2/3 bg-white/80 rounded-full blur-[1px] mr-1"
276
+ ></div> -->
277
+ </div>
278
+
279
+ <!-- Animated Background Pattern (Subtle) -->
280
+ <div
281
+ class="absolute inset-0 opacity-[0.03] pointer-events-none"
282
+ style="
283
+ background-image: repeating-linear-gradient(
284
+ 45deg,
285
+ #000 0,
286
+ #000 1px,
287
+ transparent 0,
288
+ transparent 10px
289
+ );
290
+ background-size: 20px 20px;
291
+ "
292
+ ></div>
293
+ </div>
294
+ </div>
295
+
296
+ <!-- Current Content -->
297
+ <div
298
+ class="bg-slate-900/5 p-2.5 rounded-xl border border-slate-100 min-h-[44px] space-y-1.5"
299
+ >
300
+ <p
301
+ class="text-[9px] text-slate-700 font-black uppercase tracking-[0.14em] leading-relaxed whitespace-normal break-words"
302
+ >
303
+ {{ currentTaskTitle }}
304
+ </p>
305
+ </div>
306
+
307
+ <!-- Actions -->
308
+ <div class="pt-1">
309
+ <button
310
+ v-if="
311
+ store.isGenerating || store.generationState.isReconnecting
312
+ "
313
+ @click.stop="handleStop"
314
+ class="w-full py-2 text-red-500 text-[9px] font-black uppercase tracking-[0.2em] hover:bg-red-50 rounded-xl transition-colors border border-transparent hover:border-red-100 cursor-pointer"
315
+ >
316
+ Cancel Generation
317
+ </button>
318
+
319
+ <!-- Error State Actions -->
320
+ <div v-if="store.generationState.errorMsg" class="space-y-3">
321
+ <!-- <div class="p-3 bg-red-50 rounded-2xl border border-red-100">
322
+ <p
323
+ class="text-[10px] text-red-600 font-bold text-center leading-relaxed"
324
+ >
325
+ {{ store.generationState.errorMsg }}
326
+ </p>
327
+ </div> -->
328
+ <button
329
+ @click="store.initWebSocket(store.currentSessionId)"
330
+ class="w-full py-2.5 rounded-2xl bg-slate-900 text-white text-xs font-black uppercase tracking-widest hover:bg-slate-800 transition-all shadow-lg cursor-pointer"
331
+ >
332
+ Reconnect Now
333
+ </button>
334
+ </div>
335
+ </div>
336
+ </div>
337
+
338
+ <!-- Stop Confirmation Overlay -->
339
+ <div
340
+ v-if="confirmStop"
341
+ class="absolute inset-0 z-50 bg-white/95 backdrop-blur-sm flex flex-col items-center justify-center p-4 text-center animate-in fade-in zoom-in duration-300"
342
+ >
343
+ <div
344
+ class="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mb-3"
345
+ >
346
+ <UIcon
347
+ name="i-lucide-alert-triangle"
348
+ class="w-6 h-6 text-red-500"
349
+ />
350
+ </div>
351
+ <h3 class="text-sm font-semibold text-slate-900 mb-1">
352
+ Stop Generation?
353
+ </h3>
354
+ <p class="text-xs text-slate-500 mb-4 px-2">
355
+ The AI will stop working immediately. This cannot be undone.
356
+ </p>
357
+
358
+ <!-- Delete Toggle -->
359
+ <label
360
+ class="mb-5 px-2 flex items-center gap-2 group cursor-pointer select-none justify-center"
361
+ >
362
+ <input
363
+ type="checkbox"
364
+ v-model="deleteDir"
365
+ class="w-4 h-4 rounded border-slate-300 text-red-600 focus:ring-red-500 accent-red-600"
366
+ />
367
+ <span
368
+ class="text-[10px] font-bold text-slate-600 group-hover:text-slate-900 transition-colors uppercase tracking-wider"
369
+ >
370
+ Delete project directory
371
+ </span>
372
+ </label>
373
+
374
+ <div class="flex items-center gap-2 w-full max-w-[180px]">
375
+ <button
376
+ @click.stop="cancelStop"
377
+ class="flex-1 py-1.5 px-3 rounded-lg text-xs font-medium text-slate-600 bg-slate-100 hover:bg-slate-200 transition-colors"
378
+ >
379
+ No, Keep
380
+ </button>
381
+ <button
382
+ @click.stop="confirmCancellation"
383
+ class="flex-1 py-1.5 px-3 rounded-lg text-xs font-medium text-white bg-red-600 hover:bg-red-700 transition-colors shadow-sm"
384
+ >
385
+ Stop Now
386
+ </button>
387
+ </div>
388
+ </div>
389
+ </div>
390
+ </div>
391
+ </div>
392
+ </Transition>
393
+ </template>
394
+
395
+ <script setup>
396
+ import { ref, computed } from "vue";
397
+ import { useProjectStore } from "~/stores/projects";
398
+ import { useRoute, useRouter } from "vue-router";
399
+
400
+ const store = useProjectStore();
401
+ const router = useRouter();
402
+ const route = useRoute();
403
+ const isMinimized = ref(false);
404
+ const confirmStop = ref(false);
405
+ const deleteDir = ref(true);
406
+
407
+ const LOADING_MESSAGES = [
408
+ "If this takes too long, try blinking.",
409
+ "Loading… because perfection can’t be rushed.",
410
+ "Still loading… thanks for sticking with us.",
411
+ "This is taking longer than expected… we’re on it.",
412
+ "Not gonna lie, this is a bit slow today.",
413
+ "Loading… we promise it’s doing something.",
414
+ "Still working… computers need thinking time too.",
415
+ "This part always takes a second.",
416
+ "Loading… because perfection can’t be rushed.",
417
+ "Hang tight… we’re getting there.",
418
+ "Working on it… slower than we’d like.",
419
+ "Yep, still loading. Appreciate your patience.",
420
+ "Just a moment… or two… maybe three.",
421
+ "This is awkwardly slow… thanks for waiting.",
422
+ "Loading… we wish this were faster too.",
423
+ "Still spinning… not just for decoration.",
424
+ "Doing some behind-the-scenes magic… slowly.",
425
+ "Processing… one tiny step at a time.",
426
+ "Still loading… good things take time.",
427
+ "Taking its sweet time… hang in there.",
428
+ "Loading… we didn’t expect this either.",
429
+ "Almost there… we think.",
430
+ "Still going… thanks for not leaving.",
431
+ "Loading… because things are complicated.",
432
+ "Working… just not at lightning speed.",
433
+ "This might take a sec… or a few.",
434
+ "Still loading… we owe you one.",
435
+ "Crunching numbers… very, very carefully.",
436
+ "Taking a little longer than planned.",
437
+ "Loading… please don’t judge us.",
438
+ "Still trying its best…",
439
+ "This is taking longer than expected… we blame the internet.",
440
+ "Yep, it’s still loading.",
441
+ "Getting there… slowly but surely.",
442
+ "Processing… like a Monday morning.",
443
+ "Not stuck—just thinking really hard.",
444
+ "This is why people get coffee.",
445
+ "Still happening… promise.",
446
+ "Loading… we hear you sighing.",
447
+ "One moment… we mean it this time.",
448
+ "Taking a bit… thanks for hanging in.",
449
+ "Still working… no need to refresh (yet).",
450
+ "Almost… okay not quite yet.",
451
+ "Loading… we’ll be quick-ish.",
452
+ "It’s thinking… deeply.",
453
+ "Working… at its own pace.",
454
+ "Still loading… you’re doing great.",
455
+ "Just a sec… give or take a few secs.",
456
+ "This part’s a little slow—sorry about that.",
457
+ "Loading… we promise it’s worth it.",
458
+ "Still going… we haven’t forgotten you.",
459
+ "Almost ready… hang tight just a bit more.",
460
+ "Loading… thanks for your patience, seriously.",
461
+ ];
462
+
463
+ const currentMessageIndex = ref(0);
464
+ let messageInterval = null;
465
+
466
+ const startMessageCycling = () => {
467
+ if (messageInterval) clearInterval(messageInterval);
468
+ messageInterval = setInterval(() => {
469
+ currentMessageIndex.value =
470
+ (currentMessageIndex.value + 1) % LOADING_MESSAGES.length;
471
+ }, 7000);
472
+ };
473
+
474
+ const stopMessageCycling = () => {
475
+ if (messageInterval) clearInterval(messageInterval);
476
+ messageInterval = null;
477
+ };
478
+
479
+ watch(
480
+ () => store.isGenerating,
481
+ (val) => {
482
+ if (val) startMessageCycling();
483
+ else stopMessageCycling();
484
+ },
485
+ { immediate: true },
486
+ );
487
+
488
+ const currentStatusLabel = computed(() => {
489
+ const status = store.status?.replace("_", " ");
490
+ return status || "Initializing";
491
+ });
492
+
493
+ const currentTaskTitle = computed(() => {
494
+ // Use dynamic loading messages while generating
495
+ if (store.isGenerating) {
496
+ return LOADING_MESSAGES[currentMessageIndex.value];
497
+ }
498
+
499
+ const task = store.generationState.currentTask || "";
500
+ const isFixAttempt = /^fixing\s+scene\s+\d+\s+\(attempt\s+\d+\/\d+\)$/i.test(
501
+ task.trim(),
502
+ );
503
+ const isSceneCompleted = /^scene\s+\d+\s+completed$/i.test(task.trim());
504
+ if (isFixAttempt && store.progress < 100) {
505
+ return "Improving scene quality...";
506
+ }
507
+ if (isSceneCompleted && store.progress < 100) {
508
+ return "Rendering scenes...";
509
+ }
510
+ if (task) return task;
511
+
512
+ const status = (store.status || "").toLowerCase();
513
+ const phase = store.generationState.currentPhase;
514
+ if (status.includes("assembly") || status.includes("finaliz") || phase >= 5) {
515
+ return "Assembling final video...";
516
+ }
517
+ if (status.includes("render") || phase === 4) return "Rendering scenes...";
518
+ if (status.includes("audio") || phase === 3) return "Generating narration...";
519
+ if (status.includes("script") || phase === 2)
520
+ return "Writing scene scripts...";
521
+ if (status.includes("plan") || phase === 1) return "Planning video...";
522
+ return "Processing...";
523
+ });
524
+
525
+ const currentIcon = computed(() => {
526
+ if (store.generationState.completed) return "i-lucide-party-popper";
527
+ if (store.generationState.isReconnecting) return "i-lucide-loader-2";
528
+
529
+ const status = (store.status || "").toLowerCase();
530
+ const phase = store.generationState.currentPhase;
531
+
532
+ if (status.includes("script") || status.includes("writing") || phase === 2)
533
+ return "i-lucide-scroll-text";
534
+ if (status.includes("audio") || status.includes("synth") || phase === 3)
535
+ return "i-lucide-mic-2";
536
+ if (
537
+ status.includes("video") ||
538
+ status.includes("render") ||
539
+ status.includes("scene") ||
540
+ phase === 4
541
+ )
542
+ return "i-lucide-film";
543
+ if (status.includes("finaliz") || phase >= 5) return "i-lucide-package-check";
544
+
545
+ // Default: planning / initializing
546
+ return "i-lucide-sparkles";
547
+ });
548
+
549
+ async function handleStop() {
550
+ console.log("!!! [WIDGET] handleStop triggered !!!");
551
+ confirmStop.value = true;
552
+ }
553
+
554
+ function cancelStop() {
555
+ confirmStop.value = false;
556
+ }
557
+
558
+ async function confirmCancellation() {
559
+ console.log("!!! [WIDGET] confirmCancellation triggered !!!");
560
+ confirmStop.value = false;
561
+
562
+ try {
563
+ const result = await store.stopGeneration(undefined, deleteDir.value);
564
+ console.log(
565
+ "!!! [WIDGET] store.stopGeneration finished with result:",
566
+ result,
567
+ );
568
+ } catch (err) {
569
+ console.error("!!! [WIDGET] Error in confirmCancellation API call:", err);
570
+ }
571
+ }
572
+
573
+ async function handleViewProject() {
574
+ const fromCompleted = store.generationState.completedProjectId;
575
+ const fromCurrent = store.currentProject?.id;
576
+ const fromRoute =
577
+ route.path.startsWith("/project/") && route.params.id
578
+ ? String(route.params.id)
579
+ : null;
580
+ const fromLatestCompleted =
581
+ [...store.projects].reverse().find((p) => p?.status === "completed")?.id ||
582
+ null;
583
+
584
+ const projectId =
585
+ fromCompleted || fromCurrent || fromRoute || fromLatestCompleted;
586
+
587
+ if (!projectId) {
588
+ console.error("!!! [WIDGET] Cannot redirect: No project ID available.");
589
+ return;
590
+ }
591
+
592
+ const target = `/project/${projectId}`;
593
+ if (route.path !== target) {
594
+ await router.push(target);
595
+ }
596
+ store.resetGenerationState();
597
+ }
598
+ </script>
599
+
600
+ <style scoped>
601
+ .widget-slide-enter-active,
602
+ .widget-slide-leave-active {
603
+ transition: all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
604
+ }
605
+ .widget-slide-enter-from,
606
+ .widget-slide-leave-to {
607
+ transform: translateY(100px) scale(0.5);
608
+ opacity: 0;
609
+ }
610
+
611
+ @keyframes bounce-x {
612
+ 0%,
613
+ 100% {
614
+ transform: translateX(0);
615
+ }
616
+ 50% {
617
+ transform: translateX(5px);
618
+ }
619
+ }
620
+ .animate-bounce-x {
621
+ animation: bounce-x 1s infinite;
622
+ }
623
+
624
+ @keyframes ping-slow {
625
+ 0% {
626
+ transform: scale(1);
627
+ opacity: 0.8;
628
+ }
629
+ 100% {
630
+ transform: scale(1.4);
631
+ opacity: 0;
632
+ }
633
+ }
634
+ .animate-ping-slow {
635
+ animation: ping-slow 2s cubic-bezier(0, 0, 0.2, 1) infinite;
636
+ }
637
+
638
+ @keyframes ping-slower {
639
+ 0% {
640
+ transform: scale(1);
641
+ opacity: 0.5;
642
+ }
643
+ 100% {
644
+ transform: scale(1.8);
645
+ opacity: 0;
646
+ }
647
+ }
648
+ .animate-ping-slower {
649
+ animation: ping-slower 3s cubic-bezier(0, 0, 0.2, 1) infinite;
650
+ }
651
+
652
+ @keyframes float {
653
+ 0%,
654
+ 100% {
655
+ transform: translateY(0);
656
+ }
657
+ 50% {
658
+ transform: translateY(-3px);
659
+ }
660
+ }
661
+ .animate-float {
662
+ animation: float 2s ease-in-out infinite;
663
+ }
664
+
665
+ @keyframes spin-slow {
666
+ from {
667
+ transform: rotate(0deg);
668
+ }
669
+ to {
670
+ transform: rotate(360deg);
671
+ }
672
+ }
673
+ .animate-spin-slow {
674
+ animation: spin-slow 3s linear infinite;
675
+ }
676
+
677
+ @keyframes shimmer-fast {
678
+ 0% {
679
+ transform: translateX(-150%);
680
+ }
681
+ 100% {
682
+ transform: translateX(450%);
683
+ }
684
+ }
685
+ .animate-shimmer-fast {
686
+ animation: shimmer-fast 1.8s infinite cubic-bezier(0.4, 0, 0.2, 1);
687
+ }
688
+
689
+ @keyframes shimmer-slow {
690
+ 0% {
691
+ transform: translateX(-100%);
692
+ }
693
+ 100% {
694
+ transform: translateX(100%);
695
+ }
696
+ }
697
+ .animate-shimmer-slow {
698
+ animation: shimmer-slow 4s infinite ease-in-out;
699
+ }
700
+ </style>
frontend/app/components/GlassCard.vue ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="premium-glass relative overflow-hidden rounded-[24px] p-6 lg:p-8">
3
+ <div class="relative z-10 w-full h-full">
4
+ <slot />
5
+ </div>
6
+ </div>
7
+ </template>
8
+
9
+ <script setup>
10
+ // GlassCard component providing the high-end premium frosted glass styling
11
+ </script>
12
+
13
+ <style scoped>
14
+ .glass-card {
15
+ /* Additional bespoke styling could go here, but mostly handled by Tailwind v4 */
16
+ box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
17
+ }
18
+ </style>
frontend/app/components/InteractivePanel.vue ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div
3
+ class="h-full premium-glass rounded-3xl overflow-hidden flex flex-col shadow-lg"
4
+ >
5
+ <!-- Header / Tabs (Visible only when ready) -->
6
+ <div
7
+ v-if="isReady"
8
+ class="flex p-1 bg-slate-900/5 backdrop-blur-sm border-b border-white/20 shrink-0"
9
+ >
10
+ <button
11
+ v-for="tab in ['chat', 'quiz']"
12
+ :key="tab"
13
+ :id="'tab-' + tab"
14
+ @click="activeTab = tab"
15
+ class="flex-1 py-3 text-sm font-bold uppercase tracking-wider rounded-xl transition-all flex items-center justify-center gap-2"
16
+ :class="
17
+ activeTab === tab
18
+ ? 'bg-white shadow-sm text-green-600'
19
+ : 'text-slate-500 hover:bg-white/50 hover:text-slate-700'
20
+ "
21
+ >
22
+ <UIcon
23
+ :name="
24
+ tab === 'chat'
25
+ ? 'i-lucide-messages-square'
26
+ : 'i-lucide-brain-circuit'
27
+ "
28
+ class="w-4 h-4"
29
+ />
30
+ {{ tab === "chat" ? "AI Tutor" : "Quiz" }}
31
+ </button>
32
+ </div>
33
+
34
+ <!-- Content Area -->
35
+ <div class="flex-1 overflow-hidden relative">
36
+ <Transition name="fade-slide" mode="out-in">
37
+ <template v-if="isReady">
38
+ <ChatInterface v-if="activeTab === 'chat'" :project="project" />
39
+ <QuizBuilder v-else :project="project" />
40
+ </template>
41
+ <div
42
+ v-else
43
+ class="h-full flex flex-col items-center justify-center p-8 text-center"
44
+ >
45
+ <div
46
+ class="w-20 h-20 rounded-3xl bg-green-50 flex items-center justify-center mb-6 relative"
47
+ >
48
+ <div
49
+ class="absolute inset-0 bg-green-200/30 rounded-3xl animate-ping opacity-20"
50
+ ></div>
51
+ <UIcon
52
+ name="i-lucide-lock"
53
+ class="w-10 h-10 text-green-600 relative z-10"
54
+ />
55
+ </div>
56
+ <h3 class="text-xl font-black text-slate-900 mb-3">
57
+ Interactivity Preparing
58
+ </h3>
59
+ <p class="text-slate-500 text-sm leading-relaxed max-w-[280px]">
60
+ The AI Tutor and interactive quiz features will be unlocked once the
61
+ video generation is complete.
62
+ </p>
63
+ <div
64
+ class="mt-8 flex items-center gap-2 px-4 py-2 bg-slate-900/5 rounded-full border border-slate-900/5"
65
+ >
66
+ <UIcon
67
+ name="i-lucide-loader-2"
68
+ class="w-4 h-4 text-green-500 animate-spin"
69
+ />
70
+ <span
71
+ class="text-[10px] font-bold uppercase tracking-wider text-slate-500"
72
+ >Processing Intelligence</span
73
+ >
74
+ </div>
75
+ </div>
76
+ </Transition>
77
+ </div>
78
+ </div>
79
+ </template>
80
+
81
+ <script setup lang="ts">
82
+ import { ref, computed } from "vue";
83
+ import ChatInterface from "./ChatInterface.vue";
84
+ import QuizBuilder from "./QuizBuilder.vue";
85
+
86
+ const props = defineProps({
87
+ project: { type: Object, required: true },
88
+ });
89
+
90
+ const activeTab = ref("chat");
91
+ const isReady = computed(
92
+ () =>
93
+ props.project?.status === "completed" ||
94
+ props.project?.has_video ||
95
+ props.project?.has_combined_video,
96
+ );
97
+ </script>
98
+
99
+ <style scoped>
100
+ .fade-slide-enter-active,
101
+ .fade-slide-leave-active {
102
+ transition: all 0.3s ease;
103
+ }
104
+ .fade-slide-enter-from {
105
+ opacity: 0;
106
+ transform: translateX(10px);
107
+ }
108
+ .fade-slide-leave-to {
109
+ opacity: 0;
110
+ transform: translateX(-10px);
111
+ }
112
+ </style>
frontend/app/components/OnboardingTour.vue ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <ClientOnly>
3
+ <div
4
+ v-if="active"
5
+ class="fixed inset-0 z-[100] flex flex-col items-center pointer-events-none"
6
+ >
7
+ <!-- Backdrop with Spotlight Hole -->
8
+ <div
9
+ class="absolute inset-0 bg-slate-900/60 backdrop-blur-[2px] transition-all duration-700 ease-in-out pointer-events-auto"
10
+ :style="spotlightStyle"
11
+ ></div>
12
+
13
+ <!-- Instruction Card -->
14
+ <Transition name="fade-slide">
15
+ <div
16
+ v-if="currentStepData"
17
+ class="fixed z-[101] w-full max-w-sm pointer-events-auto transition-all duration-500 ease-out"
18
+ :style="cardPosition"
19
+ >
20
+ <div
21
+ class="premium-glass p-6 rounded-3xl shadow-2xl border border-white/50 space-y-4"
22
+ >
23
+ <div class="flex items-center justify-between">
24
+ <div class="flex items-center gap-3">
25
+ <div class="bg-green-100 p-2 rounded-xl">
26
+ <UIcon
27
+ :name="currentStepData.icon"
28
+ class="w-6 h-6 text-green-600"
29
+ />
30
+ </div>
31
+ <h3 class="text-lg font-bold text-slate-900">
32
+ {{ currentStepData.title }}
33
+ </h3>
34
+ </div>
35
+ <button
36
+ @click="skip"
37
+ class="text-slate-400 hover:text-slate-600 transition-colors p-1"
38
+ >
39
+ <UIcon name="i-lucide-x" class="w-5 h-5" />
40
+ </button>
41
+ </div>
42
+
43
+ <div>
44
+ <p class="text-sm text-slate-600 leading-relaxed">
45
+ {{ currentStepData.desc }}
46
+ </p>
47
+ </div>
48
+
49
+ <div class="flex items-center justify-between pt-2">
50
+ <div class="flex gap-1">
51
+ <div
52
+ v-for="(_, i) in steps"
53
+ :key="i"
54
+ class="h-1.5 rounded-full transition-all duration-300"
55
+ :class="
56
+ i === currentStep
57
+ ? 'w-4 bg-green-500'
58
+ : 'w-1.5 bg-slate-200'
59
+ "
60
+ ></div>
61
+ </div>
62
+
63
+ <div class="flex gap-2">
64
+ <button
65
+ v-if="currentStep > 0"
66
+ @click="prev"
67
+ class="px-4 py-2 text-xs font-bold text-slate-600 btn-glass rounded-xl transition-all active:scale-95"
68
+ >
69
+ Previous
70
+ </button>
71
+ <button
72
+ @click="next"
73
+ class="px-6 py-2 btn-premium text-white text-xs font-bold rounded-xl transition-all active:scale-95 shadow-lg shadow-green-500/20"
74
+ >
75
+ {{ isLastStep ? "Finish" : "Next" }}
76
+ </button>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </Transition>
82
+ </div>
83
+ </ClientOnly>
84
+ </template>
85
+
86
+ <script setup>
87
+ import { storeToRefs } from "pinia";
88
+ const router = useRouter();
89
+ const route = useRoute();
90
+ const store = useProjectStore();
91
+ const active = computed(() => store.onboarding.active);
92
+ const currentStep = computed(() => store.onboarding.currentStep);
93
+ const spotlight = ref({ x: 0, y: 0, w: 0, h: 0, r: 0 });
94
+
95
+ const steps = computed(() => {
96
+ const firstProjectId =
97
+ store.projects && store.projects.length > 0 ? store.projects[0].id : null;
98
+
99
+ const baseSteps = [
100
+ {
101
+ path: "/ai-providers",
102
+ target: "#providers-list",
103
+ title: "Safety & Setup",
104
+ desc: "Your API keys stay on your device! Paste your API key here to power-up the AI.",
105
+ icon: "i-lucide-shield-check",
106
+ },
107
+ {
108
+ path: "/",
109
+ target: "#lesson-concept-block",
110
+ title: "Lesson Blueprint",
111
+ desc: "Describe the topic you want to learn about. Pro tip: Click 'AI Draft' for a helping hand with the research!",
112
+ icon: "i-lucide-pen-tool",
113
+ },
114
+ {
115
+ path: "/",
116
+ target: "#lesson-settings-block",
117
+ title: "Fine-Tuning",
118
+ desc: "Pick your AI model, choose a narrator voice, and set the video length. Customizing your lesson is easy!",
119
+ icon: "i-lucide-sliders",
120
+ },
121
+ {
122
+ path: "/",
123
+ target: "#generate-btn",
124
+ title: "Ready, Set, Go!",
125
+ desc: "When you're happy with your settings, hit this big button to start building your magical explanation video.",
126
+ icon: "i-lucide-sparkles",
127
+ },
128
+ {
129
+ path: "/projects",
130
+ target: "#projects-toolbar",
131
+ title: "The Lessons Vault",
132
+ desc: "Every video you make is saved here. Use the toolbar to search, filter, and keep your library organized.",
133
+ icon: "i-lucide-folder-lock",
134
+ },
135
+ ];
136
+
137
+ // If projects exist, add the viewing steps
138
+ if (firstProjectId) {
139
+ baseSteps.push(
140
+ {
141
+ path: "/projects",
142
+ target: ".project-card",
143
+ title: "Watch & Learn",
144
+ desc: "Click on any project to open the theater. Let's see what the AI has built for you!",
145
+ icon: "i-lucide-play-circle",
146
+ },
147
+ {
148
+ path: `/project/${firstProjectId}`,
149
+ target: "#video-theater",
150
+ title: "The Cinema",
151
+ desc: "Sit back and watch your personalized video lesson. The AI handles the animations and voice for you!",
152
+ icon: "i-lucide-clapperboard",
153
+ },
154
+ {
155
+ path: `/project/${firstProjectId}`,
156
+ target: "#ai-panel-container",
157
+ title: "Tutor & Quiz",
158
+ desc: "Confused? Ask the AI Tutor. Feeling smart? Take a Quiz! Everything you need to master the topic is right here.",
159
+ icon: "i-lucide-graduation-cap",
160
+ },
161
+ );
162
+ }
163
+
164
+ return baseSteps;
165
+ });
166
+
167
+ const currentStepData = computed(() => steps.value[currentStep.value]);
168
+ const isLastStep = computed(() => currentStep.value === steps.value.length - 1);
169
+
170
+ const waitForElement = async (selector, timeout = 3000) => {
171
+ const start = Date.now();
172
+ while (Date.now() - start < timeout) {
173
+ const el = document.querySelector(selector);
174
+ if (el && el.getBoundingClientRect().width > 0) return el;
175
+ await new Promise((r) => setTimeout(r, 100));
176
+ }
177
+ return null;
178
+ };
179
+
180
+ const updateSpotlight = async () => {
181
+ if (!active.value || !currentStepData.value) return;
182
+
183
+ // Check if we need to navigate
184
+ const stepPath = currentStepData.value.path;
185
+ if (route.path !== stepPath && stepPath) {
186
+ console.log("Navigating to:", stepPath);
187
+ await router.push(stepPath);
188
+ }
189
+
190
+ const el = await waitForElement(currentStepData.value.target);
191
+ if (!el) return;
192
+
193
+ // Find the nearest scrollable ancestor (could be an inner div, not window)
194
+ const getScrollParent = (node) => {
195
+ if (!node.parentElement) return window;
196
+ const style = getComputedStyle(node.parentElement);
197
+ const overflowY = style.overflowY;
198
+ if (overflowY === "auto" || overflowY === "scroll")
199
+ return node.parentElement;
200
+ return getScrollParent(node.parentElement);
201
+ };
202
+
203
+ const scrollParent = getScrollParent(el);
204
+
205
+ // Scroll the element to the center of its scroll container
206
+ if (scrollParent instanceof Window) {
207
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
208
+ } else {
209
+ const parentRect = scrollParent.getBoundingClientRect();
210
+ const elRect = el.getBoundingClientRect();
211
+ const offset =
212
+ elRect.top -
213
+ parentRect.top -
214
+ scrollParent.clientHeight / 2 +
215
+ elRect.height / 2;
216
+ scrollParent.scrollBy({ top: offset, behavior: "smooth" });
217
+ }
218
+
219
+ // Wait for scroll to fully settle, then measure from viewport
220
+ await nextTick();
221
+
222
+ const rect = el.getBoundingClientRect();
223
+ spotlight.value = {
224
+ x: Math.round(rect.left),
225
+ y: Math.round(rect.top + 12),
226
+ w: Math.round(rect.width),
227
+ h: Math.round(rect.height),
228
+ r: 12,
229
+ };
230
+ };
231
+
232
+ const spotlightStyle = computed(() => {
233
+ const { x, y, w, h, r } = spotlight.value;
234
+ const padding = 12;
235
+ const vw = window.innerWidth;
236
+ const vh = window.innerHeight;
237
+
238
+ if (!active.value || w === 0) return { opacity: 0 };
239
+
240
+ return {
241
+ clipPath: `path(evenodd, 'M 0 0 H ${vw} V ${vh} H 0 Z M ${x - padding} ${y - padding} a ${r} ${r} 0 0 1 ${r} -${r} h ${w + padding * 2 - r * 2} a ${r} ${r} 0 0 1 ${r} ${r} v ${h + padding * 2 - r * 2} a ${r} ${r} 0 0 1 -${r} ${r} h -${w + padding * 2 - r * 2} a ${r} ${r} 0 0 1 -${r} -${r} Z')`,
242
+ WebkitClipPath: `path(evenodd, 'M 0 0 H ${vw} V ${vh} H 0 Z M ${x - padding} ${y - padding} a ${r} ${r} 0 0 1 ${r} -${r} h ${w + padding * 2 - r * 2} a ${r} ${r} 0 0 1 ${r} ${r} v ${h + padding * 2 - r * 2} a ${r} ${r} 0 0 1 -${r} ${r} h -${w + padding * 2 - r * 2} a ${r} ${r} 0 0 1 -${r} -${r} Z')`,
243
+ };
244
+ });
245
+
246
+ const cardPosition = computed(() => {
247
+ const { x, y, w, h } = spotlight.value;
248
+ if (!active.value || w === 0) return { opacity: 0, visibility: "hidden" };
249
+
250
+ const vh = window.innerHeight;
251
+ const vw = window.innerWidth;
252
+ const buffer = 24;
253
+ const cardWidth = Math.min(380, vw - 32);
254
+ const estimatedHeight = 260;
255
+
256
+ // 1. Try Vertical Placement first (Standard)
257
+ const spaceBelow = vh - (y + h + buffer + 20);
258
+ const spaceAbove = y - buffer - 80; // 80 for top safety (notch/header)
259
+
260
+ let top, left;
261
+
262
+ if (spaceBelow >= estimatedHeight) {
263
+ // BEST: Below
264
+ top = y + h + buffer;
265
+ left = Math.max(
266
+ 16,
267
+ Math.min(x + w / 2 - cardWidth / 2, vw - cardWidth - 16),
268
+ );
269
+ } else if (spaceAbove >= estimatedHeight) {
270
+ // GOOD: Above
271
+ top = y - estimatedHeight - buffer;
272
+ left = Math.max(
273
+ 16,
274
+ Math.min(x + w / 2 - cardWidth / 2, vw - cardWidth - 16),
275
+ );
276
+ } else if (vw > 1100 && (vw - w) / 2 > cardWidth + buffer) {
277
+ // SIDE PLACEMENT: Only on wide screens with enough margin
278
+ const spaceRight = vw - (x + w + buffer + 20);
279
+ const spaceLeft = x - buffer - 20;
280
+
281
+ if (spaceRight >= cardWidth) {
282
+ // Side Right
283
+ top = Math.max(
284
+ 100,
285
+ Math.min(y + h / 2 - estimatedHeight / 2, vh - estimatedHeight - 40),
286
+ );
287
+ left = x + w + buffer;
288
+ } else if (spaceLeft >= cardWidth) {
289
+ // Side Left
290
+ top = Math.max(
291
+ 100,
292
+ Math.min(y + h / 2 - estimatedHeight / 2, vh - estimatedHeight - 40),
293
+ );
294
+ left = x - cardWidth - buffer;
295
+ } else {
296
+ // Vertical Squeeze fallback
297
+ top = vh - estimatedHeight - 40;
298
+ left = Math.max(
299
+ 16,
300
+ Math.min(x + w / 2 - cardWidth / 2, vw - cardWidth - 16),
301
+ );
302
+ }
303
+ } else {
304
+ // MOBILE/SQUEEZE FALLBACK: Center-ish at bottom
305
+ top = vh - estimatedHeight - 40;
306
+ left = Math.max(
307
+ 16,
308
+ Math.min(x + w / 2 - cardWidth / 2, vw - cardWidth - 16),
309
+ );
310
+ }
311
+
312
+ // Final Safety Clamping
313
+ top = Math.max(80, Math.min(top, vh - estimatedHeight - 20));
314
+
315
+ return {
316
+ top: `${top}px`,
317
+ left: `${left}px`,
318
+ width: `${cardWidth}px`,
319
+ opacity: 1,
320
+ visibility: "visible",
321
+ };
322
+ });
323
+
324
+ const next = () => {
325
+ if (isLastStep.value) {
326
+ complete();
327
+ } else {
328
+ store.setOnboardingStep(currentStep.value + 1);
329
+ }
330
+ };
331
+
332
+ const prev = () => {
333
+ if (currentStep.value > 0) {
334
+ store.setOnboardingStep(currentStep.value - 1);
335
+ }
336
+ };
337
+
338
+ const skip = () => {
339
+ store.stopOnboarding();
340
+ router.push("/");
341
+ };
342
+
343
+ const complete = () => {
344
+ store.stopOnboarding();
345
+ localStorage.setItem("onboarding_completed", "true");
346
+ router.push("/");
347
+ };
348
+
349
+ const remeasureSpotlight = () => {
350
+ if (!active.value || !currentStepData.value) return;
351
+ const el = document.querySelector(currentStepData.value.target);
352
+ if (el) {
353
+ const rect = el.getBoundingClientRect();
354
+ spotlight.value = {
355
+ x: Math.round(rect.left),
356
+ y: Math.round(rect.top + 12),
357
+ w: Math.round(rect.width),
358
+ h: Math.round(rect.height),
359
+ r: 12,
360
+ };
361
+ }
362
+ };
363
+
364
+ // Start or move spotlight on state changes
365
+ watch(
366
+ () => store.onboarding.active,
367
+ (val) => {
368
+ console.log("Onboarding active changed:", val);
369
+ if (val) updateSpotlight();
370
+ },
371
+ );
372
+
373
+ watch(
374
+ () => store.onboarding.currentStep,
375
+ (val) => {
376
+ console.log("Onboarding step changed:", val);
377
+ updateSpotlight();
378
+ },
379
+ );
380
+
381
+ onMounted(() => {
382
+ console.log(
383
+ "OnboardingTour mounted. Current state active:",
384
+ store.onboarding.active,
385
+ );
386
+ window.addEventListener("start-onboarding-tour", () => {
387
+ store.startOnboarding();
388
+ });
389
+
390
+ // Re-sync spotlight if already active (e.g. page refresh during tour)
391
+ if (active.value) updateSpotlight();
392
+
393
+ window.addEventListener("resize", remeasureSpotlight);
394
+ window.addEventListener("scroll", remeasureSpotlight, {
395
+ capture: true,
396
+ passive: true,
397
+ });
398
+ });
399
+
400
+ onUnmounted(() => {
401
+ window.removeEventListener("resize", remeasureSpotlight);
402
+ window.removeEventListener("scroll", remeasureSpotlight, { capture: true });
403
+ });
404
+ </script>
405
+
406
+ <style scoped>
407
+ .fade-slide-enter-active,
408
+ .fade-slide-leave-active {
409
+ transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
410
+ }
411
+
412
+ .fade-slide-enter-from,
413
+ .fade-slide-leave-to {
414
+ opacity: 0;
415
+ transform: translateY(20px) scale(0.95);
416
+ }
417
+ </style>
frontend/app/components/PlanReviewPanel.vue ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div
3
+ class="flex flex-col gap-8 w-full animate-fade-in-up max-h-[78vh] overflow-y-auto pr-1 custom-scrollbar"
4
+ >
5
+ <!-- Header Section -->
6
+ <div
7
+ class="premium-glass rounded-3xl p-6 border border-white/20 shadow-xl bg-white/70 backdrop-blur-xl sticky top-0 z-20"
8
+ >
9
+ <div class="flex items-center justify-between gap-4 flex-wrap">
10
+ <div class="flex items-center gap-4">
11
+ <div
12
+ class="w-12 h-12 rounded-2xl bg-indigo-500/10 flex items-center justify-center border border-indigo-500/20 shadow-inner"
13
+ >
14
+ <UIcon
15
+ name="i-lucide-scroll-text"
16
+ class="w-6 h-6 text-indigo-600"
17
+ />
18
+ </div>
19
+ <div>
20
+ <h2 class="text-xl font-black text-slate-900 leading-tight">
21
+ Review Video Plan
22
+ </h2>
23
+ <p class="text-sm text-slate-500 font-medium mt-0.5">
24
+ Iterate on scene details before starting production
25
+ </p>
26
+ </div>
27
+ </div>
28
+
29
+ <div class="flex items-center gap-3">
30
+ <UButton
31
+ @click="handleRevise"
32
+ :loading="store.isRevising"
33
+ :disabled="!hasAnyFeedback || store.isGenerating"
34
+ variant="soft"
35
+ color="primary"
36
+ icon="i-lucide-refresh-cw"
37
+ class="rounded-xl px-5 py-2.5 font-bold transition-all hover:scale-105 active:scale-95"
38
+ >
39
+ Revise Plans
40
+ </UButton>
41
+
42
+ <UButton
43
+ @click="handleApprove"
44
+ :loading="store.isGenerating && !store.isRevising"
45
+ :disabled="store.isRevising"
46
+ class="btn-premium rounded-xl px-6 py-2.5 font-black text-white shadow-lg shadow-green-500/20 transition-all hover:scale-105 active:scale-95 flex items-center gap-2"
47
+ >
48
+ <UIcon name="i-lucide-check-circle" class="w-5 h-5" />
49
+ Approve & Generate
50
+ </UButton>
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ <!-- Scene Plans List -->
56
+ <div class="flex flex-col gap-6">
57
+ <div
58
+ v-for="(scene, idx) in project.scenes"
59
+ :key="idx"
60
+ class="premium-glass rounded-3xl overflow-hidden border border-white/20 shadow-lg group hover:shadow-xl transition-all duration-500"
61
+ :class="{ 'opacity-60 grayscale-[0.2]': store.isRevising }"
62
+ >
63
+ <!-- Scene Header -->
64
+ <div
65
+ class="bg-white/60 p-5 border-b border-slate-100 flex items-center justify-between gap-4"
66
+ >
67
+ <div class="flex items-center gap-4">
68
+ <div
69
+ class="w-9 h-9 rounded-xl bg-slate-900 text-white font-mono text-sm font-black flex items-center justify-center shadow-md"
70
+ >
71
+ {{ Number(idx) + 1 }}
72
+ </div>
73
+ <div>
74
+ <h3 class="font-black text-slate-900 text-lg">
75
+ {{ scene.title || `Scene ${Number(idx) + 1}` }}
76
+ </h3>
77
+ <p
78
+ class="text-xs text-indigo-500 font-bold uppercase tracking-wider"
79
+ >
80
+ Strategy & Review
81
+ </p>
82
+ </div>
83
+ </div>
84
+ </div>
85
+
86
+ <div class="p-6 grid grid-cols-1 lg:grid-cols-2 gap-8">
87
+ <!-- Left: Current Plan Details (Parsed) -->
88
+ <div class="space-y-6">
89
+ <div class="space-y-4">
90
+ <div
91
+ v-if="scene.purpose"
92
+ class="bg-blue-50/50 p-3 rounded-lg border border-blue-100"
93
+ >
94
+ <h5
95
+ class="text-[10px] font-bold uppercase tracking-wider text-blue-500 mb-1 flex items-center gap-1"
96
+ >
97
+ <UIcon name="i-lucide-target" class="w-3 h-3" />
98
+ Scene Purpose
99
+ </h5>
100
+ <p class="text-xs text-slate-700 italic leading-snug">
101
+ {{ scene.purpose }}
102
+ </p>
103
+ </div>
104
+
105
+ <div v-if="scene.description">
106
+ <h5
107
+ class="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-1"
108
+ >
109
+ Visual Concept
110
+ </h5>
111
+ <p class="text-xs text-slate-800 leading-relaxed">
112
+ {{ scene.description }}
113
+ </p>
114
+ </div>
115
+ </div>
116
+ </div>
117
+
118
+ <!-- Right: Feedback Section -->
119
+ <div class="flex flex-col h-full">
120
+ <h4
121
+ class="text-[11px] font-black uppercase tracking-widest text-slate-400 mb-2 flex items-center gap-1.5"
122
+ >
123
+ <UIcon name="i-lucide-message-square" class="w-3.5 h-3.5" />
124
+ Your Feedback / Changes
125
+ </h4>
126
+ <div class="flex-1 relative group/input">
127
+ <textarea
128
+ v-model="feedbacks[Number(idx) + 1]"
129
+ placeholder="What should change in this scene?"
130
+ class="w-full h-full min-h-[120px] p-4 rounded-2xl bg-white border-2 border-slate-100 focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 outline-none transition-all text-sm font-medium placeholder:text-slate-300 resize-none shadow-inner"
131
+ ></textarea>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ </div>
138
+ </template>
139
+
140
+ <script setup lang="ts">
141
+ import { ref, computed } from "vue";
142
+ import { useProjectStore } from "~/stores/projects";
143
+
144
+ const props = defineProps<{
145
+ project: any;
146
+ }>();
147
+
148
+ const store = useProjectStore();
149
+ const feedbacks = ref<Record<number, string>>({});
150
+
151
+ const hasAnyFeedback = computed(() => {
152
+ return Object.values(feedbacks.value).some((f) => f.trim().length > 0);
153
+ });
154
+
155
+ function extractContent(plan: string, tag: string) {
156
+ if (!plan) return "No plan details available.";
157
+ const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, "i");
158
+ const match = plan.match(regex);
159
+ return match && match[1] ? match[1].trim() : plan.trim();
160
+ }
161
+
162
+ async function handleRevise() {
163
+ const activeFeedbacks: Record<number, string> = {};
164
+ for (const [id, msg] of Object.entries(feedbacks.value)) {
165
+ if (msg.trim()) activeFeedbacks[Number(id)] = msg.trim();
166
+ }
167
+
168
+ if (Object.keys(activeFeedbacks).length === 0) return;
169
+
170
+ await store.revisePlan(props.project.id, activeFeedbacks);
171
+ // Clear feedbacks after revision
172
+ feedbacks.value = {};
173
+ }
174
+
175
+ async function handleApprove() {
176
+ await store.approvePlan(props.project.id);
177
+ }
178
+ </script>
179
+
180
+ <style scoped>
181
+ .custom-scrollbar::-webkit-scrollbar {
182
+ width: 6px;
183
+ }
184
+ .custom-scrollbar::-webkit-scrollbar-track {
185
+ background: transparent;
186
+ }
187
+ .custom-scrollbar::-webkit-scrollbar-thumb {
188
+ background: rgba(0, 0, 0, 0.05);
189
+ border-radius: 10px;
190
+ }
191
+ </style>
frontend/app/components/PlyrVideoPlayer.vue ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="plyr-wrapper relative bg-black w-full h-full group">
3
+ <video
4
+ ref="videoRef"
5
+ class="w-full h-full object-contain"
6
+ playsinline
7
+ crossorigin="anonymous"
8
+ :key="fullVideoUrl"
9
+ >
10
+ <source :src="fullVideoUrl" type="video/mp4" />
11
+ <track
12
+ v-if="fullSubtitlesUrl"
13
+ kind="captions"
14
+ label="English"
15
+ srclang="en"
16
+ :src="fullSubtitlesUrl"
17
+ default
18
+ />
19
+ </video>
20
+
21
+ <!-- Native PiP Button Overlay -->
22
+ <button
23
+ v-if="isPipSupported"
24
+ @click="togglePip"
25
+ class="absolute top-4 right-4 z-50 bg-black/60 hover:bg-black/80 text-white p-2 rounded-lg backdrop-blur-sm transition-all opacity-0 group-hover:opacity-100"
26
+ title="Picture-in-Picture"
27
+ >
28
+ <UIcon name="i-lucide-picture-in-picture-2" class="w-5 h-5" />
29
+ </button>
30
+ </div>
31
+ </template>
32
+
33
+ <script setup>
34
+ import { ref, onMounted, onBeforeUnmount, watch, computed } from "vue";
35
+ import { config } from "~/utils/api";
36
+
37
+ // Import Plyr directly here since it's a client-only component
38
+ import "plyr/dist/plyr.css";
39
+ let Plyr = null;
40
+ if (import.meta.client) {
41
+ Plyr = (await import("plyr")).default;
42
+ }
43
+
44
+ const props = defineProps({
45
+ videoUrl: { type: String, required: true },
46
+ subtitlesUrl: { type: String, default: null },
47
+ scenes: { type: Array, default: () => [] },
48
+ });
49
+
50
+ const videoRef = ref(null);
51
+ let player = null;
52
+ const isPipSupported = ref(false);
53
+ const captionSize = ref(200); // Percentage
54
+
55
+ const fullVideoUrl = computed(() => {
56
+ if (!props.videoUrl) return "";
57
+ return props.videoUrl.startsWith("http")
58
+ ? props.videoUrl
59
+ : `${config.API_BASE_URL}${props.videoUrl}`;
60
+ });
61
+
62
+ const fullSubtitlesUrl = computed(() => {
63
+ if (!props.subtitlesUrl) return null;
64
+ return props.subtitlesUrl.startsWith("http")
65
+ ? props.subtitlesUrl
66
+ : `${config.API_BASE_URL}${props.subtitlesUrl}`;
67
+ });
68
+
69
+ onMounted(() => {
70
+ if (!import.meta.client || !videoRef.value || !Plyr) return;
71
+
72
+ isPipSupported.value = "pictureInPictureEnabled" in document;
73
+
74
+ // Extract chapters from scenes if available
75
+ const markers = {};
76
+ if (props.scenes && props.scenes.length > 0) {
77
+ let currentTime = 0;
78
+ props.scenes.forEach((scene, index) => {
79
+ // Rough estimation if duration isn't provided (fallback)
80
+ const duration =
81
+ scene.duration ||
82
+ Math.max(
83
+ 10,
84
+ scene.narration?.length ? scene.narration.length / 15 : 10,
85
+ );
86
+ markers[currentTime] = scene.title || `Scene ${index + 1}`;
87
+ currentTime += duration;
88
+ });
89
+ }
90
+
91
+ player = new Plyr(videoRef.value, {
92
+ controls: [
93
+ "play-large",
94
+ "play",
95
+ "progress",
96
+ "current-time",
97
+ "mute",
98
+ "volume",
99
+ "captions",
100
+ "settings",
101
+ "pip",
102
+ "fullscreen",
103
+ ],
104
+ settings: ["captions", "quality", "speed"],
105
+ captions: { active: true, language: "auto", update: true },
106
+ keyboard: { focused: true, global: false },
107
+ tooltips: { controls: true, seek: true },
108
+ markers: {
109
+ enabled: true,
110
+ points: Object.entries(markers).map(([time, label]) => ({
111
+ time: parseFloat(time),
112
+ label,
113
+ })),
114
+ },
115
+ fullscreen: {
116
+ enabled: true,
117
+ fallback: false, // Force native fullscreen API
118
+ iosNative: true,
119
+ },
120
+ });
121
+
122
+ // Force-enable captions once the player is ready (captions.active alone isn't always enough)
123
+ player.on("ready", () => {
124
+ if (fullSubtitlesUrl.value) {
125
+ player.currentTrack = 0;
126
+ }
127
+ });
128
+
129
+ // Hack to enable standard PiP if Plyr's built-in button acts up
130
+ player.on("pip", (event) => {
131
+ if (videoRef.value !== document.pictureInPictureElement) {
132
+ videoRef.value.requestPictureInPicture().catch(console.error);
133
+ } else {
134
+ document.exitPictureInPicture().catch(console.error);
135
+ }
136
+ });
137
+
138
+ // Ensure subtitle size is 200% in fullscreen
139
+ player.on("enterfullscreen", () => {
140
+ captionSize.value = 200;
141
+ });
142
+
143
+ player.on("exitfullscreen", () => {
144
+ // Optionally keep it at 200 or revert to a regular size
145
+ // For now, we'll keep it at 200 as per request for "default"
146
+ captionSize.value = 200;
147
+ });
148
+
149
+ // Handle keyboard sizing and global shortcuts
150
+ const handleKeydown = (e) => {
151
+ // Only handle if no text input is focused
152
+ if (["INPUT", "TEXTAREA"].includes(document.activeElement?.tagName)) return;
153
+
154
+ // If Plyr (or another component) already handled this event and called preventDefault, skip!
155
+ if (e.defaultPrevented) return;
156
+
157
+ if (e.key === "ArrowLeft") {
158
+ if (player) player.currentTime = Math.max(0, player.currentTime - 5);
159
+ e.preventDefault();
160
+ } else if (e.key === "ArrowRight") {
161
+ if (player)
162
+ player.currentTime = Math.min(player.duration, player.currentTime + 5);
163
+ e.preventDefault();
164
+ } else if (e.key === " " || e.key === "k") {
165
+ if (player) player.togglePlay();
166
+ e.preventDefault();
167
+ } else if (e.key === "+" || e.key === "=") {
168
+ captionSize.value = Math.min(captionSize.value + 10, 300);
169
+ e.preventDefault();
170
+ } else if (e.key === "-" || e.key === "_") {
171
+ captionSize.value = Math.max(captionSize.value - 10, 50);
172
+ e.preventDefault();
173
+ }
174
+ };
175
+
176
+ window.addEventListener("keydown", handleKeydown);
177
+ onBeforeUnmount(() => {
178
+ window.removeEventListener("keydown", handleKeydown);
179
+ });
180
+ });
181
+
182
+ onBeforeUnmount(() => {
183
+ if (player) {
184
+ player.destroy();
185
+ }
186
+ });
187
+
188
+ async function togglePip() {
189
+ if (!videoRef.value) return;
190
+ try {
191
+ if (document.pictureInPictureElement) {
192
+ await document.exitPictureInPicture();
193
+ } else {
194
+ await videoRef.value.requestPictureInPicture();
195
+ }
196
+ } catch (err) {
197
+ console.error("PiP error:", err);
198
+ }
199
+ }
200
+ </script>
201
+
202
+ <style>
203
+ /* Nuxt-Theme Overrides for Plyr */
204
+ :root {
205
+ --plyr-color-main: #00dc82;
206
+ --plyr-font-family: inherit;
207
+ --plyr-video-control-background-hover: #00dc82;
208
+ }
209
+
210
+ .plyr--video {
211
+ border-radius: 1.5rem;
212
+ overflow: hidden;
213
+ }
214
+
215
+ :group(.plyr--fullscreen) .plyr--video {
216
+ border-radius: 0 !important;
217
+ }
218
+
219
+ .plyr__captions {
220
+ /* Premium captions */
221
+ background: rgba(0, 0, 0, 0.3) !important;
222
+ padding: 8px 16px !important;
223
+ border-radius: 12px !important;
224
+ font-family: var(--font-sans) !important;
225
+ text-shadow: none !important;
226
+ transform: translateY(-20px) !important;
227
+ font-size: v-bind('captionSize + "%"') !important;
228
+ }
229
+
230
+ /* Specific enforcement for fullscreen captions */
231
+ .plyr--fullscreen-active .plyr__captions {
232
+ font-size: 200% !important;
233
+ transform: translateY(
234
+ -40px
235
+ ) !important; /* Move up slightly more in fullscreen */
236
+ }
237
+
238
+ .plyr__caption {
239
+ font-weight: 600 !important;
240
+ line-height: 1.4 !important;
241
+ }
242
+
243
+ /* Scene markers visual override */
244
+ .plyr__progress__marker {
245
+ background-color: rgba(255, 255, 255, 0.8) !important;
246
+ width: 4px !important;
247
+ height: 100% !important;
248
+ border-radius: 2px !important;
249
+ }
250
+ </style>
frontend/app/components/ProjectCard.vue ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <!--
3
+ Outer wrapper is draggable. Browser suppresses click-to-navigate during
4
+ a real drag, so NuxtLink navigation still works on a plain click.
5
+ -->
6
+ <div
7
+ class="project-card project-card-wrapper"
8
+ :class="{ 'is-dragging': isDragging }"
9
+ draggable="true"
10
+ @dragstart="onDragStart"
11
+ @dragend="onDragEnd"
12
+ >
13
+ <NuxtLink
14
+ :to="`/project/${project.id}`"
15
+ draggable="false"
16
+ class="premium-glass rounded-2xl overflow-hidden group hover:-translate-y-1 transition-all duration-300 block select-none"
17
+ >
18
+ <!-- Thumbnail -->
19
+ <div
20
+ class="aspect-video w-full bg-slate-900 relative overflow-hidden"
21
+ @mouseenter="playPreview"
22
+ @mouseleave="pausePreview"
23
+ >
24
+ <!-- Status Badge -->
25
+ <div class="absolute top-3 left-3 z-10 flex gap-2">
26
+ <span
27
+ class="px-2.5 py-1 rounded-full text-xs font-semibold backdrop-blur-md"
28
+ :class="statusClasses"
29
+ >
30
+ {{ formattedStatus }}
31
+ </span>
32
+ </div>
33
+
34
+ <!-- Action Buttons (Continue + Delete) -->
35
+ <div class="absolute top-3 right-3 z-10 flex gap-2">
36
+ <!-- Continue button (only for non-active projects: error, planned, or stopped) -->
37
+ <button
38
+ v-if="canContinue"
39
+ @click.prevent.stop="$emit('continue', project)"
40
+ class="w-8 h-8 rounded-full bg-white/80 backdrop-blur-md border border-white/80 flex items-center justify-center text-slate-600 hover:bg-green-500 hover:text-white hover:border-green-500 transition-all shadow-sm opacity-0 group-hover:opacity-100"
41
+ :title="
42
+ project.status === 'error'
43
+ ? 'Retry Generation'
44
+ : 'Continue Generation'
45
+ "
46
+ >
47
+ <UIcon
48
+ :name="
49
+ project.status === 'error'
50
+ ? 'i-lucide-rotate-ccw'
51
+ : 'i-lucide-play-circle'
52
+ "
53
+ class="w-4 h-4"
54
+ />
55
+ </button>
56
+
57
+ <!-- Delete button -->
58
+ <button
59
+ @click.prevent.stop="$emit('delete', project)"
60
+ class="w-8 h-8 rounded-full bg-white/80 backdrop-blur-md border border-white/80 flex items-center justify-center text-slate-600 hover:bg-red-500 hover:text-white hover:border-red-500 transition-all shadow-sm opacity-0 group-hover:opacity-100"
61
+ >
62
+ <UIcon name="i-lucide-trash-2" class="w-4 h-4" />
63
+ </button>
64
+ </div>
65
+
66
+ <!-- Drag hint badge (shows on hover) -->
67
+ <div
68
+ class="absolute bottom-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
69
+ >
70
+ <div
71
+ class="flex items-center gap-1 px-2 py-1 rounded-full bg-black/40 backdrop-blur-sm text-white text-xs font-medium"
72
+ >
73
+ <UIcon name="i-lucide-grip" class="w-3 h-3" />
74
+ Drag to group
75
+ </div>
76
+ </div>
77
+
78
+ <!-- Play icon overlay -->
79
+ <div
80
+ v-if="videoUrl"
81
+ class="absolute inset-0 z-10 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
82
+ >
83
+ <div
84
+ class="w-12 h-12 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center"
85
+ >
86
+ <UIcon
87
+ name="i-lucide-play"
88
+ class="w-6 h-6 text-white translate-x-0.5"
89
+ />
90
+ </div>
91
+ </div>
92
+
93
+ <!-- Video preview -->
94
+ <video
95
+ v-if="videoUrl"
96
+ ref="videoEl"
97
+ :src="videoUrl"
98
+ draggable="false"
99
+ class="w-full h-full object-cover transition-opacity duration-500"
100
+ :class="{
101
+ 'opacity-0': !isHovered && !videoStarted,
102
+ 'opacity-100': isHovered || videoStarted,
103
+ }"
104
+ muted
105
+ loop
106
+ preload="metadata"
107
+ @loadedmetadata="onVideoMetadata"
108
+ @play="videoStarted = true"
109
+ />
110
+
111
+ <!-- Premium Thumbnail / Generating / Fallback Overlay -->
112
+ <div
113
+ v-if="!isHovered && !videoStarted"
114
+ class="absolute inset-0 z-0 flex flex-col items-center justify-center overflow-hidden"
115
+ >
116
+ <!-- Thumbnail Image (if available) -->
117
+ <div v-if="project.thumbnail" class="absolute inset-0 bg-slate-900">
118
+ <img
119
+ :src="project.thumbnail"
120
+ class="w-full h-full object-cover opacity-60 transition-transform duration-700 group-hover:scale-110"
121
+ alt="Project Thumbnail"
122
+ />
123
+ <div
124
+ class="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-slate-950/20 to-transparent"
125
+ ></div>
126
+ </div>
127
+
128
+ <!-- Fallback Stock Image Background -->
129
+ <div v-else class="absolute inset-0 bg-slate-900">
130
+ <img
131
+ src="https://images.unsplash.com/photo-1635070041078-e363dbe005cb?auto=format&fit=crop&q=80&w=1200"
132
+ class="w-full h-full object-cover opacity-40 transition-transform duration-700 group-hover:scale-110"
133
+ alt="Default Project Thumbnail"
134
+ />
135
+ <div
136
+ class="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-slate-950/20 to-transparent"
137
+ ></div>
138
+ </div>
139
+
140
+ <!-- Generating Animation Overlay -->
141
+ <div
142
+ v-if="project.status !== 'completed' && project.status !== 'error'"
143
+ class="absolute inset-0 z-10 flex items-center justify-center"
144
+ >
145
+ <div class="absolute inset-0 bg-green-500/10 animate-pulse"></div>
146
+ <div
147
+ class="w-full h-1 bg-gradient-to-r from-transparent via-green-400 to-transparent absolute top-0 animate-[scan_3s_linear_infinite]"
148
+ ></div>
149
+ <div
150
+ class="w-full h-1 bg-gradient-to-r from-transparent via-green-400 to-transparent absolute bottom-0 animate-[scan_3s_linear_infinite_reverse]"
151
+ ></div>
152
+ </div>
153
+
154
+ <!-- Project Identity -->
155
+ <div
156
+ class="relative z-20 flex flex-col items-center px-6 text-center"
157
+ >
158
+ <div
159
+ class="px-5 py-3 rounded-2xl border border-white/20 backdrop-blur-md shadow-2xl transition-all duration-500 group-hover:scale-105"
160
+ :class="
161
+ project.status !== 'completed' && project.status !== 'error'
162
+ ? 'bg-orange-500/20 border-orange-500/40'
163
+ : 'bg-green-500/20 border-green-500/30 group-hover:bg-green-500/30'
164
+ "
165
+ >
166
+ <div
167
+ v-if="
168
+ project.status !== 'completed' && project.status !== 'error'
169
+ "
170
+ class="flex flex-col items-center gap-2"
171
+ >
172
+ <UIcon
173
+ :name="
174
+ project.status === 'awaiting_approval'
175
+ ? 'i-lucide-scroll-text'
176
+ : 'i-lucide-loader-2'
177
+ "
178
+ class="w-6 h-6"
179
+ :class="
180
+ project.status === 'awaiting_approval'
181
+ ? 'text-indigo-400'
182
+ : 'text-orange-400 animate-spin'
183
+ "
184
+ />
185
+ <h3 class="text-xl font-bold text-white leading-tight">
186
+ {{
187
+ project.status === "awaiting_approval"
188
+ ? "Review Plan"
189
+ : "Generating..."
190
+ }}
191
+ </h3>
192
+ </div>
193
+ <h3 v-else class="text-xl font-bold text-white leading-tight">
194
+ {{ project.name || "Untitled" }}
195
+ </h3>
196
+ </div>
197
+ </div>
198
+ </div>
199
+ </div>
200
+
201
+ <!-- Card Content -->
202
+ <div class="p-5">
203
+ <h3 class="font-bold text-slate-900 text-lg mb-1 truncate">
204
+ {{ project.name || "Untitled Project" }}
205
+ </h3>
206
+ <p
207
+ class="text-slate-500 text-sm line-clamp-2 h-10 mb-4"
208
+ :title="project.overview || project.topic"
209
+ >
210
+ {{ project.overview || project.topic || "No description provided." }}
211
+ </p>
212
+
213
+ <div
214
+ class="flex items-center justify-between text-xs text-slate-400 font-medium"
215
+ >
216
+ <div class="flex items-center gap-1.5">
217
+ <UIcon name="i-lucide-calendar" class="w-3.5 h-3.5" />
218
+ {{ formattedDate }}
219
+ </div>
220
+ <div class="flex items-center gap-1.5">
221
+ <UIcon name="i-lucide-clock" class="w-3.5 h-3.5" />
222
+ {{ duration || "--:--" }}
223
+ </div>
224
+ </div>
225
+ </div>
226
+ </NuxtLink>
227
+ </div>
228
+ </template>
229
+
230
+ <script setup lang="ts">
231
+ import { ref, computed } from "vue";
232
+ import { config as apiConfig } from "~/utils/api";
233
+ import { useProjectStore } from "~/stores/projects";
234
+
235
+ const props = defineProps<{
236
+ project: any;
237
+ duration?: string;
238
+ }>();
239
+
240
+ const store = useProjectStore();
241
+ const emit = defineEmits<{
242
+ delete: [project: any];
243
+ continue: [project: any];
244
+ dragstart: [projectId: string];
245
+ dragend: [];
246
+ }>();
247
+
248
+ const videoEl = ref<HTMLVideoElement | null>(null);
249
+ const isDragging = ref(false);
250
+ const isHovered = ref(false);
251
+ const videoStarted = ref(false);
252
+ const localDuration = ref<string>("");
253
+
254
+ const videoUrl = computed(() => {
255
+ if (!props.project.video_url) return null;
256
+ if (props.project.video_url.startsWith("http"))
257
+ return props.project.video_url;
258
+ return `${apiConfig.API_BASE_URL}${props.project.video_url}`;
259
+ });
260
+
261
+ const formattedStatus = computed(() => {
262
+ if (!props.project.status) return "Unknown";
263
+ return props.project.status
264
+ .replace(/_/g, " ")
265
+ .replace(/\b\w/g, (c: string) => c.toUpperCase());
266
+ });
267
+
268
+ const statusClasses = computed(() => {
269
+ const s = props.project.status;
270
+ if (s === "completed")
271
+ return "bg-white/80 border border-white/50 text-green-700 bg-green-50/80 border-green-200";
272
+ if (s === "error")
273
+ return "bg-white/80 border border-white/50 text-red-700 bg-red-50/80 border-red-200";
274
+ if (s === "awaiting_approval")
275
+ return "bg-indigo-50/80 border border-indigo-200 text-indigo-700";
276
+ return "bg-white/80 border border-white/50 text-orange-700 bg-orange-50/80 border-orange-200";
277
+ });
278
+
279
+ // Only allow continue for: error, planned, stopped, or cancelled
280
+ // BUT also check if there's already an active generation running for this project
281
+ const canContinue = computed(() => {
282
+ const s = props.project.status;
283
+ const statusAllowsContinue =
284
+ s === "error" || s === "planned" || s === "stopped" || s === "cancelled";
285
+
286
+ // Check if there's already an active generation for this project
287
+ const hasActiveGeneration = Object.values(store.activeGenerations).some(
288
+ (gen: any) => {
289
+ const matchesProject =
290
+ gen.projectId === props.project.id ||
291
+ gen.projectName === props.project.id;
292
+ const isActive = gen.status !== "stopped" && gen.status !== "completed";
293
+ return matchesProject && isActive;
294
+ },
295
+ );
296
+
297
+ // Only show continue if status allows it AND no active generation is running
298
+ return statusAllowsContinue && !hasActiveGeneration;
299
+ });
300
+
301
+ const formattedDate = computed(() => {
302
+ if (!props.project.created_at) return "";
303
+ return new Intl.DateTimeFormat("en-US", {
304
+ month: "short",
305
+ day: "numeric",
306
+ year: "numeric",
307
+ }).format(new Date(props.project.created_at));
308
+ });
309
+
310
+ const duration = computed(
311
+ () => props.duration || localDuration.value || props.project.duration,
312
+ );
313
+
314
+ function playPreview() {
315
+ isHovered.value = true;
316
+ videoEl.value?.play().catch(() => {});
317
+ }
318
+
319
+ function pausePreview() {
320
+ isHovered.value = false;
321
+ videoStarted.value = false;
322
+ if (videoEl.value) {
323
+ videoEl.value.pause();
324
+ videoEl.value.currentTime = 0;
325
+ }
326
+ }
327
+
328
+ function onVideoMetadata(e: Event) {
329
+ const secs = (e.target as HTMLVideoElement).duration;
330
+ if (!secs || !isFinite(secs)) return;
331
+ const m = Math.floor(secs / 60);
332
+ const s = Math.floor(secs % 60)
333
+ .toString()
334
+ .padStart(2, "0");
335
+ localDuration.value = `${m}:${s}`;
336
+ }
337
+
338
+ function onDragStart(e: DragEvent) {
339
+ // Small delay lets the browser snapshot the element before we dim it
340
+ requestAnimationFrame(() => {
341
+ isDragging.value = true;
342
+ });
343
+ if (e.dataTransfer) {
344
+ e.dataTransfer.effectAllowed = "move";
345
+ e.dataTransfer.setData("text/plain", props.project.id);
346
+ }
347
+ emit("dragstart", props.project.id);
348
+ }
349
+
350
+ function onDragEnd() {
351
+ isDragging.value = false;
352
+ emit("dragend");
353
+ }
354
+ </script>
355
+
356
+ <style scoped>
357
+ .project-card-wrapper {
358
+ cursor: grab;
359
+ }
360
+ .project-card-wrapper.is-dragging {
361
+ opacity: 0.45;
362
+ transform: scale(0.97);
363
+ cursor: grabbing;
364
+ }
365
+ /* Prevent browser from using its default link-drag ghost */
366
+ .project-card-wrapper a {
367
+ -webkit-user-drag: none;
368
+ }
369
+
370
+ @keyframes scan {
371
+ 0% {
372
+ transform: translateY(0);
373
+ opacity: 0;
374
+ }
375
+ 10% {
376
+ opacity: 1;
377
+ }
378
+ 90% {
379
+ opacity: 1;
380
+ }
381
+ 100% {
382
+ transform: translateY(180px);
383
+ opacity: 0;
384
+ }
385
+ }
386
+
387
+ @keyframes scan-reverse {
388
+ 0% {
389
+ transform: translateY(0);
390
+ opacity: 0;
391
+ }
392
+ 10% {
393
+ opacity: 1;
394
+ }
395
+ 90% {
396
+ opacity: 1;
397
+ }
398
+ 100% {
399
+ transform: translateY(-180px);
400
+ opacity: 0;
401
+ }
402
+ }
403
+ </style>
frontend/app/components/ProjectGroupCard.vue ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div
3
+ class="premium-glass rounded-2xl overflow-hidden animate-fade-in-up transition-all"
4
+ :class="{ 'drop-zone-active': isDragOver }"
5
+ @dragenter.prevent="onDragEnter"
6
+ @dragover.prevent="onDragOver"
7
+ @dragleave="onDragLeave"
8
+ @drop.prevent="onDrop"
9
+ >
10
+ <!-- Group Header -->
11
+ <div
12
+ class="flex items-center gap-3 px-5 py-4 border-b border-white/40 bg-white/20"
13
+ >
14
+ <!-- Collapse toggle -->
15
+ <button
16
+ @click="emit('toggle-collapse', group.id)"
17
+ class="w-6 h-6 flex items-center justify-center text-slate-400 hover:text-slate-600 transition-colors shrink-0"
18
+ >
19
+ <UIcon
20
+ :name="
21
+ group.collapsed ? 'i-lucide-chevron-right' : 'i-lucide-chevron-down'
22
+ "
23
+ class="w-4 h-4 transition-transform duration-200"
24
+ />
25
+ </button>
26
+
27
+ <!-- Folder icon -->
28
+ <UIcon
29
+ name="i-lucide-folder-open"
30
+ class="w-4 h-4 text-green-500 shrink-0"
31
+ />
32
+
33
+ <!-- Editable group name -->
34
+ <div class="flex-1 min-w-0">
35
+ <input
36
+ v-if="isEditing"
37
+ ref="nameInput"
38
+ v-model="editName"
39
+ class="w-full bg-transparent text-slate-900 font-semibold text-sm outline-none border-b border-green-400 pb-0.5 focus:border-green-500"
40
+ @blur="commitRename"
41
+ @keydown.enter.prevent="commitRename"
42
+ @keydown.escape.prevent="cancelRename"
43
+ />
44
+ <button
45
+ v-else
46
+ @click="startEditing"
47
+ class="text-slate-800 font-semibold text-sm truncate hover:text-green-600 transition-colors text-left w-full"
48
+ :title="group.name"
49
+ >
50
+ {{ group.name }}
51
+ </button>
52
+ </div>
53
+
54
+ <!-- Project count badge -->
55
+ <span
56
+ class="text-xs font-medium bg-slate-100 text-slate-500 px-2 py-0.5 rounded-full shrink-0"
57
+ >
58
+ {{ group.projectIds.length }}
59
+ </span>
60
+
61
+ <!-- Delete group button -->
62
+ <button
63
+ @click="onDeleteGroup"
64
+ class="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 transition-all shrink-0"
65
+ title="Delete group"
66
+ >
67
+ <UIcon name="i-lucide-trash-2" class="w-3.5 h-3.5" />
68
+ </button>
69
+ </div>
70
+
71
+ <!-- Drop zone / project grid -->
72
+ <div v-show="!group.collapsed" class="p-4 transition-all">
73
+ <!-- Empty drop zone -->
74
+ <div
75
+ v-if="projects.length === 0"
76
+ class="rounded-xl border-2 border-dashed border-slate-200 flex flex-col items-center justify-center py-8 text-slate-400 text-sm gap-2 transition-colors"
77
+ :class="
78
+ isDragOver ? 'border-green-400 bg-green-50/40 text-green-600' : ''
79
+ "
80
+ >
81
+ <UIcon
82
+ :name="isDragOver ? 'i-lucide-package-plus' : 'i-lucide-package'"
83
+ class="w-6 h-6"
84
+ />
85
+ <span>{{ isDragOver ? "Drop to add" : "Drag projects here" }}</span>
86
+ </div>
87
+
88
+ <!-- Project cards grid -->
89
+ <div
90
+ v-else
91
+ class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
92
+ :class="
93
+ isDragOver && projects.length > 0
94
+ ? 'ring-2 ring-green-400 ring-offset-2 rounded-xl'
95
+ : ''
96
+ "
97
+ >
98
+ <ProjectCard
99
+ v-for="project in projects"
100
+ :key="project.id"
101
+ :project="project"
102
+ :duration="durations?.[project.id]"
103
+ @delete="(p) => emit('delete-project', p)"
104
+ @continue="(p) => emit('continue-project', p)"
105
+ @dragstart="emit('project-dragstart', $event)"
106
+ @dragend="emit('project-dragend')"
107
+ />
108
+ </div>
109
+ </div>
110
+ </div>
111
+ </template>
112
+
113
+ <script setup lang="ts">
114
+ import { ref, nextTick } from "vue";
115
+ import type { ProjectGroup } from "~/stores/groups";
116
+
117
+ const props = defineProps<{
118
+ group: ProjectGroup;
119
+ projects: any[];
120
+ durations?: Record<string, string>;
121
+ }>();
122
+
123
+ const emit = defineEmits<{
124
+ "toggle-collapse": [groupId: string];
125
+ rename: [groupId: string, name: string];
126
+ delete: [groupId: string];
127
+ drop: [projectId: string, groupId: string];
128
+ "delete-project": [project: any];
129
+ "continue-project": [project: any];
130
+ "project-dragstart": [projectId: string];
131
+ "project-dragend": [];
132
+ }>();
133
+
134
+ // ── Drag-and-drop (counter avoids false dragleave from child elements) ──
135
+ const isDragOver = ref(false);
136
+ let dragEnterCount = 0;
137
+
138
+ function onDragEnter(e: DragEvent) {
139
+ dragEnterCount++;
140
+ isDragOver.value = true;
141
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
142
+ // Auto-expand collapsed group so user can see where they're dropping
143
+ if (props.group.collapsed) {
144
+ emit("toggle-collapse", props.group.id);
145
+ }
146
+ }
147
+
148
+ function onDragOver(e: DragEvent) {
149
+ isDragOver.value = true;
150
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
151
+ }
152
+
153
+ function onDragLeave(_e: DragEvent) {
154
+ dragEnterCount = Math.max(0, dragEnterCount - 1);
155
+ if (dragEnterCount === 0) {
156
+ isDragOver.value = false;
157
+ }
158
+ }
159
+
160
+ function onDrop(e: DragEvent) {
161
+ dragEnterCount = 0;
162
+ isDragOver.value = false;
163
+ const projectId = e.dataTransfer?.getData("text/plain");
164
+ if (projectId) {
165
+ emit("drop", projectId, props.group.id);
166
+ }
167
+ }
168
+
169
+ // ── Inline editing ──
170
+ const isEditing = ref(false);
171
+ const editName = ref("");
172
+ const nameInput = ref<HTMLInputElement | null>(null);
173
+
174
+ function startEditing() {
175
+ editName.value = props.group.name;
176
+ isEditing.value = true;
177
+ nextTick(() => {
178
+ nameInput.value?.select();
179
+ });
180
+ }
181
+
182
+ function commitRename() {
183
+ if (editName.value.trim()) {
184
+ emit("rename", props.group.id, editName.value.trim());
185
+ }
186
+ isEditing.value = false;
187
+ }
188
+
189
+ function cancelRename() {
190
+ isEditing.value = false;
191
+ }
192
+
193
+ // ── Delete ──
194
+ function onDeleteGroup() {
195
+ emit("delete", props.group.id);
196
+ }
197
+ </script>
frontend/app/components/QuizBuilder.vue ADDED
@@ -0,0 +1,612 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="flex flex-col h-full bg-slate-50/30">
3
+ <!-- Config State -->
4
+ <div
5
+ v-if="quizState === 'config'"
6
+ class="flex-1 p-6 overflow-y-auto custom-scrollbar flex flex-col items-center justify-center animate-fade-in"
7
+ >
8
+ <div
9
+ class="w-16 h-16 rounded-2xl bg-green-100 flex items-center justify-center mb-6 shadow-sm border border-green-200"
10
+ >
11
+ <UIcon name="i-lucide-brain-circuit" class="w-8 h-8 text-green-600" />
12
+ </div>
13
+ <h3 class="text-xl font-bold text-slate-900 mb-2 text-center">
14
+ Knowledge Check
15
+ </h3>
16
+ <p
17
+ class="text-slate-800 text-center text-sm max-w-xs mb-8 leading-relaxed"
18
+ >
19
+ Test your understanding of {{ project?.title || "this topic" }} with an
20
+ AI-generated quiz.
21
+ </p>
22
+
23
+ <div
24
+ class="w-full space-y-5 bg-white/60 backdrop-blur-md border border-slate-200/60 p-6 rounded-2xl"
25
+ >
26
+ <!-- Questions Count -->
27
+ <div class="space-y-2">
28
+ <label
29
+ class="block text-[10px] font-bold text-slate-500 uppercase tracking-[0.15em] ml-1"
30
+ >Number of Questions</label
31
+ >
32
+ <USelectMenu
33
+ v-model="settings.count"
34
+ :items="countOptions"
35
+ :searchInput="false"
36
+ value-key="value"
37
+ label-key="label"
38
+ class="premium-input rounded-xl"
39
+ variant="none"
40
+ >
41
+ <template #leading>
42
+ <UIcon
43
+ name="i-lucide-list-checks"
44
+ class="w-4 h-4 text-slate-400"
45
+ />
46
+ </template>
47
+ </USelectMenu>
48
+ </div>
49
+
50
+ <div class="grid grid-cols-1 gap-4">
51
+ <!-- Difficulty -->
52
+ <div class="space-y-2">
53
+ <label
54
+ class="block text-[10px] font-bold text-slate-500 uppercase tracking-[0.15em] ml-1"
55
+ >Difficulty</label
56
+ >
57
+ <USelectMenu
58
+ v-model="settings.difficulty"
59
+ :items="['Beginner', 'Intermediate', 'Advanced']"
60
+ :searchInput="false"
61
+ class="premium-input rounded-md"
62
+ variant="none"
63
+ >
64
+ <template #leading>
65
+ <UIcon
66
+ name="i-lucide-bar-chart"
67
+ class="w-3.5 h-3.5 text-slate-400"
68
+ />
69
+ </template>
70
+ </USelectMenu>
71
+ </div>
72
+
73
+ <!-- Type -->
74
+ <div class="space-y-2">
75
+ <label
76
+ class="block text-[10px] font-bold text-slate-500 uppercase tracking-[0.15em] ml-1"
77
+ >Format</label
78
+ >
79
+ <USelectMenu
80
+ v-model="settings.type"
81
+ :items="typeOptions"
82
+ :searchInput="false"
83
+ class="premium-input rounded-md"
84
+ variant="none"
85
+ >
86
+ <template #leading>
87
+ <UIcon
88
+ name="i-lucide-layers"
89
+ class="w-3.5 h-3.5 text-slate-400"
90
+ />
91
+ </template>
92
+ </USelectMenu>
93
+ </div>
94
+ </div>
95
+ </div>
96
+
97
+ <UButton
98
+ @click="startQuiz"
99
+ :loading="store.loading || pStore.loadingModels"
100
+ class="w-full mt-6 btn-premium py-3 rounded-xl font-semibold text-lg flex items-center justify-center justify-center shadow-md hover:shadow-lg transition-all cursor-pointer disabled:opacity-50"
101
+ >
102
+ <template #leading>
103
+ <UIcon
104
+ v-if="!store.loading && !pStore.loadingModels"
105
+ name="i-lucide-play"
106
+ class="w-5 h-5"
107
+ />
108
+ </template>
109
+ {{
110
+ pStore.loadingModels
111
+ ? "Loading AI models..."
112
+ : store.loading
113
+ ? "Generating Quiz..."
114
+ : "Generate Quiz"
115
+ }}
116
+ </UButton>
117
+
118
+ <p
119
+ v-if="store.error"
120
+ class="text-xs text-red-500 mt-4 text-center bg-red-50 p-3 rounded-xl border border-red-100"
121
+ >
122
+ {{ store.error }}
123
+ </p>
124
+ </div>
125
+
126
+ <!-- Taking State -->
127
+ <div
128
+ v-else-if="quizState === 'taking'"
129
+ class="flex-1 flex flex-col animate-fade-in relative overflow-hidden"
130
+ >
131
+ <!-- Progress Header (Sticky) -->
132
+ <div
133
+ class="px-5 py-4 border-b border-slate-200/50 bg-white/80 backdrop-blur-md sticky top-0 z-10"
134
+ >
135
+ <div class="flex justify-between items-center mb-2">
136
+ <span class="text-sm font-bold text-slate-700"
137
+ >Question {{ currentQuestionIndex + 1 }} of
138
+ {{ questions.length }}</span
139
+ >
140
+ <span class="text-xs font-semibold text-slate-400"
141
+ >{{ Math.round((currentQuestionIndex / questions.length) * 100) }}%
142
+ Complete</span
143
+ >
144
+ </div>
145
+ <div class="w-full h-1.5 bg-slate-200 rounded-full overflow-hidden">
146
+ <div
147
+ class="h-full bg-green-500 transition-all duration-300"
148
+ :style="`width: ${((currentQuestionIndex + 1) / questions.length) * 100}%`"
149
+ ></div>
150
+ </div>
151
+ </div>
152
+
153
+ <!-- Question Content (Scrollable) -->
154
+ <div class="flex-1 overflow-y-auto p-6 custom-scrollbar">
155
+ <h4 class="text-lg font-bold text-slate-900 mb-6 leading-snug">
156
+ {{ currentQuestion?.question }}
157
+ </h4>
158
+
159
+ <div class="space-y-3">
160
+ <button
161
+ v-for="(opt, idx) in currentQuestion?.options"
162
+ :key="idx"
163
+ @click="selectAnswer(opt)"
164
+ :disabled="hasAnsweredCurrent"
165
+ class="w-full text-left px-5 py-3.5 rounded-2xl border transition-all duration-200 relative group overflow-hidden cursor-pointer"
166
+ :class="getOptionClass(opt)"
167
+ >
168
+ <div class="flex items-center gap-3 relative z-10">
169
+ <div
170
+ class="w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors"
171
+ :class="getRadioClass(opt)"
172
+ >
173
+ <div
174
+ v-if="isSelected(opt)"
175
+ class="w-2.5 h-2.5 rounded-full bg-current"
176
+ ></div>
177
+ </div>
178
+ <span
179
+ class="text-sm font-medium leading-relaxed text-slate-800"
180
+ >{{ opt }}</span
181
+ >
182
+ </div>
183
+ </button>
184
+ </div>
185
+
186
+ <!-- Explanation (Shows after answering) -->
187
+ <Transition name="fade-slide">
188
+ <div
189
+ v-if="hasAnsweredCurrent"
190
+ class="mt-6 p-4 rounded-2xl bg-white border border-slate-200 shadow-sm"
191
+ >
192
+ <div
193
+ class="flex items-center gap-2 mb-2"
194
+ :class="isCorrect ? 'text-green-600' : 'text-red-600'"
195
+ >
196
+ <UIcon
197
+ :name="
198
+ isCorrect ? 'i-lucide-check-circle' : 'i-lucide-x-circle'
199
+ "
200
+ class="w-5 h-5"
201
+ />
202
+ <span class="font-bold">{{
203
+ isCorrect ? "Correct!" : "Incorrect"
204
+ }}</span>
205
+ </div>
206
+ <p class="text-sm text-slate-800 leading-relaxed">
207
+ {{ currentQuestion?.explanation }}
208
+ </p>
209
+ </div>
210
+ </Transition>
211
+
212
+ <!-- Spacer for the sticky footer button -->
213
+ <div v-if="hasAnsweredCurrent" class="h-20"></div>
214
+ </div>
215
+
216
+ <!-- Next Button (Fixed/Sticky Footer) -->
217
+ <Transition name="fade-slide">
218
+ <div
219
+ v-if="hasAnsweredCurrent"
220
+ class="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-200/50 bg-white/90 backdrop-blur-md z-20"
221
+ >
222
+ <UButton
223
+ @click="nextQuestion"
224
+ class="w-full btn-premium py-3 rounded-xl font-bold flex items-center justify-center shadow-md active:scale-95 transition-transform cursor-pointer"
225
+ >
226
+ {{ isLastQuestion ? "View Results" : "Next Question" }}
227
+ <UIcon name="i-lucide-arrow-right" class="w-4 h-4 ml-2" />
228
+ </UButton>
229
+ </div>
230
+ </Transition>
231
+ </div>
232
+
233
+ <!-- Results State -->
234
+ <div
235
+ v-else-if="quizState === 'results'"
236
+ class="flex-1 overflow-y-auto custom-scrollbar flex flex-col items-center animate-fade-in"
237
+ >
238
+ <!-- Score Visualization -->
239
+ <div
240
+ class="w-full p-4 flex flex-col items-center justify-center bg-white/40 border-b border-slate-200/50 sticky top-0 z-10 backdrop-blur-md"
241
+ >
242
+ <div class="flex items-center gap-6 w-full max-w-lg px-2">
243
+ <div
244
+ class="relative w-20 h-20 flex items-center justify-center shrink-0"
245
+ >
246
+ <!-- Circular Progress -->
247
+ <svg
248
+ viewBox="0 0 128 128"
249
+ class="w-full h-full transform -rotate-90"
250
+ >
251
+ <circle
252
+ cx="64"
253
+ cy="64"
254
+ r="58"
255
+ stroke="currentColor"
256
+ stroke-width="10"
257
+ fill="transparent"
258
+ class="text-slate-100"
259
+ />
260
+ <circle
261
+ cx="64"
262
+ cy="64"
263
+ r="58"
264
+ stroke="currentColor"
265
+ stroke-width="10"
266
+ fill="transparent"
267
+ :stroke-dasharray="circumference"
268
+ :stroke-dashoffset="dashOffset"
269
+ stroke-linecap="round"
270
+ class="transition-all duration-1000 ease-out"
271
+ :class="
272
+ scorePercentage >= 60 ? 'text-green-500' : 'text-orange-500'
273
+ "
274
+ />
275
+ </svg>
276
+ <div
277
+ class="absolute inset-0 flex flex-col items-center justify-center"
278
+ >
279
+ <span class="text-xl font-black text-slate-900 leading-none"
280
+ >{{ scorePercentage }}%</span
281
+ >
282
+ </div>
283
+ </div>
284
+
285
+ <div class="flex-1 min-w-0">
286
+ <h3 class="text-lg font-bold text-slate-900 mb-0.5 truncate">
287
+ {{
288
+ scorePercentage === 100
289
+ ? "Perfect Mastery!"
290
+ : scorePercentage >= 80
291
+ ? "Excellent Work!"
292
+ : scorePercentage >= 60
293
+ ? "Good Progress!"
294
+ : "Keep Learning!"
295
+ }}
296
+ </h3>
297
+ <p class="text-slate-600 text-xs font-medium mb-3">
298
+ You got
299
+ <span class="text-slate-900 font-bold">{{
300
+ store.getScore(project.id)
301
+ }}</span>
302
+ /
303
+ <span class="text-slate-900 font-bold">{{
304
+ questions.length
305
+ }}</span>
306
+ correct
307
+ </p>
308
+
309
+ <div class="flex gap-2">
310
+ <UButton
311
+ @click="store.resetQuiz(project.id)"
312
+ size="xs"
313
+ class="flex-1 btn-premium py-1.5 rounded-lg font-bold shadow-sm justify-center cursor-pointer"
314
+ >
315
+ Try Again
316
+ </UButton>
317
+ <UButton
318
+ @click="store.clearQuiz(project.id)"
319
+ size="xs"
320
+ variant="ghost"
321
+ class="px-3 py-1.5 border border-slate-200/60 rounded-lg text-slate-500 hover:text-slate-900 flex items-center justify-center cursor-pointer bg-white"
322
+ icon="i-lucide-settings-2"
323
+ />
324
+ </div>
325
+ </div>
326
+ </div>
327
+ </div>
328
+
329
+ <!-- Breakdown Section -->
330
+ <div class="w-full p-6 space-y-6">
331
+ <div class="flex items-center justify-between mb-4">
332
+ <h4
333
+ class="text-xs font-bold text-slate-400 uppercase tracking-[0.2em]"
334
+ >
335
+ Quiz Review
336
+ </h4>
337
+ <span class="text-[10px] font-medium text-slate-400"
338
+ >{{ questions.length }} questions</span
339
+ >
340
+ </div>
341
+
342
+ <div class="space-y-4">
343
+ <div
344
+ v-for="(q, idx) in questions"
345
+ :key="idx"
346
+ class="p-4 rounded-2xl bg-white border border-slate-200/60 shadow-sm transition-all hover:border-slate-300"
347
+ >
348
+ <div
349
+ class="flex items-start gap-3 cursor-pointer group/q"
350
+ @click="toggleQuestion(idx)"
351
+ >
352
+ <div
353
+ class="w-6 h-6 rounded-full flex items-center justify-center shrink-0 text-[10px] font-black mt-0.5"
354
+ :class="
355
+ store.quizzes[project.id].answers[idx] === q.answer
356
+ ? 'bg-green-100 text-green-600'
357
+ : 'bg-red-100 text-red-600'
358
+ "
359
+ >
360
+ {{ idx + 1 }}
361
+ </div>
362
+ <p
363
+ class="flex-1 text-sm font-bold text-slate-800 leading-snug pt-1"
364
+ >
365
+ {{ q.question }}
366
+ </p>
367
+ <UIcon
368
+ :name="
369
+ expandedQuestions[idx]
370
+ ? 'i-lucide-chevron-up'
371
+ : 'i-lucide-chevron-down'
372
+ "
373
+ class="w-5 h-5 text-slate-400 group-hover/q:text-slate-600 transition-colors mt-0.5"
374
+ />
375
+ </div>
376
+
377
+ <Transition name="fade-slide">
378
+ <div v-show="expandedQuestions[idx]" class="mt-4 ml-9 space-y-4">
379
+ <!-- Result Status -->
380
+ <div
381
+ class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider"
382
+ :class="
383
+ store.quizzes[project.id].answers[idx] === q.answer
384
+ ? 'bg-green-50 text-green-600 border border-green-100'
385
+ : 'bg-red-50 text-red-600 border border-red-100'
386
+ "
387
+ >
388
+ <UIcon
389
+ :name="
390
+ store.quizzes[project.id].answers[idx] === q.answer
391
+ ? 'i-lucide-check-circle'
392
+ : 'i-lucide-x-circle'
393
+ "
394
+ class="w-3.5 h-3.5"
395
+ />
396
+ {{
397
+ store.quizzes[project.id].answers[idx] === q.answer
398
+ ? "Correct"
399
+ : "Incorrect"
400
+ }}
401
+ </div>
402
+
403
+ <div class="text-xs space-y-1.5">
404
+ <div class="flex items-start gap-2">
405
+ <span
406
+ class="text-slate-400 font-semibold w-24 shrink-0 mt-0.5"
407
+ >Your Answer:</span
408
+ >
409
+ <span
410
+ :class="
411
+ store.quizzes[project.id].answers[idx] === q.answer
412
+ ? 'text-green-700 font-medium'
413
+ : 'text-red-700 font-medium'
414
+ "
415
+ >
416
+ {{
417
+ store.quizzes[project.id].answers[idx] || "No answer"
418
+ }}
419
+ </span>
420
+ </div>
421
+ <div
422
+ v-if="store.quizzes[project.id].answers[idx] !== q.answer"
423
+ class="flex items-start gap-2"
424
+ >
425
+ <span
426
+ class="text-slate-400 font-semibold w-24 shrink-0 mt-0.5"
427
+ >Correct Answer:</span
428
+ >
429
+ <span class="text-green-700 font-bold italic">{{
430
+ q.answer
431
+ }}</span>
432
+ </div>
433
+
434
+ <div
435
+ class="mt-3 p-3 rounded-xl bg-slate-50 border border-slate-100 italic text-slate-600 leading-relaxed text-[11px]"
436
+ >
437
+ {{ q.explanation }}
438
+ </div>
439
+ </div>
440
+ </div>
441
+ </Transition>
442
+ </div>
443
+ </div>
444
+ </div>
445
+ </div>
446
+ </div>
447
+ </template>
448
+
449
+ <script setup>
450
+ import { ref, computed, watch } from "vue";
451
+ import { useQuizStore } from "~/stores/quiz";
452
+ import { useProjectStore } from "~/stores/projects";
453
+
454
+ const props = defineProps({
455
+ project: { type: Object, required: true },
456
+ });
457
+
458
+ const store = useQuizStore();
459
+ const pStore = useProjectStore();
460
+
461
+ // Local config tracking before generation
462
+ const settings = ref({
463
+ count: 3,
464
+ difficulty: "Beginner",
465
+ type: "Multiple Choice",
466
+ });
467
+
468
+ const typeOptions = ["Multiple Choice", "True/False"];
469
+
470
+ const countOptions = [
471
+ { label: "Short (3 questions)", value: 3 },
472
+ { label: "Medium (5 questions)", value: 5 },
473
+ { label: "Deep Dive (10 questions)", value: 10 },
474
+ ];
475
+
476
+ // Store Computed mappings
477
+ const quizState = computed(() => store.getQuizState(props.project.id));
478
+ const questions = computed(() => store.getQuestions(props.project.id));
479
+ const currentQuestionIndex = computed(() =>
480
+ store.getCurrentQuestionIndex(props.project.id),
481
+ );
482
+ const currentQuestion = computed(
483
+ () => questions.value[currentQuestionIndex.value],
484
+ );
485
+ const currentAnswer = computed(() => store.getCurrentAnswer(props.project.id));
486
+
487
+ const hasAnsweredCurrent = computed(() => !!currentAnswer.value);
488
+ const isCorrect = computed(
489
+ () =>
490
+ hasAnsweredCurrent.value &&
491
+ currentAnswer.value === currentQuestion.value.answer,
492
+ );
493
+ const isLastQuestion = computed(
494
+ () => currentQuestionIndex.value === questions.value.length - 1,
495
+ );
496
+ const scorePercentage = computed(() =>
497
+ questions.value.length > 0
498
+ ? Math.round(
499
+ (store.getScore(props.project.id) / questions.value.length) * 100,
500
+ )
501
+ : 0,
502
+ );
503
+
504
+ // Results Expand/Collapse state
505
+ const expandedQuestions = ref({});
506
+
507
+ const toggleQuestion = (idx) => {
508
+ expandedQuestions.value[idx] = !expandedQuestions.value[idx];
509
+ };
510
+
511
+ // Initialize expanded state when quiz completes
512
+ watch(
513
+ quizState,
514
+ (newState) => {
515
+ if (newState === "results") {
516
+ // Default all to expanded
517
+ questions.value.forEach((_, idx) => {
518
+ expandedQuestions.value[idx] = true;
519
+ });
520
+ }
521
+ },
522
+ { immediate: true },
523
+ );
524
+
525
+ // SVG Gauge precision
526
+ const circumference = 2 * Math.PI * 58;
527
+ const dashOffset = computed(
528
+ () => circumference * (1 - scorePercentage.value / 100),
529
+ );
530
+
531
+ const startQuiz = async () => {
532
+ if (
533
+ !pStore.apiKeys.gemini &&
534
+ !pStore.apiKeys.openai &&
535
+ !pStore.apiKeys.anthropic
536
+ ) {
537
+ alert("Please configure API keys in AI Providers first.");
538
+ return;
539
+ }
540
+ await store.generateQuiz(props.project.id, settings.value);
541
+ };
542
+
543
+ const selectAnswer = (opt) => {
544
+ if (!hasAnsweredCurrent.value) {
545
+ store.answerQuestion(props.project.id, opt);
546
+ }
547
+ };
548
+
549
+ const nextQuestion = () => {
550
+ store.nextQuestion(props.project.id);
551
+ };
552
+
553
+ // Styling helpers
554
+ const isSelected = (opt) => currentAnswer.value === opt;
555
+
556
+ const getOptionClass = (opt) => {
557
+ if (!hasAnsweredCurrent.value) {
558
+ return "bg-white border-slate-200 hover:border-green-300 hover:bg-green-50/30 text-slate-700";
559
+ }
560
+
561
+ if (opt === currentQuestion.value.answer) {
562
+ // The right answer
563
+ return "bg-green-50 border-green-500 text-green-800 shadow-[0_0_0_1px_rgba(34,197,94,1)]";
564
+ }
565
+
566
+ if (isSelected(opt)) {
567
+ // Picked wrong answer
568
+ return "bg-red-50 border-red-300 text-red-700";
569
+ }
570
+
571
+ // Other unpicked answers
572
+ return "bg-slate-50 border-slate-200 text-slate-400 opacity-60";
573
+ };
574
+
575
+ const getRadioClass = (opt) => {
576
+ if (!hasAnsweredCurrent.value)
577
+ return "border-slate-300 group-hover:border-green-400";
578
+ if (opt === currentQuestion.value.answer)
579
+ return "border-green-500 text-green-500";
580
+ if (isSelected(opt)) return "border-red-400 text-red-400";
581
+ return "border-slate-200";
582
+ };
583
+ </script>
584
+
585
+ <style scoped>
586
+ .custom-scrollbar::-webkit-scrollbar {
587
+ width: 4px;
588
+ }
589
+ .custom-scrollbar::-webkit-scrollbar-track {
590
+ background: transparent;
591
+ }
592
+ .custom-scrollbar::-webkit-scrollbar-thumb {
593
+ background: rgba(203, 213, 225, 0.5);
594
+ border-radius: 2px;
595
+ }
596
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover {
597
+ background: rgba(148, 163, 184, 0.8);
598
+ }
599
+
600
+ .fade-slide-enter-active,
601
+ .fade-slide-leave-active {
602
+ transition: all 0.3s ease;
603
+ }
604
+ .fade-slide-enter-from {
605
+ opacity: 0;
606
+ transform: translateY(-5px);
607
+ }
608
+ .fade-slide-leave-to {
609
+ opacity: 0;
610
+ transform: translateY(-5px);
611
+ }
612
+ </style>
frontend/app/components/TemplateMenu.vue ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <UDropdownMenu
3
+ v-slot="{ open }"
4
+ :modal="false"
5
+ :items="[{
6
+ label: 'Starter',
7
+ to: 'https://starter-template.nuxt.dev/',
8
+ color: 'primary',
9
+ checked: true,
10
+ type: 'checkbox'
11
+ }, {
12
+ label: 'Landing',
13
+ to: 'https://landing-template.nuxt.dev/'
14
+ }, {
15
+ label: 'Docs',
16
+ to: 'https://docs-template.nuxt.dev/'
17
+ }, {
18
+ label: 'SaaS',
19
+ to: 'https://saas-template.nuxt.dev/'
20
+ }, {
21
+ label: 'Dashboard',
22
+ to: 'https://dashboard-template.nuxt.dev/'
23
+ }, {
24
+ label: 'Chat',
25
+ to: 'https://chat-template.nuxt.dev/'
26
+ }, {
27
+ label: 'Portfolio',
28
+ to: 'https://portfolio-template.nuxt.dev/'
29
+ }, {
30
+ label: 'Changelog',
31
+ to: 'https://changelog-template.nuxt.dev/'
32
+ }]"
33
+ :content="{ align: 'start' }"
34
+ :ui="{ content: 'min-w-fit' }"
35
+ size="xs"
36
+ >
37
+ <UButton
38
+ label="Starter"
39
+ variant="subtle"
40
+ trailing-icon="i-lucide-chevron-down"
41
+ size="xs"
42
+ class="-mb-[6px] font-semibold rounded-full truncate"
43
+ :class="[open && 'bg-primary/15']"
44
+ :ui="{
45
+ trailingIcon: ['transition-transform duration-200', open ? 'rotate-180' : undefined].filter(Boolean).join(' ')
46
+ }"
47
+ />
48
+ </UDropdownMenu>
49
+ </template>
frontend/app/components/TopNavbar.vue ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <header
3
+ class="sticky top-0 z-50 w-full"
4
+ :class="scrolled ? 'scrolled-header' : ''"
5
+ >
6
+ <div
7
+ class="header-inner max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between"
8
+ >
9
+ <!-- Brand -->
10
+ <NuxtLink to="/" class="flex items-center gap-2 group shrink-0">
11
+ <svg
12
+ xmlns="http://www.w3.org/2000/svg"
13
+ width="28"
14
+ height="28"
15
+ viewBox="0 0 24 24"
16
+ fill="none"
17
+ stroke="#00dc82"
18
+ stroke-width="2"
19
+ stroke-linecap="round"
20
+ stroke-linejoin="round"
21
+ class="lucide lucide-boxes-icon lucide-boxes group-hover:scale-110 transition-transform duration-300"
22
+ >
23
+ <path
24
+ d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z"
25
+ />
26
+ <path d="m7 16.5-4.74-2.85" />
27
+ <path d="m7 16.5 5-3" />
28
+ <path d="M7 16.5v5.17" />
29
+ <path
30
+ d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z"
31
+ />
32
+ <path d="m17 16.5-5-3" />
33
+ <path d="m17 16.5 4.74-2.85" />
34
+ <path d="M17 16.5v5.17" />
35
+ <path
36
+ d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z"
37
+ />
38
+ <path d="M12 8 7.26 5.15" />
39
+ <path d="m12 8 4.74-2.85" />
40
+ <path d="M12 13.5V8" />
41
+ </svg>
42
+ <span class="text-[17px] font-black tracking-tight text-slate-900">
43
+ Algo<span class="text-green-500">Vision</span>
44
+ </span>
45
+ </NuxtLink>
46
+
47
+ <!-- Center Nav -->
48
+ <nav
49
+ class="hidden md:flex items-center gap-1 bg-white/50 backdrop-blur-md border border-white/70 rounded-full px-2 py-1.5 shadow-sm"
50
+ >
51
+ <NuxtLink
52
+ v-for="link in navLinks"
53
+ :key="link.to"
54
+ :to="link.to"
55
+ :id="`nav-${link.to.replace('/', '') || 'generate'}`"
56
+ class="nav-pill"
57
+ :class="{ 'nav-pill-active': isActive(link.to) }"
58
+ >
59
+ <UIcon :name="link.icon" class="w-3.5 h-3.5" />
60
+ {{ link.label }}
61
+ </NuxtLink>
62
+ </nav>
63
+
64
+ <!-- Right Actions -->
65
+ <div class="flex items-center gap-2">
66
+ <!-- Mobile menu -->
67
+ <button
68
+ class="md:hidden p-2 rounded-lg btn-glass text-slate-600"
69
+ @click="mobileOpen = !mobileOpen"
70
+ >
71
+ <UIcon
72
+ :name="mobileOpen ? 'i-lucide-x' : 'i-lucide-menu'"
73
+ class="w-5 h-5"
74
+ />
75
+ </button>
76
+
77
+ <!-- Tutorial Button -->
78
+ <UButton
79
+ id="nav-tutorial"
80
+ class="hidden sm:flex btn-premium text-white text-sm font-semibold px-4 py-2 rounded-xl"
81
+ @click="startTour"
82
+ >
83
+ <UIcon name="i-lucide-play-circle" class="w-4 h-4 mr-1.5" />
84
+ Tutorial
85
+ </UButton>
86
+ </div>
87
+ </div>
88
+
89
+ <!-- Mobile Nav -->
90
+ <Transition name="mobile-nav">
91
+ <div
92
+ v-if="mobileOpen"
93
+ class="md:hidden premium-glass border-t border-white/40 px-4 py-4 space-y-1"
94
+ >
95
+ <NuxtLink
96
+ v-for="link in navLinks"
97
+ :key="link.to"
98
+ :to="link.to"
99
+ class="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold text-slate-700 hover:text-green-600 hover:bg-green-50/40 transition-all active:scale-95"
100
+ :class="{ 'text-green-600 bg-green-50/60': isActive(link.to) }"
101
+ @click="mobileOpen = false"
102
+ >
103
+ <UIcon :name="link.icon" class="w-4 h-4" />
104
+ {{ link.label }}
105
+ </NuxtLink>
106
+ </div>
107
+ </Transition>
108
+ </header>
109
+ </template>
110
+
111
+ <script setup>
112
+ const route = useRoute();
113
+ const store = useProjectStore();
114
+ const mobileOpen = ref(false);
115
+ const scrolled = ref(false);
116
+
117
+ const navLinks = [
118
+ { to: "/", label: "Generate", icon: "i-lucide-sparkles" },
119
+ { to: "/projects", label: "Projects", icon: "i-lucide-folder-open" },
120
+ { to: "/ai-providers", label: "AI Providers", icon: "i-lucide-server" },
121
+ ];
122
+
123
+ const isActive = (path) => {
124
+ if (path === "/") return route.path === "/";
125
+ return route.path.startsWith(path);
126
+ };
127
+
128
+ // Start Onboarding Tour
129
+ const startTour = () => {
130
+ console.log("Tutorial clicked, starting onboarding...");
131
+ store.startOnboarding();
132
+ };
133
+
134
+ onMounted(() => {
135
+ const handler = () => {
136
+ scrolled.value = window.scrollY > 20;
137
+ };
138
+ window.addEventListener("scroll", handler, { passive: true });
139
+ onUnmounted(() => window.removeEventListener("scroll", handler));
140
+ });
141
+ </script>
142
+
143
+ <style scoped>
144
+ .header-inner {
145
+ transition: all 0.3s ease;
146
+ }
147
+
148
+ .scrolled-header .header-inner {
149
+ /* subtle shadow when scrolled */
150
+ }
151
+
152
+ header {
153
+ background: rgba(248, 250, 252, 0.6);
154
+ backdrop-filter: blur(20px) saturate(1.5);
155
+ -webkit-backdrop-filter: blur(20px) saturate(1.5);
156
+ border-bottom: 1px solid rgba(255, 255, 255, 0.7);
157
+ box-shadow: 0 1px 0 rgba(0, 0, 0, 0.04);
158
+ transition: all 0.3s ease;
159
+ }
160
+
161
+ .scrolled-header {
162
+ background: rgba(255, 255, 255, 0.82) !important;
163
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06) !important;
164
+ }
165
+
166
+ .nav-pill {
167
+ display: flex;
168
+ align-items: center;
169
+ gap: 6px;
170
+ padding: 6px 14px;
171
+ border-radius: 9999px;
172
+ font-size: 0.8125rem;
173
+ font-weight: 600;
174
+ color: #475569;
175
+ transition: all 0.18s ease;
176
+ }
177
+ .nav-pill:hover {
178
+ color: #16a34a;
179
+ background: rgba(22, 163, 74, 0.08);
180
+ transform: translateY(-1.5px) scale(1.03);
181
+ box-shadow: 0 4px 12px rgba(22, 163, 74, 0.08);
182
+ }
183
+ .nav-pill-active {
184
+ color: #16a34a;
185
+ background: rgba(255, 255, 255, 0.9);
186
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
187
+ }
188
+
189
+ .mobile-nav-enter-active,
190
+ .mobile-nav-leave-active {
191
+ transition: all 0.22s ease;
192
+ }
193
+ .mobile-nav-enter-from,
194
+ .mobile-nav-leave-to {
195
+ opacity: 0;
196
+ transform: translateY(-8px);
197
+ }
198
+ </style>
frontend/app/layouts/default.vue ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div
3
+ class="relative min-h-screen bg-slate-50 text-slate-900 selection:bg-green-500/30"
4
+ >
5
+ <!-- Animated Glass Deep Layer Background -->
6
+ <div class="fixed inset-0 z-0 overflow-hidden pointer-events-none">
7
+ <!-- Base Soft Light Gradient -->
8
+ <div
9
+ class="absolute inset-0 bg-gradient-to-br from-[#ffffff] via-[#f0fdf6] to-[#f8fafc]"
10
+ ></div>
11
+
12
+ <!-- Soft Noise Filter Overlay for premium physical texture -->
13
+ <div class="absolute inset-0 bg-noise mix-blend-overlay"></div>
14
+
15
+ <!-- Animated Light Orbs -->
16
+ <div
17
+ class="absolute -top-[10%] -left-[10%] w-3/4 max-w-full aspect-square rounded-full filter blur-[120px] opacity-40 animate-blob"
18
+ style="
19
+ background: radial-gradient(
20
+ circle,
21
+ rgba(0, 220, 130, 0.5) 0%,
22
+ rgba(0, 220, 130, 0) 70%
23
+ );
24
+ "
25
+ ></div>
26
+
27
+ <div
28
+ class="absolute top-[20%] -right-[10%] w-2/3 max-w-full aspect-square rounded-full filter blur-[100px] opacity-30 animate-blob animation-delay-2000"
29
+ style="
30
+ background: radial-gradient(
31
+ circle,
32
+ rgba(56, 189, 248, 0.4) 0%,
33
+ rgba(56, 189, 248, 0) 70%
34
+ );
35
+ "
36
+ ></div>
37
+
38
+ <div
39
+ class="absolute -bottom-[20%] left-[20%] w-3/4 max-w-full aspect-square rounded-full filter blur-[120px] opacity-30 animate-blob animation-delay-4000"
40
+ style="
41
+ background: radial-gradient(
42
+ circle,
43
+ rgba(74, 222, 141, 0.5) 0%,
44
+ rgba(74, 222, 141, 0) 70%
45
+ );
46
+ "
47
+ ></div>
48
+ </div>
49
+
50
+ <!-- Main Content -->
51
+ <div class="relative z-10 flex flex-col min-h-screen">
52
+ <TopNavbar />
53
+
54
+ <main class="flex-1 w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
55
+ <slot />
56
+ </main>
57
+
58
+ <footer
59
+ v-if="!['projects', 'ai-providers'].includes(String($route.name || ''))"
60
+ class="w-full text-center py-6 text-sm text-slate-500 dark:text-slate-400 backdrop-blur-md bg-white/5 dark:bg-black/5 border-t border-white/20 dark:border-white/5"
61
+ >
62
+ &copy; {{ new Date().getFullYear() }} AlgoVision. All rights reserved.
63
+ </footer>
64
+ </div>
65
+ </div>
66
+ </template>
67
+
68
+ <style>
69
+ /* Add the blob animation keyframes directly here for global usage if needed, or in CSS */
70
+ @keyframes blob {
71
+ 0% {
72
+ transform: translate(0px, 0px) scale(1);
73
+ }
74
+ 33% {
75
+ transform: translate(30px, -50px) scale(1.1);
76
+ }
77
+ 66% {
78
+ transform: translate(-20px, 20px) scale(0.9);
79
+ }
80
+ 100% {
81
+ transform: translate(0px, 0px) scale(1);
82
+ }
83
+ }
84
+
85
+ .animate-blob {
86
+ animation: blob 7s infinite alternate;
87
+ }
88
+
89
+ .animation-delay-2000 {
90
+ animation-delay: 2s;
91
+ }
92
+
93
+ .animation-delay-4000 {
94
+ animation-delay: 4s;
95
+ }
96
+ </style>
frontend/app/layouts/workspace.vue ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div
3
+ class="relative h-screen flex flex-col bg-slate-50 text-slate-900 selection:bg-green-500/30 overflow-hidden"
4
+ >
5
+ <!-- Animated Glass Deep Layer Background -->
6
+ <div class="fixed inset-0 z-0 overflow-hidden pointer-events-none">
7
+ <!-- Base Soft Light Gradient -->
8
+ <div
9
+ class="absolute inset-0 bg-gradient-to-br from-[#ffffff] via-[#f0fdf6] to-[#f8fafc]"
10
+ ></div>
11
+
12
+ <!-- Soft Noise Filter Overlay -->
13
+ <div class="absolute inset-0 bg-noise mix-blend-overlay"></div>
14
+
15
+ <!-- Animated Light Orbs -->
16
+ <div
17
+ class="absolute -top-[10%] -left-[10%] w-3/4 max-w-full aspect-square rounded-full filter blur-[120px] opacity-40 animate-blob"
18
+ style="
19
+ background: radial-gradient(
20
+ circle,
21
+ rgba(0, 220, 130, 0.5) 0%,
22
+ rgba(0, 220, 130, 0) 70%
23
+ );
24
+ "
25
+ ></div>
26
+
27
+ <div
28
+ class="absolute top-[20%] -right-[10%] w-2/3 max-w-full aspect-square rounded-full filter blur-[100px] opacity-30 animate-blob animation-delay-2000"
29
+ style="
30
+ background: radial-gradient(
31
+ circle,
32
+ rgba(56, 189, 248, 0.4) 0%,
33
+ rgba(56, 189, 248, 0) 70%
34
+ );
35
+ "
36
+ ></div>
37
+
38
+ <div
39
+ class="absolute -bottom-[20%] left-[20%] w-3/4 max-w-full aspect-square rounded-full filter blur-[120px] opacity-30 animate-blob animation-delay-4000"
40
+ style="
41
+ background: radial-gradient(
42
+ circle,
43
+ rgba(74, 222, 141, 0.5) 0%,
44
+ rgba(74, 222, 141, 0) 70%
45
+ );
46
+ "
47
+ ></div>
48
+ </div>
49
+
50
+ <!-- Main Content -->
51
+ <div class="relative z-10 flex flex-col h-full">
52
+ <TopNavbar />
53
+
54
+ <main class="flex-1 w-full overflow-hidden">
55
+ <slot />
56
+ </main>
57
+ </div>
58
+ </div>
59
+ </template>
60
+
61
+ <style>
62
+ @keyframes blob {
63
+ 0% {
64
+ transform: translate(0px, 0px) scale(1);
65
+ }
66
+ 33% {
67
+ transform: translate(30px, -50px) scale(1.1);
68
+ }
69
+ 66% {
70
+ transform: translate(-20px, 20px) scale(0.9);
71
+ }
72
+ 100% {
73
+ transform: translate(0px, 0px) scale(1);
74
+ }
75
+ }
76
+
77
+ .animate-blob {
78
+ animation: blob 7s infinite alternate;
79
+ }
80
+
81
+ .animation-delay-2000 {
82
+ animation-delay: 2s;
83
+ }
84
+
85
+ .animation-delay-4000 {
86
+ animation-delay: 4s;
87
+ }
88
+ </style>
frontend/app/pages/ai-providers.vue ADDED
@@ -0,0 +1,595 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="relative w-full overflow-hidden flex flex-col pt-4 pb-12">
3
+ <!-- Header -->
4
+ <div
5
+ class="w-full max-w-2xl mx-auto px-4 mb-4 text-center animate-fade-in-up"
6
+ >
7
+ <div
8
+ class="w-10 h-10 rounded-2xl bg-white/60 border border-white flex items-center justify-center mx-auto mb-3 shadow-sm"
9
+ >
10
+ <UIcon name="i-lucide-key-round" class="w-5 h-5 text-slate-700" />
11
+ </div>
12
+ <h1 class="text-2xl font-extrabold text-slate-900 tracking-tight mb-1">
13
+ Connect Your AI Accounts
14
+ </h1>
15
+ <p class="text-slate-500 text-sm leading-relaxed">
16
+ AlgoVision uses AI to generate your videos. Connect at least one
17
+ provider below to get started — it only takes a minute.
18
+ </p>
19
+ </div>
20
+
21
+ <!-- Trust Pillars -->
22
+ <div class="w-full max-w-3xl mx-auto px-4 mb-8">
23
+ <!-- AI Providers Grid -->
24
+ <div
25
+ id="trust-pillars"
26
+ class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12"
27
+ >
28
+ <!-- Privacy -->
29
+ <div
30
+ class="premium-glass rounded-2xl p-4 flex flex-col items-center text-center animate-fade-in-up animation-delay-100"
31
+ >
32
+ <div
33
+ class="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center mb-3 group"
34
+ >
35
+ <UIcon
36
+ name="i-lucide-shield-check"
37
+ class="w-5 h-5 text-blue-500 group-hover:scale-110 transition-transform"
38
+ />
39
+ </div>
40
+ <h4 class="font-bold text-slate-900 text-sm mb-1.5">Local Privacy</h4>
41
+ <p class="text-xs text-slate-500 leading-relaxed">
42
+ Keys stay in your browser. We never see or store them on our
43
+ servers.
44
+ </p>
45
+ </div>
46
+
47
+ <!-- Pricing -->
48
+ <div
49
+ class="premium-glass rounded-2xl p-4 flex flex-col items-center text-center animate-fade-in-up animation-delay-150"
50
+ >
51
+ <div
52
+ class="w-10 h-10 rounded-xl bg-emerald-50 flex items-center justify-center mb-3 group"
53
+ >
54
+ <UIcon
55
+ name="i-lucide-banknote"
56
+ class="w-5 h-5 text-emerald-500 group-hover:scale-110 transition-transform"
57
+ />
58
+ </div>
59
+ <h4 class="font-bold text-slate-900 text-sm mb-1.5">Direct Cost</h4>
60
+ <p class="text-xs text-slate-500 leading-relaxed">
61
+ Pay providers directly. Most videos cost only a few USD cents to
62
+ generate.
63
+ </p>
64
+ </div>
65
+
66
+ <!-- Flexibility -->
67
+ <div
68
+ class="premium-glass rounded-2xl p-4 flex flex-col items-center text-center animate-fade-in-up animation-delay-200"
69
+ >
70
+ <div
71
+ class="w-10 h-10 rounded-xl bg-indigo-50 flex items-center justify-center mb-3 group"
72
+ >
73
+ <UIcon
74
+ name="i-lucide-layers"
75
+ class="w-5 h-5 text-indigo-500 group-hover:scale-110 transition-transform"
76
+ />
77
+ </div>
78
+ <h4 class="font-bold text-slate-900 text-sm mb-1.5">One-Key Setup</h4>
79
+ <p class="text-xs text-slate-500 leading-relaxed">
80
+ Need only one key to start. Add more to unlock different AI models.
81
+ </p>
82
+ </div>
83
+ </div>
84
+ </div>
85
+
86
+ <!-- Provider Cards -->
87
+ <div class="w-full max-w-2xl mx-auto px-4 relative z-10">
88
+ <div
89
+ id="providers-list"
90
+ class="space-y-4 animate-fade-in-up animation-delay-200"
91
+ >
92
+ <!-- Google Gemini -->
93
+ <div
94
+ id="gemini-key-input"
95
+ class="premium-glass rounded-3xl p-4 flex flex-col gap-2"
96
+ >
97
+ <div class="flex items-center justify-between gap-3">
98
+ <!-- Logo + Name -->
99
+ <div class="flex items-center gap-3">
100
+ <!-- Gemini logo: 4-pointed star -->
101
+ <div
102
+ class="w-10 h-10 rounded-xl bg-white border border-slate-200 flex items-center justify-center shrink-0 overflow-hidden"
103
+ >
104
+ <img
105
+ src="https://upload.wikimedia.org/wikipedia/commons/1/1d/Google_Gemini_icon_2025.svg"
106
+ alt="Gemini"
107
+ class="w-6 h-6"
108
+ />
109
+ </div>
110
+ <div>
111
+ <div class="flex items-center gap-2 flex-wrap">
112
+ <h3 class="font-bold text-slate-900">Google Gemini</h3>
113
+ <span
114
+ class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-green-100 text-green-700"
115
+ >Recommended</span
116
+ >
117
+ </div>
118
+ <p class="text-xs text-slate-400 mt-0.5">AI provider</p>
119
+ </div>
120
+ </div>
121
+ <!-- Status + Get key + Validation -->
122
+ <div class="flex items-center gap-2 shrink-0">
123
+ <a
124
+ v-if="!store.apiKeys.gemini"
125
+ href="https://aistudio.google.com/app/apikey"
126
+ target="_blank"
127
+ rel="noopener noreferrer"
128
+ class="inline-flex items-center gap-1 text-xs font-semibold text-slate-400 hover:text-green-600 transition-colors"
129
+ title="Get a free Gemini key"
130
+ >
131
+ Get key
132
+ <UIcon name="i-lucide-external-link" class="w-3 h-3" />
133
+ </a>
134
+ <!-- Validation status icon -->
135
+ <span
136
+ v-if="validationStatus.gemini === 'valid'"
137
+ class="text-xs font-semibold px-2.5 py-1 flex items-center gap-1.5 rounded-full bg-green-100 text-green-700"
138
+ >
139
+ <UIcon name="i-lucide-check-circle" class="w-3.5 h-3.5" />
140
+ Valid
141
+ </span>
142
+ <span
143
+ v-else-if="validationStatus.gemini === 'invalid'"
144
+ class="text-xs font-semibold px-2.5 py-1 flex items-center gap-1.5 rounded-full bg-red-100 text-red-700"
145
+ :title="validationErrors.gemini"
146
+ >
147
+ <UIcon name="i-lucide-x-circle" class="w-3.5 h-3.5" />
148
+ Invalid
149
+ </span>
150
+ <span
151
+ v-else-if="validationStatus.gemini === 'testing'"
152
+ class="text-xs font-semibold px-2.5 py-1 flex items-center gap-1.5 rounded-full bg-blue-100 text-blue-700"
153
+ >
154
+ <UIcon
155
+ name="i-lucide-loader-2"
156
+ class="w-3.5 h-3.5 animate-spin"
157
+ />
158
+ Testing...
159
+ </span>
160
+ <span
161
+ v-else-if="store.apiKeys.gemini"
162
+ class="text-xs font-semibold px-2.5 py-1 flex items-center gap-1.5 rounded-full bg-green-100 text-green-700"
163
+ >
164
+ <span class="w-1.5 h-1.5 rounded-full bg-green-500"></span>
165
+ Connected
166
+ </span>
167
+ <span
168
+ v-else
169
+ class="text-xs font-semibold px-2.5 py-1 flex items-center gap-1.5 rounded-full bg-slate-100 text-slate-500"
170
+ >
171
+ <span class="w-1.5 h-1.5 rounded-full bg-slate-400"></span>
172
+ Not connected
173
+ </span>
174
+ </div>
175
+ </div>
176
+
177
+ <!-- Input and Button side by side -->
178
+ <div class="flex gap-2">
179
+ <input
180
+ v-model="keys.gemini"
181
+ type="password"
182
+ placeholder="Paste your key here (starts with AIzaSy…)"
183
+ class="flex-1 premium-input rounded-xl px-4 py-2.5 text-sm font-mono tracking-wider outline-none text-slate-700 placeholder:font-sans placeholder:tracking-normal placeholder:text-slate-400 select-none"
184
+ @copy.prevent
185
+ @cut.prevent
186
+ @contextmenu.prevent
187
+ @paste="() => scheduleValidation('gemini', 500)"
188
+ @input="scheduleValidation('gemini', 1000)"
189
+ />
190
+ <button
191
+ @click="saveAndValidateKeys('gemini')"
192
+ class="btn-premium text-white text-xs font-semibold px-4 py-2 rounded-xl shrink-0"
193
+ :disabled="validationStatus.gemini === 'testing'"
194
+ >
195
+ {{
196
+ validationStatus.gemini === "testing"
197
+ ? "Testing..."
198
+ : "Save & Test"
199
+ }}
200
+ </button>
201
+ </div>
202
+ <!-- Error message -->
203
+ <p
204
+ v-if="
205
+ validationStatus.gemini === 'invalid' && validationErrors.gemini
206
+ "
207
+ class="text-xs text-red-600 mt-1 flex items-center gap-1"
208
+ >
209
+ <UIcon name="i-lucide-alert-circle" class="w-3 h-3" />
210
+ {{ validationErrors.gemini }}
211
+ </p>
212
+ </div>
213
+
214
+ <!-- OpenAI -->
215
+ <div class="premium-glass rounded-3xl p-4 flex flex-col gap-2">
216
+ <div class="flex items-center justify-between gap-3">
217
+ <div class="flex items-center gap-3">
218
+ <!-- OpenAI logo -->
219
+ <div
220
+ class="w-10 h-10 rounded-xl bg-white border border-slate-200 flex items-center justify-center shrink-0"
221
+ >
222
+ <svg
223
+ viewBox="0 0 41 41"
224
+ class="w-6 h-6 fill-black"
225
+ xmlns="http://www.w3.org/2000/svg"
226
+ >
227
+ <path
228
+ d="M37.532 16.87a9.963 9.963 0 0 0-.856-8.184 10.078 10.078 0 0 0-10.855-4.835 9.964 9.964 0 0 0-6.505-2.608 10.079 10.079 0 0 0-9.612 6.977 9.967 9.967 0 0 0-6.664 4.834 10.08 10.08 0 0 0 1.24 11.817 9.965 9.965 0 0 0 .856 8.185 10.079 10.079 0 0 0 10.855 4.835 9.965 9.965 0 0 0 6.504 2.608 10.079 10.079 0 0 0 9.617-6.981 9.967 9.967 0 0 0 6.663-4.834 10.079 10.079 0 0 0-1.243-11.814zm-15.217 21.362a7.477 7.477 0 0 1-4.801-1.735c.061-.033.168-.091.237-.134l7.964-4.6a1.294 1.294 0 0 0 .655-1.134V19.054l3.366 1.944a.12.12 0 0 1 .066.092v9.299a7.505 7.505 0 0 1-7.487 7.443zM6.392 31.006a7.471 7.471 0 0 1-.894-5.023c.06.036.162.099.237.141l7.964 4.6a1.297 1.297 0 0 0 1.308 0l9.724-5.614v3.888a.12.12 0 0 1-.048.103l-8.051 4.649a7.504 7.504 0 0 1-10.24-2.744zM4.297 13.62A7.469 7.469 0 0 1 8.2 10.333c0 .068-.004.19-.004.274v9.201a1.294 1.294 0 0 0 .654 1.132l9.723 5.614-3.366 1.944a.12.12 0 0 1-.114.012L7.044 23.86a7.504 7.504 0 0 1-2.747-10.24zm27.658 6.437l-9.724-5.615 3.367-1.943a.121.121 0 0 1 .114-.012l8.048 4.648a7.498 7.498 0 0 1-1.158 13.528v-9.476a1.293 1.293 0 0 0-.647-1.13zm3.35-5.043c-.059-.037-.162-.099-.236-.141l-7.965-4.6a1.298 1.298 0 0 0-1.308 0l-9.723 5.614v-3.888a.12.12 0 0 1 .048-.103l8.05-4.645a7.497 7.497 0 0 1 11.135 7.763zm-21.063 6.929l-3.367-1.944a.12.12 0 0 1-.065-.092v-9.299a7.497 7.497 0 0 1 12.293-5.756 6.94 6.94 0 0 0-.236.134l-7.965 4.6a1.294 1.294 0 0 0-.654 1.132l-.006 11.225zm1.829-3.943l4.33-2.501 4.332 2.497v4.998l-4.331 2.5-4.331-2.5V18z"
229
+ />
230
+ </svg>
231
+ </div>
232
+ <div>
233
+ <h3 class="font-bold text-slate-900">OpenAI GPT</h3>
234
+ <p class="text-xs text-slate-400 mt-0.5">AI provider</p>
235
+ </div>
236
+ </div>
237
+ <div class="flex items-center gap-2 shrink-0">
238
+ <a
239
+ v-if="!store.apiKeys.openai"
240
+ href="https://platform.openai.com/api-keys"
241
+ target="_blank"
242
+ rel="noopener noreferrer"
243
+ class="inline-flex items-center gap-1 text-xs font-semibold text-slate-400 hover:text-green-600 transition-colors"
244
+ title="Get a free OpenAI key"
245
+ >
246
+ Get key
247
+ <UIcon name="i-lucide-external-link" class="w-3 h-3" />
248
+ </a>
249
+ <!-- Validation status -->
250
+ <span
251
+ v-if="validationStatus.openai === 'valid'"
252
+ class="text-xs font-semibold px-2.5 py-1 flex items-center gap-1.5 rounded-full bg-green-100 text-green-700"
253
+ >
254
+ <UIcon name="i-lucide-check-circle" class="w-3.5 h-3.5" />
255
+ Valid
256
+ </span>
257
+ <span
258
+ v-else-if="validationStatus.openai === 'invalid'"
259
+ class="text-xs font-semibold px-2.5 py-1 flex items-center gap-1.5 rounded-full bg-red-100 text-red-700"
260
+ :title="validationErrors.openai"
261
+ >
262
+ <UIcon name="i-lucide-x-circle" class="w-3.5 h-3.5" />
263
+ Invalid
264
+ </span>
265
+ <span
266
+ v-else-if="validationStatus.openai === 'testing'"
267
+ class="text-xs font-semibold px-2.5 py-1 flex items-center gap-1.5 rounded-full bg-blue-100 text-blue-700"
268
+ >
269
+ <UIcon
270
+ name="i-lucide-loader-2"
271
+ class="w-3.5 h-3.5 animate-spin"
272
+ />
273
+ Testing...
274
+ </span>
275
+ <span
276
+ v-else-if="store.apiKeys.openai"
277
+ class="text-xs font-semibold px-2.5 py-1 flex items-center gap-1.5 rounded-full bg-green-100 text-green-700"
278
+ >
279
+ <span class="w-1.5 h-1.5 rounded-full bg-green-500"></span>
280
+ Connected
281
+ </span>
282
+ <span
283
+ v-else
284
+ class="text-xs font-semibold px-2.5 py-1 flex items-center gap-1.5 rounded-full bg-slate-100 text-slate-500"
285
+ >
286
+ <span class="w-1.5 h-1.5 rounded-full bg-slate-400"></span>
287
+ Not connected
288
+ </span>
289
+ </div>
290
+ </div>
291
+
292
+ <div class="flex gap-2">
293
+ <input
294
+ v-model="keys.openai"
295
+ type="password"
296
+ placeholder="Paste your key here (starts with sk-proj-…)"
297
+ class="flex-1 premium-input rounded-xl px-4 py-2.5 text-sm font-mono tracking-wider outline-none text-slate-700 placeholder:font-sans placeholder:tracking-normal placeholder:text-slate-400 select-none"
298
+ @copy.prevent
299
+ @cut.prevent
300
+ @contextmenu.prevent
301
+ @paste="() => scheduleValidation('openai', 500)"
302
+ @input="scheduleValidation('openai', 1000)"
303
+ />
304
+ <button
305
+ @click="saveAndValidateKeys('openai')"
306
+ class="btn-premium text-white text-xs font-semibold px-4 py-2 rounded-xl shrink-0"
307
+ :disabled="validationStatus.openai === 'testing'"
308
+ >
309
+ {{
310
+ validationStatus.openai === "testing"
311
+ ? "Testing..."
312
+ : "Save & Test"
313
+ }}
314
+ </button>
315
+ </div>
316
+ <p
317
+ v-if="
318
+ validationStatus.openai === 'invalid' && validationErrors.openai
319
+ "
320
+ class="text-xs text-red-600 mt-1 flex items-center gap-1"
321
+ >
322
+ <UIcon name="i-lucide-alert-circle" class="w-3 h-3" />
323
+ {{ validationErrors.openai }}
324
+ </p>
325
+ </div>
326
+
327
+ <!-- Anthropic -->
328
+ <div class="premium-glass rounded-3xl p-4 flex flex-col gap-2">
329
+ <div class="flex items-center justify-between gap-3">
330
+ <div class="flex items-center gap-3">
331
+ <!-- Anthropic logo: minimal "A" wedge -->
332
+ <div
333
+ class="w-10 h-10 rounded-xl bg-white border border-slate-200 flex items-center justify-center shrink-0"
334
+ >
335
+ <svg
336
+ viewBox="0 0 24 24"
337
+ class="w-6 h-6 fill-[#D97757]"
338
+ xmlns="http://www.w3.org/2000/svg"
339
+ >
340
+ <path
341
+ d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"
342
+ />
343
+ </svg>
344
+ </div>
345
+ <div>
346
+ <h3 class="font-bold text-slate-900">Anthropic Claude</h3>
347
+ <p class="text-xs text-slate-400 mt-0.5">AI provider</p>
348
+ </div>
349
+ </div>
350
+ <div class="flex items-center gap-2 shrink-0">
351
+ <a
352
+ v-if="!store.apiKeys.anthropic"
353
+ href="https://console.anthropic.com/settings/keys"
354
+ target="_blank"
355
+ rel="noopener noreferrer"
356
+ class="inline-flex items-center gap-1 text-xs font-semibold text-slate-400 hover:text-green-600 transition-colors"
357
+ title="Get a free Anthropic key"
358
+ >
359
+ Get key
360
+ <UIcon name="i-lucide-external-link" class="w-3 h-3" />
361
+ </a>
362
+ <!-- Validation status -->
363
+ <span
364
+ v-if="validationStatus.anthropic === 'valid'"
365
+ class="text-xs font-semibold px-2.5 py-1 flex items-center gap-1.5 rounded-full bg-green-100 text-green-700"
366
+ >
367
+ <UIcon name="i-lucide-check-circle" class="w-3.5 h-3.5" />
368
+ Valid
369
+ </span>
370
+ <span
371
+ v-else-if="validationStatus.anthropic === 'invalid'"
372
+ class="text-xs font-semibold px-2.5 py-1 flex items-center gap-1.5 rounded-full bg-red-100 text-red-700"
373
+ :title="validationErrors.anthropic"
374
+ >
375
+ <UIcon name="i-lucide-x-circle" class="w-3.5 h-3.5" />
376
+ Invalid
377
+ </span>
378
+ <span
379
+ v-else-if="validationStatus.anthropic === 'testing'"
380
+ class="text-xs font-semibold px-2.5 py-1 flex items-center gap-1.5 rounded-full bg-blue-100 text-blue-700"
381
+ >
382
+ <UIcon
383
+ name="i-lucide-loader-2"
384
+ class="w-3.5 h-3.5 animate-spin"
385
+ />
386
+ Testing...
387
+ </span>
388
+ <span
389
+ v-else-if="store.apiKeys.anthropic"
390
+ class="text-xs font-semibold px-2.5 py-1 flex items-center gap-1.5 rounded-full bg-green-100 text-green-700"
391
+ >
392
+ <span class="w-1.5 h-1.5 rounded-full bg-green-500"></span>
393
+ Connected
394
+ </span>
395
+ <span
396
+ v-else
397
+ class="text-xs font-semibold px-2.5 py-1 flex items-center gap-1.5 rounded-full bg-slate-100 text-slate-500"
398
+ >
399
+ <span class="w-1.5 h-1.5 rounded-full bg-slate-400"></span>
400
+ Not connected
401
+ </span>
402
+ </div>
403
+ </div>
404
+
405
+ <div class="flex gap-2">
406
+ <input
407
+ v-model="keys.anthropic"
408
+ type="password"
409
+ placeholder="Paste your key here (starts with sk-ant-…)"
410
+ class="flex-1 premium-input rounded-xl px-4 py-2.5 text-sm font-mono tracking-wider outline-none text-slate-700 placeholder:font-sans placeholder:tracking-normal placeholder:text-slate-400 select-none"
411
+ @copy.prevent
412
+ @cut.prevent
413
+ @contextmenu.prevent
414
+ @paste="() => scheduleValidation('anthropic', 500)"
415
+ @input="scheduleValidation('anthropic', 1000)"
416
+ />
417
+ <button
418
+ @click="saveAndValidateKeys('anthropic')"
419
+ class="btn-premium text-white text-xs font-semibold px-4 py-2 rounded-xl shrink-0"
420
+ :disabled="validationStatus.anthropic === 'testing'"
421
+ >
422
+ {{
423
+ validationStatus.anthropic === "testing"
424
+ ? "Testing..."
425
+ : "Save & Test"
426
+ }}
427
+ </button>
428
+ </div>
429
+ <p
430
+ v-if="
431
+ validationStatus.anthropic === 'invalid' &&
432
+ validationErrors.anthropic
433
+ "
434
+ class="text-xs text-red-600 mt-1 flex items-center gap-1"
435
+ >
436
+ <UIcon name="i-lucide-alert-circle" class="w-3 h-3" />
437
+ {{ validationErrors.anthropic }}
438
+ </p>
439
+ </div>
440
+ </div>
441
+ </div>
442
+ </div>
443
+ </template>
444
+
445
+ <script setup>
446
+ import { ref, onMounted } from "vue";
447
+ import { useProjectStore } from "~/stores/projects";
448
+ import api from "~/utils/api";
449
+
450
+ const store = useProjectStore();
451
+ const toast = useToast();
452
+
453
+ const keys = ref({ openai: "", anthropic: "", gemini: "" });
454
+
455
+ // Validation state
456
+ const validationStatus = ref({
457
+ gemini: "",
458
+ openai: "",
459
+ anthropic: "",
460
+ });
461
+ const validationErrors = ref({
462
+ gemini: "",
463
+ openai: "",
464
+ anthropic: "",
465
+ });
466
+
467
+ // Debounce timers to avoid spamming the API
468
+ const validationTimers = {};
469
+
470
+ function scheduleValidation(provider, delayMs) {
471
+ // Clear existing timer for this provider
472
+ if (validationTimers[provider]) {
473
+ clearTimeout(validationTimers[provider]);
474
+ }
475
+
476
+ // Schedule new validation after delay
477
+ validationTimers[provider] = setTimeout(async () => {
478
+ const key = keys.value[provider];
479
+ if (!key || key.trim().length < 10) {
480
+ // Key too short to be valid, clear status
481
+ validationStatus.value[provider] = "";
482
+ validationErrors.value[provider] = "";
483
+ return;
484
+ }
485
+
486
+ // Test the key
487
+ validationStatus.value[provider] = "testing";
488
+ validationErrors.value[provider] = "";
489
+
490
+ try {
491
+ const result = await api.validateApiKeys({
492
+ [`${provider}_api_key`]: key,
493
+ });
494
+
495
+ if (result.results[provider]?.valid) {
496
+ validationStatus.value[provider] = "valid";
497
+ validationErrors.value[provider] = "";
498
+ } else {
499
+ validationStatus.value[provider] = "invalid";
500
+ validationErrors.value[provider] =
501
+ result.results[provider]?.error || "Invalid API key";
502
+ }
503
+ } catch (error) {
504
+ validationStatus.value[provider] = "invalid";
505
+ validationErrors.value[provider] =
506
+ "Failed to validate key. Check your network and try again.";
507
+ }
508
+ }, delayMs);
509
+ }
510
+
511
+ async function saveAndValidateKeys(targetProvider = null) {
512
+ // Save keys to store first
513
+ store.setApiKeys(keys.value);
514
+
515
+ // Only validate the specific provider that was clicked, or all if none specified
516
+ const providersToValidate = targetProvider
517
+ ? [targetProvider]
518
+ : ["gemini", "openai", "anthropic"];
519
+ let anyInvalid = false;
520
+
521
+ for (const provider of providersToValidate) {
522
+ const key = keys.value[provider];
523
+ if (key && key.trim().length >= 10) {
524
+ validationStatus.value[provider] = "testing";
525
+ validationErrors.value[provider] = "";
526
+
527
+ try {
528
+ const result = await api.validateApiKeys({
529
+ [`${provider}_api_key`]: key,
530
+ });
531
+
532
+ if (result.results[provider]?.valid) {
533
+ validationStatus.value[provider] = "valid";
534
+ } else {
535
+ validationStatus.value[provider] = "invalid";
536
+ validationErrors.value[provider] =
537
+ result.results[provider]?.error || "Invalid key";
538
+ anyInvalid = true;
539
+ }
540
+ } catch (error) {
541
+ validationStatus.value[provider] = "invalid";
542
+ validationErrors.value[provider] = "Failed to validate key";
543
+ anyInvalid = true;
544
+ }
545
+ }
546
+ }
547
+
548
+ // Show toast based on validation results
549
+ if (targetProvider) {
550
+ // Single provider validation
551
+ const status = validationStatus.value[targetProvider];
552
+ if (status === "valid") {
553
+ toast.add({
554
+ title: "Key Validated Successfully",
555
+ description: `${targetProvider.charAt(0).toUpperCase() + targetProvider.slice(1)} API key is valid and ready to use.`,
556
+ icon: "i-lucide-check-circle",
557
+ color: "green",
558
+ });
559
+ } else if (status === "invalid") {
560
+ toast.add({
561
+ title: "Key Validation Failed",
562
+ description:
563
+ validationErrors.value[targetProvider] || "Invalid API key",
564
+ icon: "i-lucide-alert-circle",
565
+ color: "red",
566
+ });
567
+ }
568
+ } else if (anyInvalid) {
569
+ toast.add({
570
+ title: "Some Keys Invalid",
571
+ description:
572
+ "One or more API keys failed validation. Please check the errors below.",
573
+ icon: "i-lucide-alert-circle",
574
+ color: "red",
575
+ });
576
+ } else {
577
+ toast.add({
578
+ title: "Keys Validated Successfully",
579
+ description: "All provided API keys are valid and ready to use.",
580
+ icon: "i-lucide-check-circle",
581
+ color: "green",
582
+ });
583
+ }
584
+ }
585
+
586
+ onMounted(() => {
587
+ if (import.meta.client) {
588
+ store.initKeys();
589
+ keys.value = { ...store.apiKeys };
590
+
591
+ // DO NOT auto-validate existing keys on mount
592
+ // Validation should only happen when user explicitly clicks "Save & Test" or types/pastes a key
593
+ }
594
+ });
595
+ </script>
frontend/app/pages/index.vue ADDED
@@ -0,0 +1,612 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="relative w-full h-full overflow-hidden flex">
3
+ <!-- LEFT PANEL: Command Center (Form) -->
4
+ <div
5
+ class="w-full h-full overflow-y-auto custom-scrollbar transition-all duration-700 ease-in-out flex flex-col items-center p-4 sm:p-6"
6
+ >
7
+ <div
8
+ class="w-full max-w-2xl my-auto transition-all duration-700 ease-in-out"
9
+ >
10
+ <!-- Hero Text -->
11
+ <div class="text-center mb-4 relative z-10">
12
+ <h1
13
+ class="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-slate-900 tracking-tight leading-tight mb-2"
14
+ >
15
+ Explain concepts with <br />
16
+ <span
17
+ class="text-transparent bg-clip-text bg-gradient-to-br from-green-400 to-green-600"
18
+ >AlgoVision</span
19
+ >
20
+ </h1>
21
+ <p
22
+ class="text-slate-500 text-lg sm:text-xl font-medium max-w-xl mx-auto"
23
+ >
24
+ Transform any complex algorithms or mathematical concepts into a
25
+ compelling, animated video lesson.
26
+ </p>
27
+ </div>
28
+
29
+ <!-- GENERATOR MODULE -->
30
+ <div
31
+ class="premium-glass rounded-3xl p-5 sm:p-6 relative z-10 shadow-xl shadow-slate-200/50"
32
+ >
33
+ <div class="space-y-4 relative z-10">
34
+ <!-- Lesson Concept Block -->
35
+ <div id="lesson-concept-block" class="space-y-4">
36
+ <!-- Textarea -->
37
+ <div id="concept-input-group" class="group">
38
+ <label
39
+ class="block text-xs font-semibold text-slate-500 uppercase tracking-widest mb-1.5 ml-1"
40
+ >Concept Description</label
41
+ >
42
+ <div class="relative group/input">
43
+ <textarea
44
+ v-model="description"
45
+ id="concept-input"
46
+ placeholder="Describe the topic you want to explain (e.g., 'How Merge Sort works' or 'The concept of Quantum Entanglement')..."
47
+ class="w-full premium-input rounded-2xl px-5 py-4 min-h-[220px] text-md text-slate-700 placeholder:text-slate-400 resize-none transition-shadow"
48
+ rows="8"
49
+ @focus="isFocused = true"
50
+ @blur="isFocused = false"
51
+ ></textarea>
52
+
53
+ <UTooltip
54
+ text="Let AI help you research and draft a structured lesson plan for your topic."
55
+ :popper="{ placement: 'top' }"
56
+ >
57
+ <button
58
+ id="ai-draft-btn"
59
+ @click="showAiDialog = true"
60
+ class="absolute bottom-4 right-4 bg-white/70 hover:bg-white text-green-600 border border-green-200 shadow-md rounded-xl px-3.5 py-2 text-sm font-bold flex items-center gap-1.5 transition-all disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer group/draft animate-pulse-subtle hover:animate-none"
61
+ :disabled="
62
+ !description.trim() ||
63
+ store.isDrafting ||
64
+ store.loadingModels
65
+ "
66
+ >
67
+ <UIcon
68
+ :name="
69
+ store.isDrafting
70
+ ? 'i-lucide-loader-2'
71
+ : 'i-lucide-wand-2'
72
+ "
73
+ class="w-4 h-4 text-green-500 group-hover/draft:scale-110 transition-transform"
74
+ :class="store.isDrafting ? 'animate-spin' : ''"
75
+ />
76
+ {{ store.isDrafting ? "Drafting..." : "AI Draft" }}
77
+ </button>
78
+ </UTooltip>
79
+ </div>
80
+
81
+ <!-- Professional Hint -->
82
+ <div
83
+ class="mt-2 ml-1 flex items-center gap-2 transition-opacity duration-300"
84
+ :class="
85
+ description.length > 0
86
+ ? 'opacity-40 hover:opacity-100'
87
+ : 'opacity-100'
88
+ "
89
+ >
90
+ <UIcon
91
+ name="i-lucide-lightbulb"
92
+ class="w-4 h-4 text-green-500"
93
+ />
94
+ <span class="text-xs text-slate-500 font-medium italic">
95
+ Tip: A good description includes an objective, the target
96
+ audience, and 3-5 key points to cover.
97
+ </span>
98
+ </div>
99
+ </div>
100
+ </div>
101
+
102
+ <!-- Settings Row -->
103
+ <div
104
+ id="lesson-settings-block"
105
+ class="grid grid-cols-1 sm:grid-cols-3 gap-6"
106
+ >
107
+ <!-- Model -->
108
+ <div id="model-selector-group">
109
+ <label
110
+ class="flex items-center text-xs font-semibold text-slate-500 uppercase tracking-widest mb-1.5 ml-1"
111
+ >
112
+ AI Model
113
+ <UTooltip
114
+ text="Choice of AI model affects explanation depth and visual logic."
115
+ :popper="{ placement: 'top' }"
116
+ >
117
+ <UIcon
118
+ name="i-lucide-info"
119
+ class="w-3.5 h-3.5 ml-1 text-slate-400 cursor-help transition-colors hover:text-green-500"
120
+ />
121
+ </UTooltip>
122
+ </label>
123
+ <USelectMenu
124
+ v-model="model"
125
+ id="ai-model-selector"
126
+ :items="store.filteredModels"
127
+ label-key="title"
128
+ value-key="value"
129
+ class="premium-input rounded-xl w-full"
130
+ :popper="{ placement: 'bottom-start' }"
131
+ variant="none"
132
+ :ui="{
133
+ item: 'px-3 py-2 rounded-xl text-slate-700 data-highlighted:bg-green-50 data-highlighted:text-green-700 transition-colors',
134
+ }"
135
+ >
136
+ <template #leading>
137
+ <UIcon name="i-lucide-cpu" class="w-4 h-4 text-slate-400" />
138
+ </template>
139
+ </USelectMenu>
140
+ </div>
141
+
142
+ <!-- Voice & Accent -->
143
+ <div id="voice-selector-group">
144
+ <label
145
+ class="flex items-center text-xs font-semibold text-slate-500 uppercase tracking-widest mb-1.5 ml-1"
146
+ >
147
+ Voice &amp; Accent
148
+ <UTooltip
149
+ text="Select a narrator voice. You can preview each voice to hear it before generating."
150
+ :popper="{ placement: 'top' }"
151
+ >
152
+ <UIcon
153
+ name="i-lucide-info"
154
+ class="w-3.5 h-3.5 ml-1 text-slate-400 cursor-help transition-colors hover:text-green-500"
155
+ />
156
+ </UTooltip>
157
+ </label>
158
+ <USelectMenu
159
+ id="voice-selector"
160
+ v-model="voice"
161
+ :items="voices"
162
+ label-key="name"
163
+ value-key="id"
164
+ class="premium-input rounded-xl w-full"
165
+ placeholder="Select voice"
166
+ variant="none"
167
+ :ui="{
168
+ item: 'px-3 py-2.5 rounded-xl text-slate-700 data-highlighted:bg-green-50 data-highlighted:text-green-700 transition-all duration-200',
169
+ }"
170
+ >
171
+ <template #leading>
172
+ <UIcon name="i-lucide-mic" class="w-4 h-4 text-slate-400" />
173
+ </template>
174
+ <template #item="{ item }">
175
+ <div class="flex flex-col w-full gap-2.5 py-1">
176
+ <!-- Name, Gender & Grade -->
177
+ <div class="flex items-center justify-between gap-2">
178
+ <div class="flex items-center gap-1.5 min-w-0">
179
+ <UIcon
180
+ v-if="item.gender"
181
+ :name="
182
+ item.gender.toLowerCase() === 'female'
183
+ ? 'i-lucide-venus'
184
+ : 'i-lucide-mars'
185
+ "
186
+ class="w-3.5 h-3.5 shrink-0"
187
+ :class="
188
+ item.gender.toLowerCase() === 'female'
189
+ ? 'text-pink-400'
190
+ : 'text-blue-400'
191
+ "
192
+ />
193
+ <span
194
+ class="truncate font-bold text-slate-900 leading-tight text-sm"
195
+ >{{ item.name }}</span
196
+ >
197
+ </div>
198
+ <button
199
+ type="button"
200
+ @click.stop="togglePreview(item.id)"
201
+ :disabled="previewLoading === item.id"
202
+ class="shrink-0 flex items-center justify-center p-1.5 rounded-lg transition-all cursor-pointer border shadow-sm group/prev"
203
+ :class="[
204
+ previewPlaying === item.id
205
+ ? 'bg-red-50 text-red-600 border-red-200'
206
+ : 'bg-slate-50 text-slate-500 border-slate-200 hover:bg-green-50 hover:text-green-600 hover:border-green-200',
207
+ ]"
208
+ :title="
209
+ previewPlaying === item.id
210
+ ? 'Stop Preview'
211
+ : 'Play Preview'
212
+ "
213
+ >
214
+ <UIcon
215
+ :name="
216
+ previewLoading === item.id
217
+ ? 'i-lucide-loader-2'
218
+ : previewPlaying === item.id
219
+ ? 'i-lucide-circle-stop'
220
+ : 'i-lucide-play-circle'
221
+ "
222
+ class="w-4 h-4"
223
+ :class="{
224
+ 'animate-spin': previewLoading === item.id,
225
+ 'group-hover/prev:scale-110 transition-transform':
226
+ !previewLoading,
227
+ }"
228
+ />
229
+ </button>
230
+ </div>
231
+
232
+ <!-- Metadata: Language -->
233
+ <div
234
+ class="flex flex-col gap-1 text-[10px] text-slate-400 font-bold uppercase tracking-wider"
235
+ >
236
+ <div v-if="item.language" class="truncate opacity-80">
237
+ {{ item.language }}
238
+ </div>
239
+ </div>
240
+ </div>
241
+ </template>
242
+ </USelectMenu>
243
+ </div>
244
+
245
+ <!-- Target Duration -->
246
+ <div id="duration-selector-group">
247
+ <label
248
+ class="flex items-center text-xs font-semibold text-slate-500 uppercase tracking-widest mb-1.5 ml-1"
249
+ >
250
+ Target Duration
251
+ <UTooltip
252
+ text="Sets the target length of the video lesson. Longer durations allow for deeper step-by-step explanations."
253
+ :popper="{ placement: 'top' }"
254
+ >
255
+ <UIcon
256
+ name="i-lucide-info"
257
+ class="w-3.5 h-3.5 ml-1 text-slate-400 cursor-help transition-colors hover:text-green-500"
258
+ />
259
+ </UTooltip>
260
+ </label>
261
+ <USelectMenu
262
+ id="duration-selector"
263
+ v-model="store.targetDuration"
264
+ :items="[
265
+ '1-2 minutes',
266
+ '2-3 minutes',
267
+ '3-5 minutes',
268
+ '5-10 minutes',
269
+ '10-15 minutes',
270
+ ]"
271
+ :searchInput="false"
272
+ class="premium-input rounded-xl w-full"
273
+ placeholder="Select Duration"
274
+ variant="none"
275
+ :ui="{
276
+ item: 'px-3 py-2 rounded-xl text-slate-700 data-highlighted:bg-green-50 data-highlighted:text-green-700 transition-colors',
277
+ }"
278
+ >
279
+ <template #leading>
280
+ <UIcon
281
+ name="i-lucide-timer"
282
+ class="w-4 h-4 text-slate-400"
283
+ />
284
+ </template>
285
+ </USelectMenu>
286
+ </div>
287
+ </div>
288
+
289
+ <!-- Visual Feedback Loop Toggle -->
290
+ <!-- <div
291
+ id="visual-fix-group"
292
+ class="flex items-center justify-between p-3.5 bg-slate-50/50 rounded-2xl border border-slate-100 mt-2 hover:bg-slate-50 transition-colors group/fix"
293
+ >
294
+ <div class="flex items-center gap-3">
295
+ <div
296
+ class="w-10 h-10 rounded-xl bg-white shadow-sm flex items-center justify-center border border-slate-100 group-hover/fix:border-green-100 transition-colors"
297
+ >
298
+ <UIcon name="i-lucide-eye" class="w-5 h-5 text-green-500" />
299
+ </div>
300
+ <div>
301
+ <div class="flex items-center gap-1.5">
302
+ <span class="text-sm font-bold text-slate-700"
303
+ >Auto Visual Fix</span
304
+ >
305
+ <UTooltip
306
+ text="Enables iterative code fixing based on visual feedback. Improves scene quality by detecting overlaps and positioning errors."
307
+ :popper="{ placement: 'top' }"
308
+ >
309
+ <UIcon
310
+ name="i-lucide-info"
311
+ class="w-3.5 h-3.5 text-slate-400 cursor-help hover:text-green-500 transition-colors"
312
+ />
313
+ </UTooltip>
314
+ </div>
315
+ <span class="text-xs text-slate-400 font-medium"
316
+ >Iteratively refines scene visuals through
317
+ self-reflection</span
318
+ >
319
+ </div>
320
+ </div>
321
+ <USwitch
322
+ v-model="store.useVisualFixCode"
323
+ color="primary"
324
+ @update:model-value="store._persistConfig()"
325
+ class="scale-110"
326
+ />
327
+ </div> -->
328
+
329
+ <!-- Action Button -->
330
+ <button
331
+ id="generate-btn"
332
+ @click="handleGenerate"
333
+ :disabled="
334
+ !description.trim() ||
335
+ store.isGenerating ||
336
+ store.loadingModels ||
337
+ store.loadingVoices
338
+ "
339
+ class="w-full btn-premium rounded-2xl py-4 flex items-center justify-center gap-2 text-white font-bold text-lg disabled:opacity-50 disabled:cursor-not-allowed group mt-6 cursor-pointer"
340
+ >
341
+ <UIcon
342
+ :name="
343
+ store.isGenerating ? 'i-lucide-loader-2' : 'i-lucide-sparkles'
344
+ "
345
+ class="w-5 h-5"
346
+ :class="{ 'animate-spin-slow': store.isGenerating }"
347
+ />
348
+ {{ store.isGenerating ? "Processing..." : "Generate Video" }}
349
+ </button>
350
+
351
+ <!-- Disclaimer -->
352
+ <p
353
+ class="text-center text-[10px] text-slate-400/80 mt-4 font-medium leading-relaxed"
354
+ >
355
+ AlgoVision uses AI to generate content and can make mistakes.
356
+ <br />
357
+ Please verify important information.
358
+ </p>
359
+ </div>
360
+ </div>
361
+
362
+ <!-- Missing API Keys Warning -->
363
+ <ClientOnly>
364
+ <div
365
+ v-if="!hasApiKeys"
366
+ class="mt-4 p-4 rounded-2xl bg-orange-50/50 border border-orange-200 flex items-start gap-3 backdrop-blur-sm animate-fade-in-up"
367
+ >
368
+ <UIcon
369
+ name="i-lucide-alert-circle"
370
+ class="w-5 h-5 text-orange-500 shrink-0 mt-0.5"
371
+ />
372
+ <div class="text-sm text-orange-800">
373
+ <span class="font-semibold block mb-0.5"
374
+ >One quick step before you generate</span
375
+ >
376
+ AlgoVision needs an AI provider to create your video. It only
377
+ takes a minute to connect one —
378
+ <NuxtLink
379
+ to="/ai-providers"
380
+ class="underline font-semibold hover:text-orange-600"
381
+ >set it up here</NuxtLink
382
+ >.
383
+ </div>
384
+ </div>
385
+ </ClientOnly>
386
+ </div>
387
+ </div>
388
+
389
+ <AiAssistDialog v-model="showAiDialog" :initialTopic="projectName" />
390
+ </div>
391
+ </template>
392
+
393
+ <script setup lang="ts">
394
+ import { ref, computed, watch, nextTick, onMounted, onUnmounted } from "vue";
395
+ import { useProjectStore } from "~/stores/projects";
396
+ import { useRouter } from "vue-router";
397
+ import AiAssistDialog from "~/components/AiAssistDialog.vue";
398
+ import GenerationWidget from "~/components/GenerationWidget.vue";
399
+
400
+ const store = useProjectStore();
401
+ const router = useRouter();
402
+
403
+ definePageMeta({
404
+ layout: "workspace",
405
+ });
406
+
407
+ const showAiDialog = ref(false);
408
+
409
+ // Form State
410
+ const projectName = computed({
411
+ get: () => store.draftProjectName,
412
+ set: (val) => (store.draftProjectName = val),
413
+ });
414
+
415
+ const description = computed({
416
+ get: () => store.draftTopic,
417
+ set: (val) => (store.draftTopic = val),
418
+ });
419
+
420
+ const isFocused = ref(false);
421
+
422
+ const voice = computed({
423
+ get: () => store.selectedVoice,
424
+ set: (val) => (store.selectedVoice = val),
425
+ });
426
+
427
+ const model = computed({
428
+ get: () => store.planningModel,
429
+ set: (val) => {
430
+ store.planningModel = val;
431
+ store.codingModel = val;
432
+ },
433
+ });
434
+
435
+ const voices = computed(() => {
436
+ const list = [...store.availableVoices];
437
+ const hasHeart = list.some((v) => v.id === "af_heart");
438
+ // Inject a placeholder "Heart" item if af_heart is selected but not yet in the list (e.g. before loading)
439
+ if (!hasHeart && store.selectedVoice === "af_heart") {
440
+ list.unshift({
441
+ id: "af_heart",
442
+ name: "Heart",
443
+ gender: "female",
444
+ language: "English (US)",
445
+ });
446
+ }
447
+ return list;
448
+ });
449
+
450
+ const hasApiKeys = computed(() => {
451
+ return !!(
452
+ store.apiKeys.openai ||
453
+ store.apiKeys.anthropic ||
454
+ store.apiKeys.gemini
455
+ );
456
+ });
457
+
458
+ async function handleGenerate() {
459
+ if (!description.value.trim()) return;
460
+
461
+ if (!hasApiKeys.value) {
462
+ router.push("/ai-providers");
463
+ return;
464
+ }
465
+
466
+ // Update store config
467
+ store.planningModel = model.value;
468
+ store.codingModel = model.value;
469
+ store.selectedVoice = voice.value;
470
+
471
+ const result = await store.generatePlanOnly(
472
+ projectName.value,
473
+ description.value,
474
+ );
475
+ if (result?.project_id) {
476
+ router.push(`/project/${result.project_id}`);
477
+ }
478
+ }
479
+
480
+ import api from "~/utils/api";
481
+
482
+ // ── Voice preview ──────────────────────────────────────────────────────────
483
+ const previewLoading = ref<string | null>(null);
484
+ const previewPlaying = ref<string | null>(null);
485
+ let previewAudio: HTMLAudioElement | null = null;
486
+ let previewBlobUrl: string | null = null;
487
+
488
+ async function togglePreview(voiceId: string) {
489
+ // Stop any current audio first
490
+ if (previewAudio) {
491
+ previewAudio.pause();
492
+ previewAudio = null;
493
+ }
494
+ if (previewBlobUrl) {
495
+ URL.revokeObjectURL(previewBlobUrl);
496
+ previewBlobUrl = null;
497
+ }
498
+
499
+ // Toggle off if same voice was playing
500
+ if (previewPlaying.value === voiceId) {
501
+ previewPlaying.value = null;
502
+ return;
503
+ }
504
+
505
+ previewLoading.value = voiceId;
506
+ previewPlaying.value = null;
507
+
508
+ try {
509
+ const url = await api.previewVoice(voiceId);
510
+ previewBlobUrl = url;
511
+ const el = new Audio(url);
512
+ previewAudio = el;
513
+ previewPlaying.value = voiceId;
514
+
515
+ el.addEventListener("ended", () => {
516
+ previewPlaying.value = null;
517
+ if (previewBlobUrl) {
518
+ URL.revokeObjectURL(previewBlobUrl);
519
+ previewBlobUrl = null;
520
+ }
521
+ });
522
+
523
+ el.play();
524
+ } catch (e) {
525
+ console.error("Voice preview failed:", e);
526
+ previewPlaying.value = null;
527
+ } finally {
528
+ previewLoading.value = null;
529
+ }
530
+ }
531
+
532
+ onUnmounted(() => {
533
+ previewAudio?.pause();
534
+ if (previewBlobUrl) URL.revokeObjectURL(previewBlobUrl);
535
+ });
536
+ </script>
537
+
538
+ <style scoped>
539
+ .custom-scrollbar::-webkit-scrollbar {
540
+ width: 6px;
541
+ }
542
+ .custom-scrollbar::-webkit-scrollbar-track {
543
+ background: rgba(0, 0, 0, 0.02);
544
+ }
545
+ .custom-scrollbar::-webkit-scrollbar-thumb {
546
+ background: rgba(0, 0, 0, 0.1);
547
+ border-radius: 3px;
548
+ }
549
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover {
550
+ background: rgba(0, 0, 0, 0.2);
551
+ }
552
+
553
+ .animate-scan {
554
+ animation: scan 2s linear infinite;
555
+ }
556
+
557
+ @keyframes pulse-slow {
558
+ 0%,
559
+ 100% {
560
+ opacity: 0.1;
561
+ transform: scale(1);
562
+ }
563
+ 50% {
564
+ opacity: 0.4;
565
+ transform: scale(1.5);
566
+ }
567
+ }
568
+
569
+ .animate-pulse-slow {
570
+ animation: pulse-slow 8s ease-in-out infinite;
571
+ }
572
+
573
+ /* Stage Transitions */
574
+ .stage-fade-enter-active,
575
+ .stage-fade-leave-active {
576
+ transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
577
+ }
578
+ .stage-fade-enter-from,
579
+ .stage-fade-leave-to {
580
+ opacity: 0;
581
+ transform: scale(0.95);
582
+ }
583
+
584
+ .stage-slide-enter-active,
585
+ .stage-slide-leave-active {
586
+ transition: all 0.8s cubic-bezier(0.2, 1, 0.2, 1);
587
+ }
588
+ .stage-slide-enter-from {
589
+ opacity: 0;
590
+ transform: translateY(30px);
591
+ }
592
+ .stage-slide-leave-to {
593
+ opacity: 0;
594
+ transform: translateY(-30px);
595
+ }
596
+
597
+ @keyframes pulse-subtle {
598
+ 0%,
599
+ 100% {
600
+ transform: scale(1);
601
+ box-shadow: 0 4px 12px rgba(22, 163, 74, 0.1);
602
+ }
603
+ 50% {
604
+ transform: scale(1.02);
605
+ box-shadow: 0 4px 20px rgba(22, 163, 74, 0.2);
606
+ }
607
+ }
608
+
609
+ .animate-pulse-subtle {
610
+ animation: pulse-subtle 3s ease-in-out infinite;
611
+ }
612
+ </style>
frontend/app/pages/project/[id].vue ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div
3
+ class="w-full flex-1 flex flex-col mt-4 px-4 sm:px-6 lg:px-8 gap-6 -mb-8"
4
+ >
5
+ <!-- TOP ROW: Video & AI Tutor -->
6
+ <!-- CSS Grid: left column determines row height; right cell (overflow-hidden) stretches to match -->
7
+ <div
8
+ class="flex flex-col gap-6"
9
+ :class="{
10
+ 'md:grid md:grid-cols-[1fr_300px] lg:grid-cols-[1fr_380px] xl:grid-cols-[1fr_460px]':
11
+ project?.status !== 'awaiting_approval'
12
+ }"
13
+ >
14
+ <!-- LEFT: Theater & Overview -->
15
+ <div class="flex flex-col gap-6 w-full min-w-0 min-h-0">
16
+ <!-- Back button & Title -->
17
+ <div
18
+ class="flex items-center gap-4 sticky top-4 premium-glass py-4 px-6 z-20 animate-fade-in-down rounded-3xl shadow-lg border border-white/20 mb-8"
19
+ >
20
+ <UButton
21
+ to="/projects"
22
+ variant="ghost"
23
+ color="neutral"
24
+ icon="i-lucide-arrow-left"
25
+ class="rounded-2xl w-11 h-11 flex items-center justify-center bg-white/80 shadow-md hover:shadow-lg transition-all border border-white/40"
26
+ />
27
+ <h1
28
+ class="text-xl sm:text-2xl md:text-3xl font-black text-slate-900 leading-tight tracking-tight"
29
+ >
30
+ {{ project?.name || "Loading Project..." }}
31
+ </h1>
32
+ </div>
33
+
34
+ <div
35
+ v-if="loading"
36
+ class="w-full aspect-video bg-slate-200/50 rounded-3xl animate-pulse"
37
+ />
38
+
39
+ <!-- Main Video Theater -->
40
+ <div
41
+ v-else-if="project && project.video_url"
42
+ id="video-theater"
43
+ class="premium-glass rounded-3xl overflow-hidden shadow-2xl relative z-10"
44
+ >
45
+ <ClientOnly>
46
+ <PlyrVideoPlayer
47
+ :key="playerMediaKey"
48
+ :video-url="project.video_url || undefined"
49
+ :subtitles-url="subtitlesUrl || undefined"
50
+ :scenes="project.scenes"
51
+ />
52
+ <template #fallback>
53
+ <div
54
+ class="w-full aspect-video bg-slate-900 flex items-center justify-center text-white"
55
+ >
56
+ <UIcon
57
+ name="i-lucide-loader-2"
58
+ class="w-8 h-8 text-green-500 animate-spin"
59
+ />
60
+ </div>
61
+ </template>
62
+ </ClientOnly>
63
+ </div>
64
+
65
+ <!-- Topic Overview -->
66
+ <div
67
+ v-if="project && project.overview && project.status === 'completed'"
68
+ class="premium-glass rounded-3xl p-5 sm:p-8 md:p-10 animate-fade-in-up relative overflow-hidden group"
69
+ >
70
+ <div
71
+ class="absolute top-0 left-0 w-1.5 h-full bg-green-500/20 group-hover:bg-green-500/40 transition-colors"
72
+ />
73
+ <div class="flex flex-wrap items-center gap-3 mb-5">
74
+ <div
75
+ class="w-10 h-10 rounded-2xl bg-green-50/50 flex items-center justify-center border border-green-100/50 shadow-sm shrink-0"
76
+ >
77
+ <UIcon
78
+ name="i-lucide-info"
79
+ class="w-5 h-5 text-green-600"
80
+ />
81
+ </div>
82
+ <div>
83
+ <span
84
+ class="text-[10px] font-black uppercase tracking-[0.2em] text-green-500/70 block mb-0.5"
85
+ >Project Intelligence</span>
86
+ <h3 class="text-lg font-black text-slate-900">
87
+ Topic Overview
88
+ </h3>
89
+ </div>
90
+ </div>
91
+ <p
92
+ class="text-[15px] text-slate-700 leading-relaxed whitespace-pre-wrap font-medium"
93
+ >
94
+ {{ project.overview }}
95
+ </p>
96
+ </div>
97
+
98
+ <!-- Plan Approval State -->
99
+ <div
100
+ v-if="project && project.status === 'awaiting_approval'"
101
+ class="w-full"
102
+ >
103
+ <PlanReviewPanel :project="project" />
104
+ </div>
105
+
106
+ <!-- Processing/Error States -->
107
+ <div
108
+ v-else-if="
109
+ project
110
+ && project.status !== 'completed'
111
+ && project.status !== 'awaiting_approval'
112
+ "
113
+ class="premium-glass rounded-3xl p-12 text-center flex flex-col items-center"
114
+ >
115
+ <div
116
+ class="w-16 h-16 rounded-full flex items-center justify-center mb-6"
117
+ :class="
118
+ project.status === 'error'
119
+ ? 'bg-red-100 text-red-600'
120
+ : 'bg-green-100 text-green-600'
121
+ "
122
+ >
123
+ <UIcon
124
+ :name="
125
+ project.status === 'error'
126
+ ? 'i-lucide-alert-triangle'
127
+ : 'i-lucide-loader-2'
128
+ "
129
+ class="w-8 h-8"
130
+ :class="{ 'animate-spin': project.status !== 'error' }"
131
+ />
132
+ </div>
133
+ <h2 class="text-xl font-bold text-slate-900 mb-2">
134
+ {{
135
+ project.status === "error"
136
+ ? "Generation Failed"
137
+ : "Generation in Progress"
138
+ }}
139
+ </h2>
140
+ <p class="text-slate-500 mb-6 max-w-md mx-auto">
141
+ {{
142
+ project.status === "error"
143
+ ? project.error_details || "An unknown error occurred."
144
+ : "This project is currently rendering. You can view progress in the Projects."
145
+ }}
146
+ </p>
147
+ <div class="flex items-center gap-3">
148
+ <UButton
149
+ v-if="canContinueStatus(project.status)"
150
+ :loading="isContinuing"
151
+ class="btn-premium rounded-xl px-6 py-2"
152
+ @click="handleContinue"
153
+ >
154
+ {{
155
+ project.status === "error"
156
+ ? "Retry Generation"
157
+ : "Continue Generation"
158
+ }}
159
+ </UButton>
160
+ <UButton
161
+ to="/projects"
162
+ variant="ghost"
163
+ color="neutral"
164
+ class="rounded-xl px-6 py-2"
165
+ >
166
+ Return to Projects
167
+ </UButton>
168
+ </div>
169
+ </div>
170
+
171
+ <!-- Mobile-only: AI Tutor & Quiz (hidden during planning) -->
172
+ <div
173
+ v-if="project && project.status !== 'awaiting_approval'"
174
+ class="block md:hidden mt-6 pb-6 min-h-[600px]"
175
+ >
176
+ <InteractivePanel :project="project" />
177
+ </div>
178
+ </div>
179
+
180
+ <!-- RIGHT: AI Interactive Panel (hidden during planning) -->
181
+ <div
182
+ v-if="project && project.status !== 'awaiting_approval'"
183
+ id="ai-panel-container"
184
+ class="hidden md:block overflow-hidden h-0 min-h-full"
185
+ >
186
+ <InteractivePanel
187
+ :project="project"
188
+ class="h-full max-h-full animate-fade-in-up animation-delay-200"
189
+ />
190
+ </div>
191
+ </div>
192
+
193
+ <!-- BOTTOM ROW: Technical Breakdown (Only if not awaiting approval) -->
194
+ <div
195
+ v-if="project && project.scenes && project.status !== 'awaiting_approval' && project.status !== 'completed'"
196
+ id="scene-breakdown"
197
+ class="premium-glass rounded-3xl p-8 sm:p-10 animate-fade-in-up mb-12 mt-4"
198
+ >
199
+ <h3 class="text-lg font-bold text-slate-900 flex items-center gap-2 mb-6">
200
+ <UIcon
201
+ name="i-lucide-layout-list"
202
+ class="w-5 h-5 text-green-600"
203
+ />
204
+ Scenes Breakdown
205
+ </h3>
206
+ <div class="max-h-[68vh] overflow-y-auto pr-1 custom-scrollbar">
207
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
208
+ <div
209
+ v-for="(scene, idx) in project.scenes"
210
+ :key="scene.id || idx"
211
+ class="flex gap-4"
212
+ >
213
+ <div
214
+ class="w-8 h-8 rounded-lg bg-green-50 text-green-700 font-mono text-sm font-bold flex items-center justify-center shrink-0 border border-green-200"
215
+ >
216
+ {{ Number(idx) + 1 }}
217
+ </div>
218
+ <div class="flex-1">
219
+ <h4 class="font-bold text-slate-800 mb-1">
220
+ {{ scene.title || `Scene ${Number(idx) + 1}` }}
221
+ </h4>
222
+ <div class="space-y-4">
223
+ <!-- High-Level Content -->
224
+ <div
225
+ v-if="scene.purpose"
226
+ class="bg-blue-50/50 p-3 rounded-lg border border-blue-100"
227
+ >
228
+ <h5
229
+ class="text-[10px] font-bold uppercase tracking-wider text-blue-500 mb-1 flex items-center gap-1"
230
+ >
231
+ <UIcon
232
+ name="i-lucide-target"
233
+ class="w-3 h-3"
234
+ />
235
+ Scene Purpose
236
+ </h5>
237
+ <p class="text-sm text-slate-700 italic">
238
+ {{ scene.purpose }}
239
+ </p>
240
+ </div>
241
+
242
+ <div v-if="scene.description">
243
+ <h5
244
+ class="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-1"
245
+ >
246
+ Visual Concept
247
+ </h5>
248
+ <p class="text-sm text-slate-800 leading-relaxed">
249
+ {{ scene.description }}
250
+ </p>
251
+ </div>
252
+ </div>
253
+ </div>
254
+ </div>
255
+ </div>
256
+ </div>
257
+ </div>
258
+ </div>
259
+ </template>
260
+
261
+ <script setup lang="ts">
262
+ import { ref, computed, onMounted } from 'vue'
263
+ import { useRoute, useRouter } from 'vue-router'
264
+ import { useProjectStore } from '~/stores/projects'
265
+ import { config as apiConfig } from '~/utils/api'
266
+
267
+ const route = useRoute()
268
+ const router = useRouter()
269
+ const store = useProjectStore()
270
+ const toast = useToast()
271
+
272
+ const project = computed(() => store.getProjectById(route.params.id as string))
273
+ const loading = ref(true)
274
+ const localError = ref<string | null>(null)
275
+ const isContinuing = ref(false)
276
+
277
+ onMounted(async () => {
278
+ const id = route.params.id
279
+ if (!id) {
280
+ router.push('/projects')
281
+ return
282
+ }
283
+
284
+ try {
285
+ await store.fetchProject(id as string)
286
+ if (!project.value) {
287
+ router.push('/projects')
288
+ }
289
+ } catch (e: any) {
290
+ console.error(e)
291
+ localError.value = e.message || 'Failed to load project'
292
+ } finally {
293
+ loading.value = false
294
+ }
295
+ })
296
+
297
+ // Handle continue generation
298
+ async function handleContinue() {
299
+ if (!project.value) return
300
+ isContinuing.value = true
301
+ try {
302
+ await store.continueGeneration(project.value.id)
303
+ // Refresh project data after starting generation
304
+ await store.fetchProject(project.value.id)
305
+
306
+ toast.add({
307
+ title: 'Generation Started',
308
+ description: 'Continuing generation from where it left off',
309
+ icon: 'i-lucide-play-circle',
310
+ color: 'success'
311
+ })
312
+ } catch (error: any) {
313
+ console.error('Failed to continue generation:', error)
314
+ localError.value = 'Failed to continue generation. Please try again.'
315
+ toast.add({
316
+ title: 'Failed to Continue',
317
+ description:
318
+ error.message || 'An error occurred while continuing generation',
319
+ icon: 'i-lucide-alert-circle',
320
+ color: 'error'
321
+ })
322
+ } finally {
323
+ isContinuing.value = false
324
+ }
325
+ }
326
+
327
+ // Only allow continue for specific statuses (not while actively generating)
328
+ function canContinueStatus(status: string) {
329
+ return (
330
+ status === 'error'
331
+ || status === 'planned'
332
+ || status === 'stopped'
333
+ || status === 'cancelled'
334
+ )
335
+ }
336
+
337
+ // Derive the VTT subtitle URL from the files[] array on the project
338
+ // The API returns a files list like ["topic/topic_combined.vtt", ...]
339
+ const subtitlesUrl = computed(() => {
340
+ const videoUrl = project.value?.video_url
341
+ if (!videoUrl) return null
342
+
343
+ // Prefer explicit .vtt file from backend listing when available.
344
+ const vttFile = project.value?.files?.find((f: string) => f.endsWith('.vtt'))
345
+ if (vttFile) {
346
+ const basePath = videoUrl.substring(
347
+ 0,
348
+ videoUrl.lastIndexOf('/') + 1
349
+ )
350
+ return `${apiConfig.API_BASE_URL}${basePath}${vttFile.split('/').pop()}`
351
+ }
352
+
353
+ // Fallback: derive VTT from combined MP4 path when files[] is stale/late.
354
+ if (videoUrl.endsWith('.mp4')) {
355
+ return `${apiConfig.API_BASE_URL}${videoUrl.replace(/\.mp4$/i, '.vtt')}`
356
+ }
357
+
358
+ return null
359
+ })
360
+
361
+ const playerMediaKey = computed(() => {
362
+ const video = project.value?.video_url || ''
363
+ const subs = subtitlesUrl.value || ''
364
+ return `${video}|${subs}`
365
+ })
366
+ </script>
367
+
368
+ <style scoped>
369
+ .custom-scrollbar::-webkit-scrollbar {
370
+ width: 6px;
371
+ }
372
+ .custom-scrollbar::-webkit-scrollbar-track {
373
+ background: transparent;
374
+ }
375
+ .custom-scrollbar::-webkit-scrollbar-thumb {
376
+ background: rgba(100, 116, 139, 0.2);
377
+ border-radius: 3px;
378
+ }
379
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover {
380
+ background: rgba(100, 116, 139, 0.4);
381
+ }
382
+ </style>
frontend/app/pages/projects.vue ADDED
@@ -0,0 +1,620 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div
3
+ id="projects-root"
4
+ class="relative w-full overflow-hidden flex flex-col pt-8 pb-20"
5
+ >
6
+ <!-- Header Area -->
7
+ <div class="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mb-8">
8
+ <div
9
+ id="projects-header"
10
+ class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8 animate-fade-in-up"
11
+ >
12
+ <div>
13
+ <h1
14
+ class="text-3xl font-extrabold text-slate-900 tracking-tight flex items-center gap-3"
15
+ >
16
+ Projects
17
+ <span
18
+ class="text-sm font-medium bg-green-100 text-green-700 px-2.5 py-0.5 rounded-full"
19
+ >
20
+ {{ sortedAndFilteredProjects.length }}
21
+ </span>
22
+ </h1>
23
+ <p class="text-slate-500 mt-1">
24
+ Manage and review your generated video lessons.
25
+ </p>
26
+ </div>
27
+
28
+ <div class="flex items-center gap-3">
29
+ <!-- New → split button -->
30
+ <UDropdownMenu
31
+ :items="[
32
+ [
33
+ { label: 'New Project', icon: 'i-lucide-plus', to: '/' },
34
+ {
35
+ label: 'New Group',
36
+ icon: 'i-lucide-folder-plus',
37
+ onSelect: createGroup,
38
+ },
39
+ ],
40
+ ]"
41
+ >
42
+ <UButton class="btn-premium rounded-xl px-4 py-2 font-medium">
43
+ <UIcon name="i-lucide-plus" class="w-4 h-4 mr-1.5" />
44
+ New
45
+ <UIcon
46
+ name="i-lucide-chevron-down"
47
+ class="w-3.5 h-3.5 ml-1.5 opacity-70"
48
+ />
49
+ </UButton>
50
+ </UDropdownMenu>
51
+ </div>
52
+ </div>
53
+
54
+ <!-- Toolbar -->
55
+ <div
56
+ id="projects-toolbar"
57
+ class="mt-8 flex flex-col sm:flex-row gap-4 animate-fade-in-up animation-delay-200"
58
+ >
59
+ <!-- Search -->
60
+ <div id="project-search-bar" class="relative flex-1 max-w-sm">
61
+ <UIcon
62
+ name="i-lucide-search"
63
+ class="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400"
64
+ />
65
+ <input
66
+ v-model="searchQuery"
67
+ type="text"
68
+ placeholder="Search projects..."
69
+ class="w-full premium-input rounded-xl pl-10 pr-4 py-2.5 text-sm outline-none"
70
+ />
71
+ </div>
72
+
73
+ <!-- Filters -->
74
+ <div
75
+ id="project-filters"
76
+ class="flex items-center gap-2 overflow-x-auto pb-2 sm:pb-0 hide-scrollbar"
77
+ >
78
+ <button
79
+ v-for="filter in ['all', 'completed', 'in_progress', 'error']"
80
+ :key="filter"
81
+ @click="activeFilter = filter"
82
+ class="whitespace-nowrap px-4 py-2 rounded-xl text-sm font-medium transition-all"
83
+ :class="
84
+ activeFilter === filter
85
+ ? 'bg-slate-900 text-white shadow-md'
86
+ : 'bg-white/50 text-slate-600 hover:bg-white border border-white/60 shadow-sm'
87
+ "
88
+ >
89
+ {{
90
+ filter.charAt(0).toUpperCase() + filter.slice(1).replace("_", " ")
91
+ }}
92
+ </button>
93
+ </div>
94
+
95
+ <div class="flex-1" />
96
+
97
+ <!-- Sort -->
98
+ <div
99
+ id="project-sort-group"
100
+ class="flex items-center gap-2 shrink-0 text-sm"
101
+ >
102
+ <USelectMenu
103
+ v-model="sortBy"
104
+ :items="sortOptions"
105
+ :searchInput="false"
106
+ label-key="label"
107
+ class="premium-input rounded-xl min-w-[150px]"
108
+ variant="none"
109
+ >
110
+ <template #leading>
111
+ <UIcon
112
+ name="i-lucide-arrow-up-down"
113
+ class="w-4 h-4 text-slate-400"
114
+ />
115
+ </template>
116
+ </USelectMenu>
117
+ </div>
118
+ </div>
119
+ </div>
120
+
121
+ <!-- Content Area -->
122
+ <div
123
+ class="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10 min-h-[400px] space-y-6"
124
+ >
125
+ <!-- Loading -->
126
+ <div
127
+ v-if="store.loading"
128
+ class="absolute inset-0 flex items-center justify-center bg-white/30 backdrop-blur-sm z-20 rounded-3xl"
129
+ >
130
+ <UIcon
131
+ name="i-lucide-loader-2"
132
+ class="w-8 h-8 text-green-500 animate-spin"
133
+ />
134
+ </div>
135
+
136
+ <!-- Empty -->
137
+ <div
138
+ v-else-if="sortedAndFilteredProjects.length === 0"
139
+ class="premium-glass rounded-3xl p-12 text-center flex flex-col items-center justify-center animate-scale-in"
140
+ >
141
+ <div
142
+ class="w-16 h-16 rounded-2xl bg-slate-100 flex items-center justify-center mb-4"
143
+ >
144
+ <UIcon name="i-lucide-folder-search" class="w-8 h-8 text-slate-400" />
145
+ </div>
146
+ <h3 class="text-lg font-bold text-slate-900 mb-2">No projects found</h3>
147
+ <p class="text-slate-500 max-w-sm mb-6">
148
+ {{
149
+ searchQuery
150
+ ? `No results matching "${searchQuery}"`
151
+ : "You haven't generated any videos yet."
152
+ }}
153
+ </p>
154
+ <UButton
155
+ v-if="!searchQuery"
156
+ to="/"
157
+ class="btn-premium rounded-xl px-6 py-2.5 font-semibold"
158
+ >
159
+ Start Generating
160
+ </UButton>
161
+ <UButton
162
+ v-else
163
+ @click="searchQuery = ''"
164
+ variant="ghost"
165
+ color="neutral"
166
+ class="rounded-xl"
167
+ >
168
+ Clear search
169
+ </UButton>
170
+ </div>
171
+
172
+ <!-- ── GRID VIEW ── -->
173
+ <div>
174
+ <!-- Groups -->
175
+ <ProjectGroupCard
176
+ v-for="(group, idx) in groupStore.groups"
177
+ :key="group.id"
178
+ :group="group"
179
+ :projects="projectsForGroup(group.id)"
180
+ :durations="videoDurations"
181
+ :style="`animation-delay: ${idx * 60}ms`"
182
+ class="group-slide-in"
183
+ @toggle-collapse="groupStore.toggleCollapse($event)"
184
+ @rename="(gId, n) => groupStore.renameGroup(gId, n)"
185
+ @delete="onDeleteGroup"
186
+ @drop="onDropToGroup"
187
+ @delete-project="openDeleteModal"
188
+ @continue-project="openContinueModal"
189
+ @project-dragstart="activeDragId = $event"
190
+ @project-dragend="activeDragId = null"
191
+ />
192
+
193
+ <!-- Ungrouped section -->
194
+ <div
195
+ v-if="ungroupedFiltered.length > 0 || groupStore.groups.length > 0"
196
+ class="relative"
197
+ @dragover.prevent="onDragOverUngrouped"
198
+ @dragleave="onDragLeaveUngrouped"
199
+ @drop.prevent="onDropToUngrouped"
200
+ >
201
+ <!-- Section header only when groups exist -->
202
+ <div
203
+ v-if="groupStore.groups.length > 0"
204
+ class="flex items-center gap-3 mb-4"
205
+ >
206
+ <span
207
+ class="text-xs font-semibold text-slate-400 uppercase tracking-wider"
208
+ >Ungrouped</span
209
+ >
210
+ <div class="flex-1 h-px bg-slate-200/60" />
211
+ <span class="text-xs text-slate-400">{{
212
+ ungroupedFiltered.length
213
+ }}</span>
214
+ </div>
215
+
216
+ <!-- Ungrouped drop zone (only when dragging) -->
217
+ <div
218
+ v-if="activeDragId && groupStore.groups.length > 0"
219
+ class="mb-4 rounded-xl border-2 border-dashed py-4 px-6 flex items-center gap-2 text-sm transition-colors"
220
+ :class="
221
+ isOverUngrouped
222
+ ? 'border-green-400 bg-green-50/40 text-green-600'
223
+ : 'border-slate-200 text-slate-400'
224
+ "
225
+ >
226
+ <UIcon
227
+ :name="
228
+ isOverUngrouped ? 'i-lucide-package-minus' : 'i-lucide-package'
229
+ "
230
+ class="w-4 h-4"
231
+ />
232
+ Drop here to ungroup
233
+ </div>
234
+
235
+ <div
236
+ v-if="ungroupedFiltered.length"
237
+ class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"
238
+ :class="
239
+ isOverUngrouped
240
+ ? 'ring-2 ring-green-400 ring-offset-2 rounded-xl p-2'
241
+ : ''
242
+ "
243
+ >
244
+ <ProjectCard
245
+ v-for="(project, index) in ungroupedFiltered"
246
+ :key="project.id"
247
+ :project="project"
248
+ :duration="videoDurations[project.id]"
249
+ :style="`animation-delay: ${index * 50}ms`"
250
+ class="animate-fade-in-up"
251
+ @delete="openDeleteModal"
252
+ @continue="openContinueModal"
253
+ @dragstart="activeDragId = $event"
254
+ @dragend="activeDragId = null"
255
+ />
256
+ </div>
257
+
258
+ <!-- No ungrouped but page has groups -->
259
+ <div
260
+ v-else-if="groupStore.groups.length > 0"
261
+ class="rounded-xl border-2 border-dashed border-slate-100 py-6 flex items-center justify-center text-slate-300 text-sm gap-2"
262
+ >
263
+ <UIcon name="i-lucide-package-check" class="w-4 h-4" />
264
+ All projects are organized in groups
265
+ </div>
266
+ </div>
267
+ </div>
268
+ </div>
269
+
270
+ <!-- Delete Project Modal -->
271
+ <UModal
272
+ v-model:open="deleteModalOpen"
273
+ title="Delete Project?"
274
+ :ui="{ footer: 'justify-end' }"
275
+ >
276
+ <template #content>
277
+ <div class="p-6">
278
+ <div class="flex items-start gap-4 mb-6">
279
+ <div
280
+ class="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center shrink-0"
281
+ >
282
+ <UIcon
283
+ name="i-lucide-alert-triangle"
284
+ class="w-5 h-5 text-red-600"
285
+ />
286
+ </div>
287
+ <div>
288
+ <h3 class="text-lg font-bold text-slate-900 mb-1">
289
+ Delete Project?
290
+ </h3>
291
+ <p class="text-sm text-slate-500">
292
+ Are you sure you want to delete
293
+ <span class="font-semibold text-slate-700"
294
+ >"{{ projectToDelete?.name || "Untitled" }}"</span
295
+ >? This action cannot be undone and will remove all associated
296
+ assets.
297
+ </p>
298
+ </div>
299
+ </div>
300
+ <div class="flex items-center justify-end gap-3">
301
+ <UButton
302
+ @click="deleteModalOpen = false"
303
+ variant="ghost"
304
+ color="neutral"
305
+ class="rounded-xl px-4 font-semibold"
306
+ >Cancel</UButton
307
+ >
308
+ <UButton
309
+ @click="confirmDelete"
310
+ color="error"
311
+ class="rounded-xl px-6 font-semibold shadow-md inline-flex items-center"
312
+ :loading="isDeleting"
313
+ >
314
+ Yes, delete project
315
+ </UButton>
316
+ </div>
317
+ </div>
318
+ </template>
319
+ </UModal>
320
+
321
+ <!-- Continue Project Modal -->
322
+ <UModal
323
+ v-model:open="continueModalOpen"
324
+ title="Continue Generation?"
325
+ :ui="{ footer: 'justify-end' }"
326
+ >
327
+ <template #content>
328
+ <div class="p-6">
329
+ <div class="flex items-start gap-4 mb-6">
330
+ <div
331
+ class="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center shrink-0"
332
+ >
333
+ <UIcon
334
+ name="i-lucide-play-circle"
335
+ class="w-5 h-5 text-green-600"
336
+ />
337
+ </div>
338
+ <div>
339
+ <h3 class="text-lg font-bold text-slate-900 mb-1">
340
+ Continue Generation?
341
+ </h3>
342
+ <p class="text-sm text-slate-500">
343
+ Resume generation for
344
+ <span class="font-semibold text-slate-700"
345
+ >"{{ projectToContinue?.name || "Untitled" }}"</span
346
+ >? The AI will continue from where it left off.
347
+ </p>
348
+ </div>
349
+ </div>
350
+ <div class="flex items-center justify-end gap-3">
351
+ <UButton
352
+ @click="continueModalOpen = false"
353
+ variant="ghost"
354
+ color="neutral"
355
+ class="rounded-xl px-4 font-semibold"
356
+ >Cancel</UButton
357
+ >
358
+ <UButton
359
+ @click="confirmContinue"
360
+ color="success"
361
+ class="rounded-xl px-6 font-semibold shadow-md inline-flex items-center"
362
+ :loading="isContinuing"
363
+ >
364
+ Continue Generation
365
+ </UButton>
366
+ </div>
367
+ </div>
368
+ </template>
369
+ </UModal>
370
+ </div>
371
+ </template>
372
+
373
+ <script setup lang="ts">
374
+ import { ref, computed, onMounted, watch } from "vue";
375
+ import { useProjectStore } from "~/stores/projects";
376
+ import { useProjectGroupStore } from "~/stores/groups";
377
+ import { config as apiConfig } from "~/utils/api";
378
+
379
+ const store = useProjectStore();
380
+ const groupStore = useProjectGroupStore();
381
+ const toast = useToast();
382
+
383
+ // ── View state ──
384
+ const searchQuery = ref("");
385
+ const activeFilter = ref("all");
386
+ const sortOptions = [
387
+ { label: "Newest first", value: "newest" },
388
+ { label: "Oldest first", value: "oldest" },
389
+ { label: "Name (A → Z)", value: "name" },
390
+ ];
391
+ const sortBy = ref(sortOptions[0]);
392
+
393
+ // ── Delete modal ──
394
+ const deleteModalOpen = ref(false);
395
+ const projectToDelete = ref<any>(null);
396
+ const isDeleting = ref(false);
397
+
398
+ // ── Continue modal ──
399
+ const continueModalOpen = ref(false);
400
+ const projectToContinue = ref<any>(null);
401
+ const isContinuing = ref(false);
402
+
403
+ // ── Drag state ──
404
+ const activeDragId = ref<string | null>(null);
405
+ const isOverUngrouped = ref(false);
406
+
407
+ // ── Video durations ──
408
+ const videoDurations = ref<Record<string, string>>({});
409
+
410
+ onMounted(() => {
411
+ groupStore.init();
412
+ store.fetchProjects();
413
+ });
414
+
415
+ // Prune deleted projects from groups after fetch
416
+ watch(
417
+ () => store.projects,
418
+ (projects) => {
419
+ groupStore.pruneDeletedProjects(projects.map((p: any) => p.id));
420
+ },
421
+ { deep: true },
422
+ );
423
+
424
+ // ── Computed: filtered+sorted project list ──
425
+ const sortedAndFilteredProjects = computed(() => {
426
+ let result = [...store.projects];
427
+
428
+ if (activeFilter.value !== "all") {
429
+ if (activeFilter.value === "in_progress") {
430
+ result = result.filter((p) => !["completed", "error"].includes(p.status));
431
+ } else {
432
+ result = result.filter((p) => p.status === activeFilter.value);
433
+ }
434
+ }
435
+
436
+ if (searchQuery.value) {
437
+ const q = searchQuery.value.toLowerCase();
438
+ result = result.filter(
439
+ (p) =>
440
+ (p.name && p.name.toLowerCase().includes(q)) ||
441
+ (p.topic && p.topic.toLowerCase().includes(q)),
442
+ );
443
+ }
444
+
445
+ const sortVal = sortBy.value?.value || "newest";
446
+ if (sortVal === "oldest") {
447
+ return result.sort(
448
+ (a, b) =>
449
+ new Date(a.created_at ?? 0).getTime() -
450
+ new Date(b.created_at ?? 0).getTime(),
451
+ );
452
+ } else if (sortVal === "name") {
453
+ return result.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
454
+ }
455
+ return result.sort(
456
+ (a, b) =>
457
+ new Date(b.created_at ?? 0).getTime() -
458
+ new Date(a.created_at ?? 0).getTime(),
459
+ );
460
+ });
461
+
462
+ const ungroupedFiltered = computed(() => {
463
+ const allIds = sortedAndFilteredProjects.value.map((p: any) => p.id);
464
+ const grouped = new Set(groupStore.groups.flatMap((g) => g.projectIds));
465
+ return sortedAndFilteredProjects.value.filter((p: any) => !grouped.has(p.id));
466
+ });
467
+
468
+ function projectsForGroup(groupId: string): any[] {
469
+ const group = groupStore.groups.find((g) => g.id === groupId);
470
+ if (!group) return [];
471
+ const filtered = new Set(
472
+ sortedAndFilteredProjects.value.map((p: any) => p.id),
473
+ );
474
+ return group.projectIds
475
+ .filter((id) => filtered.has(id))
476
+ .map((id) => sortedAndFilteredProjects.value.find((p: any) => p.id === id))
477
+ .filter(Boolean);
478
+ }
479
+
480
+ function listProjectsForGroup(groupId: string): any[] {
481
+ return projectsForGroup(groupId);
482
+ }
483
+
484
+ // ── Group actions ──
485
+ function createGroup() {
486
+ groupStore.createGroup("New Group");
487
+ }
488
+
489
+ function onDeleteGroup(groupId: string) {
490
+ // Move its projects out before deleting
491
+ const group = groupStore.groups.find((g) => g.id === groupId);
492
+ if (group) {
493
+ // Projects become ungrouped automatically since we just remove them from the group
494
+ groupStore.deleteGroup(groupId);
495
+ }
496
+ }
497
+
498
+ // ── Drag-and-drop ──
499
+ function onDropToGroup(projectId: string, groupId: string) {
500
+ groupStore.addToGroup(projectId, groupId);
501
+ activeDragId.value = null;
502
+ }
503
+
504
+ function onDragOverUngrouped() {
505
+ isOverUngrouped.value = true;
506
+ }
507
+
508
+ function onDragLeaveUngrouped(e: DragEvent) {
509
+ const related = e.relatedTarget as Node | null;
510
+ if (!related || !(e.currentTarget as HTMLElement).contains(related)) {
511
+ isOverUngrouped.value = false;
512
+ }
513
+ }
514
+
515
+ function onDropToUngrouped() {
516
+ isOverUngrouped.value = false;
517
+ if (activeDragId.value) {
518
+ groupStore.removeFromAllGroups(activeDragId.value);
519
+ activeDragId.value = null;
520
+ }
521
+ }
522
+
523
+ // ── Delete project ──
524
+ function openDeleteModal(project: any) {
525
+ projectToDelete.value = project;
526
+ deleteModalOpen.value = true;
527
+ }
528
+
529
+ async function confirmDelete() {
530
+ if (!projectToDelete.value) return;
531
+ isDeleting.value = true;
532
+ try {
533
+ await store.deleteProject(projectToDelete.value.id);
534
+ deleteModalOpen.value = false;
535
+ } catch (error) {
536
+ console.error("Failed to delete project:", error);
537
+ } finally {
538
+ isDeleting.value = false;
539
+ projectToDelete.value = null;
540
+ }
541
+ }
542
+
543
+ // ── Continue project ──
544
+ function openContinueModal(project: any) {
545
+ projectToContinue.value = project;
546
+ continueModalOpen.value = true;
547
+ }
548
+
549
+ async function confirmContinue() {
550
+ if (!projectToContinue.value) return;
551
+ isContinuing.value = true;
552
+ try {
553
+ const sessionId = await store.continueGeneration(
554
+ projectToContinue.value.id
555
+ );
556
+ continueModalOpen.value = false;
557
+
558
+ // Show success toast
559
+ toast.add({
560
+ title: "Generation Started",
561
+ description: `Continuing generation for "${projectToContinue.value.name || "Untitled"}"`,
562
+ icon: "i-lucide-play-circle",
563
+ color: "success",
564
+ });
565
+
566
+ // Navigate to project detail page to watch progress
567
+ await navigateTo(`/project/${projectToContinue.value.id}`);
568
+ } catch (error: any) {
569
+ console.error("Failed to continue project:", error);
570
+ toast.add({
571
+ title: "Failed to Continue",
572
+ description: error.message || "An error occurred while continuing generation",
573
+ icon: "i-lucide-alert-circle",
574
+ color: "error",
575
+ });
576
+ } finally {
577
+ isContinuing.value = false;
578
+ projectToContinue.value = null;
579
+ }
580
+ }
581
+
582
+ // ── Helpers ──
583
+ function formatStatus(status: string) {
584
+ if (!status) return "Unknown";
585
+ return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
586
+ }
587
+
588
+ function canContinueProject(status: string) {
589
+ // Only allow continue for: error, planned, stopped, or cancelled (not actively generating)
590
+ return status === "error" || status === "planned" || status === "stopped" || status === "cancelled";
591
+ }
592
+
593
+ function getStatusClasses(status: string, withBackdrop = true) {
594
+ const base = withBackdrop ? "bg-white/80 border border-white/50 " : "";
595
+ if (status === "completed")
596
+ return base + "text-green-700 bg-green-50/80 border-green-200";
597
+ if (status === "error")
598
+ return base + "text-red-700 bg-red-50/80 border-red-200";
599
+ return base + "text-orange-700 bg-orange-50/80 border-orange-200";
600
+ }
601
+
602
+ function formatDate(dateString: string) {
603
+ if (!dateString) return "";
604
+ return new Intl.DateTimeFormat("en-US", {
605
+ month: "short",
606
+ day: "numeric",
607
+ year: "numeric",
608
+ }).format(new Date(dateString));
609
+ }
610
+ </script>
611
+
612
+ <style scoped>
613
+ .hide-scrollbar::-webkit-scrollbar {
614
+ display: none;
615
+ }
616
+ .hide-scrollbar {
617
+ -ms-overflow-style: none;
618
+ scrollbar-width: none;
619
+ }
620
+ </style>
frontend/app/stores/chat.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineStore } from "pinia";
2
+ import api from "~/utils/api";
3
+ import { useProjectStore } from "./projects";
4
+
5
+ export interface Message {
6
+ role: "user" | "assistant";
7
+ content: string;
8
+ }
9
+
10
+ export const useChatStore = defineStore("chat", {
11
+ state: () => ({
12
+ histories: {} as Record<string, Message[]>,
13
+ suggestions: {} as Record<string, string[]>,
14
+ loadingSuggestions: {} as Record<string, boolean>,
15
+ }),
16
+
17
+ getters: {
18
+ getHistory: (state) => (projectId: string) => {
19
+ return state.histories[projectId] || [];
20
+ },
21
+ getSuggestions: (state) => (projectId: string) => {
22
+ return state.suggestions[projectId] || [];
23
+ },
24
+ isLoadingSuggestions: (state) => (projectId: string) => {
25
+ return !!state.loadingSuggestions[projectId];
26
+ },
27
+ },
28
+
29
+ actions: {
30
+ addMessage(projectId: string, message: Message) {
31
+ if (!this.histories[projectId]) {
32
+ this.histories[projectId] = [];
33
+ }
34
+ this.histories[projectId].push(message);
35
+ },
36
+
37
+ setHistory(projectId: string, history: Message[]) {
38
+ this.histories[projectId] = history;
39
+ },
40
+
41
+ async fetchSuggestions(projectId: string, apiKeys: any) {
42
+ // Return cached suggestions if they exist
43
+ if (
44
+ this.suggestions[projectId] &&
45
+ this.suggestions[projectId].length > 0
46
+ ) {
47
+ return this.suggestions[projectId];
48
+ }
49
+
50
+ if (this.loadingSuggestions[projectId]) return;
51
+
52
+ this.loadingSuggestions[projectId] = true;
53
+ const projectStore = useProjectStore();
54
+ try {
55
+ const res = await api.chatSuggestions(projectId, {
56
+ model:
57
+ projectStore.planningModel ||
58
+ projectStore.selectedModel ||
59
+ "gemini/gemini-3.1-flash-latest",
60
+ openai_api_key: apiKeys.openai,
61
+ anthropic_api_key: apiKeys.anthropic,
62
+ gemini_api_key: apiKeys.gemini,
63
+ });
64
+ if (res.suggestions) {
65
+ this.suggestions[projectId] = res.suggestions;
66
+ }
67
+ return this.suggestions[projectId];
68
+ } catch (err) {
69
+ console.error(
70
+ `Error fetching suggestions for project ${projectId}:`,
71
+ err,
72
+ );
73
+ throw err;
74
+ } finally {
75
+ this.loadingSuggestions[projectId] = false;
76
+ }
77
+ },
78
+
79
+ clearHistory(projectId: string) {
80
+ delete this.histories[projectId];
81
+ },
82
+ },
83
+ });
frontend/app/stores/groups.ts ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineStore } from "pinia";
2
+
3
+ export interface ProjectGroup {
4
+ id: string;
5
+ name: string;
6
+ projectIds: string[];
7
+ collapsed: boolean;
8
+ }
9
+
10
+ const STORAGE_KEY = "project-groups";
11
+
12
+ function generateId(): string {
13
+ return Math.random().toString(36).slice(2, 10);
14
+ }
15
+
16
+ export const useProjectGroupStore = defineStore("projectGroups", {
17
+ state: () => ({
18
+ groups: [] as ProjectGroup[],
19
+ }),
20
+
21
+ getters: {
22
+ /**
23
+ * Map of projectId → groupId for fast lookup.
24
+ */
25
+ projectGroupMap: (state): Record<string, string> => {
26
+ const map: Record<string, string> = {};
27
+ for (const group of state.groups) {
28
+ for (const pid of group.projectIds) {
29
+ map[pid] = group.id;
30
+ }
31
+ }
32
+ return map;
33
+ },
34
+
35
+ /**
36
+ * Returns projects that are NOT in any group.
37
+ */
38
+ ungroupedProjectIds: (state) => (allProjectIds: string[]) => {
39
+ const grouped = new Set(state.groups.flatMap((g) => g.projectIds));
40
+ return allProjectIds.filter((id) => !grouped.has(id));
41
+ },
42
+ },
43
+
44
+ actions: {
45
+ init() {
46
+ this._recover();
47
+ },
48
+
49
+ createGroup(name = "New Group"): string {
50
+ const newGroup: ProjectGroup = {
51
+ id: generateId(),
52
+ name,
53
+ projectIds: [],
54
+ collapsed: false,
55
+ };
56
+ this.groups.push(newGroup);
57
+ this._persist();
58
+ return newGroup.id;
59
+ },
60
+
61
+ renameGroup(groupId: string, name: string) {
62
+ const group = this.groups.find((g) => g.id === groupId);
63
+ if (group) {
64
+ group.name = name.trim() || "Untitled Group";
65
+ this._persist();
66
+ }
67
+ },
68
+
69
+ deleteGroup(groupId: string) {
70
+ this.groups = this.groups.filter((g) => g.id !== groupId);
71
+ this._persist();
72
+ },
73
+
74
+ addToGroup(projectId: string, groupId: string) {
75
+ // Remove from any existing group first
76
+ this.removeFromAllGroups(projectId);
77
+ const group = this.groups.find((g) => g.id === groupId);
78
+ if (group && !group.projectIds.includes(projectId)) {
79
+ group.projectIds.push(projectId);
80
+ this._persist();
81
+ }
82
+ },
83
+
84
+ removeFromAllGroups(projectId: string) {
85
+ for (const group of this.groups) {
86
+ group.projectIds = group.projectIds.filter((id) => id !== projectId);
87
+ }
88
+ this._persist();
89
+ },
90
+
91
+ toggleCollapse(groupId: string) {
92
+ const group = this.groups.find((g) => g.id === groupId);
93
+ if (group) {
94
+ group.collapsed = !group.collapsed;
95
+ this._persist();
96
+ }
97
+ },
98
+
99
+ /**
100
+ * Reorder groups by providing an ordered list of IDs.
101
+ */
102
+ reorderGroups(orderedIds: string[]) {
103
+ const map = new Map(this.groups.map((g) => [g.id, g]));
104
+ this.groups = orderedIds.map((id) => map.get(id)!).filter(Boolean);
105
+ this._persist();
106
+ },
107
+
108
+ /**
109
+ * Called after projects are fetched — removes stale project IDs.
110
+ */
111
+ pruneDeletedProjects(existingProjectIds: string[]) {
112
+ const existing = new Set(existingProjectIds);
113
+ let changed = false;
114
+ for (const group of this.groups) {
115
+ const before = group.projectIds.length;
116
+ group.projectIds = group.projectIds.filter((id) => existing.has(id));
117
+ if (group.projectIds.length !== before) changed = true;
118
+ }
119
+ if (changed) this._persist();
120
+ },
121
+
122
+ _persist() {
123
+ try {
124
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(this.groups));
125
+ } catch (e) {
126
+ console.warn("Failed to persist project groups", e);
127
+ }
128
+ },
129
+
130
+ _recover() {
131
+ try {
132
+ const saved = localStorage.getItem(STORAGE_KEY);
133
+ if (saved) {
134
+ this.groups = JSON.parse(saved) as ProjectGroup[];
135
+ }
136
+ } catch (e) {
137
+ console.warn("Failed to recover project groups", e);
138
+ this.groups = [];
139
+ }
140
+ },
141
+ },
142
+ });
frontend/app/stores/projects.ts ADDED
@@ -0,0 +1,1596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineStore } from "pinia";
2
+ import api from "~/utils/api";
3
+
4
+ export const useProjectStore = defineStore("projects", {
5
+ state: () => ({
6
+ projects: [] as any[],
7
+ loading: false,
8
+ activeGenerations: {} as Record<string, any>, // session_id -> status
9
+ systemStatus: null,
10
+
11
+ // Configuration State
12
+ planningModel: "",
13
+ codingModel: "",
14
+ selectedModel: "", // Keep for backward compatibility
15
+ useRag: false,
16
+ useVisualFixCode: false,
17
+ targetDuration: "3-5 minutes",
18
+ maxRetries: 5,
19
+ maxSceneConcurrency: 1,
20
+ selectedVoice: "af_heart",
21
+ availableModels: [] as any[],
22
+ availableVoices: [] as any[],
23
+ loadingModels: false,
24
+ loadingVoices: false,
25
+
26
+ // API Keys (for BYOK model)
27
+ apiKeys: {
28
+ openai: "",
29
+ anthropic: "",
30
+ gemini: "",
31
+ },
32
+
33
+ // Draft State (for persistence across navigation)
34
+ draftProjectName: "",
35
+ draftTopic: "",
36
+ isDrafting: false,
37
+ isRevising: false,
38
+ pendingPlanStartAt: null as number | null,
39
+
40
+ // Evaluation State
41
+ evaluationResults: {} as Record<string, any>,
42
+ evaluationLoading: {} as Record<string, boolean>,
43
+
44
+ // Background Persistence State (Live Generation)
45
+ currentSessionId: null as string | null,
46
+ ws: null as WebSocket | null,
47
+ // Onboarding State
48
+ onboarding: {
49
+ active: false,
50
+ currentStep: 0,
51
+ },
52
+
53
+ generationState: {
54
+ loading: false,
55
+ completed: false,
56
+ logs: [] as string[],
57
+ sceneStatuses: {} as Record<string, string>,
58
+ stageProgress: {} as Record<string, { current: number; total: number }>,
59
+ totalScenes: 0,
60
+ currentScene: 0,
61
+ currentSceneSteps: 0,
62
+ currentPhase: 0,
63
+ logCount: 0, // total lines received; used for trickle progress
64
+ friendlyTitle: "Production Studio",
65
+ friendlyStatus: "Ready to produce",
66
+ startTime: null as string | null,
67
+ errorMsg: null as string | null,
68
+ latestLog: "" as string,
69
+ currentTask: "" as string,
70
+ isReconnecting: false,
71
+ reconnectAttempts: 0,
72
+ accumulatedCost: 0,
73
+ completedProjectId: null as string | null,
74
+ completedProjectTitle: null as string | null,
75
+ // Rate limit countdown state
76
+ rateLimitCountdown: 0 as number,
77
+ rateLimitInterval: null as ReturnType<typeof setInterval> | null,
78
+ },
79
+ }),
80
+
81
+ getters: {
82
+ getProjectById: (state) => (id: string) => {
83
+ return state.projects.find((p) => p.id === id);
84
+ },
85
+
86
+ completedProjects: (state) => {
87
+ return state.projects.filter((p) => p.status === "completed");
88
+ },
89
+
90
+ inProgressProjects: (state) => {
91
+ return state.projects.filter((p) => p.status === "in_progress");
92
+ },
93
+
94
+ totalScenes: (state) => {
95
+ return state.projects.reduce(
96
+ (total, p) => total + (p.scene_count || 0),
97
+ 0,
98
+ );
99
+ },
100
+
101
+ completedScenes: (state) => {
102
+ return state.projects.reduce(
103
+ (total, p) => total + (p.completed_scenes || 0),
104
+ 0,
105
+ );
106
+ },
107
+
108
+ isGenerating: (state) => {
109
+ return (
110
+ state.generationState.loading ||
111
+ state.generationState.isReconnecting ||
112
+ Object.values(state.activeGenerations).some(
113
+ (g) =>
114
+ g.status === "started" ||
115
+ g.status === "planning" ||
116
+ g.status === "revising" ||
117
+ g.status === "retrying" ||
118
+ g.status === "continuing",
119
+ )
120
+ );
121
+ },
122
+
123
+ currentProject: (state) => {
124
+ const active = Object.values(state.activeGenerations);
125
+ if (active.length === 0) return null;
126
+ const sorted = [...active].sort(
127
+ (a, b) => (b.timestamp || 0) - (a.timestamp || 0),
128
+ );
129
+ const p = sorted[0];
130
+ return {
131
+ id: p.projectId || p.projectName,
132
+ title: p.projectName,
133
+ };
134
+ },
135
+
136
+ status: (state) => {
137
+ return state.generationState.friendlyStatus;
138
+ },
139
+
140
+ progress: (state) => {
141
+ const { stageProgress, completed } = state.generationState;
142
+ if (completed) return 100;
143
+
144
+ // Define weights for each stage (total = 100%)
145
+ const weights = {
146
+ "Video Planning": 50,
147
+ "Scene Generation": 25,
148
+ "Code Rendering": 24,
149
+ "Video Assembly": 1,
150
+ } as Record<string, number>;
151
+
152
+ const orderedStages = [
153
+ "Video Planning",
154
+ "Scene Generation",
155
+ "Code Rendering",
156
+ "Video Assembly",
157
+ ];
158
+
159
+ const logFallback = Math.min(
160
+ Math.floor(state.generationState.logCount / 3),
161
+ 5,
162
+ );
163
+
164
+ // Detect the "furthest" stage we have information for
165
+ let currentStageIndex = -1;
166
+ orderedStages.forEach((stageName, index) => {
167
+ if (stageProgress[stageName]) {
168
+ currentStageIndex = index;
169
+ }
170
+ });
171
+
172
+ if (currentStageIndex !== -1) {
173
+ let totalProgress = 0;
174
+ orderedStages.forEach((stageName, index) => {
175
+ const weight = weights[stageName] || 0;
176
+ if (index < currentStageIndex) {
177
+ // Previous stages are weighted fully
178
+ totalProgress += weight;
179
+ } else if (index === currentStageIndex) {
180
+ // Current stage is weighted by its relative progress
181
+ const prog = stageProgress[stageName];
182
+ if (prog) {
183
+ totalProgress +=
184
+ (prog.total > 0 ? prog.current / prog.total : 0) * weight;
185
+ }
186
+ }
187
+ });
188
+ // Use higher of calculated and fallback to ensure we don't drop to 0
189
+ return Math.min(Math.max(Math.round(totalProgress), logFallback), 99);
190
+ }
191
+
192
+ return logFallback;
193
+ },
194
+
195
+ filteredModels: (state) => {
196
+ return state.availableModels.filter((m: any) => {
197
+ const val = m.value || "";
198
+ if (val.startsWith("gemini/")) return !!state.apiKeys.gemini;
199
+ if (val.startsWith("openai/")) return !!state.apiKeys.openai;
200
+ if (val.startsWith("anthropic/")) return !!state.apiKeys.anthropic;
201
+ // Keep others (ollama, llamacpp, lmstudio, etc)
202
+ return true;
203
+ });
204
+ },
205
+
206
+ accumulatedCostPHP: (state) => {
207
+ const usd = state.generationState.accumulatedCost || 0;
208
+ const rate = 60.0; // Current approx conversion rate USD -> PHP
209
+ const php = usd * rate;
210
+ // Using 2 decimals for PHP as it's more significant than 4-decimal USD
211
+ return `\u20B1${php.toFixed(2)}`;
212
+ },
213
+ },
214
+
215
+ actions: {
216
+ // Project Management
217
+ async fetchProjects() {
218
+ // Recover active sessions and config first
219
+ this._recoverSessions();
220
+ this._recoverConfig();
221
+
222
+ this.loading = true;
223
+ try {
224
+ const fetchedProjects = await api.getProjects();
225
+ this.projects = fetchedProjects;
226
+
227
+ // --- STATUS RECONCILIATION ---
228
+ // Cross-reference activeGenerations with fetched projects to clear "stuck" states
229
+ let modified = false;
230
+ const GRACE_PERIOD_MS = 30_000; // 30 seconds — backend may not have created the project yet
231
+ for (const sessionId of Object.keys(this.activeGenerations)) {
232
+ const active = this.activeGenerations[sessionId];
233
+ const isNew = Date.now() - (active.timestamp || 0) < GRACE_PERIOD_MS;
234
+
235
+ // Match ONLY by unique project_id (folder name).
236
+ // Never match by display name — two projects can share the same name.
237
+ // Legacy sessions (pre-projectId) used projectName as the folder name, so keep that fallback.
238
+ const matchedProject = fetchedProjects?.find(
239
+ (p: { id: any }) =>
240
+ p.id === active.projectId ||
241
+ (!active.projectId && p.id === active.projectName),
242
+ );
243
+
244
+ if (
245
+ matchedProject &&
246
+ (matchedProject as any).status === "completed"
247
+ ) {
248
+ console.log(
249
+ `Reconciliation: Clearing session ${sessionId} as project ${active.projectName} is completed.`,
250
+ );
251
+ delete this.activeGenerations[sessionId];
252
+ modified = true;
253
+
254
+ // If this was our current session, stop loading
255
+ if (this.currentSessionId === sessionId) {
256
+ this.generationState.loading = false;
257
+ this.generationState.completed = true;
258
+ this.generationState.currentPhase = 5;
259
+ this.ws?.close();
260
+ this.ws = null;
261
+ }
262
+ } else if (!matchedProject && !isNew) {
263
+ // Only clear stale sessions — give new generations a 30s grace period
264
+ // for the backend to register the project
265
+ console.log(
266
+ `Reconciliation: Clearing session ${sessionId} as project ${active.projectName} no longer exists.`,
267
+ );
268
+ delete this.activeGenerations[sessionId];
269
+ modified = true;
270
+
271
+ // If this was our current session, stop loading
272
+ if (this.currentSessionId === sessionId) {
273
+ this.generationState.loading = false;
274
+ this.currentSessionId = null;
275
+ this.ws?.close();
276
+ this.ws = null;
277
+ }
278
+ }
279
+ }
280
+
281
+ if (modified) {
282
+ this._persistSessions();
283
+ }
284
+ // -----------------------------
285
+ } catch (error) {
286
+ console.error("Failed to fetch projects:", error);
287
+ throw error;
288
+ } finally {
289
+ this.loading = false;
290
+ }
291
+ },
292
+
293
+ async fetchProject(id: string) {
294
+ this.loading = true;
295
+ try {
296
+ const project = await api.getProject(id);
297
+ // Update or add the project in the list
298
+ const index = this.projects.findIndex((p) => p.id === id);
299
+ if (index !== -1) {
300
+ this.projects[index] = { ...this.projects[index], ...project };
301
+ } else {
302
+ this.projects.push(project);
303
+ }
304
+ return project;
305
+ } catch (error: any) {
306
+ console.error(`Failed to fetch project ${id}:`, error);
307
+ throw error;
308
+ } finally {
309
+ this.loading = false;
310
+ }
311
+ },
312
+
313
+ async deleteProject(id: string) {
314
+ try {
315
+ await api.deleteProject(id);
316
+ this.projects = this.projects.filter((p) => p.id !== id);
317
+ // Clean up evaluation results
318
+ delete this.evaluationResults[id];
319
+ delete this.evaluationLoading[id];
320
+ } catch (error: any) {
321
+ console.error(`Failed to delete project ${id}:`, error);
322
+ throw error;
323
+ }
324
+ },
325
+
326
+ // Generation
327
+ async triggerGeneration(projectName: string, topic: string) {
328
+ // Validate API keys before starting generation to prevent mid-pipeline failures
329
+ const keysToValidate: any = {};
330
+ if (this.apiKeys.openai)
331
+ keysToValidate.openai_api_key = this.apiKeys.openai;
332
+ if (this.apiKeys.anthropic)
333
+ keysToValidate.anthropic_api_key = this.apiKeys.anthropic;
334
+ if (this.apiKeys.gemini)
335
+ keysToValidate.gemini_api_key = this.apiKeys.gemini;
336
+
337
+ if (Object.keys(keysToValidate).length > 0) {
338
+ try {
339
+ const validationResult = await api.validateApiKeys(keysToValidate);
340
+ if (!validationResult.valid) {
341
+ const errorMsg =
342
+ validationResult.errors?.join(", ") || "Invalid API keys";
343
+ throw new Error(`API key validation failed: ${errorMsg}`);
344
+ }
345
+ } catch (error: any) {
346
+ console.error("API key validation failed:", error);
347
+ throw error; // Don't start generation if keys are invalid
348
+ }
349
+ }
350
+
351
+ // Reset state entirely for a fresh start, then show the widget immediately
352
+ this.resetGenerationState();
353
+ this.generationState.loading = true;
354
+ this.generationState.errorMsg = null;
355
+ try {
356
+ const result = await api.startGeneration({
357
+ project_name: projectName,
358
+ topic: topic,
359
+ model: this.planningModel,
360
+ coding_model: this.codingModel,
361
+ use_rag: this.useRag,
362
+ use_visual_fix_code: this.useVisualFixCode,
363
+ target_duration: this.targetDuration,
364
+ max_retries: this.maxRetries,
365
+ max_scene_concurrency: this.maxSceneConcurrency,
366
+ voice: this.selectedVoice,
367
+ openai_api_key: this.apiKeys.openai,
368
+ anthropic_api_key: this.apiKeys.anthropic,
369
+ gemini_api_key: this.apiKeys.gemini,
370
+ });
371
+ this.activeGenerations[result.session_id] = {
372
+ projectId: result.project_id, // unique folder ID
373
+ projectName: result.project_name, // human-readable display name
374
+ status: "started",
375
+ timestamp: Date.now(),
376
+ };
377
+ this._persistSessions();
378
+ // Automatically start listening for progress
379
+ this.initWebSocket(result.session_id);
380
+ return result.session_id;
381
+ } catch (error: any) {
382
+ console.error("Failed to trigger generation:", error);
383
+ throw error;
384
+ }
385
+ },
386
+
387
+ async stopGeneration(sessionId?: string, deleteDir: boolean = false) {
388
+ let id = sessionId || this.currentSessionId;
389
+
390
+ if (!id) {
391
+ const active = Object.entries(this.activeGenerations).filter(
392
+ ([_, g]) =>
393
+ g.status === "started" ||
394
+ g.status === "planning" ||
395
+ g.status === "revising" ||
396
+ g.status === "retrying" ||
397
+ g.status === "continuing",
398
+ );
399
+
400
+ if (active.length > 0) {
401
+ const sorted = active.sort(
402
+ (a, b) => (b[1].timestamp ?? 0) - (a[1].timestamp ?? 0),
403
+ );
404
+ const first = sorted[0];
405
+ if (first) {
406
+ id = first[0];
407
+ console.log(
408
+ `!!! [STORE] Auto-resolved session ID from active generations: ${id}`,
409
+ );
410
+ }
411
+ }
412
+ }
413
+
414
+ if (!id && this.pendingPlanStartAt) {
415
+ try {
416
+ const response = await api.getActiveSessions();
417
+ const sessions = response?.sessions || [];
418
+ const threshold = this.pendingPlanStartAt - 15000;
419
+ const candidates = sessions
420
+ .filter((s: any) => {
421
+ const createdAt = new Date(s.created_at || 0).getTime();
422
+ return (
423
+ s?.status === "active" &&
424
+ createdAt >= threshold &&
425
+ (!this.planningModel || s?.model === this.planningModel)
426
+ );
427
+ })
428
+ .sort(
429
+ (a: any, b: any) =>
430
+ new Date(b.updated_at || 0).getTime() -
431
+ new Date(a.updated_at || 0).getTime(),
432
+ );
433
+
434
+ if (candidates.length > 0) {
435
+ id = candidates[0].session_id;
436
+ if (id && !this.activeGenerations[id]) {
437
+ this.activeGenerations[id] = {
438
+ projectId: candidates[0].project_id,
439
+ projectName: candidates[0].project_id,
440
+ status: "planning",
441
+ timestamp: Date.now(),
442
+ };
443
+ this._persistSessions();
444
+ }
445
+ console.log(
446
+ `!!! [STORE] Recovered just-started session for stop: ${id}`,
447
+ );
448
+ }
449
+ } catch (e) {
450
+ console.warn("!!! [STORE] Failed to recover pending session:", e);
451
+ }
452
+ }
453
+
454
+ if (!id) {
455
+ console.warn("!!! [STORE] No active session to stop.");
456
+ return "not_found";
457
+ }
458
+
459
+ console.log("!!! [STORE] stopGeneration started !!!", {
460
+ id,
461
+ currentSessionId: this.currentSessionId,
462
+ activeGenerationsCount: Object.keys(this.activeGenerations).length,
463
+ deleteDir,
464
+ });
465
+
466
+ try {
467
+ console.log("!!! [STORE] Sending API call to stop session:", id);
468
+ await api.stopGeneration(id, deleteDir);
469
+
470
+ console.log(
471
+ `!!! [STORE] API response for stopGeneration: { message: 'Generation stopped', session_id: '${id}' }`,
472
+ );
473
+
474
+ // Get the project ID from the session before removing it
475
+ const sessionInfo = this.activeGenerations[id];
476
+ const projectId = sessionInfo?.projectId || sessionInfo?.projectName;
477
+
478
+ // Update local state
479
+ if (this.activeGenerations[id]) {
480
+ console.log("!!! [STORE] Marking session as stopped in state:", id);
481
+ this.activeGenerations[id].status = "stopped";
482
+ }
483
+
484
+ // If this was our current session, clear it
485
+ if (this.currentSessionId === id) {
486
+ console.log("!!! [STORE] Clearing local state for session:", id);
487
+ this.currentSessionId = null;
488
+ this.generationState.loading = false;
489
+ }
490
+
491
+ this._persistSessions();
492
+
493
+ // Update the project status in the local projects array
494
+ if (projectId) {
495
+ const projectIndex = this.projects.findIndex(
496
+ (p) => p.id === projectId,
497
+ );
498
+ if (projectIndex !== -1) {
499
+ this.projects[projectIndex].status = "stopped";
500
+ console.log(
501
+ `!!! [STORE] Updated project ${projectId} status to 'stopped'`,
502
+ );
503
+ }
504
+ }
505
+
506
+ return "stopped";
507
+ } catch (error: any) {
508
+ // If session not found (404), it's already stopped on server
509
+ if (error.response && error.response.status === 404) {
510
+ console.warn(
511
+ `Session ${id} not found on server, marking as stopped locally.`,
512
+ );
513
+ if (this.activeGenerations[id]) {
514
+ this.activeGenerations[id].status = "stopped";
515
+ }
516
+ if (this.currentSessionId === id) {
517
+ console.log("!!! [STORE] Clearing currentSessionId due to 404 !!!");
518
+ this.currentSessionId = null;
519
+ this.generationState.loading = false;
520
+ }
521
+ this._persistSessions();
522
+ return "not_found";
523
+ }
524
+ console.error("Failed to stop generation:", error);
525
+ throw error;
526
+ }
527
+ },
528
+
529
+ async retryScene(projectId: string, sceneNumber: number) {
530
+ try {
531
+ const result = await api.retryScene(projectId, sceneNumber, {
532
+ model: this.planningModel,
533
+ coding_model: this.codingModel,
534
+ openai_api_key: this.apiKeys.openai,
535
+ anthropic_api_key: this.apiKeys.anthropic,
536
+ gemini_api_key: this.apiKeys.gemini,
537
+ max_retries: this.maxRetries,
538
+ });
539
+ this.activeGenerations[result.session_id] = {
540
+ projectName: projectId,
541
+ sceneNumber: sceneNumber,
542
+ status: "retrying",
543
+ timestamp: Date.now(),
544
+ };
545
+ // Automatically start listening for progress
546
+ this.initWebSocket(result.session_id);
547
+ return result.session_id;
548
+ } catch (error: any) {
549
+ console.error(`Failed to retry scene ${sceneNumber}:`, error);
550
+ throw error;
551
+ }
552
+ },
553
+
554
+ async continueGeneration(projectId: string) {
555
+ // Check if there's already an active generation for this project
556
+ // Use projectId (unique ID) instead of projectName (display name) to avoid false matches
557
+ const existingGeneration = Object.entries(this.activeGenerations).find(
558
+ ([_, gen]) => {
559
+ const matchesProjectId =
560
+ gen.projectId === projectId || gen.projectName === projectId;
561
+ const isActive =
562
+ gen.status !== "stopped" && gen.status !== "completed";
563
+ return matchesProjectId && isActive;
564
+ },
565
+ );
566
+
567
+ if (existingGeneration) {
568
+ console.warn(
569
+ `Generation already in progress for project: ${projectId}`,
570
+ );
571
+ return existingGeneration[0]; // Return existing session_id
572
+ }
573
+
574
+ try {
575
+ const result = await api.continueGeneration(projectId, {
576
+ model: this.planningModel,
577
+ coding_model: this.codingModel,
578
+ openai_api_key: this.apiKeys.openai,
579
+ anthropic_api_key: this.apiKeys.anthropic,
580
+ gemini_api_key: this.apiKeys.gemini,
581
+ max_retries: this.maxRetries,
582
+ });
583
+ // Extract clean display name from project ID (remove UUID suffix)
584
+ const displayNameMatch = projectId.match(/^(.+?)_[a-f0-9]{8}$/);
585
+ const displayName = (displayNameMatch?.[1] ?? projectId).replace(
586
+ /_/g,
587
+ " ",
588
+ );
589
+
590
+ this.activeGenerations[result.session_id] = {
591
+ projectId: result.project_id || projectId, // Store unique project ID
592
+ projectName: displayName, // Clean display name for UI
593
+ status: "continuing",
594
+ timestamp: Date.now(),
595
+ };
596
+ this._persistSessions();
597
+ // Automatically start listening for progress
598
+ this.initWebSocket(result.session_id);
599
+ return result.session_id;
600
+ } catch (error: any) {
601
+ console.error(`Failed to continue generation for ${projectId}:`, error);
602
+ throw error;
603
+ }
604
+ },
605
+
606
+ async draftPlan(topic: string, persona: string, targetDuration: string) {
607
+ if (this.isDrafting) return;
608
+
609
+ this.isDrafting = true;
610
+ const originalDraft = this.draftTopic;
611
+ let generatedDraft = "";
612
+
613
+ try {
614
+ const response = await api.draftPlan({
615
+ topic: topic,
616
+ persona: persona,
617
+ model: this.planningModel,
618
+ target_duration: targetDuration,
619
+ openai_api_key: this.apiKeys.openai,
620
+ anthropic_api_key: this.apiKeys.anthropic,
621
+ gemini_api_key: this.apiKeys.gemini,
622
+ });
623
+
624
+ if (!response.body) throw new Error("No response body");
625
+ const reader = response.body.getReader();
626
+ const decoder = new TextDecoder();
627
+
628
+ while (true) {
629
+ const { done, value } = await reader.read();
630
+ if (done) break;
631
+
632
+ const chunk = decoder.decode(value, { stream: true });
633
+
634
+ // Check for error markers in the stream
635
+ if (chunk.startsWith("Error:")) {
636
+ let userMessage = "Drafting failed. Please try again later.";
637
+
638
+ // Map common technical errors to user-friendly messages
639
+ if (
640
+ chunk.includes("503") ||
641
+ chunk.includes("high demand") ||
642
+ chunk.includes("ServiceUnavailableError")
643
+ ) {
644
+ userMessage =
645
+ "AI models are currently under heavy load. Let's try again in a few moments!";
646
+ } else if (chunk.includes("429") || chunk.includes("rate limit")) {
647
+ userMessage =
648
+ "We've hit a temporary rate limit. Please wait a minute before trying again.";
649
+ }
650
+
651
+ throw new Error(userMessage);
652
+ }
653
+
654
+ generatedDraft += chunk;
655
+ }
656
+
657
+ // Only replace user's text when generation succeeds with non-empty output.
658
+ if (generatedDraft.trim().length > 0) {
659
+ this.draftTopic = generatedDraft;
660
+ } else {
661
+ this.draftTopic = originalDraft;
662
+ }
663
+ } catch (error: any) {
664
+ console.error("Failed to draft plan:", error);
665
+ // Preserve what the user has written when drafting fails.
666
+ this.draftTopic = originalDraft;
667
+ throw error;
668
+ } finally {
669
+ this.isDrafting = false;
670
+ }
671
+ },
672
+
673
+ async testApiKey(provider: string, key: string) {
674
+ try {
675
+ return await api.testApiKey({ provider, key });
676
+ } catch (error: any) {
677
+ console.error(`Failed to test ${provider} key:`, error);
678
+ return { status: "error", message: error.message };
679
+ }
680
+ },
681
+
682
+ // Plan Approval Actions
683
+ async generatePlanOnly(topic: string, description: string) {
684
+ this.loading = true;
685
+ this.pendingPlanStartAt = Date.now();
686
+ // Show immediate feedback while waiting for backend to create session/project id.
687
+ this.generationState.loading = true;
688
+ this.generationState.completed = false;
689
+ this.generationState.errorMsg = null;
690
+ this.generationState.friendlyStatus = "Starting generation";
691
+ this.generationState.currentTask = "Preparing project...";
692
+ try {
693
+ const result = await api.generatePlan({
694
+ topic,
695
+ description,
696
+ model: this.planningModel,
697
+ openai_api_key: this.apiKeys.openai,
698
+ anthropic_api_key: this.apiKeys.anthropic,
699
+ gemini_api_key: this.apiKeys.gemini,
700
+ max_retries: this.maxRetries,
701
+ use_rag: this.useRag,
702
+ target_duration: this.targetDuration,
703
+ voice: this.selectedVoice,
704
+ });
705
+
706
+ // Add to active generations to track the "Designing plan..." phase
707
+ this.activeGenerations[result.session_id] = {
708
+ projectId: result.project_id,
709
+ projectName: topic,
710
+ status: "planning",
711
+ timestamp: Date.now(),
712
+ };
713
+ this._persistSessions();
714
+ this.initWebSocket(result.session_id);
715
+
716
+ return result;
717
+ } catch (error: any) {
718
+ console.error("Failed to generate plan:", error);
719
+ this.generationState.loading = false;
720
+ this.generationState.errorMsg =
721
+ error?.message || "Failed to start generation";
722
+ throw error;
723
+ } finally {
724
+ this.pendingPlanStartAt = null;
725
+ this.loading = false;
726
+ }
727
+ },
728
+
729
+ async revisePlan(projectId: string, feedbacks: Record<number, string>) {
730
+ if (this.isRevising) return;
731
+ this.isRevising = true;
732
+
733
+ try {
734
+ const result = await api.revisePlan(projectId, {
735
+ feedbacks,
736
+ model: this.planningModel,
737
+ openai_api_key: this.apiKeys.openai,
738
+ anthropic_api_key: this.apiKeys.anthropic,
739
+ gemini_api_key: this.apiKeys.gemini,
740
+ max_retries: this.maxRetries,
741
+ });
742
+
743
+ this.activeGenerations[result.session_id] = {
744
+ projectId: result.project_id || projectId,
745
+ projectName: projectId.replace(/_/g, " "),
746
+ status: "revising",
747
+ timestamp: Date.now(),
748
+ };
749
+ this.generationState.friendlyStatus = "Revising plans";
750
+ this.generationState.currentTask =
751
+ "Revising scene outline and implementation plans";
752
+ this._persistSessions();
753
+ this.initWebSocket(result.session_id);
754
+
755
+ return result.session_id;
756
+ } catch (error: any) {
757
+ console.error("Failed to revise plan:", error);
758
+ throw error;
759
+ } finally {
760
+ this.isRevising = false;
761
+ }
762
+ },
763
+
764
+ async approvePlan(projectId: string) {
765
+ try {
766
+ const result = await api.approvePlan(projectId, {
767
+ model: this.planningModel,
768
+ coding_model: this.codingModel,
769
+ openai_api_key: this.apiKeys.openai,
770
+ anthropic_api_key: this.apiKeys.anthropic,
771
+ gemini_api_key: this.apiKeys.gemini,
772
+ max_retries: this.maxRetries,
773
+ });
774
+ if (result?.session_id) {
775
+ this.activeGenerations[result.session_id] = {
776
+ projectId: result.project_id || projectId,
777
+ projectName: projectId.replace(/_/g, " "),
778
+ status: "continuing",
779
+ timestamp: Date.now(),
780
+ };
781
+ this.generationState.friendlyStatus = "Continuing generation";
782
+ this.generationState.currentTask =
783
+ "Generating scenes and final video";
784
+ this._persistSessions();
785
+ this.initWebSocket(result.session_id);
786
+ }
787
+ await this.fetchProject(projectId);
788
+ return result?.session_id || null;
789
+ } catch (error: any) {
790
+ console.error("Failed to approve plan:", error);
791
+ throw error;
792
+ }
793
+ },
794
+
795
+ // File Management
796
+ async getProjectFile(projectId: string, filePath: string) {
797
+ try {
798
+ return await api.getProjectFile(projectId, filePath);
799
+ } catch (error: any) {
800
+ console.error(`Failed to get file ${filePath}:`, error);
801
+ throw error;
802
+ }
803
+ },
804
+
805
+ // Evaluation
806
+ async evaluateProject(projectId: string, config: any = {}) {
807
+ this.evaluationLoading[projectId] = true;
808
+ try {
809
+ const result = await api.evaluateProject(projectId, {
810
+ eval_type: config.evalType || "all",
811
+ model_text: config.modelText || "gemini-3.1-flash-latest",
812
+ model_video: config.modelVideo || "gemini-3.1-flash-latest",
813
+ model_image: config.modelImage || "gemini-3.1-flash-latest",
814
+ openai_api_key: this.apiKeys.openai,
815
+ anthropic_api_key: this.apiKeys.anthropic,
816
+ gemini_api_key: this.apiKeys.gemini,
817
+ });
818
+ this.evaluationResults[projectId] = result;
819
+ return result;
820
+ } catch (error: any) {
821
+ console.error(`Failed to evaluate project ${projectId}:`, error);
822
+ throw error;
823
+ } finally {
824
+ this.evaluationLoading[projectId] = false;
825
+ }
826
+ },
827
+
828
+ // System
829
+ async fetchModels() {
830
+ // Recover keys first
831
+ this._recoverKeys();
832
+
833
+ // Guard against concurrent or redundant calls
834
+ if (this.loadingModels && this.availableModels.length > 0) return;
835
+ if (this.availableModels.length > 0) return;
836
+
837
+ this.loadingModels = true;
838
+ try {
839
+ const data = await api.getModels();
840
+ this.availableModels = data.models;
841
+
842
+ // Set defaults to the first available model if not already set
843
+ // Set defaults to the first available filtered model if not already set
844
+ const firstModel = this.filteredModels[0]?.value;
845
+ if (firstModel) {
846
+ if (!this.planningModel) this.planningModel = firstModel;
847
+ if (!this.codingModel) this.codingModel = firstModel;
848
+ if (!this.selectedModel) this.selectedModel = firstModel;
849
+ }
850
+ } catch (error) {
851
+ console.error("Failed to fetch models:", error);
852
+ } finally {
853
+ this.loadingModels = false;
854
+ }
855
+ },
856
+
857
+ async fetchSystemStatus() {
858
+ try {
859
+ this.systemStatus = await api.getSystemStatus();
860
+ } catch (error) {
861
+ console.error("Failed to fetch system status:", error);
862
+ }
863
+ },
864
+
865
+ async fetchVoices() {
866
+ // Recover keys first
867
+ this._recoverKeys();
868
+
869
+ // Guard against concurrent or redundant calls
870
+ if (this.loadingVoices && this.availableVoices.length > 0) return;
871
+ if (this.availableVoices.length > 0) return;
872
+
873
+ this.loadingVoices = true;
874
+ try {
875
+ const data = await api.getVoices();
876
+ this.availableVoices = data.voices;
877
+ if (this.availableVoices.length > 0) {
878
+ const selected = this.selectedVoice;
879
+ const byId = this.availableVoices.find((v: any) => v.id === selected);
880
+ if (!byId && selected) {
881
+ const byName = this.availableVoices.find(
882
+ (v: any) =>
883
+ (v.name || "").toLowerCase() === selected.toLowerCase(),
884
+ );
885
+ if (byName?.id) {
886
+ this.selectedVoice = byName.id;
887
+ }
888
+ }
889
+
890
+ // Set default if still not set or invalid
891
+ const finalIsValid = this.availableVoices.some(
892
+ (v: any) => v.id === this.selectedVoice,
893
+ );
894
+ if (!finalIsValid) {
895
+ this.selectedVoice = this.availableVoices[0].id;
896
+ }
897
+ }
898
+ } catch (error) {
899
+ console.error("Failed to fetch voices:", error);
900
+ } finally {
901
+ this.loadingVoices = false;
902
+ }
903
+ },
904
+
905
+ // Utility
906
+ initKeys() {
907
+ this._recoverKeys();
908
+ },
909
+
910
+ setApiKeys(keys: any) {
911
+ this.apiKeys = { ...this.apiKeys, ...keys };
912
+ this._persistKeys();
913
+ this._validateModelsAfterKeyChange();
914
+ },
915
+
916
+ setApiKey(provider: string, key: string) {
917
+ this.apiKeys[provider as keyof typeof this.apiKeys] = key;
918
+ this._persistKeys();
919
+ this._validateModelsAfterKeyChange();
920
+ },
921
+
922
+ _validateModelsAfterKeyChange() {
923
+ // Validate current models against filtered list
924
+ const filteredValues = this.filteredModels.map((m: any) => m.value);
925
+ const firstValid = filteredValues[0] || "";
926
+
927
+ if (this.planningModel && !filteredValues.includes(this.planningModel)) {
928
+ this.planningModel = firstValid;
929
+ }
930
+ if (this.codingModel && !filteredValues.includes(this.codingModel)) {
931
+ this.codingModel = firstValid;
932
+ }
933
+ if (this.selectedModel && !filteredValues.includes(this.selectedModel)) {
934
+ this.selectedModel = firstValid;
935
+ }
936
+ },
937
+
938
+ updateGenerationStatus(sessionId: string, status: string) {
939
+ if (this.activeGenerations[sessionId]) {
940
+ this.activeGenerations[sessionId].status = status;
941
+ this._persistSessions();
942
+ }
943
+ },
944
+
945
+ removeActiveGeneration(sessionId: string) {
946
+ delete this.activeGenerations[sessionId];
947
+ this._persistSessions();
948
+ },
949
+
950
+ _persistSessions() {
951
+ localStorage.setItem(
952
+ "activeGenerations",
953
+ JSON.stringify(this.activeGenerations),
954
+ );
955
+ },
956
+
957
+ _recoverSessions() {
958
+ const saved = localStorage.getItem("activeGenerations");
959
+ if (saved) {
960
+ try {
961
+ this.activeGenerations = JSON.parse(saved);
962
+
963
+ // Re-establish current session if lost but active generations exist
964
+ if (!this.currentSessionId) {
965
+ const active = Object.entries(this.activeGenerations).filter(
966
+ ([_, g]) =>
967
+ g.status === "started" ||
968
+ g.status === "planning" ||
969
+ g.status === "revising" ||
970
+ g.status === "retrying" ||
971
+ g.status === "continuing",
972
+ );
973
+
974
+ if (active.length > 0) {
975
+ const sorted = active.sort(
976
+ (a, b) => (b[1].timestamp || 0) - (a[1].timestamp || 0),
977
+ );
978
+ const first = sorted[0];
979
+ if (first) {
980
+ const latestSessionId = first[0];
981
+ console.log(
982
+ `RecoverSessions: Restored current session ID: ${latestSessionId}`,
983
+ );
984
+ this.currentSessionId = latestSessionId;
985
+
986
+ // Only auto-connect if we're in the browser and not already connected
987
+ if (import.meta.client && !this.ws) {
988
+ this.initWebSocket(latestSessionId);
989
+ }
990
+ }
991
+ }
992
+ }
993
+ } catch (e: any) {
994
+ console.error("Failed to recover sessions", e);
995
+ }
996
+ }
997
+ },
998
+
999
+ _persistKeys() {
1000
+ localStorage.setItem("apiKeys", JSON.stringify(this.apiKeys));
1001
+ },
1002
+
1003
+ _recoverKeys() {
1004
+ const saved = localStorage.getItem("apiKeys");
1005
+ if (saved) {
1006
+ try {
1007
+ const keys = JSON.parse(saved);
1008
+ this.apiKeys = { ...this.apiKeys, ...keys };
1009
+ } catch (e: any) {
1010
+ console.error("Failed to recover keys", e);
1011
+ }
1012
+ }
1013
+ },
1014
+
1015
+ // Session recovery after WebSocket failure
1016
+ async recoverActiveSession() {
1017
+ try {
1018
+ const response = await api.getActiveSessions();
1019
+ const sessions = response.sessions || [];
1020
+
1021
+ if (sessions.length === 0) {
1022
+ console.log("No active sessions to recover");
1023
+ return null;
1024
+ }
1025
+
1026
+ // Get the most recently updated session
1027
+ const sorted = sessions.sort(
1028
+ (a: any, b: any) =>
1029
+ new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
1030
+ );
1031
+
1032
+ const latestSession = sorted[0];
1033
+ console.log(
1034
+ `Recovering session: ${latestSession.session_id} for project: ${latestSession.project_id}`,
1035
+ );
1036
+
1037
+ // Extract clean display name from project ID
1038
+ const projectId = latestSession.project_id;
1039
+ const displayNameMatch = projectId.match(/^(.+?)_[a-f0-9]{8}$/);
1040
+ const displayName = (displayNameMatch?.[1] ?? projectId).replace(
1041
+ /_/g,
1042
+ " ",
1043
+ );
1044
+
1045
+ // Re-attach to the session
1046
+ this.currentSessionId = latestSession.session_id;
1047
+ this.activeGenerations[latestSession.session_id] = {
1048
+ projectId: projectId,
1049
+ projectName: displayName, // Clean display name instead of full ID
1050
+ status:
1051
+ latestSession.status === "active"
1052
+ ? "recovered"
1053
+ : latestSession.status,
1054
+ timestamp: Date.now(),
1055
+ };
1056
+
1057
+ this._persistSessions();
1058
+ this.initWebSocket(latestSession.session_id);
1059
+
1060
+ return latestSession.session_id;
1061
+ } catch (error: any) {
1062
+ console.error("Failed to recover active session:", error);
1063
+ throw error;
1064
+ }
1065
+ },
1066
+
1067
+ // Onboarding
1068
+ startOnboarding() {
1069
+ console.log("STORE: startOnboarding called");
1070
+ this.onboarding.active = true;
1071
+ this.onboarding.currentStep = 0;
1072
+ },
1073
+
1074
+ setOnboardingStep(step: number) {
1075
+ console.log("STORE: setOnboardingStep", step);
1076
+ this.onboarding.currentStep = step;
1077
+ },
1078
+
1079
+ stopOnboarding() {
1080
+ console.log("STORE: stopOnboarding called");
1081
+ this.onboarding.active = false;
1082
+ },
1083
+
1084
+ // Background Persistence Actions
1085
+ resetGenerationState() {
1086
+ this.currentSessionId = null;
1087
+ this.generationState = {
1088
+ loading: false,
1089
+ completed: false,
1090
+ logs: [],
1091
+ sceneStatuses: {},
1092
+ totalScenes: 0,
1093
+ currentScene: 0,
1094
+ currentSceneSteps: 0,
1095
+ currentPhase: 0,
1096
+ logCount: 0,
1097
+ friendlyTitle: "Production Studio",
1098
+ friendlyStatus: "Ready to produce",
1099
+ startTime: null,
1100
+ errorMsg: null,
1101
+ latestLog: "",
1102
+ currentTask: "",
1103
+ isReconnecting: false,
1104
+ reconnectAttempts: 0,
1105
+ accumulatedCost: 0,
1106
+ completedProjectId: null,
1107
+ completedProjectTitle: null,
1108
+ stageProgress: {},
1109
+ rateLimitCountdown: 0,
1110
+ rateLimitInterval: null,
1111
+ };
1112
+ if (this.ws) {
1113
+ this.ws.close();
1114
+ this.ws = null;
1115
+ }
1116
+ },
1117
+
1118
+ clearCurrentProject() {
1119
+ this.activeGenerations = {};
1120
+ this._persistSessions();
1121
+ this.resetGenerationState();
1122
+ },
1123
+
1124
+ _persistConfig() {
1125
+ if (import.meta.client) {
1126
+ localStorage.setItem(
1127
+ "useVisualFixCode",
1128
+ JSON.stringify(this.useVisualFixCode),
1129
+ );
1130
+ }
1131
+ },
1132
+
1133
+ _recoverConfig() {
1134
+ if (import.meta.client) {
1135
+ const saved = localStorage.getItem("useVisualFixCode");
1136
+ if (saved !== null) {
1137
+ try {
1138
+ this.useVisualFixCode = JSON.parse(saved);
1139
+ } catch (e) {
1140
+ console.error("Failed to recover config", e);
1141
+ }
1142
+ }
1143
+ }
1144
+ },
1145
+
1146
+ initWebSocket(sessionId: string) {
1147
+ if (
1148
+ this.ws &&
1149
+ (this.ws.readyState === WebSocket.OPEN ||
1150
+ this.ws.readyState === WebSocket.CONNECTING)
1151
+ ) {
1152
+ if (this.currentSessionId === sessionId) {
1153
+ console.log("WebSocket already connected for session:", sessionId);
1154
+ return;
1155
+ }
1156
+ this.ws.close();
1157
+ }
1158
+
1159
+ this.currentSessionId = sessionId;
1160
+ // Don't reset everything, just ensure flags are set, so we don't clear logs if reconnecting
1161
+ if (!this.generationState.isReconnecting) {
1162
+ this.generationState.loading = true;
1163
+ this.generationState.errorMsg = null;
1164
+ }
1165
+ this.generationState.completed = false;
1166
+
1167
+ // Use relative path for production or absolute for local dev
1168
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
1169
+ const host =
1170
+ window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"
1171
+ ? `${window.location.hostname}:8001`
1172
+ : window.location.host;
1173
+ this.ws = new WebSocket(`${protocol}//${host}/ws/logs/${sessionId}`);
1174
+
1175
+ this.ws.onopen = () => {
1176
+ console.log("WebSocket connected for session:", sessionId);
1177
+ this.generationState.isReconnecting = false;
1178
+ this.generationState.reconnectAttempts = 0;
1179
+ this.generationState.errorMsg = null;
1180
+ this.generationState.loading = true;
1181
+ };
1182
+
1183
+ this.ws.onmessage = (event) => {
1184
+ const msg = event.data;
1185
+ this.handleLogMessage(msg);
1186
+
1187
+ if (msg === "COMPLETED") {
1188
+ // Save project info before removing active generation
1189
+ const active = this.activeGenerations[sessionId];
1190
+ let completedProjectId: string | null = null;
1191
+ const sessionType = active?.status || "";
1192
+ const isPlanOrRevisionSession =
1193
+ sessionType === "planning" || sessionType === "revising";
1194
+ if (active) {
1195
+ completedProjectId = active.projectId || active.projectName;
1196
+ // Only mark "project ready" for full generation runs.
1197
+ if (!isPlanOrRevisionSession) {
1198
+ this.generationState.completedProjectId = completedProjectId;
1199
+ this.generationState.completedProjectTitle = active.projectName;
1200
+ }
1201
+ }
1202
+
1203
+ this.generationState.loading = false;
1204
+ this.generationState.completed = !isPlanOrRevisionSession;
1205
+ this.generationState.currentPhase = 5;
1206
+ this.removeActiveGeneration(sessionId);
1207
+ this.currentSessionId = null;
1208
+ this.ws?.close();
1209
+ this.fetchProjects()
1210
+ .then(async () => {
1211
+ // Ensure detailed project payload (with updated plans/scenes) wins over list payload.
1212
+ if (completedProjectId) {
1213
+ const refreshed = await this.fetchProject(completedProjectId);
1214
+ const hasVideo = !!refreshed?.video_url;
1215
+ const hasVtt = Array.isArray(refreshed?.files)
1216
+ ? refreshed.files.some((f: string) => f.endsWith(".vtt"))
1217
+ : false;
1218
+
1219
+ // Some runs surface the mp4 slightly earlier than the .vtt on first refresh.
1220
+ // Do a couple of delayed refetches so subtitles attach without manual page reload.
1221
+ if (hasVideo && !hasVtt) {
1222
+ for (const delayMs of [1200, 2500]) {
1223
+ await new Promise((resolve) =>
1224
+ setTimeout(resolve, delayMs),
1225
+ );
1226
+ const retryProject =
1227
+ await this.fetchProject(completedProjectId);
1228
+ const retryHasVtt = Array.isArray(retryProject?.files)
1229
+ ? retryProject.files.some((f: string) =>
1230
+ f.endsWith(".vtt"),
1231
+ )
1232
+ : false;
1233
+ if (retryHasVtt) break;
1234
+ }
1235
+ }
1236
+ }
1237
+ })
1238
+ .catch((err) =>
1239
+ console.warn(
1240
+ "Failed to refresh project list/details on completion:",
1241
+ err,
1242
+ ),
1243
+ );
1244
+ } else if (msg === "STOPPED") {
1245
+ // Get project ID before removing session
1246
+ const active = this.activeGenerations[sessionId];
1247
+ const projectId = active?.projectId || active?.projectName;
1248
+
1249
+ this.generationState.loading = false;
1250
+ this.removeActiveGeneration(sessionId);
1251
+ this.currentSessionId = null;
1252
+ this.ws?.close();
1253
+ this.fetchProjects();
1254
+
1255
+ // Also update local project status immediately
1256
+ if (projectId) {
1257
+ const projectIndex = this.projects.findIndex(
1258
+ (p) => p.id === projectId,
1259
+ );
1260
+ if (projectIndex !== -1) {
1261
+ this.projects[projectIndex].status = "stopped";
1262
+ }
1263
+ }
1264
+ } else if (msg.startsWith("ERROR")) {
1265
+ // Get project ID before removing session
1266
+ const active = this.activeGenerations[sessionId];
1267
+ const projectId = active?.projectId || active?.projectName;
1268
+
1269
+ this.generationState.loading = false;
1270
+ this.generationState.errorMsg = msg;
1271
+ this.removeActiveGeneration(sessionId);
1272
+ this.currentSessionId = null;
1273
+ this.ws?.close();
1274
+
1275
+ // Update local project status to error
1276
+ if (projectId) {
1277
+ const projectIndex = this.projects.findIndex(
1278
+ (p) => p.id === projectId,
1279
+ );
1280
+ if (projectIndex !== -1) {
1281
+ this.projects[projectIndex].status = "error";
1282
+ this.projects[projectIndex].error_details = msg;
1283
+ }
1284
+ }
1285
+ }
1286
+ };
1287
+
1288
+ this.ws.onerror = () => {
1289
+ console.error("WebSocket error for session:", sessionId);
1290
+ };
1291
+
1292
+ this.ws.onclose = () => {
1293
+ const gen = this.activeGenerations[sessionId];
1294
+ const isStopped = gen && gen.status === "stopped";
1295
+
1296
+ if (
1297
+ !this.generationState.completed &&
1298
+ !isStopped &&
1299
+ this.currentSessionId === sessionId
1300
+ ) {
1301
+ this.handleReconnection(sessionId);
1302
+ }
1303
+ };
1304
+ },
1305
+
1306
+ handleReconnection(sessionId: string) {
1307
+ if (this.generationState.reconnectAttempts >= 10) {
1308
+ this.generationState.loading = false;
1309
+ this.generationState.isReconnecting = false;
1310
+ this.generationState.errorMsg =
1311
+ "Connection lost. Max reconnection attempts reached.";
1312
+ return;
1313
+ }
1314
+
1315
+ this.generationState.isReconnecting = true;
1316
+ this.generationState.reconnectAttempts++;
1317
+
1318
+ const delay = Math.min(
1319
+ 1000 * Math.pow(1.5, this.generationState.reconnectAttempts),
1320
+ 15000,
1321
+ );
1322
+ console.log(
1323
+ `Attempting reconnection (${this.generationState.reconnectAttempts}) in ${delay}ms...`,
1324
+ );
1325
+
1326
+ setTimeout(() => {
1327
+ if (this.currentSessionId === sessionId) {
1328
+ this.initWebSocket(sessionId);
1329
+ }
1330
+ }, delay);
1331
+ },
1332
+
1333
+ handleLogMessage(rawMsg: string) {
1334
+ const clearRetryCountdown = () => {
1335
+ if (this.generationState.rateLimitInterval) {
1336
+ clearInterval(this.generationState.rateLimitInterval);
1337
+ this.generationState.rateLimitInterval = null;
1338
+ }
1339
+ this.generationState.rateLimitCountdown = 0;
1340
+ };
1341
+
1342
+ const startRetryCountdown = (
1343
+ retrySecs: number,
1344
+ label: string,
1345
+ waitingTask: string,
1346
+ ) => {
1347
+ clearRetryCountdown();
1348
+ this.generationState.rateLimitCountdown = retrySecs;
1349
+ this.generationState.friendlyStatus = `${label} - Retrying in ${retrySecs}s`;
1350
+ this.generationState.currentTask = waitingTask;
1351
+
1352
+ this.generationState.rateLimitInterval = setInterval(() => {
1353
+ this.generationState.rateLimitCountdown--;
1354
+ if (this.generationState.rateLimitCountdown <= 0) {
1355
+ clearRetryCountdown();
1356
+ this.generationState.friendlyStatus = `${label} - Retrying...`;
1357
+ } else {
1358
+ this.generationState.friendlyStatus = `${label} - Retrying in ${this.generationState.rateLimitCountdown}s`;
1359
+ }
1360
+ }, 1000);
1361
+ };
1362
+
1363
+ const getProviderLabel = (model: string) => {
1364
+ const m = String(model || "").toLowerCase();
1365
+ if (m.includes("gemini")) return "Gemini";
1366
+ if (m.includes("claude")) return "Claude";
1367
+ if (m.includes("gpt") || m.includes("openai")) return "OpenAI";
1368
+ if (m.includes("vertex")) return "Provider";
1369
+ return "Model provider";
1370
+ };
1371
+
1372
+ const getActiveSceneCount = () =>
1373
+ Object.values(this.generationState.sceneStatuses).filter(
1374
+ (s) => s === "active",
1375
+ ).length;
1376
+
1377
+ const setActiveScenesTask = () => {
1378
+ const activeCount = getActiveSceneCount();
1379
+ this.generationState.currentTask =
1380
+ activeCount > 0
1381
+ ? `Processing scenes (${activeCount} active)`
1382
+ : "Rendering scenes...";
1383
+ };
1384
+
1385
+ const stripAnsi = (str: string) =>
1386
+ str.replace(
1387
+ /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
1388
+ "",
1389
+ );
1390
+
1391
+ const lines = rawMsg.split("\n").filter((line) => line.trim().length > 0);
1392
+
1393
+ for (const rawLine of lines) {
1394
+ const msg = stripAnsi(rawLine);
1395
+ this.generationState.logCount++;
1396
+
1397
+ if (msg.startsWith("|STATUS|")) {
1398
+ try {
1399
+ const payload = JSON.parse(msg.substring(8));
1400
+ const { type, data } = payload;
1401
+
1402
+ switch (type) {
1403
+ case "PHASE_START":
1404
+ case "SCENE_COUNT":
1405
+ case "SCENE_START":
1406
+ case "SCENE_PLAN_START":
1407
+ case "SUBPLAN_START":
1408
+ case "SCENE_FIX_START":
1409
+ case "SCENE_COMPLETE":
1410
+ case "STAGE_PROGRESS":
1411
+ case "PIPELINE_COMPLETE":
1412
+ clearRetryCountdown();
1413
+ switch (type) {
1414
+ case "PHASE_START":
1415
+ if (data.phase_number)
1416
+ this.generationState.currentPhase = data.phase_number;
1417
+ this.generationState.friendlyStatus =
1418
+ data.phase || "Processing";
1419
+ this.generationState.currentTask = "";
1420
+ if (
1421
+ data.phase_number === 4 ||
1422
+ /scene|render/i.test(String(data.phase || ""))
1423
+ ) {
1424
+ this.generationState.sceneStatuses = {};
1425
+ }
1426
+ break;
1427
+ case "SCENE_COUNT":
1428
+ this.generationState.totalScenes = data.count;
1429
+ break;
1430
+ case "SCENE_START":
1431
+ this.generationState.currentScene = data.scene_number;
1432
+ this.generationState.sceneStatuses = {
1433
+ ...this.generationState.sceneStatuses,
1434
+ [String(data.scene_number)]: "active",
1435
+ };
1436
+ setActiveScenesTask();
1437
+ break;
1438
+ case "SCENE_PLAN_START":
1439
+ this.generationState.currentScene = data.scene_number;
1440
+ this.generationState.sceneStatuses = {
1441
+ ...this.generationState.sceneStatuses,
1442
+ [String(data.scene_number)]: "active",
1443
+ };
1444
+ setActiveScenesTask();
1445
+ break;
1446
+ case "SUBPLAN_START":
1447
+ this.generationState.currentScene = data.scene_number;
1448
+ this.generationState.sceneStatuses = {
1449
+ ...this.generationState.sceneStatuses,
1450
+ [String(data.scene_number)]: "active",
1451
+ };
1452
+ setActiveScenesTask();
1453
+ break;
1454
+ case "SCENE_FIX_START":
1455
+ this.generationState.currentScene = data.scene_number;
1456
+ this.generationState.sceneStatuses = {
1457
+ ...this.generationState.sceneStatuses,
1458
+ [String(data.scene_number)]: "active",
1459
+ };
1460
+ setActiveScenesTask();
1461
+ break;
1462
+ case "SCENE_COMPLETE":
1463
+ this.generationState.sceneStatuses = {
1464
+ ...this.generationState.sceneStatuses,
1465
+ [String(data.scene_number)]: data.success
1466
+ ? "completed"
1467
+ : "failed",
1468
+ };
1469
+ setActiveScenesTask();
1470
+ break;
1471
+ case "STAGE_PROGRESS":
1472
+ this.generationState.stageProgress = {
1473
+ ...this.generationState.stageProgress,
1474
+ [data.stage_name]: {
1475
+ current: data.current,
1476
+ total: data.total,
1477
+ },
1478
+ };
1479
+ this.generationState.friendlyStatus = data.stage_name;
1480
+ break;
1481
+ case "PIPELINE_COMPLETE":
1482
+ this.generationState.currentTask = "Pipeline complete";
1483
+ break;
1484
+ }
1485
+ break;
1486
+ case "RATE_LIMIT": {
1487
+ const retrySecs = Math.round(data.retry_in || 60);
1488
+ startRetryCountdown(
1489
+ retrySecs,
1490
+ "Rate Limited",
1491
+ `${data.error || "API rate limit hit"}`,
1492
+ );
1493
+ this.generationState.latestLog = `Rate limited for ${data.model || "model"}. Will retry automatically.`;
1494
+ break;
1495
+ }
1496
+ case "API_ERROR": {
1497
+ const apiRetrySecs = Math.round(data.retry_in || 5);
1498
+ const providerLabel = getProviderLabel(
1499
+ String(data.model || ""),
1500
+ );
1501
+ const shortDetail = String(
1502
+ data.detail || data.error || "Temporary provider issue",
1503
+ ).replace(/\s+/g, " ");
1504
+ const compactDetail =
1505
+ shortDetail.length > 180
1506
+ ? `${shortDetail.slice(0, 180)}...`
1507
+ : shortDetail;
1508
+ startRetryCountdown(
1509
+ apiRetrySecs,
1510
+ "Provider Unavailable",
1511
+ `${providerLabel} is temporarily unavailable. Retrying automatically.`,
1512
+ );
1513
+ this.generationState.latestLog = compactDetail;
1514
+ break;
1515
+ }
1516
+ }
1517
+ continue;
1518
+ } catch (e) {
1519
+ console.error("Failed to parse status message:", e);
1520
+ }
1521
+ }
1522
+
1523
+ this.generationState.latestLog = msg;
1524
+ this.generationState.logs.push(msg);
1525
+ if (this.generationState.logs.length > 100) {
1526
+ this.generationState.logs.shift();
1527
+ }
1528
+
1529
+ const transientMatch = msg.match(
1530
+ /\[LiteLLM\]\s+Transient error.*Retrying in\s+([\d.]+)s/i,
1531
+ );
1532
+ if (transientMatch?.[1]) {
1533
+ const parsedSecs = Math.max(
1534
+ 1,
1535
+ Math.round(parseFloat(transientMatch[1])),
1536
+ );
1537
+ startRetryCountdown(
1538
+ parsedSecs,
1539
+ "Provider Unavailable",
1540
+ "Temporary provider issue, retrying automatically",
1541
+ );
1542
+ }
1543
+
1544
+ const totalScenesMatch =
1545
+ msg.match(/Total Scenes: (\d+)/i) ||
1546
+ msg.match(/plan for (\d+) scenes/i);
1547
+ if (totalScenesMatch?.[1]) {
1548
+ this.generationState.totalScenes = parseInt(totalScenesMatch[1]);
1549
+ }
1550
+
1551
+ if (msg.match(/Phase 1|CREATING PLAN|PLANNING/i)) {
1552
+ this.generationState.currentPhase = 1;
1553
+ this.generationState.friendlyStatus = "Planning project";
1554
+ } else if (msg.match(/Phase 2|GENERATING SCRIPTS|WRITING/i)) {
1555
+ this.generationState.currentPhase = 2;
1556
+ this.generationState.friendlyStatus = "Writing scripts";
1557
+ } else if (msg.match(/Phase 3|GENERATING AUDIO|SYNTHESIZING/i)) {
1558
+ this.generationState.currentPhase = 3;
1559
+ this.generationState.friendlyStatus = "Synthesizing audio";
1560
+ } else if (msg.match(/Phase 4|PRODUCING SCENES|RENDERING/i)) {
1561
+ this.generationState.currentPhase = 4;
1562
+ this.generationState.friendlyStatus = "Rendering scenes";
1563
+ } else if (msg.match(/Phase 5|MERGING VIDEO|FINALIZING/i)) {
1564
+ this.generationState.currentPhase = 5;
1565
+ this.generationState.friendlyStatus = "Finalizing video";
1566
+ }
1567
+
1568
+ const sceneMatch =
1569
+ msg.match(/Scene (\d+)[\/| of ](\d+)/i) ||
1570
+ msg.match(/\[(\d+)\/(\d+)\]/);
1571
+ if (sceneMatch?.[1] && sceneMatch?.[2]) {
1572
+ this.generationState.currentScene = parseInt(sceneMatch[1] as string);
1573
+ this.generationState.totalScenes = parseInt(sceneMatch[2] as string);
1574
+ }
1575
+
1576
+ const stepMatch = msg.match(/Step (\d+)\/(\d+)/i);
1577
+ if (stepMatch?.[1]) {
1578
+ this.generationState.currentSceneSteps = parseInt(
1579
+ stepMatch[1] as string,
1580
+ );
1581
+ }
1582
+
1583
+ const costMatch = msg.match(/COST_UPDATE:([\.\d]+):([\.\d]+)/i);
1584
+ if (costMatch) {
1585
+ const cost1 = parseFloat(costMatch[1] || "0") || 0;
1586
+ const cost2 = parseFloat(costMatch[2] || "0") || 0;
1587
+ const newCost = Math.max(cost1, cost2);
1588
+ this.generationState.accumulatedCost = Math.max(
1589
+ this.generationState.accumulatedCost,
1590
+ newCost,
1591
+ );
1592
+ }
1593
+ }
1594
+ },
1595
+ },
1596
+ });
frontend/app/stores/quiz.ts ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineStore } from "pinia";
2
+ import api from "../utils/api";
3
+ import { useProjectStore } from "./projects";
4
+
5
+ interface Question {
6
+ question: string;
7
+ options: string[];
8
+ answer: string;
9
+ explanation: string;
10
+ }
11
+
12
+ interface QuizState {
13
+ state: "config" | "taking" | "results";
14
+ questions: Question[];
15
+ currentQuestionIndex: number;
16
+ answers: Record<number, string>;
17
+ score: number;
18
+ settings: {
19
+ count: number;
20
+ difficulty: string;
21
+ type: string;
22
+ };
23
+ }
24
+
25
+ export const useQuizStore = defineStore("quiz", {
26
+ state: () => ({
27
+ quizzes: {} as Record<string, QuizState>,
28
+ loading: false,
29
+ error: null as string | null,
30
+ abortController: null as AbortController | null,
31
+ }),
32
+
33
+ getters: {
34
+ getQuizState(state) {
35
+ return (projectId: string) => state.quizzes[projectId]?.state || "config";
36
+ },
37
+ getQuestions(state) {
38
+ return (projectId: string) => state.quizzes[projectId]?.questions || [];
39
+ },
40
+ getCurrentQuestionIndex(state) {
41
+ return (projectId: string) =>
42
+ state.quizzes[projectId]?.currentQuestionIndex || 0;
43
+ },
44
+ getCurrentAnswer(state) {
45
+ return (projectId: string) => {
46
+ const quiz = state.quizzes[projectId];
47
+ if (!quiz) return null;
48
+ return quiz.answers[quiz.currentQuestionIndex];
49
+ };
50
+ },
51
+ getScore(state) {
52
+ return (projectId: string) => state.quizzes[projectId]?.score || 0;
53
+ },
54
+ hasQuiz(state) {
55
+ return (projectId: string) =>
56
+ !!state.quizzes[projectId] &&
57
+ state.quizzes[projectId].questions.length > 0;
58
+ },
59
+ },
60
+
61
+ actions: {
62
+ initQuiz(projectId: string) {
63
+ if (!this.quizzes[projectId]) {
64
+ this.quizzes[projectId] = {
65
+ state: "config",
66
+ questions: [],
67
+ currentQuestionIndex: 0,
68
+ answers: {},
69
+ score: 0,
70
+ settings: {
71
+ count: 3,
72
+ difficulty: "Medium",
73
+ type: "Multiple Choice",
74
+ },
75
+ };
76
+ }
77
+ },
78
+
79
+ updateSettings(
80
+ projectId: string,
81
+ settings: Partial<QuizState["settings"]>,
82
+ ) {
83
+ this.initQuiz(projectId);
84
+ if (this.quizzes[projectId]) {
85
+ this.quizzes[projectId]!.settings = {
86
+ ...this.quizzes[projectId]!.settings,
87
+ ...settings,
88
+ };
89
+ }
90
+ },
91
+
92
+ async generateQuiz(projectId: string, settings: QuizState["settings"]) {
93
+ if (this.abortController) {
94
+ this.abortController.abort();
95
+ }
96
+ this.abortController = new AbortController();
97
+
98
+ this.initQuiz(projectId);
99
+ this.loading = true;
100
+ this.error = null;
101
+
102
+ const projectStore = useProjectStore();
103
+
104
+ try {
105
+ if (this.quizzes[projectId]) {
106
+ this.quizzes[projectId]!.settings = settings;
107
+ }
108
+
109
+ const res = await api.generateQuiz(
110
+ {
111
+ project_id: projectId,
112
+ count: settings.count,
113
+ difficulty: settings.difficulty,
114
+ type: settings.type,
115
+ model:
116
+ projectStore.planningModel ||
117
+ projectStore.selectedModel ||
118
+ "gemini/gemini-3.1-flash-latest",
119
+ openai_api_key: projectStore.apiKeys.openai,
120
+ anthropic_api_key: projectStore.apiKeys.anthropic,
121
+ gemini_api_key: projectStore.apiKeys.gemini,
122
+ },
123
+ { signal: this.abortController.signal },
124
+ );
125
+
126
+ let questions = res.quiz.quiz || res.quiz;
127
+ if (!Array.isArray(questions)) {
128
+ questions = [];
129
+ }
130
+
131
+ if (this.quizzes[projectId]) {
132
+ this.quizzes[projectId]!.questions = questions;
133
+ this.quizzes[projectId]!.state = "taking";
134
+ this.quizzes[projectId]!.currentQuestionIndex = 0;
135
+ this.quizzes[projectId]!.answers = {};
136
+ this.quizzes[projectId]!.score = 0;
137
+ }
138
+ } catch (err: any) {
139
+ if (err.name === "CanceledError" || err.code === "ERR_CANCELED") {
140
+ console.log("Quiz generation canceled");
141
+ return;
142
+ }
143
+ console.error("Quiz generation failed:", err);
144
+ this.error = err.response?.data?.detail || err.message;
145
+ } finally {
146
+ if (
147
+ this.abortController &&
148
+ this.abortController.signal.aborted === false
149
+ ) {
150
+ this.loading = false;
151
+ this.abortController = null;
152
+ }
153
+ }
154
+ },
155
+
156
+ stopGeneration() {
157
+ if (this.abortController) {
158
+ this.abortController.abort();
159
+ this.abortController = null;
160
+ }
161
+ this.loading = false;
162
+ },
163
+
164
+ answerQuestion(projectId: string, option: string) {
165
+ const quiz = this.quizzes[projectId];
166
+ if (!quiz || quiz.state !== "taking") return;
167
+
168
+ const currentQ = quiz.questions[quiz.currentQuestionIndex];
169
+ if (!currentQ || quiz.answers[quiz.currentQuestionIndex]) return;
170
+
171
+ quiz.answers[quiz.currentQuestionIndex] = option;
172
+ if (option === currentQ.answer) {
173
+ quiz.score++;
174
+ }
175
+ },
176
+
177
+ nextQuestion(projectId: string) {
178
+ const quiz = this.quizzes[projectId];
179
+ if (!quiz) return;
180
+
181
+ if (quiz.currentQuestionIndex < quiz.questions.length - 1) {
182
+ quiz.currentQuestionIndex++;
183
+ } else {
184
+ quiz.state = "results";
185
+ }
186
+ },
187
+
188
+ resetQuiz(projectId: string) {
189
+ const quiz = this.quizzes[projectId];
190
+ if (!quiz) return;
191
+
192
+ quiz.currentQuestionIndex = 0;
193
+ quiz.answers = {};
194
+ quiz.score = 0;
195
+ quiz.state = "taking";
196
+ },
197
+
198
+ clearQuiz(projectId: string) {
199
+ if (this.quizzes[projectId]) {
200
+ this.quizzes[projectId].state = "config";
201
+ this.quizzes[projectId].questions = [];
202
+ this.quizzes[projectId].answers = {};
203
+ this.quizzes[projectId].score = 0;
204
+ this.quizzes[projectId].currentQuestionIndex = 0;
205
+ }
206
+ },
207
+ },
208
+ });
frontend/app/utils/api.ts ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from "axios";
2
+
3
+ export const config = {
4
+ API_BASE_URL: typeof window !== "undefined"
5
+ ? (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"
6
+ ? `${window.location.protocol}//${window.location.hostname}:8001`
7
+ : window.location.origin)
8
+ : "http://localhost:8001",
9
+ };
10
+
11
+ const client = axios.create({
12
+ baseURL: typeof window !== "undefined"
13
+ ? (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"
14
+ ? `${window.location.protocol}//${window.location.hostname}:8001/api`
15
+ : "/api")
16
+ : `${config.API_BASE_URL}/api`,
17
+ timeout: 3600000, // Increased timeout for slow local LLMs and evaluation requests
18
+ });
19
+
20
+ export default {
21
+ // Project Management
22
+ async getProjects() {
23
+ const response = await client.get("/projects");
24
+ return response.data;
25
+ },
26
+ async getProject(id: string) {
27
+ const response = await client.get(`/projects/${id}`);
28
+ return response.data;
29
+ },
30
+ async deleteProject(id: string) {
31
+ const response = await client.delete(`/projects/${id}`);
32
+ return response.data;
33
+ },
34
+
35
+ // Generation
36
+ async startGeneration(data: any) {
37
+ const response = await client.post("/generate", data);
38
+ return response.data;
39
+ },
40
+ async stopGeneration(sessionId: string, deleteDir: boolean = false) {
41
+ const response = await client.post(
42
+ `/generate/stop/${sessionId}?delete_dir=${deleteDir}`,
43
+ );
44
+ return response.data;
45
+ },
46
+ async retryScene(projectId: string, sceneNumber: number, data: any) {
47
+ const response = await client.post(
48
+ `/projects/${projectId}/retry-scene/${sceneNumber}`,
49
+ data,
50
+ );
51
+ return response.data;
52
+ },
53
+ async continueGeneration(projectId: string, data: any) {
54
+ const response = await client.post(`/projects/${projectId}/continue`, data);
55
+ return response.data;
56
+ },
57
+
58
+ // File Management
59
+ async getProjectFile(projectId: string, filePath: string) {
60
+ const response = await client.get(
61
+ `/projects/${projectId}/files/${filePath}`,
62
+ );
63
+ return response.data;
64
+ },
65
+
66
+ // Evaluation
67
+ async evaluateProject(projectId: string, evaluationConfig: any) {
68
+ const response = await client.post(`/projects/${projectId}/evaluate`, {
69
+ project_id: projectId,
70
+ ...evaluationConfig,
71
+ });
72
+ return response.data;
73
+ },
74
+
75
+ // System
76
+ async getModels() {
77
+ const response = await client.get("/models");
78
+ return response.data;
79
+ },
80
+ async getSystemStatus() {
81
+ const response = await client.get("/system/status");
82
+ return response.data;
83
+ },
84
+ async validateApiKeys(data: {
85
+ openai_api_key?: string;
86
+ anthropic_api_key?: string;
87
+ gemini_api_key?: string;
88
+ }) {
89
+ const response = await client.post("/validate-keys", data);
90
+ return response.data;
91
+ },
92
+ async getActiveSessions() {
93
+ const response = await client.get("/sessions/active");
94
+ return response.data;
95
+ },
96
+ async getVoices() {
97
+ const response = await client.get("/voices");
98
+ return response.data;
99
+ },
100
+
101
+ async previewVoice(voiceId: string): Promise<string> {
102
+ const response = await client.post(
103
+ "/voices/preview",
104
+ { voice_id: voiceId },
105
+ { responseType: "blob" },
106
+ );
107
+ return URL.createObjectURL(response.data);
108
+ },
109
+
110
+ async draftPlan(data: any) {
111
+ // Axios doesn't handle streams as easily as fetch for simple text/plain streams
112
+ const response = await fetch(`${config.API_BASE_URL}/api/assist/plan`, {
113
+ method: "POST",
114
+ headers: {
115
+ "Content-Type": "application/json",
116
+ },
117
+ body: JSON.stringify(data),
118
+ });
119
+
120
+ if (!response.ok) {
121
+ throw new Error(`HTTP error! status: ${response.status}`);
122
+ }
123
+
124
+ return response;
125
+ },
126
+
127
+ async testApiKey(data: any) {
128
+ const response = await client.post("/assist/test-key", data);
129
+ return response.data;
130
+ },
131
+
132
+ // Interactive Learning
133
+ async chat(projectId: string, data: any) {
134
+ const response = await client.post(`/assist/chat`, {
135
+ project_id: projectId,
136
+ ...data,
137
+ });
138
+ return response.data;
139
+ },
140
+ async chatSuggestions(projectId: string, data: any) {
141
+ const response = await client.post(
142
+ `/projects/${projectId}/chat/suggestions`,
143
+ data,
144
+ );
145
+ return response.data;
146
+ },
147
+ async generateQuiz(payload: any, axiosConfig: any) {
148
+ const response = await client.post("/assist/quiz", payload, axiosConfig);
149
+ return response.data;
150
+ },
151
+
152
+ // Plan Approval Workflow
153
+ async generatePlan(data: any) {
154
+ const response = await client.post("/generate/plan", data);
155
+ return response.data;
156
+ },
157
+
158
+ async revisePlan(
159
+ projectId: string,
160
+ data: any,
161
+ ) {
162
+ const response = await client.post(`/projects/${projectId}/revise-plan`, data);
163
+ return response.data;
164
+ },
165
+
166
+ async approvePlan(projectId: string, data: any = {}) {
167
+ const response = await client.post(`/projects/${projectId}/approve`, data);
168
+ return response.data;
169
+ },
170
+ };
frontend/eslint.config.mjs ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ // @ts-check
2
+ import withNuxt from './.nuxt/eslint.config.mjs'
3
+
4
+ export default withNuxt(
5
+ // Your custom configs here
6
+ )
frontend/nuxt.config.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default defineNuxtConfig({
2
+ modules: ['@nuxt/eslint', '@nuxt/ui', '@pinia/nuxt'],
3
+
4
+ devtools: {
5
+ enabled: true
6
+ },
7
+
8
+ css: ['~/assets/css/main.css'],
9
+
10
+ routeRules: {
11
+ '/': { prerender: true },
12
+ // Handle well-known and nuxt asset paths to avoid router warnings
13
+ '/.well-known/**': { prerender: false },
14
+ '/_nuxt/**': { prerender: false }
15
+ },
16
+
17
+ compatibilityDate: '2025-01-15',
18
+
19
+ nitro: {
20
+ routeRules: {
21
+ // Bypass proxy for local Nuxt Icon files
22
+ '/api/_nuxt_icon/**': {},
23
+
24
+ // Backend API Routes
25
+ '/api/**': { proxy: 'http://localhost:8001/api/**' },
26
+
27
+ // Video Outputs
28
+ '/output/**': { proxy: 'http://localhost:8001/output/**' },
29
+
30
+ // WebSocket
31
+ '/ws/**': { proxy: 'http://localhost:8001/ws/**' }
32
+ }
33
+ },
34
+
35
+ eslint: {
36
+ config: {
37
+ stylistic: {
38
+ commaDangle: 'never',
39
+ braceStyle: '1tbs'
40
+ }
41
+ }
42
+ }
43
+ })
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "build": "nuxt build",
7
+ "dev": "nuxt dev",
8
+ "preview": "nuxt preview",
9
+ "postinstall": "nuxt prepare",
10
+ "lint": "eslint .",
11
+ "typecheck": "nuxt typecheck"
12
+ },
13
+ "dependencies": {
14
+ "@iconify-json/lucide": "^1.2.96",
15
+ "@iconify-json/simple-icons": "^1.2.72",
16
+ "@nuxt/ui": "^4.5.1",
17
+ "@pinia/nuxt": "^0.11.3",
18
+ "axios": "^1.13.6",
19
+ "highlight.js": "^11.11.1",
20
+ "katex": "^0.16.43",
21
+ "marked": "^17.0.4",
22
+ "marked-katex-extension": "^5.1.7",
23
+ "nuxt": "^4.3.1",
24
+ "pinia": "^3.0.4",
25
+ "plyr": "^3.8.4",
26
+ "tailwindcss": "^4.2.1",
27
+ "ws": "^8.21.0"
28
+ },
29
+ "devDependencies": {
30
+ "@iconify-json/heroicons": "^1.2.3",
31
+ "@nuxt/eslint": "^1.15.2",
32
+ "eslint": "^10.0.3",
33
+ "typescript": "^5.9.3",
34
+ "vue-tsc": "^3.2.5"
35
+ },
36
+ "packageManager": "pnpm@10.31.0"
37
+ }
frontend/plugins/suppress-router-warnings.client.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default defineNuxtPlugin(() => {
2
+ if (process.dev) {
3
+ const originalWarn = console.warn;
4
+ console.warn = (...args) => {
5
+ if (
6
+ typeof args[0] === "string" &&
7
+ args[0].includes("[Vue Router warn]: No match found for location")
8
+ ) {
9
+ return;
10
+ }
11
+ originalWarn(...args);
12
+ };
13
+ }
14
+ });
frontend/pnpm-lock.yaml ADDED
The diff for this file is too large to render. See raw diff
 
frontend/pnpm-workspace.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ ignoredBuiltDependencies:
2
+ - '@parcel/watcher'
3
+ - '@tailwindcss/oxide'
4
+ - esbuild
5
+ - unrs-resolver
6
+ - vue-demi
frontend/public/favicon.ico ADDED

Git LFS Details

  • SHA256: 1057b17aec08a7191d134000203947f195a8aa7c84c39f1164cee8d01279762a
  • Pointer size: 129 Bytes
  • Size of remote file: 4.29 kB
frontend/public/favicon.svg ADDED
frontend/renovate.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": [
3
+ "github>nuxt/renovate-config-nuxt"
4
+ ],
5
+ "lockFileMaintenance": {
6
+ "enabled": true
7
+ },
8
+ "packageRules": [{
9
+ "matchDepTypes": ["resolutions"],
10
+ "enabled": false
11
+ }],
12
+ "postUpdateOptions": ["pnpmDedupe"]
13
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ // https://nuxt.com/docs/guide/concepts/typescript
3
+ "files": [],
4
+ "references": [
5
+ { "path": "./.nuxt/tsconfig.app.json" },
6
+ { "path": "./.nuxt/tsconfig.server.json" },
7
+ { "path": "./.nuxt/tsconfig.shared.json" },
8
+ { "path": "./.nuxt/tsconfig.node.json" }
9
+ ]
10
+ }