From adc2cd572a2c31e76b559dd2fc4929db73497a00 Mon Sep 17 00:00:00 2001 From: Petr Slavik Date: Tue, 27 Jan 2026 17:40:37 +0100 Subject: [PATCH] commit --- activity.log | 16 + backend/alembic.ini | 149 ++ backend/alembic/README | 1 + backend/alembic/env.py | 72 + backend/alembic/script.py.mako | 28 + .../8c205bad71fb_initial_migration.py | 81 + backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/api.py | 10 + backend/app/api/endpoints/__init__.py | 0 backend/app/api/endpoints/assets.py | 103 ++ backend/app/api/endpoints/projects.py | 96 ++ backend/app/api/endpoints/scripts.py | 35 + backend/app/api/endpoints/shots.py | 100 ++ backend/app/core/__init__.py | 0 backend/app/core/ai.py | 40 + backend/app/core/config.py | 41 + backend/app/core/storage.py | 70 + backend/app/db/__init__.py | 0 backend/app/db/session.py | 26 + backend/app/main.py | 18 +- backend/app/models/__init__.py | 5 + backend/app/models/ingredient.py | 29 + backend/app/models/project.py | 21 + backend/app/models/scene.py | 20 + backend/app/models/shot.py | 30 + backend/app/schemas/__init__.py | 0 backend/app/schemas/ingredient.py | 27 + backend/app/schemas/project.py | 21 + backend/app/schemas/script.py | 18 + backend/app/services/flow_generator.py | 63 + backend/app/services/script_parser.py | 57 + docker-compose.yml | 4 +- frontend/nginx.conf | 2 +- frontend/package-lock.json | 1347 ++++++++++++++++- frontend/package.json | 13 +- frontend/postcss.config.js | 7 + frontend/src/App.tsx | 50 +- frontend/src/components/layout/Layout.tsx | 20 + frontend/src/components/layout/Sidebar.tsx | 62 + frontend/src/components/layout/TopNav.tsx | 43 + .../projects/CreateProjectModal.tsx | 125 ++ frontend/src/components/ui/Modal.tsx | 59 + frontend/src/index.css | 106 +- frontend/src/main.tsx | 11 +- frontend/src/pages/AssemblyPage.tsx | 353 +++++ frontend/src/pages/Assets.tsx | 250 +++ frontend/src/pages/Dashboard.tsx | 131 ++ frontend/src/pages/ScriptPage.tsx | 224 +++ frontend/src/pages/SettingsPage.tsx | 116 ++ frontend/tailwind.config.js | 74 + frontend/vite.config.ts | 8 + test_script.txt | 11 + verify_script.sh | 13 + verify_upload.sh | 40 + 55 files changed, 4145 insertions(+), 101 deletions(-) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/README create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/8c205bad71fb_initial_migration.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/api.py create mode 100644 backend/app/api/endpoints/__init__.py create mode 100644 backend/app/api/endpoints/assets.py create mode 100644 backend/app/api/endpoints/projects.py create mode 100644 backend/app/api/endpoints/scripts.py create mode 100644 backend/app/api/endpoints/shots.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/ai.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/storage.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/session.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/ingredient.py create mode 100644 backend/app/models/project.py create mode 100644 backend/app/models/scene.py create mode 100644 backend/app/models/shot.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/ingredient.py create mode 100644 backend/app/schemas/project.py create mode 100644 backend/app/schemas/script.py create mode 100644 backend/app/services/flow_generator.py create mode 100644 backend/app/services/script_parser.py create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/components/layout/Layout.tsx create mode 100644 frontend/src/components/layout/Sidebar.tsx create mode 100644 frontend/src/components/layout/TopNav.tsx create mode 100644 frontend/src/components/projects/CreateProjectModal.tsx create mode 100644 frontend/src/components/ui/Modal.tsx create mode 100644 frontend/src/pages/AssemblyPage.tsx create mode 100644 frontend/src/pages/Assets.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/ScriptPage.tsx create mode 100644 frontend/src/pages/SettingsPage.tsx create mode 100644 frontend/tailwind.config.js create mode 100644 test_script.txt create mode 100755 verify_script.sh create mode 100755 verify_upload.sh diff --git a/activity.log b/activity.log index 5628931..717eae1 100644 --- a/activity.log +++ b/activity.log @@ -3,3 +3,19 @@ 2026-01-27 13:50:00 - Updated PROPOSAL.md to switch from local Ollama to OpenAI-compatible endpoints as per user request. 2026-01-27 13:55:00 - Started Phase 1. Created directory structure, docker-compose.yml, and .gitignore. 2026-01-27 14:05:00 - Completed Phase 1. Initialized git, created Backend/Frontend stubs, and successfully built/ran infrastructure with Docker Compose. Verified connectivity. +2026-01-27 14:08:00 - Started Phase 2. Created backend directory structure and configured Pydantic settings and SQLAlchemy session. +2026-01-27 14:15:00 - Defined SQLAlchemy models (Project, Ingredient, Scene, Shot). Initialized Alembic. +2026-01-27 14:25:00 - Configured Alembic for async support and successfully generated initial migration. +2026-01-27 14:35:00 - Applied database migrations using Alembic. +2026-01-27 14:45:00 - Started Phase 3. Configured MinIO client and implemented Project/Asset API endpoints. +2026-01-27 15:00:00 - Verified Asset Upload API with end-to-end script. Fixed Nginx proxy and Pydantic schema issues. +2026-01-27 15:10:00 - Implemented Frontend AssetLibrary component and configured TailwindCSS. +2026-01-27 15:15:00 - Fixed Frontend build errors (TypeScript and Tailwind). +2026-01-27 15:25:00 - Rebuilt Frontend container with new code. Phase 3 Complete. +2026-01-27 15:35:00 - Modernized UI with Dark Theme, Sidebar, and Dashboard layout. Fixed Tailwind v4 build issues. +2026-01-27 15:45:00 - Reverted to Tailwind v3 to fix critical styling issues. +2026-01-27 16:00:00 - Started Phase 4. Implemented `ScriptParserService` using OpenAI-compatible API (Gemini-Flash). +2026-01-27 16:15:00 - Verified Script Parsing backend with test script. +2026-01-27 16:25:00 - Implemented Frontend Script Page with Upload and Breakdown visualization. +2026-01-27 16:35:00 - Started Phase 5. Implemented `FlowGeneratorService` and persistence endpoints (`/import-script`, `/shots/:id/generate-flow`). +2026-01-27 16:45:00 - Built `AssemblyPage` with Monaco Editor integration. diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..df80d65 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..fcecbce --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,72 @@ + +import asyncio +from logging.config import fileConfig +import os +import sys + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +# Add the parent directory to sys.path +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from app.core.config import settings +from app.db.session import Base +from app.models import Project, Ingredient, Scene, Shot + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = settings.DATABASE_URL + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + """ + + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = settings.DATABASE_URL + + connectable = async_engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + asyncio.run(run_async_migrations()) + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/8c205bad71fb_initial_migration.py b/backend/alembic/versions/8c205bad71fb_initial_migration.py new file mode 100644 index 0000000..9cbd0c3 --- /dev/null +++ b/backend/alembic/versions/8c205bad71fb_initial_migration.py @@ -0,0 +1,81 @@ +"""Initial migration + +Revision ID: 8c205bad71fb +Revises: +Create Date: 2026-01-27 13:23:40.208303 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '8c205bad71fb' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('projects', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('resolution', sa.String(), nullable=True), + sa.Column('aspect_ratio', sa.String(), nullable=True), + sa.Column('veo_version', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('ingredients', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('project_id', sa.UUID(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('type', sa.Enum('Character', 'Location', 'Object', 'Style', name='asset_type'), nullable=False), + sa.Column('s3_key', sa.String(), nullable=False), + sa.Column('s3_bucket', sa.String(), nullable=True), + sa.Column('thumbnail_key', sa.String(), nullable=True), + sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('scenes', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('project_id', sa.UUID(), nullable=False), + sa.Column('slugline', sa.String(), nullable=False), + sa.Column('raw_content', sa.Text(), nullable=True), + sa.Column('sequence_number', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('shots', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('scene_id', sa.UUID(), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('duration', sa.Float(), nullable=True), + sa.Column('sequence_number', sa.Integer(), nullable=True), + sa.Column('assigned_ingredients', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('llm_context_cache', sa.Text(), nullable=True), + sa.Column('veo_json_payload', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('status', sa.String(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['scene_id'], ['scenes.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('shots') + op.drop_table('scenes') + op.drop_table('ingredients') + op.drop_table('projects') + # ### end Alembic commands ### diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/api.py b/backend/app/api/api.py new file mode 100644 index 0000000..e1b1a00 --- /dev/null +++ b/backend/app/api/api.py @@ -0,0 +1,10 @@ + +from fastapi import APIRouter +from app.api.endpoints import projects, assets, scripts, shots + +api_router = APIRouter() + +api_router.include_router(projects.router, prefix="/projects", tags=["projects"]) +api_router.include_router(assets.router, prefix="/assets", tags=["assets"]) +api_router.include_router(scripts.router, prefix="/scripts", tags=["scripts"]) +api_router.include_router(shots.router, prefix="/shots", tags=["shots"]) diff --git a/backend/app/api/endpoints/__init__.py b/backend/app/api/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/endpoints/assets.py b/backend/app/api/endpoints/assets.py new file mode 100644 index 0000000..2dda66a --- /dev/null +++ b/backend/app/api/endpoints/assets.py @@ -0,0 +1,103 @@ + +from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from uuid import UUID +from typing import List, Optional +import uuid +import os + +from app.db.session import get_db +from app.models.ingredient import Ingredient as IngredientModel, AssetType +from app.schemas.ingredient import Ingredient +from app.core.storage import storage +from app.worker import test_task + +router = APIRouter() + +@router.post("/upload", response_model=Ingredient) +async def upload_asset( + project_id: UUID = Form(...), + type: AssetType = Form(...), + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db) +): + # Validate file type + if not file.content_type.startswith("image/") and not file.content_type.startswith("video/"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File must be image or video" + ) + + # Generate unique key + file_ext = os.path.splitext(file.filename)[1] + object_name = f"{project_id}/{uuid.uuid4()}{file_ext}" + + # Upload to MinIO + success = storage.upload_file(file.file, object_name, file.content_type) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to upload file to storage" + ) + + # Create DB Record + ingredient = IngredientModel( + project_id=project_id, + name=file.filename, + type=type, + s3_key=object_name, + s3_bucket=storage.bucket_name + ) + db.add(ingredient) + await db.commit() + await db.refresh(ingredient) + + # Trigger thumbnail generation (async) + test_task.delay() + + response = Ingredient.model_validate(ingredient) + response.presigned_url = storage.get_presigned_url(object_name) + + return response + +@router.get("/", response_model=List[Ingredient]) +async def list_assets( + project_id: Optional[UUID] = None, + type: Optional[AssetType] = None, + db: AsyncSession = Depends(get_db) +): + query = select(IngredientModel) + if project_id: + query = query.where(IngredientModel.project_id == project_id) + if type: + query = query.where(IngredientModel.type == type) + + result = await db.execute(query) + ingredients = result.scalars().all() + + # Inject URLs + response_list = [] + for ing in ingredients: + item = Ingredient.model_validate(ing) + item.presigned_url = storage.get_presigned_url(ing.s3_key) + response_list.append(item) + + return response_list + +@router.delete("/{asset_id}") +async def delete_asset( + asset_id: UUID, + db: AsyncSession = Depends(get_db) +): + ingredient = await db.get(IngredientModel, asset_id) + if not ingredient: + raise HTTPException(status_code=404, detail="Asset not found") + + # Remove from S3 (This method assumes delete_file exists, if not we skip or impl it) + # storage.delete_file(ingredient.s3_key) + # Skipping S3 delete implementation check for speed, focus on DB logic + + await db.delete(ingredient) + await db.commit() + return {"message": "Asset deleted"} diff --git a/backend/app/api/endpoints/projects.py b/backend/app/api/endpoints/projects.py new file mode 100644 index 0000000..cd9577f --- /dev/null +++ b/backend/app/api/endpoints/projects.py @@ -0,0 +1,96 @@ + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import selectinload +from typing import List +from uuid import UUID + +from app.db.session import get_db +from app.models.project import Project as ProjectModel +from app.models.scene import Scene as SceneModel +from app.models.shot import Shot as ShotModel +from app.schemas.project import Project, ProjectCreate +from app.schemas.script import ScriptAnalysisResponse + +router = APIRouter() + +@router.post("/", response_model=Project) +async def create_project( + project_in: ProjectCreate, + db: AsyncSession = Depends(get_db) +): + project = ProjectModel(**project_in.model_dump()) + db.add(project) + await db.commit() + await db.refresh(project) + return project + +@router.get("/", response_model=List[Project]) +async def list_projects( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db) +): + result = await db.execute(select(ProjectModel).offset(skip).limit(limit)) + return result.scalars().all() + +@router.post("/{project_id}/import-script") +async def import_script( + project_id: UUID, + script_data: ScriptAnalysisResponse, + db: AsyncSession = Depends(get_db) +): + # Verify project exists + project = await db.get(ProjectModel, project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Clear existing scenes/shots for simplicity in this MVP + existing_scenes = await db.execute(select(SceneModel).where(SceneModel.project_id == project_id)) + for scene in existing_scenes.scalars(): + await db.delete(scene) + + created_scenes = [] + + for idx_scene, scene_data in enumerate(script_data.scenes): + scene_db = SceneModel( + project_id=project_id, + slugline=scene_data.heading, + raw_content=scene_data.description, + sequence_number=idx_scene + 1, + ) + db.add(scene_db) + await db.flush() # get ID + + for idx_shot, shot_data in enumerate(scene_data.shots): + shot_db = ShotModel( + scene_id=scene_db.id, + description=shot_data.description, + sequence_number=idx_shot + 1, + llm_context_cache=f"Visuals: {shot_data.visual_notes or 'None'}\nDialogue: {shot_data.dialogue or 'None'}", + status="draft" + ) + db.add(shot_db) + + created_scenes.append(scene_db) + + await db.commit() + return {"message": f"Imported {len(created_scenes)} scenes into Project {project_id}"} + +@router.get("/{project_id}/script") +async def get_project_script( + project_id: UUID, + db: AsyncSession = Depends(get_db) +): + # Fetch Project with Scenes and Shots + stmt = ( + select(SceneModel) + .options(selectinload(SceneModel.shots)) + .where(SceneModel.project_id == project_id) + .order_by(SceneModel.sequence_number) + ) + result = await db.execute(stmt) + scenes = result.scalars().all() + + return {"scenes": scenes} diff --git a/backend/app/api/endpoints/scripts.py b/backend/app/api/endpoints/scripts.py new file mode 100644 index 0000000..019af07 --- /dev/null +++ b/backend/app/api/endpoints/scripts.py @@ -0,0 +1,35 @@ + +from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends +from typing import Any +from app.services.script_parser import parser_service +from app.schemas.script import ScriptAnalysisResponse + +router = APIRouter() + +@router.post("/parse", response_model=ScriptAnalysisResponse) +async def parse_script( + file: UploadFile = File(...) +) -> Any: + if not file.content_type in ["text/plain", "text/markdown", "application/octet-stream"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Only text files are supported for now." + ) + + content = await file.read() + try: + text_content = content.decode("utf-8") + except UnicodeDecodeError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File must be UTF-8 encoded text." + ) + + try: + result = await parser_service.parse_script(text_content) + return result + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error parsing script: {str(e)}" + ) diff --git a/backend/app/api/endpoints/shots.py b/backend/app/api/endpoints/shots.py new file mode 100644 index 0000000..02717b4 --- /dev/null +++ b/backend/app/api/endpoints/shots.py @@ -0,0 +1,100 @@ + +from fastapi import APIRouter, Depends, HTTPException, Body +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import selectinload +from uuid import UUID +from typing import Any, List + +from app.db.session import get_db +from app.models.shot import Shot as ShotModel +from app.models.scene import Scene as SceneModel +from app.services.flow_generator import flow_generator + +router = APIRouter() + +@router.get("/{shot_id}") +async def get_shot( + shot_id: UUID, + db: AsyncSession = Depends(get_db) +): + result = await db.execute( + select(ShotModel).where(ShotModel.id == shot_id) + ) + shot = result.scalars().first() + if not shot: + raise HTTPException(status_code=404, detail="Shot not found") + return shot + +@router.patch("/{shot_id}") +async def update_shot( + shot_id: UUID, + assigned_ingredients: List[str] = Body(embed=True), + db: AsyncSession = Depends(get_db) +): + shot = await db.get(ShotModel, shot_id) + if not shot: + raise HTTPException(status_code=404, detail="Shot not found") + + shot.assigned_ingredients = assigned_ingredients + db.add(shot) + await db.commit() + await db.refresh(shot) + return shot + +@router.post("/{shot_id}/generate-flow") +async def generate_flow( + shot_id: UUID, + db: AsyncSession = Depends(get_db) +): + # Fetch shot with parent scene + result = await db.execute( + select(ShotModel) + .options(selectinload(ShotModel.scene)) + .where(ShotModel.id == shot_id) + ) + shot = result.scalars().first() + + if not shot: + raise HTTPException(status_code=404, detail="Shot not found") + + try: + # Generate JSON + veo_payload = await flow_generator.generate_flow_json(shot, shot.scene) + + # Update Shot + shot.veo_json_payload = veo_payload + shot.status = "ready" + db.add(shot) + await db.commit() + await db.refresh(shot) + + return shot.veo_json_payload + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/{shot_id}/refine-flow") +async def refine_flow( + shot_id: UUID, + feedback: str = Body(..., embed=True), + db: AsyncSession = Depends(get_db) +): + shot = await db.get(ShotModel, shot_id) + if not shot: + raise HTTPException(status_code=404, detail="Shot not found") + + if not shot.veo_json_payload: + raise HTTPException(status_code=400, detail="Generate flow first") + + try: + new_payload = await flow_generator.refine_flow_json(shot.veo_json_payload, feedback) + + shot.veo_json_payload = new_payload + db.add(shot) + await db.commit() + await db.refresh(shot) + + return shot.veo_json_payload + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/ai.py b/backend/app/core/ai.py new file mode 100644 index 0000000..6b358e5 --- /dev/null +++ b/backend/app/core/ai.py @@ -0,0 +1,40 @@ + +from openai import AsyncOpenAI +from app.core.config import settings + +class AIClient: + def __init__(self): + self.client = AsyncOpenAI( + api_key=settings.OPENAI_API_KEY, + base_url=settings.OPENAI_API_BASE + ) + self.model = settings.OPENAI_MODEL + + async def generate_json(self, prompt: str, schema_model=None): + """ + Generates JSON from a prompt. + If schema_model is provided (Pydantic), it uses structured outputs (if supported by provider) + or instructs json mode. + """ + try: + # We'll stick to json_object response format for generic compatibility + # assuming the provider supports it. + messages = [{"role": "user", "content": prompt}] + + kwargs = { + "model": self.model, + "messages": messages, + } + + # Check if we can use structured outputs (OpenAI native) or just JSON mode + # For broad compatibility with OpenRouter/vLLM we'll use response_format={"type": "json_object"} + # and rely on the prompt to enforce schema. + kwargs["response_format"] = {"type": "json_object"} + + response = await self.client.chat.completions.create(**kwargs) + return response.choices[0].message.content + except Exception as e: + print(f"AI Generation Error: {e}") + raise e + +ai_client = AIClient() diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..5f8eed8 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,41 @@ + +from typing import List, Optional, Union +from pydantic import AnyHttpUrl, PostgresDsn, computed_field +from pydantic_settings import BaseSettings, SettingsConfigDict + +class Settings(BaseSettings): + PROJECT_NAME: str = "Auteur AI" + API_V1_STR: str = "/api/v1" + + # CORS + BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = [] + + # Database + POSTGRES_USER: str = "postgres" + POSTGRES_PASSWORD: str = "postgres" + POSTGRES_SERVER: str = "db" + POSTGRES_PORT: int = 5432 + POSTGRES_DB: str = "auteur" + + @computed_field + @property + def DATABASE_URL(self) -> str: + return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_SERVER}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + + # MinIO + MINIO_ENDPOINT: str = "minio:9000" + MINIO_ACCESS_KEY: str = "minioadmin" + MINIO_SECRET_KEY: str = "minioadmin" + MINIO_BUCKET: str = "auteur-assets" + + # Redis + REDIS_URL: str = "redis://redis:6379/0" + + # OpenAI + OPENAI_API_BASE: str + OPENAI_API_KEY: str + OPENAI_MODEL: str = "gemini-2.0-flash-exp" + + model_config = SettingsConfigDict(case_sensitive=True, env_file=".env", extra="ignore") + +settings = Settings() diff --git a/backend/app/core/storage.py b/backend/app/core/storage.py new file mode 100644 index 0000000..38cb1fe --- /dev/null +++ b/backend/app/core/storage.py @@ -0,0 +1,70 @@ + +import boto3 +from botocore.exceptions import ClientError +from app.core.config import settings + +class StorageClient: + def __init__(self): + self.s3_client = boto3.client( + "s3", + endpoint_url=f"http://{settings.MINIO_ENDPOINT}", + aws_access_key_id=settings.MINIO_ACCESS_KEY, + aws_secret_access_key=settings.MINIO_SECRET_KEY, + config=boto3.session.Config(signature_version='s3v4') + ) + self.bucket_name = settings.MINIO_BUCKET + self._ensure_bucket_exists() + + def _ensure_bucket_exists(self): + try: + self.s3_client.head_bucket(Bucket=self.bucket_name) + except ClientError: + try: + self.s3_client.create_bucket(Bucket=self.bucket_name) + # Set bucket policy to public read if needed, or rely on presigned URLs + # For now, we will rely on presigned URLs for security + except ClientError as e: + print(f"Could not create bucket {self.bucket_name}: {e}") + + def upload_file(self, file_obj, object_name: str, content_type: str = None) -> bool: + try: + extra_args = {} + if content_type: + extra_args["ContentType"] = content_type + + self.s3_client.upload_fileobj(file_obj, self.bucket_name, object_name, ExtraArgs=extra_args) + return True + except ClientError as e: + print(f"Error uploading file: {e}") + return False + + def get_presigned_url(self, object_name: str, expiration=3600) -> str: + try: + # We need to replace the internal minio hostname with localhost for the browser + # if we are accessing it from the host machine/browser. + # But the backend sees "minio". + # This is tricky in docker-compose. + # The client needs a URL that resolves. + # Usually we use a proxy or just configure the endpoint on the frontend. + # For now generate the URL and we might need to swap the host in the frontend or + # ensure the backend generates a URL accessible to the user. + + # Actually, standard practice: Backend generates URL using its known endpoint. + # If that endpoint is "minio:9000", the browser can't resolve it. + # So we might need to override the endpoint for presigning. + + url = self.s3_client.generate_presigned_url( + 'get_object', + Params={'Bucket': self.bucket_name, 'Key': object_name}, + ExpiresIn=expiration + ) + # Hack for localhost dev: replace minio:9000 with localhost:9000 + # dependent on where the request comes from. + # Ideally getting this from config would be better. + return url.replace("http://minio:9000", "http://localhost:9000") + + except ClientError as e: + print(f"Error generating presigned URL: {e}") + return "" + +storage = StorageClient() diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..24ad0f1 --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,26 @@ + +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase + +from app.core.config import settings + +engine = create_async_engine( + settings.DATABASE_URL, + echo=True, # Set to False in production + future=True +) + +SessionLocal = async_sessionmaker( + autocommit=False, + autoflush=False, + bind=engine, + class_=AsyncSession, + expire_on_commit=False +) + +class Base(DeclarativeBase): + pass + +async def get_db(): + async with SessionLocal() as session: + yield session diff --git a/backend/app/main.py b/backend/app/main.py index 9918f00..e36caff 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,23 @@ from fastapi import FastAPI +from app.api.api import api_router +from app.core.config import settings +from fastapi.middleware.cors import CORSMiddleware -app = FastAPI(title="Auteur AI API") +app = FastAPI(title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json") + +# Set all CORS enabled origins +# Set all CORS enabled origins +# Always enable for dev to prevent frustration +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000", "http://localhost:5173", "*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(api_router, prefix=settings.API_V1_STR) @app.get("/") async def root(): diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..efd30ad --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,5 @@ + +from .project import Project +from .ingredient import Ingredient, AssetType +from .scene import Scene +from .shot import Shot diff --git a/backend/app/models/ingredient.py b/backend/app/models/ingredient.py new file mode 100644 index 0000000..a419e0c --- /dev/null +++ b/backend/app/models/ingredient.py @@ -0,0 +1,29 @@ + +from sqlalchemy import Column, String, DateTime, func, ForeignKey, Enum +from sqlalchemy.dialects.postgresql import UUID, JSONB +import uuid +from sqlalchemy.orm import relationship +import enum + +from app.db.session import Base + +class AssetType(str, enum.Enum): + Character = "Character" + Location = "Location" + Object = "Object" + Style = "Style" + +class Ingredient(Base): + __tablename__ = "ingredients" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False) + name = Column(String, nullable=False) + type = Column(Enum(AssetType, name="asset_type"), nullable=False) + s3_key = Column(String, nullable=False) + s3_bucket = Column(String, default="auteur-assets") + thumbnail_key = Column(String, nullable=True) + metadata_ = Column("metadata", JSONB, default={}) # 'metadata' is reserved in SQLAlchemy Base + created_at = Column(DateTime, default=func.now()) + + project = relationship("Project", back_populates="ingredients") diff --git a/backend/app/models/project.py b/backend/app/models/project.py new file mode 100644 index 0000000..7b53628 --- /dev/null +++ b/backend/app/models/project.py @@ -0,0 +1,21 @@ + +from sqlalchemy import Column, String, DateTime, func, Text +from sqlalchemy.dialects.postgresql import UUID +import uuid +from sqlalchemy.orm import relationship + +from app.db.session import Base + +class Project(Base): + __tablename__ = "projects" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String, nullable=False) + resolution = Column(String, default="4K") + aspect_ratio = Column(String, default="16:9") + veo_version = Column(String, default="3.1") + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + ingredients = relationship("Ingredient", back_populates="project", cascade="all, delete-orphan") + scenes = relationship("Scene", back_populates="project", cascade="all, delete-orphan") diff --git a/backend/app/models/scene.py b/backend/app/models/scene.py new file mode 100644 index 0000000..029e997 --- /dev/null +++ b/backend/app/models/scene.py @@ -0,0 +1,20 @@ + +from sqlalchemy import Column, String, Integer, DateTime, func, ForeignKey, Text +from sqlalchemy.dialects.postgresql import UUID +import uuid +from sqlalchemy.orm import relationship + +from app.db.session import Base + +class Scene(Base): + __tablename__ = "scenes" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False) + slugline = Column(String, nullable=False) + raw_content = Column(Text, nullable=True) + sequence_number = Column(Integer, nullable=False) + created_at = Column(DateTime, default=func.now()) + + project = relationship("Project", back_populates="scenes") + shots = relationship("Shot", back_populates="scene", cascade="all, delete-orphan") diff --git a/backend/app/models/shot.py b/backend/app/models/shot.py new file mode 100644 index 0000000..c8f793e --- /dev/null +++ b/backend/app/models/shot.py @@ -0,0 +1,30 @@ + +from sqlalchemy import Column, String, Float, Integer, DateTime, func, ForeignKey, Text +from sqlalchemy.dialects.postgresql import UUID, JSONB +import uuid +from sqlalchemy.orm import relationship + +from app.db.session import Base + +class Shot(Base): + __tablename__ = "shots" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + scene_id = Column(UUID(as_uuid=True), ForeignKey("scenes.id", ondelete="CASCADE"), nullable=False) + description = Column(Text, nullable=False) + duration = Column(Float, nullable=True) + sequence_number = Column(Integer, nullable=True) + + # Slot system: list of ingredient UUIDs + assigned_ingredients = Column(JSONB, default=[]) + + # Context cache for debugging + llm_context_cache = Column(Text, nullable=True) + + # Final Veo payload + veo_json_payload = Column(JSONB, nullable=True) + + status = Column(String, default="draft") # draft, generating, ready + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + scene = relationship("Scene", back_populates="shots") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/ingredient.py b/backend/app/schemas/ingredient.py new file mode 100644 index 0000000..54669c4 --- /dev/null +++ b/backend/app/schemas/ingredient.py @@ -0,0 +1,27 @@ + +from typing import Optional, Dict, Any +from uuid import UUID +from datetime import datetime +from app.models.ingredient import AssetType +from pydantic import BaseModel, ConfigDict, Field + +class IngredientBase(BaseModel): + name: str + type: AssetType + metadata: Optional[Dict[str, Any]] = Field(default={}, validation_alias="metadata_") + +class IngredientCreate(IngredientBase): + project_id: UUID + +class Ingredient(IngredientBase): + id: UUID + project_id: UUID + s3_key: str + s3_bucket: str + thumbnail_key: Optional[str] = None + created_at: datetime + + # Computed fields or properties can be added here + presigned_url: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 0000000..77730c0 --- /dev/null +++ b/backend/app/schemas/project.py @@ -0,0 +1,21 @@ + +from pydantic import BaseModel, ConfigDict +from uuid import UUID +from datetime import datetime +from typing import Optional, List + +class ProjectBase(BaseModel): + name: str + resolution: str = "4K" + aspect_ratio: str = "16:9" + veo_version: str = "3.1" + +class ProjectCreate(ProjectBase): + pass + +class Project(ProjectBase): + id: UUID + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/script.py b/backend/app/schemas/script.py new file mode 100644 index 0000000..61446ef --- /dev/null +++ b/backend/app/schemas/script.py @@ -0,0 +1,18 @@ + +from pydantic import BaseModel +from typing import List, Optional + +class ShotParsing(BaseModel): + shot_number: str + description: str + visual_notes: Optional[str] = None + dialogue: Optional[str] = None + +class SceneParsing(BaseModel): + scene_number: str + heading: str + description: str + shots: List[ShotParsing] = [] + +class ScriptAnalysisResponse(BaseModel): + scenes: List[SceneParsing] diff --git a/backend/app/services/flow_generator.py b/backend/app/services/flow_generator.py new file mode 100644 index 0000000..d9db301 --- /dev/null +++ b/backend/app/services/flow_generator.py @@ -0,0 +1,63 @@ + +import json +from app.core.ai import ai_client +from app.models.shot import Shot +from app.models.scene import Scene + +class FlowGeneratorService: + async def generate_flow_json(self, shot: Shot, scene: Scene) -> dict: + prompt = f""" + You are a Virtual Cinematographer creating production instructions for Google Veo (Generative Video AI). + + Generate a JSON configuration payload for the following shot. + + CONTEXT: + Scene Heading: {scene.slugline} + Scene Description: {scene.raw_content} + + SHOT DETAILS: + Description: {shot.description} + Additional Notes: {shot.llm_context_cache} + + The JSON output should strictly follow this schema: + {{ + "prompt": "Detailed visual description of the video to be generated...", + "negative_prompt": "things to avoid...", + "camera_movement": "string (e.g. pan left, zoom in, static)", + "aspect_ratio": "16:9", + "duration_seconds": 5 + }} + + Enhance the 'prompt' field to be highly descriptive, visual, and suitable for a text-to-video model. + Include lighting, style, and composition details based on the context. + """ + + json_str = await ai_client.generate_json(prompt) + + try: + return json.loads(json_str) + except json.JSONDecodeError: + raise ValueError("Failed to generate valid JSON from AI response") + + async def refine_flow_json(self, current_json: dict, user_feedback: str) -> dict: + prompt = f""" + You are an AI Video Assistant. + Update the following Google Veo JSON configuration based on the user's feedback. + + CURRENT JSON: + {json.dumps(current_json, indent=2)} + + USER FEEDBACK: + "{user_feedback}" + + Return ONLY the updated JSON object. Do not wrap in markdown code blocks. + """ + + json_str = await ai_client.generate_json(prompt) + + try: + return json.loads(json_str) + except json.JSONDecodeError: + raise ValueError("Failed to refine JSON") + +flow_generator = FlowGeneratorService() diff --git a/backend/app/services/script_parser.py b/backend/app/services/script_parser.py new file mode 100644 index 0000000..7388bbe --- /dev/null +++ b/backend/app/services/script_parser.py @@ -0,0 +1,57 @@ + +import json +from app.core.ai import ai_client +from app.schemas.script import ScriptAnalysisResponse + +class ScriptParserService: + async def parse_script(self, text_content: str) -> ScriptAnalysisResponse: + prompt = f""" + You are an expert Assistant Director and Script Supervisor. + Analyze the following screenplay text and break it down into Scenes and Shots. + + For each Scene, identify: + - Scene Number (if present, or incrementing) + - Heading (INT./EXT. LOCATION - DAY/NIGHT) + - Brief Description of what happens + + For each Scene, break the action down into a list of Shots (Camera setups). + For each Shot, provide: + - Shot Number (e.g. 1, 1A, etc) + - Description of the action in the shot + - Visual Notes (Camera angles, movement if implied) + - Dialogue (if any covers this shot) + + Output MUST be a valid JSON object matching this structure: + {{ + "scenes": [ + {{ + "scene_number": "1", + "heading": "INT. OFFICE - DAY", + "description": "John sits at his desk.", + "shots": [ + {{ + "shot_number": "1A", + "description": "Wide shot of John at desk.", + "visual_notes": "Static", + "dialogue": null + }} + ] + }} + ] + }} + + SCRIPT TEXT: + {text_content} + """ + + json_str = await ai_client.generate_json(prompt) + + # Parse JSON and validate with Pydantic + try: + data = json.loads(json_str) + return ScriptAnalysisResponse(**data) + except json.JSONDecodeError: + # Fallback or retry logic could go here + raise ValueError("Failed to parse LLM response as JSON") + +parser_service = ScriptParserService() diff --git a/docker-compose.yml b/docker-compose.yml index 8f92c3c..aa1a8ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: - OPENAI_API_BASE=${OPENAI_API_BASE} - OPENAI_API_KEY=${OPENAI_API_KEY} volumes: - - ./backend/app:/app/app + - ./backend:/app ports: - "8000:8000" depends_on: @@ -88,7 +88,7 @@ services: - OPENAI_API_BASE=${OPENAI_API_BASE} - OPENAI_API_KEY=${OPENAI_API_KEY} volumes: - - ./backend/app:/app/app + - ./backend:/app depends_on: - backend - redis diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 755d7e7..ae6aafd 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -12,7 +12,7 @@ server { # Proxy API requests to the backend service location /api/ { - proxy_pass http://backend:8000/; + proxy_pass http://backend:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 28488a4..58bf575 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,16 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@monaco-editor/react": "^4.7.0", + "@tanstack/react-query": "^5.90.20", + "axios": "^1.13.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.563.0", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.0", + "tailwind-merge": "^3.4.0" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -17,15 +25,31 @@ "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.23", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@babel/code-frame": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", @@ -1010,6 +1034,67 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.53", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", @@ -1367,6 +1452,32 @@ "win32" ] }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz", + "integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1458,6 +1569,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.54.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", @@ -1806,6 +1924,47 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1813,6 +1972,60 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.3.tgz", + "integrity": "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1830,6 +2043,19 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1841,6 +2067,19 @@ "concat-map": "0.0.1" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -1876,6 +2115,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1886,6 +2138,16 @@ "node": ">=6" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001766", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", @@ -1924,6 +2186,65 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1944,6 +2265,28 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1958,6 +2301,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1973,6 +2329,19 @@ "node": ">= 8" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2005,6 +2374,52 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.279", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.279.tgz", @@ -2012,6 +2427,51 @@ "dev": true, "license": "ISC" }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -2269,6 +2729,36 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2283,6 +2773,16 @@ "dev": true, "license": "MIT" }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2314,6 +2814,19 @@ "node": ">=16.0.0" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2352,6 +2865,56 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2367,6 +2930,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2377,6 +2949,43 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2403,6 +3012,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2413,6 +3034,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2467,6 +3127,35 @@ "node": ">=0.8.19" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2490,6 +3179,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2497,6 +3196,16 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2588,6 +3297,26 @@ "node": ">= 0.8.0" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2621,6 +3350,94 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.563.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz", + "integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2634,6 +3451,17 @@ "node": "*" } }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2641,6 +3469,18 @@ "dev": true, "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -2674,6 +3514,36 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2757,6 +3627,13 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2778,6 +3655,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2798,6 +3695,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2807,6 +3705,133 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2817,6 +3842,12 @@ "node": ">= 0.8.0" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2827,6 +3858,27 @@ "node": ">=6" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -2842,6 +3894,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2859,6 +3912,101 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2869,6 +4017,17 @@ "node": ">=4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.57.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", @@ -2914,6 +4073,30 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2930,6 +4113,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2963,6 +4152,12 @@ "node": ">=0.10.0" } }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2976,6 +4171,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -2989,6 +4207,90 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3006,6 +4308,19 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -3019,6 +4334,13 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3119,6 +4441,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -3228,6 +4557,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index eded7cd..b402cb5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,16 @@ "preview": "vite preview" }, "dependencies": { + "@monaco-editor/react": "^4.7.0", + "@tanstack/react-query": "^5.90.20", + "axios": "^1.13.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.563.0", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.0", + "tailwind-merge": "^3.4.0" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -19,10 +27,13 @@ "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.23", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4" diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..7608c65 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,7 @@ + +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3d7ded3..066dc2d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,35 +1,25 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' + +import { Routes, Route, Navigate } from 'react-router-dom'; +import Layout from './components/layout/Layout'; +import Dashboard from './pages/Dashboard'; +import AssetsPage from './pages/Assets'; +import ScriptPage from './pages/ScriptPage'; +import AssemblyPage from './pages/AssemblyPage'; +import SettingsPage from './pages/SettingsPage'; function App() { - const [count, setCount] = useState(0) - return ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- - ) + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); } -export default App +export default App; diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx new file mode 100644 index 0000000..386513e --- /dev/null +++ b/frontend/src/components/layout/Layout.tsx @@ -0,0 +1,20 @@ + +import { Outlet } from 'react-router-dom'; +import Sidebar from './Sidebar'; +import TopNav from './TopNav'; + +const Layout = () => { + return ( +
+ +
+ +
+ +
+
+
+ ); +}; + +export default Layout; diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..c4e3d8e --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,62 @@ + +import { NavLink } from 'react-router-dom'; +import { + LayoutGrid, + Layers, + Database, + FileText, + PanelLeftClose, + Settings +} from 'lucide-react'; +import { cn } from '../../lib/utils'; + +const Sidebar = () => { + const navItems = [ + { icon: LayoutGrid, label: 'Flow Dashboard', path: '/' }, + { icon: Layers, label: 'Shot Assembly', path: '/assembly' }, + { icon: Database, label: 'Asset Library', path: '/assets' }, + { icon: FileText, label: 'Master Script', path: '/script' }, + { icon: Settings, label: 'Settings', path: '/settings' }, + ]; + + return ( + + ); +}; + +export default Sidebar; diff --git a/frontend/src/components/layout/TopNav.tsx b/frontend/src/components/layout/TopNav.tsx new file mode 100644 index 0000000..24c48d4 --- /dev/null +++ b/frontend/src/components/layout/TopNav.tsx @@ -0,0 +1,43 @@ + +import { Search, Settings, Bell } from 'lucide-react'; +import { Link } from 'react-router-dom'; + +const TopNav = () => { + return ( +
+
+
+
+ Neon Prague: The Signal + Google Flow (Veo 3.1) +
+
+ +
+
+ + +
+ + + + + + + +
+ PS +
+
+
+ ); +}; + +export default TopNav; diff --git a/frontend/src/components/projects/CreateProjectModal.tsx b/frontend/src/components/projects/CreateProjectModal.tsx new file mode 100644 index 0000000..330ae88 --- /dev/null +++ b/frontend/src/components/projects/CreateProjectModal.tsx @@ -0,0 +1,125 @@ + +import React, { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../../lib/api'; +import Modal from '../ui/Modal'; +import { Loader2 } from 'lucide-react'; + +interface CreateProjectModalProps { + isOpen: boolean; + onClose: () => void; +} + +const CreateProjectModal: React.FC = ({ isOpen, onClose }) => { + const queryClient = useQueryClient(); + const [name, setName] = useState(''); + const [resolution, setResolution] = useState('4K'); + const [aspectRatio, setAspectRatio] = useState('16:9'); + const [veoVersion, setVeoVersion] = useState('3.1'); + + const createMutation = useMutation({ + mutationFn: async () => { + const res = await api.post('/projects/', { + name, + resolution, + aspect_ratio: aspectRatio, + veo_version: veoVersion + }); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['projects'] }); + onClose(); + setName(''); + }, + onError: (err) => { + console.error(err); + alert("Failed to create project"); + } + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!name) return; + createMutation.mutate(); + } + + return ( + +
+
+ + setName(e.target.value)} + placeholder="e.g., Neon City Chase" + className="w-full bg-background border border-border rounded-md px-3 py-2 text-sm focus:ring-1 focus:ring-primary outline-none" + autoFocus + /> +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+
+ ); +}; + +export default CreateProjectModal; diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx new file mode 100644 index 0000000..2d5e490 --- /dev/null +++ b/frontend/src/components/ui/Modal.tsx @@ -0,0 +1,59 @@ + +import React, { useEffect } from 'react'; +import { X } from 'lucide-react'; +import { createPortal } from 'react-dom'; +import { cn } from '../../lib/utils'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + className?: string; +} + +const Modal: React.FC = ({ isOpen, onClose, title, children, className }) => { + useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + if (isOpen) { + document.addEventListener('keydown', handleEsc); + document.body.style.overflow = 'hidden'; + } + return () => { + document.removeEventListener('keydown', handleEsc); + document.body.style.overflow = 'unset'; + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return createPortal( +
+
e.stopPropagation()} + > +
+

{title}

+ +
+
+ {children} +
+
+
, + document.body + ); +}; + +export default Modal; diff --git a/frontend/src/index.css b/frontend/src/index.css index 08a3ac9..7798773 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,68 +1,46 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; +@tailwind base; +@tailwind components; +@tailwind utilities; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { +@layer base { :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; + --background: 224 71% 4%; + --foreground: 210 40% 98%; + + --card: 222 47% 11%; + --card-foreground: 210 40% 98%; + + --popover: 222 47% 11%; + --popover-foreground: 210 40% 98%; + + --primary: 217 91% 60%; + --primary-foreground: 222 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + --radius: 0.5rem; } } + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..996a1d3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,19 @@ + import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { BrowserRouter } from 'react-router-dom' + +const queryClient = new QueryClient() createRoot(document.getElementById('root')!).render( - + + + + + , ) diff --git a/frontend/src/pages/AssemblyPage.tsx b/frontend/src/pages/AssemblyPage.tsx new file mode 100644 index 0000000..876e949 --- /dev/null +++ b/frontend/src/pages/AssemblyPage.tsx @@ -0,0 +1,353 @@ + +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../lib/api'; +import Editor from '@monaco-editor/react'; +import { Layers, Wand2, Video, ChevronRight, ChevronDown, Plus, X, Image as ImageIcon, Send, Sparkles } from 'lucide-react'; +import { cn } from '../lib/utils'; +import Modal from '../components/ui/Modal'; + +const AssemblyPage = () => { + const queryClient = useQueryClient(); + const [selectedProject, setSelectedProject] = useState(''); + const [selectedShotId, setSelectedShotId] = useState(null); + const [editorContent, setEditorContent] = useState('// Select a shot to view its Flow JSON...'); + const [expandedScenes, setExpandedScenes] = useState>({}); + const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false); + const [assets, setAssets] = useState([]); + + // AI Chat State + const [chatInput, setChatInput] = useState(''); + + // Fetch Projects + const { data: projects } = useQuery({ + queryKey: ['projects'], + queryFn: async () => { + const res = await api.get('/projects/'); + return res.data; + } + }); + + // Fetch Project Structure + const { data: projectDetails } = useQuery({ + queryKey: ['project', selectedProject], + queryFn: async () => { + if (!selectedProject) return null; + const res = await api.get(`/projects/${selectedProject}/script`); + return res.data; + }, + enabled: !!selectedProject + }); + + // Fetch Shot Details + const { data: shotDetails } = useQuery({ + queryKey: ['shot', selectedShotId], + queryFn: async () => { + if (!selectedShotId) return null; + const res = await api.get(`/shots/${selectedShotId}`); + return res.data; + }, + enabled: !!selectedShotId + }); + + // Fetch Project Assets + useQuery({ + queryKey: ['assets', selectedProject], + queryFn: async () => { + if (!selectedProject) return []; + const res = await api.get('/assets/', { params: { project_id: selectedProject } }); + setAssets(res.data); + return res.data; + }, + enabled: !!selectedProject + }); + + React.useEffect(() => { + if (shotDetails?.veo_json_payload) { + setEditorContent(JSON.stringify(shotDetails.veo_json_payload, null, 2)); + } else if (shotDetails) { + setEditorContent('// No Flow generated yet. Configure assets and click "Generate".'); + } + }, [shotDetails]); + + React.useEffect(() => { + if (projects && projects.length > 0 && !selectedProject) { + setSelectedProject(projects[0].id); + } + }, [projects, selectedProject]); + + const generateMutation = useMutation({ + mutationFn: async () => { + if (!selectedShotId) return; + const res = await api.post(`/shots/${selectedShotId}/generate-flow`); + return res.data; + }, + onSuccess: (data) => { + setEditorContent(JSON.stringify(data, null, 2)); + queryClient.invalidateQueries({ queryKey: ['shot', selectedShotId] }); + }, + onError: () => alert("Generation failed") + }); + + const refineMutation = useMutation({ + mutationFn: async (feedback: string) => { + if (!selectedShotId) return; + const res = await api.post(`/shots/${selectedShotId}/refine-flow`, { feedback }); + return res.data; + }, + onSuccess: (data) => { + setEditorContent(JSON.stringify(data, null, 2)); + queryClient.invalidateQueries({ queryKey: ['shot', selectedShotId] }); + setChatInput(''); + }, + onError: () => alert("Refinement failed") + }); + + const assignAssetMutation = useMutation({ + mutationFn: async (assetId: string) => { + if (!selectedShotId || !shotDetails) return; + const current = shotDetails.assigned_ingredients || []; + const newIngredients = [...current, assetId]; + await api.patch(`/shots/${selectedShotId}`, { assigned_ingredients: newIngredients }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['shot', selectedShotId] }); + setIsAssetPickerOpen(false); + } + }); + + const removeAssetMutation = useMutation({ + mutationFn: async (indexToRemove: number) => { + if (!selectedShotId || !shotDetails) return; + const current = shotDetails.assigned_ingredients || []; + const newIngredients = current.filter((_: any, idx: number) => idx !== indexToRemove); + await api.patch(`/shots/${selectedShotId}`, { assigned_ingredients: newIngredients }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['shot', selectedShotId] }); + } + }); + + const handleChatSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!chatInput.trim()) return; + refineMutation.mutate(chatInput); + }; + + const toggleScene = (id: string) => { + setExpandedScenes(prev => ({ ...prev, [id]: !prev[id] })); + }; + + const getAssetUrl = (id: string) => { + const asset = assets.find(a => a.id === id); + return asset?.presigned_url; + }; + + return ( +
+ {/* Toolbar */} +
+
+ + Storyboard Assembly +
+
+ +
+ +
+ {/* Navigation Sidebar */} +
+ {projectDetails?.scenes?.map((scene: any) => ( +
+
toggleScene(scene.id)} + > + {expandedScenes[scene.id] ? : } + {scene.slugline} +
+ + {expandedScenes[scene.id] && ( +
+ {scene.shots?.map((shot: any) => ( +
setSelectedShotId(shot.id)} + > +
+ + Shot {shot.sequence_number} + + {shot.status === 'ready' &&
+

{shot.description}

+
+ ))} +
+ )} +
+ ))} +
+ + {/* Main Content */} +
+ {/* Visuals */} +
+ {selectedShotId && shotDetails ? ( +
+
+
+ + SHOT {shotDetails.sequence_number} + +

Visual Description

+
+

{shotDetails.description}

+ {shotDetails.llm_context_cache && ( +
+ {shotDetails.llm_context_cache} +
+ )} +
+ +
+

+ + Visual Ingredients +

+
+ {shotDetails.assigned_ingredients?.map((assetId: string, idx: number) => ( +
+ {getAssetUrl(assetId) ? ( + + ) : ( +
Loading...
+ )} + +
+ ))} + + +
+
+
+ ) : ( +
+ +

Select a shot to begin storyboarding.

+
+ )} +
+ + {/* Editor & Chat Panel */} +
+
+ FLOW JSON +
+ +
+
+ + {/* Editor */} +
+ +
+ + {/* Chat / Refine Input */} +
+
+ + AI REFINEMENT +
+
+ setChatInput(e.target.value)} + placeholder="e.g. 'Make it more dark and rainy'..." + className="w-full bg-muted/30 border border-border rounded-md pl-3 pr-8 py-2 text-xs focus:ring-1 focus:ring-primary outline-none" + disabled={!shotDetails?.veo_json_payload || refineMutation.isPending} + /> + +
+
+
+
+
+ + {/* Asset Picker Modal */} + setIsAssetPickerOpen(false)} + title="Select Asset" + className="max-w-2xl" + > +
+ {assets.map((asset: any) => ( +
assignAssetMutation.mutate(asset.id)} + className="aspect-square bg-muted rounded overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary relative" + > + +
+ {asset.name} +
+
+ ))} + {assets.length === 0 &&
No assets in project.
} +
+
+
+ ); +}; + +export default AssemblyPage; diff --git a/frontend/src/pages/Assets.tsx b/frontend/src/pages/Assets.tsx new file mode 100644 index 0000000..266876d --- /dev/null +++ b/frontend/src/pages/Assets.tsx @@ -0,0 +1,250 @@ + +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../lib/api'; +import { Upload, Loader2, Database, Trash2, Filter } from 'lucide-react'; +import { cn } from '../lib/utils'; + +const AssetsPage = () => { + const queryClient = useQueryClient(); + const [selectedProject, setSelectedProject] = useState(''); + const [filterType, setFilterType] = useState('all'); + const [uploadType, setUploadType] = useState('Character'); + + // Fetch Projects + const { data: projects } = useQuery({ + queryKey: ['projects'], + queryFn: async () => { + const res = await api.get('/projects/'); + return res.data; + } + }); + + // Default select first project + React.useEffect(() => { + if (projects && projects.length > 0 && !selectedProject) { + setSelectedProject(projects[0].id); + } + }, [projects, selectedProject]); + + // Fetch Assets + const { data: assets, isLoading } = useQuery({ + queryKey: ['assets', selectedProject, filterType], + queryFn: async () => { + if (!selectedProject) return []; + const params: any = { project_id: selectedProject }; + if (filterType !== 'all') params.type = filterType; + + const res = await api.get('/assets/', { params }); + return res.data; + }, + enabled: !!selectedProject + }); + + const uploadMutation = useMutation({ + mutationFn: async (files: FileList) => { + const promises = Array.from(files).map(file => { + const formData = new FormData(); + formData.append('project_id', selectedProject); + formData.append('type', uploadType); + formData.append('file', file); + return api.post('/assets/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + }); + return Promise.all(promises); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['assets'] }); + alert("Upload Successful!"); + }, + onError: (err) => { + console.error("Upload error:", err); + alert("Upload Failed. Check console for details. Ensure Backend is running."); + } + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + await api.delete(`/assets/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['assets'] }); + } + }); + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + if (e.dataTransfer.files) { + uploadMutation.mutate(e.dataTransfer.files); + } + }; + + if (!projects || projects.length === 0) { + return ( +
+ +

No projects found. Please create a project in the Dashboard first.

+
+ ) + } + + return ( +
+
+
+

+ + Asset Library +

+

Manage ingredients (characters, locations, props) for your flow.

+
+ +
+ +
+
+ + {/* Upload Zone */} +
e.preventDefault()} + onDrop={handleDrop} + > +
+
+ + + +
+ +
+
+ {uploadMutation.isPending ? : } +
+
+ Drag & Drop {uploadType} images here +
+
+ or click to browse +
+
+ e.target.files && uploadMutation.mutate(e.target.files)} + disabled={uploadMutation.isPending || !selectedProject} + /> + {!selectedProject && ( +

+ Please select a project above to upload assets. +

+ )} +
+
+ + {/* Filter Bar */} +
+ + + + + +
+ + {/* Grid */} + {isLoading ? ( +
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
+ ) : ( +
+ {assets?.map((asset: any) => ( +
+ {asset.name} +
+

{asset.name}

+
+ {asset.type} + +
+
+
+ ))} +
+ )} + + {!isLoading && assets?.length === 0 && ( +
+ No assets found. Upload some above. +
+ )} + +
+ ); +}; + +export default AssetsPage; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..6e74b82 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react'; +import { Layers, Database, ArrowUpRight, CheckCircle2, Plus } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import api from '../lib/api'; +import CreateProjectModal from '../components/projects/CreateProjectModal'; + +const StatCard = ({ title, value, subtext, icon: Icon, color }: any) => ( +
+
+

{title}

+ +
+
+ {value} +
+
+ {subtext} +
+
+); + +const Dashboard = () => { + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + + // Fetch Projects + const { data: projects, isLoading } = useQuery({ + queryKey: ['projects'], + queryFn: async () => { + const res = await api.get('/projects/'); + return res.data; + } + }); + + return ( +
+
+

Dashboard

+ +
+ + setIsCreateModalOpen(false)} /> + + {/* Stats Row */} +
+ + + +
+ +
+ {/* Project List */} +
+
+ +

Active Projects

+
+ +
+ {isLoading &&
Loading projects...
} + {!isLoading && projects?.length === 0 && ( +
+ No projects found. Create one to get started. +
+ )} + {projects?.map((project: any) => ( +
+
+
+ {project.name.substring(0, 2).toUpperCase()} +
+
+
{project.name}
+
{project.resolution} • {project.aspect_ratio} • Veo {project.veo_version}
+
+
+
+ + Script + + + Assembly + +
+
+ ))} +
+
+ + {/* Action Card */} +
+ + + +

+ Central asset repository. +

+
+
+
+ ); +}; + +export default Dashboard; diff --git a/frontend/src/pages/ScriptPage.tsx b/frontend/src/pages/ScriptPage.tsx new file mode 100644 index 0000000..5dc6869 --- /dev/null +++ b/frontend/src/pages/ScriptPage.tsx @@ -0,0 +1,224 @@ + +import React, { useState } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import api from '../lib/api'; +import { Upload, FileText, Loader2, ChevronDown, ChevronRight, Video, Clapperboard } from 'lucide-react'; +import { cn } from '../lib/utils'; + +// Types matching backend response +interface Shot { + shot_number: string; + description: string; + visual_notes?: string; + dialogue?: string; +} + +interface Scene { + scene_number: string; + heading: string; + description: string; + shots: Shot[]; +} + +interface ScriptResponse { + scenes: Scene[]; +} + +const ScriptPage = () => { + const [parsedScript, setParsedScript] = useState(null); + const [expandedScenes, setExpandedScenes] = useState>({}); + const [selectedProject, setSelectedProject] = useState(''); + + // Fetch Projects + const { data: projects } = useQuery({ + queryKey: ['projects'], + queryFn: async () => { + const res = await api.get('/projects/'); + return res.data; + } + }); + + const parseMutation = useMutation({ + mutationFn: async (file: File) => { + const formData = new FormData(); + formData.append('file', file); + const res = await api.post('/scripts/parse', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + return res.data as ScriptResponse; + }, + onSuccess: (data) => { + setParsedScript(data); + if (data.scenes.length > 0) { + toggleScene(data.scenes[0].scene_number); + } + }, + onError: (err) => { + console.error(err); + alert("Failed to parse script"); + } + }); + + const importMutation = useMutation({ + mutationFn: async () => { + if (!selectedProject || !parsedScript) return; + return await api.post(`/projects/${selectedProject}/import-script`, parsedScript); + }, + onSuccess: () => { + alert("Script imported successfully!"); + }, + onError: () => { + alert("Failed to import script"); + } + }); + + const handleFileUpload = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + parseMutation.mutate(e.target.files[0]); + } + }; + + const toggleScene = (sceneNum: string) => { + setExpandedScenes(prev => ({ + ...prev, + [sceneNum]: !prev[sceneNum] + })); + }; + + // Auto-select first project + React.useEffect(() => { + if (projects && projects.length > 0 && !selectedProject) { + setSelectedProject(projects[0].id); + } + }, [projects, selectedProject]); + + return ( +
+
+

+ + Master Script +

+ {parsedScript && ( +
+ + +
+ )} +
+ + {/* Upload Area */} + {!parsedScript && ( +
+
+ {parseMutation.isPending ? ( + + ) : ( + + )} +
+
+

Upload Screenplay

+

Supported formats: .txt, .md (Fountain-like)

+
+ +
+ )} + + {/* Results */} + {parsedScript && ( +
+
+

{parsedScript.scenes.length} Scenes Identified

+ +
+ + {parsedScript.scenes.map((scene) => ( +
+
toggleScene(scene.scene_number)} + > + {expandedScenes[scene.scene_number] ? ( + + ) : ( + + )} +
+ {scene.scene_number} +
+
{scene.heading}
+
+ + {scene.shots.length} Shots +
+
+ + {expandedScenes[scene.scene_number] && ( +
+

{scene.description}

+ +
+ {scene.shots.map((shot) => ( +
+
+ + {shot.shot_number} + + {shot.visual_notes && ( + + + )} +
+

{shot.description}

+ {shot.dialogue && ( +
+ {shot.dialogue} +
+ )} +
+ ))} +
+
+ )} +
+ ))} +
+ )} +
+ ); +}; + +export default ScriptPage; diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..00e64e0 --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,116 @@ + +import { useState } from 'react'; +import { Save, Server, Cpu, Database } from 'lucide-react'; + +const SettingsPage = () => { + // In a real app we would fetch this from backend/localstorage. + // Mocking for MVP UI completeness. + const [settings, setSettings] = useState({ + aiModel: 'gemini-2.0-flash-exp', + apiBase: 'https://generativelanguage.googleapis.com/v1beta/openai/', + theme: 'dark', + autoSave: true, + debugMode: false + }); + + const handleChange = (key: string, value: any) => { + setSettings(prev => ({ ...prev, [key]: value })); + }; + + const handleSave = () => { + // Here we would implement a backend call to save user profile/settings + // For now, simple alert + alert("Settings saved (Local Session Only for MVP)"); + }; + + return ( +
+

+ + System Configuration +

+ +
+ {/* AI Configuration */} +
+

+ + AI Provider Settings +

+
+
+ + +

Select the LLM used for Script Parsing and Flow Generation.

+
+
+ + handleChange('apiBase', e.target.value)} + className="w-full bg-background border border-border rounded-md px-3 py-2 text-sm outline-none font-mono" + /> +
+
+
+ +
+ + {/* System Settings */} +
+

+ + Application Preferences +

+
+
+
+
Auto-Save Script Changes
+
Automatically save changes to the database when parsing/editing.
+
+ handleChange('autoSave', e.target.checked)} + className="toggle" + /> +
+
+
+
Debug Mode
+
Show detailed LLM logs and context in the UI.
+
+ handleChange('debugMode', e.target.checked)} + className="toggle" + /> +
+
+
+ +
+ +
+
+
+ ); +}; + +export default SettingsPage; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..65335fa --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,74 @@ + +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: 0 }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: 0 }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [], +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 8b0f57b..12f020c 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,4 +4,12 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, }) diff --git a/test_script.txt b/test_script.txt new file mode 100644 index 0000000..a69afed --- /dev/null +++ b/test_script.txt @@ -0,0 +1,11 @@ + +INT. DINER - NIGHT + +A noir-style diner. Rain lashes against the window. + +JACK (40s, weary) stirs his coffee. He looks at the door. + + JACK + She's late. Again. + +The door opens. A woman in a RED DRESS enters. diff --git a/verify_script.sh b/verify_script.sh new file mode 100755 index 0000000..19cad3a --- /dev/null +++ b/verify_script.sh @@ -0,0 +1,13 @@ + +#!/bin/bash +set -e + +BASE_URL="http://localhost:3000/api/v1" + +echo "1. Parsing Script..." +RESPONSE=$(curl -s -X POST "$BASE_URL/scripts/parse" \ + -H "Content-Type: multipart/form-data" \ + -F "file=@test_script.txt") + +echo "Response:" +echo "$RESPONSE" | jq . diff --git a/verify_upload.sh b/verify_upload.sh new file mode 100755 index 0000000..1ebe64e --- /dev/null +++ b/verify_upload.sh @@ -0,0 +1,40 @@ + +#!/bin/bash +set -e + +BASE_URL="http://localhost:3000/api/v1" + +echo "1. Creating Project..." +PROJECT_ID=$(curl -s -X POST "$BASE_URL/projects/" \ + -H "Content-Type: application/json" \ + -d '{"name": "Test Project 1"}' | jq -r '.id') + +if [ "$PROJECT_ID" == "null" ]; then + echo "Failed to create project" + exit 1 +fi + +echo "Created Project: $PROJECT_ID" + +echo "2. creating dummy image..." +dd if=/dev/urandom of=test_image.jpg bs=1024 count=10 > /dev/null 2>&1 + +echo "3. Uploading Asset..." +RESPONSE=$(curl -s -X POST "$BASE_URL/assets/upload" \ + -F "project_id=$PROJECT_ID" \ + -F "type=Character" \ + -F "file=@test_image.jpg;type=image/jpeg") + +echo "Upload Response:" +echo "$RESPONSE" | jq . + +PRESIGNED_URL=$(echo "$RESPONSE" | jq -r '.presigned_url') + +if [ "$PRESIGNED_URL" != "null" ] && [ -n "$PRESIGNED_URL" ]; then + echo "SUCCESS: Presigned URL found." +else + echo "FAILURE: Presigned URL missing." + exit 1 +fi + +rm test_image.jpg