Spaces:
Sleeping
Sleeping
AlgoVision Deployer commited on
Commit ·
1a25b7f
0
Parent(s):
deploy: minimal bootloader for public Space
Browse files- .gitattributes +13 -0
- Dockerfile +41 -0
- README.md +27 -0
- bootloader.sh +30 -0
- frontend/.editorconfig +13 -0
- frontend/.github/workflows/ci.yml +34 -0
- frontend/.gitignore +24 -0
- frontend/LICENSE +21 -0
- frontend/README.md +60 -0
- frontend/app/app.config.ts +8 -0
- frontend/app/app.vue +51 -0
- frontend/app/assets/css/main.css +287 -0
- frontend/app/components/AiAssistDialog.vue +236 -0
- frontend/app/components/AppLogo.vue +66 -0
- frontend/app/components/ChatInterface.vue +341 -0
- frontend/app/components/GenerationWidget.vue +700 -0
- frontend/app/components/GlassCard.vue +18 -0
- frontend/app/components/InteractivePanel.vue +112 -0
- frontend/app/components/OnboardingTour.vue +417 -0
- frontend/app/components/PlanReviewPanel.vue +191 -0
- frontend/app/components/PlyrVideoPlayer.vue +250 -0
- frontend/app/components/ProjectCard.vue +403 -0
- frontend/app/components/ProjectGroupCard.vue +197 -0
- frontend/app/components/QuizBuilder.vue +612 -0
- frontend/app/components/TemplateMenu.vue +49 -0
- frontend/app/components/TopNavbar.vue +198 -0
- frontend/app/layouts/default.vue +96 -0
- frontend/app/layouts/workspace.vue +88 -0
- frontend/app/pages/ai-providers.vue +595 -0
- frontend/app/pages/index.vue +612 -0
- frontend/app/pages/project/[id].vue +382 -0
- frontend/app/pages/projects.vue +620 -0
- frontend/app/stores/chat.ts +83 -0
- frontend/app/stores/groups.ts +142 -0
- frontend/app/stores/projects.ts +1596 -0
- frontend/app/stores/quiz.ts +208 -0
- frontend/app/utils/api.ts +170 -0
- frontend/eslint.config.mjs +6 -0
- frontend/nuxt.config.ts +43 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +37 -0
- frontend/plugins/suppress-router-warnings.client.ts +14 -0
- frontend/pnpm-lock.yaml +0 -0
- frontend/pnpm-workspace.yaml +6 -0
- frontend/public/favicon.ico +3 -0
- frontend/public/favicon.svg +1 -0
- frontend/renovate.json +13 -0
- 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 |
+
[](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 |
+
[](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 |
+
© {{ 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 & 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
|
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 |
+
}
|